The popularity of microservices is growing due to the range of benefits they offer to developers and businesses. In this article, I will tell you about my experience of using onion architecture with a harmonized combination of DDD, ASP.NET Core Web API and CQRS for building microservices.
Briefly about Microservices
In fact, while there are numerous definitions of microservices, there is no single clear and unified definition. Broadly speaking, microservices are web services that create a type of service-oriented architecture.
The following are some attributes that can describe a microservice.
- Small size — smaller microservices are easier to work with.
- Independence — each microservice should function independently from others.
- Bounded context — each microservice is built around some business function and uses bounded context as a design pattern.
- Network protocols — microservices interact with each other via network protocols such as HTTP and HTTPS.
- Design-for-Failure principle — this provides the main advantage of microservices architecture: if one service fails, it doesn’t affect the entire system.
- Automation — microservices should be deployed and updated automatically and independently from each other. Manual deployment and updating would be challenging because even the smallest project comprises from five to ten microservices, while large systems may comprise up to 500 microservices.
Note: In our project, we used Docker at the development stage and Kubernetes while deploying in the real environment.
At SaM Solutions, we’ve developed a kind of platform that allows you to automate the development and deployment of systems that use Docker. And Kubernetes is used to manage containers.
- Various technologies — microservices within a project can be written in various programming languages and technologies.
Note: In our project, the backend is written in .NET, the frontend — ReactJS + TypeScript. NGINX was used for distributed tracing. ASP.NET Core technology was used to easily implement Web API.
Why Microservices Are Good for Our Project
The challenge was to create a cloud software solution for a digital signage hardware manufacturer.
Digital signage is a technology for displaying information from devices installed in public places and used for interacting with people. This comprises LED displays, video walls, interactive kiosks, terminals and more.
Our customer needed a software system compatible with their hardware so that clients could buy equipment, install software and create and manage content.
Such a system is complicated, consisting of various modules for:
- company management (as a client of the system) and users within it
- device management (configuration, registration)
- content creation (presentations, videos)
- creation of playlists and schedules
- media content management (videos, images).
In fact, there can be many such functional modules, and each of them perfectly fits the concept of microservices: small size, maximum independence from other modules, well-defined functionality.
Domain-Driven Design (DDD)
To organize business logic for our project, we used Domain-Driven Design (DDD).
The concept of Domain-Driven Design implies that the structure of a software solution is built around a certain business domain and matches its requirements.
DDD implies that you distinguish a certain bounded context, which is a set of entities tightly connected with each other but minimally connected with other entities in your system.
Bounded context is a good fit for a microservices architecture. It is much easier to build a microservice around a bounded context.
Based on the DDD model, we’ve created onion architecture (aka hexagonal or clean architecture).
The idea of the Onion Architecture is based on the inversion of control principle, i.e. placing the domain and services layers at the center of your application, externalizing the infrastructure.
Onion architecture consists of several concentric layers interacting with each other towards the core, which is the domain. The architecture does not depend on the data layer, as in a traditional three-tier architecture; it depends on real domain models.
A three-tier architecture includes:
- Data Access Layer — responsible for data processing
- Business Logic Layer — responsible for app logic
- Presentation Layer — responsible for the display (UI, Web API).
The main problem with this architecture is that all layers are built on top of the Data Access Layer and are, in fact, tied to a certain type of data storage. If this type changes, it causes changes at all levels. The Entity Framework partially solves this problem, but it supports a limited number of database types.
With onion architecture, there is only an object model at the lowest level, which does not depend on the type of database. The actual type of database and the way of storing data is determined at the upper infrastructure level.
Benefits of Onion Architecture
- This approach makes it possible to create a universal business logic that is not tied to anything.
- It’s a good fit for microservices, where it’s not only a database that can act as a data access layer, but also for example an http client, if you need to get data from another microservice, or even from an external system.
- Onion architecture ensures flexibility, sustainability and portability.
- The system can be quickly tested because the application core is independent.
Challenges We Faced
The main issues we faced were related to maintaining the low connectivity of microservices. That’s why it was difficult to immediately divide the functionality into the necessary microservices.
At times, we had to move a particular functionality into a separate microservice if it appeared in many places in the system. On the contrary, if some functionalities were tightly connected, we had to combine microservices into one. And the most challenging task was to find a balance between all these functions.
In addition, the onion architecture itself introduced certain problems. It took us some time to distribute functional parts between appropriate layers. But eventually, this problem was practically eliminated.
Command-Query Request System (CQRS)
Having created a domain model and a web API, we needed to seamlessly connect them. To complete this task, we chose the CQRS approach.
CQRS is a development principle claiming that a method must be either a command that performs an action or a request that returns data.
To put it simply, every action in Web API is either a request (get data) or a command (put data), but it shouldn’t do both. Consequently, each API method is divided into requests and commands.
The main advantage of this approach is that get and put operations are separated. Why is that important?
The practice has shown that 90 percent of requests concern get operations; as a rule, they are small and quick. 10 percent of requests concern put operations; these operations are usually complicated due to a range of transactions and validations.
Hence, when you separate these requests, you can use different technologies for handler implementation (Dapper, Entity Framework).
We’ve chosen MediatR to implement CQRS in the project. It allows you to quickly start using CQRS, and makes it possible to embed your own code into the execution process (due to a pipeline into which you can embed pipeline behavior — the code that will be executed when processing each request). MediatR supports IoC and async/await.
But it does not quite solve the validation problem, especially if you need to take information from a database or from another microservice. Therefore, we built a validation mechanism into the MediatR pipeline using Fluent Validation.
This library provides almost limitless opportunities for setting data validation rules. It is well compatible with CQRS due to pipeline behaviors. It supports IoC and async/await.
ASP.NET Core offers Health Checks Middleware and libraries for reporting the health of app infrastructure components.
Using Health Checks, you can configure a variety of monitoring scenarios, for example to control the availability of the:
- connected microservices
- service bus.
Health Checks allow you to send status to:
- application insights
- your local implementation.
Health Checks is responsible for the system’s performance and will not help in case of malfunction. In other words, it is designed to monitor the health of the system, and not to find bottlenecks and errors in the system.
This is where ODD comes in.
Observability-Driven Development (ODD)
With ODD, you can control data flows between different microservices. Observability–driven development (ODD) is an approach that involves the use of tools and/or libraries to solve the following tasks:
- Data collection (metrics, logs, traces) — use mainly libraries to collect various data during code execution.
- Data storage — use tools that enable central storage of the collected data (sorting, indexing, etc.)
- Visualization — use tools that allow you to visualize the collected data.
There are various tools that help perform these tasks. We used the following stack:
- an Open Tracing library for data collection
- a distributed tracing system Jaeger for data storage
- a distributed tracing system Jaeger for visualization.
I hope you’ll find my experience useful for your projects. You can check my github repository for technical details.