Onion Architecture explained — Building maintainable software
Introduction
When doing software development, one of the most important things to have in mind is that your software should always be evolving. It will always be maintained, evolved, receiving new features, improvements, and bug fixes.
Then, after some years, with developers joining and leaving the company, it’s easy to get things messy, where it’s harder to maintain the software than to build a brand new one, with the same features.
So, we can see that it’s important to build maintainable software. We should be able to build a software that can be maintained by future developers.
But, what is maintainable software?
It’s a software that any developer should be able to do improvements and fixes without worrying about breaking something under the hood. Any developer, familiar with the domain, should be able to understand the code, and easily know where to change things.
Modifying the view layer should not break any domain logic. Modifying the database modeling should not affect the software’s business rules. You should be able to easily test your domain logic.
Then, we should start thinking about separating different concerns into different units of code.
What is Onion Architecture?
Onion Architecture is an architectural pattern which proposes that software should be made in layers, each layer with it’s own concern.
The architecture’s golden rule, is that:
Nothing in an inner circle can know anything at all about something in an outer circle. That includes functions, classes, variables, or any other named software entity.
Robert C. Martin
This rule also exists in other similar architectures, such as Clean Architecture.
It relies on dependency injection for doing it’s layer’s abstraction, so you can isolate your business rules from your infrastructure code, like repositories and views.
By isolating your domain logic, it becomes easy to test, and easier to maintain.
It was proposed by Jeffrey Pallermo on his website.
DDD
Domain-driven design (DDD) is the concept that developers and domain experts should use the same names both in code and business domain.
The term was coined by Eric Evans in his book Domain-driven Design: Tackling Complexity in the Heart of Software.
Ubiquitous language between domain experts and developers
Domain aware names like, for example, Customer, Product, User, and so on, should mean the same thing both in the domain rules and in the software code.
Both software developers and domain experts should be able to talk in a Ubiquitous Language.
By using the same names, the language barrier between developers and domain experts will not exist, and new demands like features and improvements will be better understood by both sides, and, consequently, the development will flow better and faster.
Domain Model
One of the core concepts in DDD is the Domain Model.
A Domain Model is an entity that incorporates behavior and data from some business model.
In fact, it can be, for example, in an OOP language, a class that contains methods which describe the model’s behavior according to the business rules, and also the data contained in the model.
Layer separation
Onion Architecture proposes three different layers:
- The Domain Layer
- The Application Layer
- The Infrastructure Layer
Domain Layer
The domain layer is the innermost layer of the architecture.
The domain models and services will be inside this layer, containing all the business rules of the software. It should be purely logical, not performing any IO operations at all.
As this layer is purely logical, it should be pretty easy to test it, as you don’t have to worry about mocking IO operations.
This layer also cannot know about anything declared in the application or infrastructure layers.Domain models
Domain Models
The Domain Models are the core of the Domain Layer. They represent the business models, containing the business rules from it’s domain.
Domain services
There are some cases where it’s hard to fit a behavior into a single domain model.
Imagine that you are modeling a banking system, where you have the Account domain model. Then, you need to implement the Transfer feature, which involves two Accounts.
It’s not so clear if this behavior should be implemented by the Account model, so you can choose to implement it in a Domain Service.
A Domain Service contains behavior that is not attached to a specific domain model. It could be basically a function, instead of a method.
Note that, ideally, you should always try to implement behaviors in Domain Models to avoid falling in the Anemic Domain Model pitfall.
Value objects
A Value Object is an object that has no identity, and is immutable.
These objects have no behavior, being just bags of data used alongside your models.
Examples:
- An Address object, which represents a physical location.
- A Point object, which represents a coordinate.
Your Domain models can have Value objects in their attributes, but the opposite is not allowed.
Application Layer
The Application Layer is the second most inner layer of the architecture.
This layer is responsible for preparing the environment for your models, so they can execute their business rules.
The application layer implements Application rules (sometimes called use cases) instead of Business rules.
Application rules are different than business rules. The former are rules that are executed to implement a use case of your application. The latter are rules that belong to the business itself.
Example of an Application rule:
- Load an Account from a repository, call the Account.Withdraw() method passing an amount, and send the new balance on the Account owner’s email.
Example of a Business rule:
- When a Withdraw is requested in an Account, a tax of 0.1% must be charged.
So, for these given examples, if computers did not exist, the Business rules would still be applied. The Application rules would not. This rule of thumb usually can help you distinguish between these different kinds of rules.
Note that the Application Layer itself does not implement any IO operation. The only layer that implements IO is the Infrastructure Layer.
The Application Layer only calls methods from objects that implement the interfaces it expects, and these objects (from the Infrastructure Layer) may do some IO.
Services
An Application Service is a piece of code which implements a use case.
It can receive objects that implement some known interfaces (dependency injection), and it’s allowed to import entities from the Domain Layer.
Interfaces
If the Application Layer is expected to coordinate operations that involve IO, like loading data from a repository, or sending an email, it should declare some interfaces with the methods it wants to use.
This layer should not worry about implementing these methods, just declaring their signatures.
DTOs
A Data Transfer Object (DTO) is an object that contains data that will be transferred between different layers, in some specific format.
Sometimes you want to transfer data that is not exactly a Domain Model, or a Value Object.
For example, let’s say you are developing a banking system. Then, you are implementing a use case which lets the user check her or his account balance.
So, the use case is:
- The application receives a request.
- The user’s Account is loaded from a repository.
- The application returns an object containing the account balance, the current timestamp, the request ID, and some other minor data for audit purposes.
Let’s say that the returned object looks like this:
This object has no behavior. It just contains data, and is used only in this use case as a return value.
This object can be a DTO.
DTOs are well suited as objects with really specific formats and data.
Usually, you should not implement them willing to reuse them on other use cases, as this would couple the two different use cases.
Infrastructure Layer
The Infrastructure Layer is the outermost layer of the Onion Architecture. It’s responsible for implementing all the IO operations that are required for the software.
This layer is also allowed to know about everything contained in the inner layers, being able to import entities from the Application and Domain layers.
The Infrastructure Layer should not implement any business logic, as well as any use case flow.
Repositories, external APIs, Event listeners, and all other code that deal with IO in some way should be implemented in this layer.
Repositories
A Repository is a pattern for a collection of domain objects.
It’s responsible for dealing with the persistence (such as a database), and acts like a in-memory collection of domain objects.
Usually, each domain aggregate has its own repository (if it should be persisted), so you could have a repository for Accounts, another for Customers, and so on.
Usually it’s not a good idea to try to use a single repository for more than one aggregate, because maybe you will end up having a Generic Repository.
In Onion Architecture, the database is just a infrastructure detail. The rest of your code shouldn’t worry if you are storing your data in a database, in a file, or just in memory.
Views (APIs, CLI, etc)
The parts of your code that expose your application to the outside world are also part of the Infrastructure Layer, as they deal with IO.
The inner layers shouldn’t know if your application is being exposed through an API, through a CLI, or whatever.
The application’s entrypoint — dependency injection
The application’s entrypoint (usually, the main) should be responsible for instantiating all necessary dependencies and injecting them into your code.
If you have a repository that expects a PostgreSQL client, the main should instantiate it and pass it to the repository during its initialization. That’s the dependency injection mechanism.
So, the only place in your application that actually creates objects that are capable of doing IO is the application’s entrypoint. The Infrastructure Layer uses them, but is does not create them.
By doing this, your Infrastructure code can expect to receive an object that implements an interface, and the main can create the clients and pass them to the infrastructure. So, when you need to test your infrastructure code, you can make a mock that implements the interface (libs like Python’s MagicMock and Go’s gomock are perfect for this).
Advantages
The Onion Architecture, as any pattern, has its advantages and downsides.
Easy to maintain
It’s easier to maintain an application that has a good separation of concerns. You can change things in the Infrastructure Layer without having to worry about breaking a business rule.
It’s easy to find where are the business rules, the use cases, the code that deals with the database, the code that exposes an API, and so on.
Also, the code is easier to test due to dependency injection, which also contributes to making the software more maintainable.
Language and framework independent
The Onion Architecture does not depend on any specific language or framework. You can implement it in basically any language that supports dependency injection.
Dependency injection all the way! Easy to test
By doing dependency injection in all the code, everything becomes easier to test.
Instead of each module being responsible of instantiating it’s own dependencies, it has its dependencies injected during it’s initialization. This way, when you want to test it, you can just inject a mock that implements the interface your code is expecting to.
Disadvantages
However, as there is no silver bullet, there are some downsides.
Cumbersome when you don’t have many business rules
When you are creating a software that does not deal with business rules, this architecture won’t fit well. It would be really cumbersome to implement, for example, a simple gateway using Onion Architecture.
This architecture should be used when creating services that deal with business rules. If that’s not the case, it will only waste your time.
Not so easy learning curve
It can be hard to implement a service using Onion Architecture when you have a database-centric background.
The change in paradigm is not so straightforward, so you will need to invest some time in learning the architecture before you can use it effortlessly.
Pitfalls to avoid
There some pitfalls you should avoid when using this architecture.
Anemic Domain Models
When all your business rules are in domain services instead of in your domain models, probably you have an Anemic Domain Model.
An Anemic Domain Model is a domain model that has no behavior, just data. It acts just like a bag of data, while the behavior itself is implemented in a service.
This anti pattern has a lot of problems which are well described in Fowler’s article.
Note that Anemic Domain Models is an anti-pattern when working in OOP languages, because, when executing a business rule, you are expected to change the current state of a domain object. When working in a FP language, because of immutability, you are expected to return a new domain object, instead of modifying the current one. So in functional languages, your data em behaviors won’t tightly coupled, and it isn’t a bad thing. But, of course, your business rules should still be in the right layer, to grant a good separation of concerns.
Start by modeling the database
When working with Scrum, you will probably want to break the development of the software into different tasks, so it can be done by different people.
Naturally, maybe you want to start the development by the database, but it’s a mistake! When working with Onion Architecture, you should always start developing the inner layers before the outer ones.
So, you should start by modeling your domain layer, instead of the database layer. In Onion, the database is just a detail.
It would be pretty difficult to start by the repositories, because:
- The repositories depend on the Domain Layer, as they act as a collection of domain objects.
- They also depend on the interfaces defined by the Application Layer, so, you still don’t know which methods you’ll need to implement.
Similar architectures
There are other similar architectures that uses some of the same principles.
Example:
Other important things
There are other important things that you need to care when building a maintainable software, such as observability (logs, metrics, tracing), but that’s for another article :)