The Onion architecture (Figure 1) is a well-known architectural pattern, although we often call it something else: Hexagonal architecture, Ports and Adapters, Clean architecture. The goal of all these patterns is the same: Decoupled layers and a clear separation of concerns. With that in place, high maintainability comes naturally.
Figure 1
If business rules change, application and infrastructure layers shouldn't change. If a technology stack changes, the domain model shouldn't change, etc. Over the years, I had a chance to create and maintain codebases organized in this manner. In my experience, the processes of development and maintenance can be challenging, to say the least. In physics, there is a great analogy that can describe that:
Unstable Equilibrium: “a state of equilibrium in which a small disturbance will produce a large change” (Figure 2).
Figure 2
In his excellent talk, Functional architecture: The Pits of Success, Mark Seemann describes the process of maintaining the Onion architecture as a Sisyphean task. One of his main arguments is that developers must read a lot of thick books to be able to understand it. Undeniably, only a well-educated team can bring the Onion architecture to its full potential.
Is there something more to it besides the fact that you need to be a well-read developer?
No architecture can save you from poorly implemented object-oriented code, complex procedures, etc. I will not use these as arguments. Besides the Dependency inversion principle, benefits of the Onion architecture rely heavily on the proper separation of concerns, and in that sense, the main problem that can happen is a leak of the domain logic across the other layers.
In his book, Patterns of Enterprise Application Architecture, Martin Fowler states:
“One of the hardest parts of working with domain logic seems to be that people often find it difficult to recognize what is domain logic and what is other forms of logic.”
One of the most important aspects of code quality is that code should be exemplary. Every line of code, every pattern that you use will probably influence someone working on the same codebase to do similar or the same thing as you did. So, even if a small leak happens here and there, it's only a matter of time before that leak replicates, and the whole effort of achieving the precious equilibrium goes to waste. Having logic inappropriately mixed between layers can be even worse than a monolith. To be fair, all layered architectures subject to this potential issue, and the Onion architecture is not an exception.
The goal of this article is not to state that the Onion architecture is bad, but to outline the difficulties that one can face during the processes of implementation and maintenance.
Mixing domain and presentation logic
The domain model is the central part of the Onion architecture. It encapsulates business logic and holds entities, value objects, aggregates, and domain services. Or, to put it another way, this layer is about the problem that you are solving.
The presentation layer, on the other hand, is for presentation logic. Its concern is a presentation of domain/business rules to the user.
Even if domain and presentation logic don't have anything in common in theory, sometimes it's not so easy to distinguish between the two. Let's look at the example:
We have a task to create an application for a student's ranking. The algorithm for a calculation of points sums grades. The student with the most points receives the highest rank and a reward. The students are sorted by rank in increasing order. If the two or more students have the same number of points, they are receiving the same rank ( “1224” ranking algorithm). In the case of the same rank, we should sort the students by the personal number in increasing order.
We have a task to create an application for a student's ranking. The algorithm for a calculation of points sums grades. The student with the most points receives the highest rank and a reward. The students are sorted by rank in increasing order. If the two or more students have the same number of points, they are receiving the same rank ( “1224” ranking algorithm). In the case of the same rank, we should sort the students by the personal number in increasing order.
Figure 3
The question is: where to implement these requirements? The calculation of the points is the obvious business logic. What about ranking? It affects the presentation of the students to the user, right? The rank determining algorithm is a business concept, and it belongs to the domain model. Is the sorting of the students by the personal number in case of the same rank a business rule? No. The only concern of that rule is the presentation to the user. A similar request in that context could be sorting of the students in decreasing order or presenting the rank with letters.
In practice, of course, it usually gets much more complicated, and every case needs to be analyzed on its own to eliminate potential leaks.
Mixing domain and application layer logic
The application layer connects the business layer and the boundary technology (database access, HTTP framework, etc.). It depends only on the domain layer. For communication with the outer world, the application layer supplies ports and DTO objects.
In the example that we mentioned above, the application layer would send a message through a port to get students. It would then pass the students to the ranking domain logic, and finally, it would send a message (again through the port) to create a side effect with the received results. It can call a web service, persist, or return the results to the UI. In the best-case scenario, the cyclomatic complexity of this layer should be 1. This is also a good sign that there is no leak of the business logic to the application layer and that the domain layer is well-isolated. Ports in the domain layer are a clear sign of the application logic leak. This should be avoided because the separation of side effects and the domain logic reduces complexity.
Let's add a new business requirement:
If there are multiple students with the highest rank, we need to rank them by the last year's rank. If the students also had the same rank last year, all of them will receive a reward.
To get the last year's rank of a student, we need to call an external service (through a port). The API receives a student's personal number as a parameter.
Now, we need to make a design decision. If we want to keep the domain model isolated, we have to put some domain logic in the application layer (calling the API only if there are multiple students with the highest rank). On the other hand, to keep the wholeness of the domain model, we need to break the domain model isolation (calling the API from the domain model). Often, there is a tension between pureness and the wholeness of the domain. A decision usually varies from case to case. I always try to keep the domain model pure, but again, we need to be careful that the leak of domain logic caused by pureness will not replicate in the future. In our example, one more option is to pass both the data needed for ranking and a last year's rank of all the students in a single domain model call. Sometimes, this can also be an option if there are no technical boundaries (performance, a large amount of data, etc.).
Mixing domain and persistence logic
Often, we can find a lot of business logic in stored procedures, views, etc. However, SQL has limited structuring mechanisms, which can lead to code that is hard to maintain in cases of increasing business complexity.
Still, there are some scenarios when business logic is appropriate for SQL. For example, potential performance issues. Of course, we should always be careful not to apply premature optimization. We should sacrifice the wholeness of the domain model only if there is an actual need for that. This decision should be based on the system requirements, and not something that accidentally happened.
Again, let's change the business requirements: Only the final year students can participate in the competition.
This requirement can easily slip to a store procedure, view, or even the infrastructure layer. As we mentioned earlier, a store procedure or a view can be a reasonable decision (a large amount of data, system requirements). Even if this decision is justified, we should also express that rule in the domain layer because we want it to be independent of the outside world. Of course, this will break the DRY principle. One of the potential solutions for that issue could be the Specification pattern.
On the other hand, the filtering of the students in the infrastructure layer will cause the domain logic leak without any benefits.
Conclusion
The majority of the arguments from this article apply to any layered architecture. I argue that the Onion architecture heavily subjects to these because it requires a well-isolated rich domain model to provide real value.
We should also be aware that the examples were simplified. The fact is that in the real world with deadlines, inexperienced developers in a team (which is a natural thing), and much more complex business requirements, it can get challenging to achieve and maintain the equilibrium. One of the main reasons is that even a small disturbance in the system tends to replicate itself. The best way to improve this is to educate a team on how to be dogmatic in different contexts and then to practice the making of pragmatic decisions.