Make your microservices tastier by using the Onion architecture
Jérôme Navez
Posted on May 25, 2023
Most of the traditional architectures are subject to raising issues of separation of concerns by increasing the coupling between the component. Being too permissive, those architectures stay in the theoretical concepts and do not force the developers to apply its principles.
Jump to the Onion architecture
The Layered Architecture
The most natural architecture when building a microservice is the Layered architecture. The Layered architecture provides guidelines to separate your service into 3 layers, from the presentation to the database. But nothing stops the developers from using entities and repositories in the presentation layer. This is mainly because of the direction of the dependencies going from the presentation to the persistence.
Compared to the Layered architecture, the Onion architecture provides a similar way of separating your classes in modules, but by adding the usage of a specific pattern to solve this dependency issue.
By browsing the web, you can find many documents about the Onion architecture, some terms that you could see in those documents may differ from the terms used in this article. Different terms, but same principles.
The Onion Architecture
Without further notice, let’s jump into the Onion architecture:
The center of the graph is the domain model: our business objects and the relations between those objects. Those objects are “clean” and are only meant to describe our business in the best possible way. By clean, I mean that they don’t contain any JSON, JPA, or XML annotations.
Around the domain model, we find our domain services. This is the business logic, our use-cases. The domain services strongly rely on the domain objects.
The controllers (and the scheduled jobs) are the entry modules of the architecture. Thus they need to call the domain services to respond to the frontend or other clients.
The persistence module and the clients module are part of the infrastructure. Here, “Infrastructure” is a conceptual term used to group all the modules related to persistence, external communications, and any other module that the domain needs to process its tasks. The term “clients” refer to components that interact with other microservices or with external services. They are the clients of those services.
Principle
The base principle of the Onion architecture is that the module dependencies lead to the domain module. Meaning that the persistence module and the clients depend on the domain module. This is the main difference with the Layered rchitecture where the dependency go from the domain to the persistence module.
More generically, the external layers depend on the inner layers.
This ecosystem can be represented as an onion. A microservice project is therefore an onion. The center is sweet and must be protected from outside threats. The Onion architecture enhances this protection by blocking the domain module from accessing the infrastructure layer.
While the Layered architecture provides guidelines to structure and to place our code in the right place, the Onion architecture provides barriers to force the devs to apply those guidelines. That’s what makes this architecture so strong.
Let’s zoom into the presentation layer and the way it interacts with the domain module.
Presentation Layer And Facades
Facades are not defined in the Onion architecture but are nice to have. In our case, they can help us to have a clearer view of our presentation module.
Each RestController contains very little logic and has its Facade.
@Service
@Validated
@RequiredArgsConstructor
public class TaskManagerFacade {
/* dependencies: mapper, domain service */
private final DtoMapper dtoMapper;
private final TaskService taskService;
@Secured("ROLE_VIEWER")
public List<TaskDto> getTasks() {
List<TaskDto> tasks = taskService.getTasks();
return dtoMapper.toDto(tasks);
}
@Secured("ROLE_EDITOR")
public TaskDto addTask(@Valid TaskDto taskDto) {
Task task = dtoMapper.toModel(taskDto);
Task savedTask = taskService.add(task);
return dtoMapper.toDto(savedTask);
}
}
The Facades follow this structure:
- Mapping the DTO arguments into domain models (DTO → Model);
- Calling the domain service;
- Mapping and returning the result into a DTO (Model → DTO).
More roles are given to the Facades as they act as guardians of our microservice for aggressions coming from the outside:
-
Security and access (
@PreAuthorize
,@Secured
) - DTO validations (with
@Validated
and@Valid
)
Depending on your business, a Facade could also “link” domain services between them and act as an orchestrator of services. In this case, more logic could be written in Facades. As long as no logic is executed on DTOs.
Persistence Layer, Gateways, And Adapters
As stated above, the inner layer needs the infrastructure layer to process its tasks.
Let’s take the example of a business use case that needs to store information. While the more natural way would be to import a repository in the service, the Onion architecture relies on another pattern to achieve this, the inversion of dependency.
Inversion of dependency
The domain module will define an interface containing all the methods he needs to communicate with a persistence module, the Gateway. The Gateway contains methods with arguments and return types from the domain model.
public interface PersistenceGateway {
List<Task> getTasks();
Item saveTask(Task task);
}
This way, the domain explicitly states that it’s required to implement those methods to make the microservice work. It doesn’t care about how the methods are implemented and what technologies and libraries are used to achieve it.
The persistence module is required to implement the Gateway to be compliant with the domain. The Adapter takes care of this responsibility and is the entry point of the module. This is where the dependency from the persistence module to the domain module appears.
@Service
@RequiredArgsConstructor
public class PersistenceAdapter implements PersistenceGateway {
/* dependencies: repository, mapper, query builder etc */
private final EntityMapper entityMapper;
private final TaskRepository taskRepository;
@Override
public List<Task> getTasks() {
List<TaskEntity> taskEntities = taskRepository.findAll();
return entityMapper.toModel(taskEntities);
}
@Override
public Task saveTask(Task task) {
Task taskEntity = entityMapper.toEntity(task);
Task savedTaskEntity = taskRepository.save(taskEntity);
return entityMapper.toModel(savedTaskEntity);
}
}
Those method implementations follow this structure:
- Mapping the arguments into entities or queries (Model → Entity);
- Execute the query;
- Mapping and returning the query result into a domain model (Entity → Model).
The Adapter needs to adapt itself to the Gateway. Most of the time, one Adapter mechanism will be needed to communicate. But as for every class, if the number of methods grows too much, it might be wise to split the Adapters and the Gateways into multiple classes.
This Gateway/Adapter is transposable to other infrastructure modules as the clients module. In this case, the mapper would map between our model and DTOs provided by the external service.
We can see multiple advantages to this dependency inversion, here are some of them:
- Enhance Domain Driven Design;
- Protect our model from API changes in Clients;
- Possibility to have multiple Adapters for one Gateway;
- Easier to test our domain;
- Domain being independent of any dependency choice.
Running everything in a Spring Boot Project
Now that we have our domain, presentation, and persistence modules, we want to launch everything in a Spring Boot application. To achieve this, we need an application module.
├── spring-project
├── app
├── domain
├── persistence
└── presentation
Application module
This module is a no-code module. Its responsibility is only to provide a way to make everything run together. It imports persistence, presentation, and domain modules in its pom.xml
(or build.gradle
).
<project>
<!-- ... -->
<dependencies>
<dependency>
<groupId>com.woodchopper.tuto.onion</groupId>
<artifactId>presentation</artifactId>
</dependency>
<dependency>
<groupId>com.woodchopper.tuto.onion</groupId>
<artifactId>domain</artifactId>
</dependency>
<dependency>
<groupId>com.woodchopper.tuto.onion</groupId>
<artifactId>persistence</artifactId>
</dependency>
</dependencies>
</project>
The only source class present in this module is the Spring Boot application class containing the main method:
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
The module also contains the different property files needed to run the application. The integration tests responsible fortesting the flows from the controllers to the repository are written in this module.
What and how to test?
Ideally, we should be able to cover the entire project with our tests. Two kinds of tests can be made:
- Unit testing on a targeted class;
- Integration testing on an endpoint.
Unit tests
Each class containing logic code needs to be tested. logic code means business logic but also mapping logic.
Integration tests
Those tests provide a way to cover the global microservice flows. The goal is not to cover each specific case of the mapper or the domain services.
This will also allow the developer to cover the Adapters and Facades. Hence, the security and the validation behaviors will be tested.
Those tests are located in the application module since they need the Spring Boot context to be launched. They will mainly use MockMvc as their main tool.
Proof of Concept Project
I made a little playground application In Angular and Spring Boot. The backend part uses the Onion architecture as described above. Take a look to see a concrete example!
Conclusion
We saw a way to implement the Onion architecture in a SpringBoot microservice. While this architecture certainly comes with more classes than the Layered architecture, its structure enhances the maintainability by imposing a strong structure.
Happy coding!
Posted on May 25, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.