About Onions, Use Cases and Maintainability

Sven Haase, 25 October 2023

In an earlier post from April Juande discussed the idea of self-contained systems. He concluded that a self-contained system should be an entire vertical cut through the domain, e.g., for an online shop this might be checkout, catalog and ERP. Including the UI in the self-contained system allows the team to have full technical responsibility for a particular use case. This reduces inter-team-communication and thereby undesired coupling and dependencies. In an API economy landscape this UI might also be optional and users might directly consume the provided API. How you implement and deploy those verticals is a different question.

Self-Contained Systems
Self-Contained Systems

In this article I would like to dive one step deeper and take a closer look how to implement the backend part within such a vertical. Usually, the backend is highly coupled with the frontend and the persistence layer within a self-contained system. These components might have different requirements towards the backend and thereby also different views on the domain model. The core of the backend builds around the domain model which should be robust against non-business related change requests. How to find the correct domains and domain objects is subject to another big topic around Domain-Driven Design 1 and not part of this article. To achieve this robustness of the core within your code, several different ideas have evolved over the last decade.

Onion, Hexagonal and Clean Architecture

When you are looking for a particular architecture style to implement the backend part in the above-mentioned self-contained system, you usually stumble across three similar ideas: Onion Architecture, Hexagonal Architecture and Clean Architecture. The fundamental idea in all of them is quite similar: You aim to keep domain logic in the core of your application, while dependencies from outside can access components towards the core, but not the other way around. That means that changes enforced by the API consumer e.g., the UI framework, or requirements from some external systems e.g., annotations from the SDK of the persistence product being used, should not induce changes in the domain models. The business logic remains exactly the same. For further info on these styles I refer to 2, 3, 4, 5 6 7.

In a simplified diagram the general idea can be visualized with surrounding dependencies e.g., a desktop UI using REST, a mobile app querying via GraphQL, storing data in a specific database or sending emails to customers. Note that the arrows do not denote code dependency, but data flow. The API layer might directly call a service from the domain layer, but the domain layer should not depend on technical implementations directly. Use the inversion of controls and dependency injection 8 patterns to have the domain trigger external systems by capabilities defined in interfaces rather than being aware and dependent on the technical adapter.

Onion Architecture
Onion Architecture

This style allows the API layer to directly interact with infrastructure components e.g., for simple CRUD operations. I prefer to always have validation logic and mapping between each inter-component communication. This anti-corruption layer decouples the components and implicitly enhances security, as only required data from one component is visible and mapped to the other componentĀ“s view on the entity. Keep in mind that this additional mapping might happen twice for a simple validated CRUD operation: Once when mapping the persisted entity into the domain model and then once when mapping the domain model to the API delivering DTO. A similar idea is brought up in an article by Oliver Drotbohm9 and was referred as the sliced onion. But keep in mind, if you have a very time-critical use case, this might induce too much latency. For the previously mentioned online shop with three subdomains this might result in a folder structure like this:

Folder Structure
Folder Structure for Subdomains

All the above-mentioned ideas have one premise in common: Your surrounding dependencies might change and thereby we want to decouple our own domain from their influence. Whenever the API or the database changes, we only need to refactor small parts within the ports and adapters. But what happens, if your use cases i.e., the business domain, change more often than requirements of your external dependencies?

Use Cases as Verticals

In comparison to the above-mentioned technique, where we try to protect a robust domain and simplify technical maintenance by pushing those to the boundaries of our application code, having lots of changes within the domain confronts developers with higher complexity here. Changes within the domain often need to be persisted and exposed as well and thereby require changes in the entire code base. By separating the subdomains in separate modules or packages this simplifies the developerĀ“s effort already by restricting code changes within those folders only. Those subdomains for checkout could be registration and cart. If we now take that idea to the extreme, instead of entire subdomains within a module we could also split up that even more and thereby create a use case driven project structure as shown in the image below.

Folder Structure
Folder Structure for Subdomains and Use Cases

If those use cases are small enough, it might make sense to get rid of api, domain and infrastructure modules as folders as they might only include few simple files and directly implement those in classes with the same name. If your use cases are that simple that you usually might only have a single API, a single domain service and a very simple infrastructure component, having those all in one file e.g., as allowed in Kotlin, also seems like a valid option.

Folder Structure
Folder Structure for Use Cases

Now the complexity for use case changes is quite easy as the code bases is narrowed down to exactly that. At the same way, this of course increases the complexity if your external dependencies demand changes. If you have all your database code in all use case files, then we have removed all the advantages of the ports and adapters idea.

Combining the Strengths

To sum this article, the right architecture and project structure always depends on your upcoming features. Will you have a project that is very business driven and the tech stack is quite fix, you might lean more into the use case driven approach. If technology and external consumers are quite uncertain, then you should consider leaning more towards the onion approach.

Project Structure Summary
Project Structure Summary

Sources