Layers of generic APIs required to be 1000x more complex than would be required if they were just coupled to the layer above
Changing requirements means tunneling data through many layers
Layers are generic, which means either you tightly couple your APIs for the above-layer's use case, or your API will limit the performance of your system
Everyone who thinks they can design systems does it this way, then they end up managing a system that runs 10x slower than it should + complaining about managers changing requirements 'at the last minute'
The point of abstraction is to limit blast radius of requirement changes.
Someone decides to rename field in API? You don't need to change your database schema and 100500 microservices on top of it. You just change DTO object and keep old name in the other places. May be you'll change old name some day, but you can do it in small steps.
If your layer repeats another layer, why is it a layer in the first place? The point of layer is to introduce abstraction and redirection. There's cost and there's gain.
Every problem can be solved by introducing another layer of indirection. Except the problem of having too many layers of indirection.
So let's say you have some 'User' ORM entity for a food app. Each user has a favourite food and food preferences. You have a function `List<User> getListOfUsersWithFoodPreferences(FoodPreference preference)` which queries another service for users with a given food preference.
The `User` entity has a `String getName()` and `String getFavouriteFood()` methods, cool
Some other team builds some UI on top of that, which takes a list of users and displays their names and their favourite food.
Another team in your org uses the same API call to get a list of users with the same food prefs as you, so they loop over all your food prefs + call the function multiple times.
Amazing, we've layered the system and reused it twice!
Now, the database needs to change, because users can have multiple favourite foods, so the database gets restructured and favourite foods are now more expensive to query - they're not just in the same table row anymore.
As a result, `getListOfUsersWithFoodPreferences` runs a bit slower, because the favourite food query is more expensive.
This is fine for the UI, but the other team using this function to loop over all your food prefs now have their system running 4x slower! They didn't even need the user's favourite food!
If we're lucky that team gets time to investigate the performance regression, and we end up with another function `getListOfUsersWithFoodPreferencesWithoutFavouriteFoods`. Nice.
The onion layer limited the 'blast radius' of the DB change, but only in the API - the performance of the layer changed, and that broke another team.
Your example was a read. So in that case since there's no change in state (no need for protection of the data/invariants) there's no dangers in having different clients read the User records from the datastore however makes sense for them. They could use the ORM or hit the DB directly or anything, really. So getListOfUsersWithFoodPreferences and getListOfUsersWithFoodPreferencesWithoutFavouriteFoods living together as client-specific methods is absolutely fine. It's only when state changes that you need to bring in the User Entity that has all of the domain rules and restrictions.
The idea is that while on Commands (writes) you need your User entity, but on Queries (reads) there's no need to treat the User data as a one-size-fits-all User object that must be hydrated in the same way by all clients.
Sorry; my point was that adding this function as a public API 'onion layer' in your code means you're less able to adapt to change. The fact this function returns a `User` entity isn't particularly important - it's the fact when you make a function public, other teams will reuse your function and add invariants you didn't realise existed, so that changing your function in the future will break other teams' code.
Less public 'onion layers' means less of this
No, the point of abstraction is to make things easier to handle.
At least that is the original meaning of the term, before the OOP ideology got its hands on it. A biology textbook talks about organs before it talks about tissues before it talks about cells before it talks about enzymes. That is the meaning of abstraction: Simple interface to a complex implementation.
In OOP-World however, "abstraction", for some reason, denotes something MORE COMPLEX than the things that are abstracted. It's a kind of logic-flow-routing-layer between the actually useful components that implement the actual business logic.
And such middleware is perfectly fine ... as long as it is required. Usually it isn't, which is where YAGNI comes from.
Now, pointless abstractions are bad enough. But things get REALLY bad, when we drag things that should sit together in the same component, kicking and screaming, into yet another abstraction, so we can maybe, someday, but really never going to happen, do something like rename or add a field to a component. Because now we don't even have useful components any more, we have abstractions, which make up components, and seeing where a component starts and ends, becomes a non-trivial task.
In theory this all seems amazing, sure. It's flexible, it's OOP, it is correct according to all kinds of books written by very smart people.
In reality however, these abstractions introduce a cost, and I am not even talking about performance here, I am talkig about readability and maintainability. And as it turns out in the majority of usecases, these costs far outweigh any gains from applying this methodology. Again: There is a reason YAGNI became a thing.
As someone who had the dubious pleasure to bring several legacy Java services into the 21st century, usually what following these principles dogmatically results in, is a huge, bloated, unreadable codebase, where business functionality is nearly impossible to locate, and so are types that actually represent business objects. Because things that could be handled in 2 functions and a struct that are tightly coupled (which is okay, because they represent one unit of business logic anyway), are instead spread out between 24 different types in as many files. And not only does this make the code slow and needlessly hard to maintain, it also makes it brittle. Because when I change the wrong Base-Type, the whole oh-so-very-elegant pile of abstractions suddenly comes crashing down like a house of cards.
When "where does X happen" stops being answerable with a simple `grep` over the codebase, things have taken a wrong turn.
The problem is in many/most? systems there's no way it can possibly do this, because the abstraction that looked like a perfect fit for requirements set 1 can't know what the requirements in set 2 look like. So in my experience what ends up happening with the abstraction thing is people put all sorts of abstractions all over the place that seem like a good idea and when requirements set #2, #3, etc come along you end up having to change all the actual code to meet the requirements and all of the abstraction layers which no longer fit.
To choose a couple of many examples from my personal experience:
- One place I worked had a system the author thought was very elegant which used virtual functions to do everything. "When we need to extend it we can just add a new set of classes which implement this interface and it will Just Work". Except when the new requirements came in we now needed to dispatch based on the type of two things, not just one. Although you can do this type of thing in lisp and haskell you can't in C++ which is what we were using. So the whole abstraction ediface cost us extra to build in the first place, performance while in use and extra to tear down and rewrite when the actual requirements changed
- One place I worked allowed people to extend the system by implementing a particular java interface to make plugins. Client went nuts developing 300+ of these. When the requirements changed it was clear we needed to change this interface in a way a straight automated refactor just couldn't achieve. Cue me having to rewrite 300+ plugins from InterfaceWhichIsDefinitelyNeverGoingToChangeA format to InterfaceWhichIsHonestlyISwearThisTimeAbsolutelyNeverGoingToChangeB format. I was really happy with all the time this abstraction was saving me while doing so.
Most of the time abstraction doesn't save you time. It may save you cognitive overload by making certain parts of the system simpler to reason about, and that can be a valid reason to do it, but multiple layers is almost never worth it and the idea that you can somehow see the future and know the right abstraction to prevent future pain is delusional unless the problem space is really really well known and understood, which is almost never the case in my experience.
The Gary Bernhardt talk "Boundaries" shows an end result that is very close to The Onion Architecture presented here. And Onion is of course very close to the also popular Clean Architecture and Hexagonal Architecture. Which at the end are very close to applications built using the principles that cjohnson318 mentioned: "have well defined interfaces, and pass simple data through them".
This is all very close to some of the principles Bertrand Meyer teaches. For example, having different modules that make decisions and different modules that perform actions. Which is close to Event Sourcing and CQRS. Which once again is close to BASIC having SUBs and FUNs.
Sure, under a microscope you will have different terminologies, and even apply different techniques and patterns, but the principles in the end are very similar. You might not have anti-corruption layers anywhere, as the sibling commenter mentioned, but that's missing the forest for the trees: the end goal and end result are virtually the same, even if the implementation is different.
In the end happy families have different socioeconomic backgrounds, different ethnicities and religions, but they're still alike. It's the bad ones that have lots of special cases and exceptions everywhere in their design or whatever it is.
Always analyze and decide if it is worth it.
You get some benefits like being able to write very straightforward business logic, but in return you:
- have to constantly fight the entropy, because every day you'll have to implement another corner case that is 2 points if you violate the layer isolation and 10 points if you reengineer the layers to preserve it
- have to constantly repeat yourself and create helpers, because your API layer objects, your domain layer objects, your DB layer objects all look very similar to each other.
Sometimes a transaction script (in Fowler's terminology) with basic DI scaffolding is easier both to write and maintain, especially when the domain isn't rocket science.
Part of the reason onion still exists is because academia is still teaching what they did almost 30 years ago, because a lot of engineers were taught 30 years ago and because a lot of code bases are simply old. The primary issue with onion layering is that it just doesn't scale. Both in terms of actual compute but also in terms of maintenance. That being said, a lot of the ideas and principles in onion layering are excellent and as I mentioned still in use even in more modern architectures. You'll likely even see parts of onion layering in things like micro-services, and I guess you could even argue that some micro-service architectures are a modern form of onion layering.
The more competent a system is designed, however, is often shown in how few abstractions are present. Not because abstractions are an inherent evil, but because any complexity you add is something you'll have to pay for later. Not when you create it, not a year later, but in five years when 20 different people have touched the same lines of code a hundred times you're very likely going to be up to your neck in technical debt. Which is why you really shouldn't try to be clever until you absolutely need to.
This should be put on the cover of every programming textbook, in very bold, very red, letters.
Right next to: "Measure before you optimize" and "Code is read 1000x more often than it is written"