In this article, we are going to describe an architecture known as the onion architecture. The onion architecture is a software application architecture that adheres to the SOLID principles. It uses the dependency injection principle extensively, and it is profoundly influenced by the Domain Driven Design (DDD) principles and some functional programming principles.

Prerequisites

The following section describes some software design principles and design patterns that we must learn to be able to understand the onion architecture.

The separation of concerns (SoC) principle

Concerns are the different aspects of software functionality. For instance, the "business logic" of software is a concern, and the interface through which a person uses this logic is another concern.

The separation of concerns is keeping the code for each of these concerns separated. Changing the interface should not require changing the business logic code, and vice versa.

The SOLID principles

SOLID is an acronym that stands for the following five principles:

Single responsibility principle

A class should have only a single responsibility

The most effective way to break applications is to create GOD classes.

A God class is a class that knows too much or does too much. The God object is an example of an anti-pattern.

God classes keep track of a lot of information and have several responsibilities. One code change will most likely affect other parts of the class and therefore indirectly all other classes that use it. That, in turn, leads to an even bigger maintenance mess since no one dares to do any changes other than adding new functionality to it.

The following example is a TypeScript class that defines a Person; this class should not include email validation because that is not related to a person behavior:



class Person { public name : string ; public surname : string ; public email : string ; constructor ( name : string , surname : string , email : string ){ this . surname = surname ; this . name = name ; if ( this . validateEmail ( email )) { this . email = email ; } else { throw new Error ( " Invalid email! " ); } } validateEmail ( email : string ) { var re = /^ ([\w - ] + (?:\.[\w - ] + ) * ) @ ((?:[\w - ] + \.) * \w[\w - ]{0,66})\.([ a-z ]{2,6}(?:\.[ a-z ]{2})?) $/i ; return re . test ( email ); } greet () { alert ( " Hi! " ); } }

We can improve the class above by removing the responsibility of email validation from the Person class and creating a new Email class that will have that responsibility:



class Email { public email : string ; constructor ( email : string ){ if ( this . validateEmail ( email )) { this . email = email ; } else { throw new Error ( " Invalid email! " ); } } validateEmail ( email : string ) { var re = /^ ([\w - ] + (?:\.[\w - ] + ) * ) @ ((?:[\w - ] + \.) * \w[\w - ]{0,66})\.([ a-z ]{2,6}(?:\.[ a-z ]{2})?) $/i ; return re . test ( email ); } } class Person { public name : string ; public surname : string ; public email : Email ; constructor ( name : string , surname : string , email : Email ){ this . email = email ; this . name = name ; this . surname = surname ; } greet () { alert ( " Hi! " ); } }

Making sure that a class has a single responsibility makes it per default also easier to see what it does and how you can extend/improve it.

Open/close principle

Software entities should be open for extension, but closed for modification.

The following code snippet is an example of a piece of code that doesn't adhere to the open/close principle:



class Rectangle { public width : number ; public height : number ; } class Circle { public radius : number ; } function getArea ( shapes : ( Rectangle | Circle )[]) { return shapes . reduce ( ( previous , current ) => { if ( current instanceof Rectangle ) { return current . width * current . height ; } else if ( current instanceof Circle ) { return current . radius * current . radius * Math . PI ; } else { throw new Error ( " Unknown shape! " ) } }, 0 ); }

The preceding code snippet allows us to calculate the area of two shapes (Rectangle and Circle). If we try to add support for a new kind of shape we will be extending our program. We can certainly add support for a new shape (our application is open for extension), the problem is that to do so we will need to modify the getArea function, which means that our application is also open for modification.

The solution to this problem is to take advantage of polymorphism in object-oriented programming as demonstrated by the following code snippet:



interface Shape { area (): number ; } class Rectangle implements Shape { public width : number ; public height : number ; public area () { return this . width * this . height ; } } class Circle implements Shape { public radius : number ; public area () { return this . radius * this . radius * Math . PI ; } } function getArea ( shapes : Shape []) { return shapes . reduce ( ( previous , current ) => previous + current . area (), 0 ); }

The new solution allows us to add support for a new shape (open for extension) without modifying the existing source code (closed for modification).

Liskov substitution principle

Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.

The Liskov substitution principle also encourages us to take advantage of polymorphism in object-oriented programming. In the preceding example:



function getArea ( shapes : Shape []) { return shapes . reduce ( ( previous , current ) => previous + current . area (), 0 ); }

We used the interface Shape to ensure that our program is open for extension but closed for modification. The Liskov substitution principle tells us that we should be able to pass any subtype of Shape to the getArea function without altering the correctness of that program. In static programming languages like TypeScript, the compiler will check for us the correct implementation of a subtype (e.g., if an implementation of Shape is missing the area method we will get a compilation error). This means that we will not need to do any manual work to ensure that our application adheres to the Liskov substitution principle.

Interface segregation principle

Many client-specific interfaces are better than one general-purpose interface.

The interface segregation principle helps us prevent violations of the single responsibility principle and the separation of concerns principle.

Let’s imagine that you have two domain entities: Rectangle and Circle. You have been using these entities in your domain services to calculate their area, and it was working very well, but now you need to be able to serialize them in one of your infrastructure layers. We could solve the problem by adding an extra method to the Shape interface:



interface Shape { area (): number ; serialize (): string ; } class Rectangle implements Shape { public width : number ; public height : number ; public area () { return this . width * this . height ; } public serialize () { return JSON . stringify ( this ); } } class Circle implements Shape { public radius : number ; public area () { return this . radius * this . radius * Math . PI ; } public serialize () { return JSON . stringify ( this ); } }

Our domain layer needs the area method (from the Shape interface), but it doesn't need to know anything about serialization:



function getArea ( shapes : Shape []) { return shapes . reduce ( ( previous , current ) => previous + current . area (), 0 ); }

Our infrastructure layer needs the serialize method (from the Shape interface), but it doesn't need to know anything about the area:



// ... return rectangle . serialize ();

The problem is that adding a method named serialize to the Shape interface is a violation of the SoC principle and the single responsibility principles. The Shape is a business concern and being serializable is an infrastructure concern. We shouldn’t mix these two concerns in the same interface.

The Interface segregation principle tells us that many client-specific interfaces are better than one general-purpose interface, which means that we should split our interfaces:



interface RectangleInterface { width : number ; height : number ; } interface CircleInterface { radius : number ; } interface Shape { area (): number ; } interface Serializable { serialize (): string ; }

Using the new interfaces, we are implementing our domain layer in a way that is completely isolated from infrastructure concerns like serialization:



class Rectangle implements RectangleInterface , Shape { public width : number ; public height : number ; public area () { return this . width * this . height ; } } class Circle implements CircleInterface , Shape { public radius : number ; public area () { return this . radius * this . radius * Math . PI ; } } function getArea ( shapes : Shape []) { return shapes . reduce ( ( previous , current ) => previous + current . area (), 0 ); }

In the infrastructure layer we can use a new set of entities that deal with serialization:



class RectangleDTO implements RectangleInterface , Serializable { public width : number ; public height : number ; public serialize () { return JSON . stringify ( this ); } } class CircleDTO implements CircleInterface , Serializable { public radius : number ; public serialize () { return JSON . stringify ( this ); } }

Using multiple interfaces instead of one general-purpose interface has helped us to prevent a violation of the SoC principle (the business layer doesn’t know anything about serialization) and the Single responsibility principle (we don’t have a class God class that knows about both the serialization and the calculation of the area).

We can argue that RectangleDTO and rectangle Rectangle are almost identical and they are a violation of the "Don't repeat yourself" (DRY) principle. I don't think it is the case because while they look the same, they are related to two different concerns. When two pieces of code look alike, it doesn't always mean that they are the same thing.

Also, even if they are a violation of the DRY principle, we would have to choose between violating the DRY principle or the SOLID principles. I believe that the DRY principle is less important than the SOLID principles and I would, therefore "repeat myself" in this particular case.

Dependency inversion principle

One should depend upon abstractions, [not] concretions.

The dependency inversion principle tells us that we should always try to have dependencies on interfaces, not classes. It is important to mention that dependency inversion and dependency injection are NOT the same thing.

It is unfortunate that the dependency inversion principle is represented by the D in SOLID. It is always the last principle explained, but it is the most important principle in SOLID. Without the dependency inversion principle, most of the other SOLID principles are not possible. If we go back and revisit all the previously explained principles we will realize that the usage of interfaces is one of the most fundamental elements in each of the principles:

Depending on an interface that follows the interface segregation principle allows us to isolate a layer from the implementation details of another layer (SoC principle) and helps us to prevent violations of the single responsibility principle.

Depending on an interface also allows us to replace an implementation with another (Liskov substitution principle).

Depending on an interface enables us to write applications that are open for extension but close for modification (Open/close principle).

Implementing the SOLID principles in a programming language that doesn’t support interfaces or in a programming paradigm that doesn’t support polymorphism is very unnatural. For example, implementing the SOLID principles in JavaScript ES5 or even ES6 feels very unnatural. However, in TypeScript, it feels as natural as it can be.

The model-view-controller (MVC) design pattern

The MVC design pattern separates an application into three main components: the model, the view, and the controller.

Model

Model objects are the parts of the application that implement the logic for the application's data domain. Often, model objects retrieve and store model state in a database. For example, a Product object might retrieve information from a database, operate on it, and then write updated information back to a Products table in a SQL Server database.

In small applications, the model is often a conceptual separation instead of a physical one. For example, if the application only reads a dataset and sends it to the view, the application does not have a physical model layer and associated classes. In that case, the dataset takes on the role of a model object.

View

Views are the components that display the application's user interface (UI). Typically, this UI is created from the model data. An example would be an edit view of a Products table that displays text boxes, drop-down lists, and checks boxes based on the current state of a Product object.

Controller

Controllers are the components that handle user interaction, work with the model, and ultimately select a view to render that displays UI. In an MVC application, the view only displays information; the controller handles and responds to user input and interaction. For example, the controller processes query-string values and passes these values to the model, which in turn might use these values to query the database.

The MVC pattern helps you create applications that separate the different aspects of the application (input logic, business logic, and UI logic) while providing a loose coupling between these elements. The pattern specifies where each kind of logic should be located in the application. The UI logic belongs in the view. Input logic belongs in the controller. Business logic resides in the model. This separation helps you manage complexity when you build an application because it enables you to focus on one aspect of the implementation at a time. For example, you can focus on the view without depending on the business logic.

The loose coupling between the three main components of an MVC application also promotes parallel development. For example, one developer can work on the view, a second developer can work on the controller logic, and a third developer can focus on the business logic in the model. The Model-View-Controller (MVC) design pattern is an excellent example of separating these concerns for better software maintainability.

The repository and the data mapper design patterns

The MVC pattern helps us to decouple the input logic, business logic, and UI logic. However, the model is responsible for too many things. We can use a repository pattern to separate the logic that retrieves the data and maps it to the entity model from the business logic that acts on the model. The business logic should be agnostic to the type of data that comprises the data source layer. For example, the data source layer can be a database, a static file or a Web service.

The repository mediates between the data source layer and the business layers of the application. It queries the data source for the data, maps the data from the data source to a business entity, and persists changes in the business entity to the data source. A repository separates the business logic from the interactions with the underlying data source. The separation between the data and business tiers has three benefits:

It centralizes the data logic or Web service access logic.

It provides a substitution point for the unit tests.

It provides a flexible architecture that can be adapted as the overall design of - the application evolves.

The repository creates queries on the client's behalf. The repository returns a matching set of entities that satisfy the query. The repository also persists new or changed entities. The following diagram shows the interactions of the repository with the client and the data source.

Repositories are bridges between data and operations that are in different domains. A common case is mapping from a domain where data is weakly typed, such as a database, into a domain where objects are strongly typed, such as a domain entity model.

A repository issues the appropriate queries to the data source, and then it maps the result sets to the externally exposed business entities. Repositories often use the Data Mapper pattern to translate between representations.

Repositories remove dependencies that the calling clients have on specific technologies. For example, if a client calls a catalog repository to retrieve some product data, it only needs to use the catalog repository interface. For example, the client does not need to know if the product information is retrieved with SQL queries to a database or Collaborative Application Markup Language (CAML) queries to a SharePoint list. Isolating these types of dependences provides flexibility to evolve implementations.

The onion architecture

The onion architecture divides the application into circular layers (like an onion):

The central layer is the domain model. As we move towards the outer layers, we can see the domain services, the application services and, finally, the test, infrastructure, and UI layers.

In DDD, the center of everything is what is known as “the domain” The domain is composed of two main components:

Domain model

Domain services

In functional programming, one of the main architecture principles is to push side-effects to the boundaries of the application. The onion architecture also follows this principle. The application core (domain services and domain model) should be free of side effects and implementation details, which means that there should be no references to things like data persistence (e.g., SQL) or data transportation (e.g., HTTP) implementation details.

The domain model and domain services don’t know anything about databases, protocols, cache or any other implementation-specific concern. The application core is only concerned about the characteristics and rules of the business. The external layers (infrastructure, test and user interface) are the ones that interact with the system resources (Network, Storage, etc.) and is where side-effects are isolated and kept away from the application core.

The separation between layers is achieved via the usage of interfaces and the application of the dependency inversion principle: Components should depend upon abstractions (interfaces) not concretions (classes). For example, one of the infrastructure layers is the HTTP layer which is mainly composed of controllers. A controller named AircraftController can have a dependency on an interface named AircraftRepository:



import { inject } from " inversify " ; import { response , controller , httpGet } from " inversify-express-utils " ; import * as express from " express " ; import { AircraftRepository } from " @domain/interfaces " ; import { Aircraft } from " @domain/entitites/aircraft " ; import { TYPE } from " @domain/types " ; @ controller ( " /api/v1/aircraft " ) export class AircraftController { @ inject ( TYPE . AircraftRepository ) private readonly _aircraftRepository : AircraftRepository ; @ httpGet ( " / " ) public async get (@ response () res : express . Response ) { try { return await this . _aircraftRepository . readAll (); } catch ( e ) { res . status ( 500 ). send ({ error : " Internal server error " }); } } // ... }

AircraftController is part of the infrastructure layer and its main responsibility is dealing with HTTP related concerns and delegate work to the AircraftRepository The AircraftRepository implementation should be completely unaware of any HTTP concern. At this point, our dependency graph looks as follows:

The arrows in the diagram have different meanings the “comp” arrow defines that AircraftRepository is a property of AircraftController (composition). The “ref” arrow defines that AircraftController has a reference or dependency on Aircraft .

The AircraftRepository interface is part of the domain services while the AircraftController and AircraftRepository implementation are part of the infrastructure layer:

This means that we have a reference from one of the outer layers (infrastructure) to one of the inside layers (domain services). In the onion architecture we are only allowed to reference from the outer layers to the inner layers and not the other way around:

We use the AircraftRepository interface to decouple the domain layer from the infrastructure layer at design time. However, at runtime, the two layers must be somehow connected. This "connection" between interfaces and implementation is managed by InversifyJS. InversifyJS allow use to declare dependencies to be injected using the @inject decorator. At design time, we can declare that we wish to inject an implementation of an interface:



@ inject ( TYPE . AircraftRepository ) private readonly _aircraftRepository : AircraftRepository ;

At runtime, InversifyJS will use its configuration to inject an actual implementation:



container . bind < AircraftRepository > ( TYPE . AircraftRepository ). to ( AircraftRepositoryImpl );

We will now take a look at the AircratRepository and Repository<T> interfaces which is part of the domain services layer.



import { Aircraft } from " @domain/entitites/aircraft " ; export interface Repository < T > { readAll (): Promise < T [] > ; readOneById ( id : string ): Promise < T > ; // ... } export interface AircraftRepository extends Repository < Aircraft > { // Add custom methods here ... }

At this point, our dependency graph looks as follows:

We now need to implement the Repository<T> interface and the AircraftRepository interface:

Repository<T> is going to be implemented by a class named GenericRepositoryImpl<D, E>

AircraftRepository is going to be implemented by a class named AircraftRepositoryImpl .

Let's start by implementing Repository<T> :



import { injectable , unmanaged } from " inversify " ; import { Repository } from " @domain/interfaces " ; import { EntityDataMapper } from " @dal/interfaces " ; import { Repository as TypeOrmRepository } from " typeorm " ; @ injectable () export class GenericRepositoryImpl < TDomainEntity , TDalEntity > implements Repository < TDomainEntity > { private readonly _repository : TypeOrmRepository < TDalEntity > ; private readonly _dataMapper : EntityDataMapper < TDomainEntity , TDalEntity > ; public constructor ( @ unmanaged () repository : TypeOrmRepository < TDalEntity > , @ unmanaged () dataMapper : EntityDataMapper < TDomainEntity , TDalEntity > ) { this . _repository = repository ; this . _dataMapper = dataMapper ; } public async readAll () { const entities = await this . _repository . readAll (); return entities . map (( e ) => this . _dataMapper . toDomain ( e )); } public async readOneById ( id : string ) { const entity = await this . _repository . readOne ({ id }); return this . _dataMapper . toDomain ( entity ); } // ... }

This particular Repository<T> implementation expects an EntityDataMapper and a TypeOrmRepository to be injected via its constructor. Then it uses both dependencies to read from the database and map the results to domain entities.

We also need the EntityDataMapper interface:



export interface EntityDataMapper < Domain , Entity > { toDomain ( entity : Entity ): Domain ; toDalEntity ( domain : Domain ): Entity ; }

And the EntityDataMapper implementation:



import { toDateOrNull , toLocalDateOrNull } from " @lib/universal/utils/date_utils " ; import { Aircraft } from " @domain/entitites/aircraft " ; import { AircraftEntity } from " @dal/entities/aircraft " ; import { EntityDataMapper } from " @dal/interfaces " ; export class AircraftDataMapper implements EntityDataMapper < Aircraft , AircraftEntity > { public toDomain ( entity : AircraftEntity ): Aircraft { // ... } public toDalEntity ( mortgage : Aircraft ): AircraftEntity { // ... } }

We use the EntityDataMapper to map from the entities returned by the TypeOrmRepository to our domain entities. At this point, our dependency graph looks as follows:

We can finally implement AircraftRepository :



import { inject , injectable } from " inversify " ; import { Repository as TypeOrmRepository } from " typeorm " ; import { AircraftRepository } from " @domain/interfaces " ; import { Aircraft } from " @domain/entitites/aircraft " ; import { GenericRepositoryImpl } from " @dal/generic_repository " ; import { AircraftEntity } from " @dal/entities/aircraft " ; import { AircraftDataMapper } from " @dal/data_mappers/aircraft " ; import { TYPE } from " @dal/types " ; @ injectable () export class AircraftRepositoryImpl extends GenericRepositoryImpl < Aircraft , AircraftEntity > implements AircraftRepository { public constructor ( @ inject ( TYPE . TypeOrmRepositoryOfAircraftEntity ) repository : TypeOrmRepository < AircraftEntity > ) { super ( repository , new AircraftDataMapper ()) } // Add custom methods here ... }

At this point, we are done, and our dependency graph looks as follows:

The preceding diagram uses colors to identify concretions (classes, blue) and abstractions (interfaces, orange):

The following diagram uses colors to identify a component that belongs to the domain layer (green) and components that belong to the infrastructure layer (blue):

This architecture has worked very well for me in large enterprise software projects over the last ten years. I also ended up breaking up some colossal monolithic onions into microservices that follow the same architecture. I like to say that when we have microservices that implement the onion architecture, we have a "bag of onions".

I hope you enjoyed the article! Please let me know your thoughts using the comments or at @RemoHJansen.