Let’s slice it like a Pro
Domain-driven design (DDD) is an approach to developing software for complex needs by deeply connecting the implementation to an evolving model of the core business concepts.
The domain is a sphere of knowledge. It refers to the business knowledge that our software is trying to model. Domain-Driven Design centres on the domain model that has a rich understanding of the processes and rules of a domain. Onion architecture implements this concept and dramatically increases code quality, reduces complexity and enables evolutionary enterprise systems.
Why Onion Architecture?
Domain entities are the core and centre part. Onion architecture is built on a domain model in which layers are connected through interfaces. The idea is to keep external dependencies as far outward as possible where domain entities and business rules form the core part of the architecture.
- It provides flexible, sustainable and portable architecture.
- Layers are not tightly coupled and have a separation of concerns.
- It provides better maintainability as all the code depends on deeper layers or the centre.
- Improves overall code testability as unit tests can be created for separate layers without impacting other modules.
- Frameworks/technologies can be easily changed without impacting the core domain. e.g. RabbitMQ can be replaced by ActiveMQ, SQL can be replaced by MongoDB
Onion Architecture is comprised of multiple concentric layers interfacing with each other towards the core that represents the domain. It is based on the inversion of control principle. The architecture does not focus on underlying technology or frameworks but the actual domain models. It is based on the following principles.
The circles represent different layers of responsibility. In general, the deeper we dive, the closer we get to the domain and business rules. The outer circles represent mechanisms and the inner circles represent core domain logic. The outer layers depend on inner layers and the inner layers are completely unaware of outer circles. Classes, methods, variables, and source code in general belonging to the outer circle depends on the inner circle but not vice versa.
Data formats/structures may vary from layers. Outer layer data formats should not be used by inner layers. E.g. Data formats used in an API can vary from those used in a DB for persistence. Data flow can use data transfer objects. Whenever data crosses layers/boundaries, it should be in a form that is convenient for that layer. E.g. API’s can have DTO’s, DB layer can have Entity Objects depending on how objects stored in a database vary from the domain model.
Each layer/circle encapsulates or hides internal implementation details and exposes an interface to the outer layer. All layers also need to provide information that is conveniently consumed by inner layers. The goal is to minimize coupling between layers and maximize coupling within a vertical slice across layers. We define abstract interfaces at deeper layers and provide their concrete implementation at the outermost layer. This ensures we focus on the domain model without worrying too much about implementation details. We can also use dependency injection frameworks, like Spring, to connect interfaces with implementation at runtime. E.g. Repositories used in the domain and external services used in Application Services are implemented at the infrastructure layer.
Separation of concerns
Application is divided into layers where each layer has a set of responsibilities and addresses separate concerns. Each layer acts as modules/package/namespace within the application.
Low coupling in which one module interacts with another module and does not need to be concerned with the other module’s internals. All the internal layers need not be concerned about internal implementation of external layers.
Onion Architecture Layers
Let’s understand different layers of the architecture and their responsibilities with an order creation use case. When receiving a create order request, we would like to validate the order, save the order in the database, update inventory for all order items, debit order amount and lastly send a notification to the customer about order completion.
Domain Entities are the fundamental building block of Domain-Driven Design and they’re used to model concepts of your Ubiquitous Language in code. Entities are Domain concepts that have a unique identity in the problem domain. Domain entities encapsulate attributes and entity behaviour. It is supposed to be independent of specific technologies like databases or web APIs. E.g. In the Orders domain. Order is an entity and has attributes like OrderId, Address, UserInfo, OrderItems, PricingInfo and behaviour like AddOrderItems, GetPricingInfo, ValidateOrder, etc.
Domain services are responsible for holding domain logic and business rules. All the business logic should be implemented as a part of domain services. Domain services are orchestrated by application services to serve business use-case. They are NOT typically CRUD services and are usually standalone services. Domain services are responsible for complex business rules like computing pricing and tax information when processing order, Order repository interface for saving and updating order, Inventory Interface for updating information about items purchased, etc.
It consists of algorithms that are essential to its purpose and implement the use cases that are the heart of the application.
Application services also referred to as “Use Cases”, are services responsible for just orchestrating steps for requests and should not have any business logic. Application Services interact with other services to fulfil the client’s request. Let’s consider the use case to create an order with a list of items. We first need to calculate the price including tax computation/discounts, etc., save order items and send order confirmation notification to the customer. Pricing computation should be part of the domain service, but orchestration involving pricing computation, checking availability, saving order and notifying users should be part of the application service. The application services can be only invoked by Infrastructure services.
Infrastructure services also referred to as Infrastructure adapters are the outermost layer in onion architecture. These services are responsible for interacting with the external world and do not solve any domain problem. These services just communicate with external resources and don’t have any logic. E.g. External notification Service, GRPC Server endpoint, Kafka event stream adapter, database adapters.
Observability services are responsible for monitoring the application. These services help perform tasks like :
- Data collection (metrics, logs, traces) — use mainly libraries/sidecars to collect various data during code execution.
- Data storage — use tools that enable central storage of the collected data (sorting, indexing, etc.)
- Visualisation — use tools that allow you to visualise the collected data.
Few examples include Splunk, ELK, Grafana, Graphite, Datadog.
Different layers of onion architecture have a different set of responsibilities and accordingly, there are different testing strategies. The testing pyramid is a great framework that lays out the different types of tests. Business rules that belong to the domain model, domain services and application services should be tested via Unit Testing. As we move to the outer layer, it makes more sense to have integration tests in infrastructure services. For our application End to End testing and BDD are the most appropriate testing strategies.
Onion architecture is also applicable to microservices when viewing each microservice in isolation. Each microservice has its own model, its own use cases and defines its own external interfaces for retrieving or modifying the data. These interfaces can be implemented with an adapter that connects to another microservice by exposing HTTP Rest, GRPC, Thrift Endpoints, etc. It’s a good fit for microservices, where data access layer not only comprises database, but also for example an http client, to get data from another microservice, or even from an external system.
Application Structure & Layers
Modularisation vs Packaging
There are two ways to organise application source code:
- Either, we can have all the packages in a single module/project or
- Divide the application into different modules/projects each responsible for a layer in onion architecture.
It greatly depends on the complexity of the application and the size of the project to divide source code into multiple modules. In a microservice architecture, modularisation may or may not make sense depending upon the complexity and use-case.
Frameworks, Clients and Drivers
The infrastructure layer composes frameworks for web or servers, clients for databases, queues or external services. It is responsible for configuring and stitching all the external services and frameworks together. Onion architecture provides decoupling so that it becomes easier to swap technologies at any point in time.
Do We Need Every Layer?
Organising our application in layers helps in achieving separation of concerns. But do we need all the layers? Maybe, maybe not. It depends on the use cases and the complexity of the application. It is also possible to create more layers of abstractions depending on application needs. E.g. for smaller applications that don’t have a lot of business logic, it might not make sense to have domain services. Regardless of layers, dependencies should always be from outer layers to inner layers.
Onion architecture might seem hard in beginning but is widely accepted in the industry. It is a powerful architecture and enables easy evolution of software. By separating the application into layers, the system becomes more testable, maintainable and portable. It helps easy adoption of new frameworks/technologies when old frameworks become obsolete. Similar to other architectural styles like Hexagonal, Layered, Clean Architecture, etc. it provides a solution for common problems.