Stop Adding Layers: Your Architecture Might Be the Bottleneck
I built a pretty ambitious project, shared part of it with the community through "silentJson", and wrote a detailed article explaining how to achieve some pretty crazy performance results.
The results were so unrealistic to some people that I was accused of spam, and the article was removed from a few subreddits.
Still, I'm genuinely grateful that many people at least took the time to read the articles, and that some of them actually appreciated the work.
So, here is the continuation.
The problem isn't only that we need to remove unnecessary layers, conversions, or simplify the logic. The bigger problem is that all of this is actually standard practice in production systems. And most people simply don't notice it anymore.
Let me give you a hint where to look.
Take PL/pgSQL, for example. You can put the logic directly inside the database.
Or Elasticsearch. It works in a similar way.
You probably use approaches like these all the time.
And while PostgreSQL itself doesn't necessarily give you some massive raw speed advantage, it can still give you a huge overall performance win because you are not constantly moving information through layers of OOP abstractions and paying the cost of all those calls and transformations.
The business logic is already where it needs to be.
You can use the same idea in your own applications.
But for some reason, we often care more about creating interfaces and generics so that we can mock everything in unit tests instead of simply writing integration tests.
This can significantly reduce the load on the system and, more importantly, make your tests validate real scenarios instead of proving that "sum(1, 2)" returns 3.
So where do you start?
Take a piece of paper.
Write down the actual goal of the system, what you have, and what you want to achieve.
Use blocks, lists, arrows, whatever works for you.
At this stage, completely forget about the programming language.
Seriously.
If you start thinking about Go, Java, Rust, interfaces, frameworks, or whatever else too early, you will probably start designing around the language instead of designing around the problem.
First, work out how the data should actually flow through the system.
Only at that point should you start thinking about what data you need, how it should be stored, and, perhaps even more importantly, what should not be stored or processed at all.
Then start writing code.
But write it according to the data flow and decomposition you already designed.
At every stage, use only what is actually necessary.
For example: maps, interface{}, channels, mutexes, goroutines.
Sometimes it is incredibly convenient to throw together a worker pool using channels. It's simple, readable, and often works very well.
But do you actually need it?
Creating goroutines isn't free.
A WaitGroup isn't automatically the best solution just because it's convenient.
And combining maps with mutexes can very quickly turn into an adventure where you're trying to figure out who locked what, when it happened, and why something never got unlocked.
Sometimes a simple slice, struct, or primitive type is all you need.
Programming has spent decades developing good ways to work with data efficiently. Look at the natural primitives we already have.
Take JSON.
It is simple, universal, and fast enough for an enormous number of real-world applications.
Yet many people consider JSON "slow".
Usually, the problem isn't JSON itself.
The problem is what you put inside it and what you make it do.
Add a "time.Time", a "map[string]interface{}", or several layers of dynamic structures, and suddenly you get the performance penalty you were blaming on JSON.
The same principle applies everywhere else.
If the job is to store data, store it.
If the job is to return data, return it.
Don't build obstacles in between.
The fewer steps the information has to go through, the faster it reaches its destination.
And don't try to return more than you actually have.
Separate responsibilities.
If a service is supposed to provide data, it should provide data.
If a BFF is supposed to compose data, let it compose data.
And let it do that as quickly and simply as possible.
It shouldn't be transforming everything just because it can.
A BFF doesn't need to contain your business logic.
Wait.
What about business logic?
This is where things get interesting.
Business logic can actually operate on different levels.
Some logic changes the data itself. Other logic changes how that data is presented.
The first kind belongs as close as possible to the point where the data is created, retrieved, or indexed.
The second kind can live at the composition layer, frontend, or a dedicated business layer.
Where exactly you put it depends on the architecture and business requirements of the project.
And that's why the architecture should be designed before you start writing the code.
Once the data flow is clear, you can look at the system and ask a much more useful question:
"Where is the bottleneck?"
Then remove everything on the path that creates an unnecessary restriction.
Profile your application.
If you don't know how, ask an LLM. There is no shame in that.
When you've done all of this and finally discover that the bottleneck isn't your architecture anymore, but the hardware itself, then you can start asking whether that abstraction, interface, mock, or extra layer is really worth its cost.
Especially when the only reason for adding it was to prove that one tiny function correctly calculates 2 + 2.
I'm curious how other people approach this.
How do you design, develop, and profile your systems?
And how do you decide when an abstraction is actually useful and when it is just another layer between the data and its destination?