In this article, we are going to learn about Onion architecture and what are its advantages. We will build a RESTful API that follows the Onion architecture, with ASP.NET Core and .NET.
The Onion architecture is also commonly known as the “Clean architecture” or “Ports and adapters”. These architectural approaches are just variations of the same theme.
Let’s get started!
VIDEO: Onion Architecture in ASP.NET Core Web API.
What is the Onion Architecture?
The Onion architecture is a form of layered architecture and we can visualize these layers as concentric circles. Hence the name Onion architecture. The Onion architecture was first introduced by Jeffrey Palermo, to overcome the issues of the traditional N-layered architecture approach.
There are multiple ways that we can split the onion, but we are going to choose the following approach where we are going to split the architecture into 4 layers:
- Domain Layer
- Service Layer
- Infrastructure Layer
- Presentation Layer
Conceptually, we can consider that the Infrastructure and Presentation layers are on the same level of the hierarchy.
Now, let us go ahead and look at each layer with more detail to see why we are introducing it and what we are going to create inside of that layer:
Advantages of the Onion Architecture
Let us take a look at what are the advantages of Onion architecture, and why we would want to implement it in our projects.
All of the layers interact with each other strictly through the interfaces defined in the layers below. The flow of dependencies is towards the core of the Onion. We will explain why this is important in the next section.
Using dependency inversion throughout the project, depending on abstractions (interfaces) and not the implementations, allows us to switch out the implementation at runtime transparently. We are depending on abstractions at compile-time, which gives us strict contracts to work with, and we are being provided with the implementation at runtime.
Testability is very high with the Onion architecture because everything depends on abstractions. The abstractions can be easily mocked with a mocking library such as Moq. To learn more about unit testing your projects in ASP.NET Core check out this article Testing MVC Controllers in ASP.NET Core.
We can write business logic without concern about any of the implementation details. If we need anything from an external system or service, we can just create an interface for it and consume it. We do not have to worry about how it will be implemented. The higher layers of the Onion will take care of implementing that interface transparently.
Flow of Dependencies
The main idea behind the Onion architecture is the flow of dependencies, or rather how the layers interact with each other. The deeper the layer resides inside the Onion, the fewer dependencies it has.
The Domain layer does not have any direct dependencies on the outside layers. It is isolated, in a way, from the outside world. The outer layers are all allowed to reference the layers that are directly below them in the hierarchy.
We can conclude that all the dependencies in the Onion architecture flow inwards. But we should ask ourselves, why is this important?
The flow of dependencies dictates what a certain layer in the Onion architecture can do. Because it depends on the layers below it in the hierarchy, it can only call the methods that are exposed by the lower layers.
We can use lower layers of the Onion architecture to define contracts or interfaces. The outer layers of the architecture implement these interfaces. This means that in the Domain layer, we are not concerning ourselves with infrastructure details such as the database or external services.
Using this approach, we can encapsulate all of the rich business logic in the Domain and Service layers without ever having to know any implementation details. In the Service layer, we are going to depend only on the interfaces that are defined by the layer below, which is the Domain layer.
Enough theory, let us see some code. We have already prepared a working project for you and we’re going to be looking at each of the projects in the solution, and talking about how they fit into the Onion architecture.
Solution Structure
Let’s take a look at the solution structure we are going to be using:
As we can see, it consists of the Web
project, which is our ASP.NET Core application, and six class libraries. The Domain
project will hold the Domain layer implementation. The Services
and Services.Abstractions
are going to be our Service layer implementation. The Persistence
project will be our Infrastructure layer, and the Presentation
project will be the Presentation layer implementation.
Domain Layer
The Domain layer is at the core of the Onion architecture. In this layer, we are typically going to define the core aspects of our domain:
- Entities
- Repository interfaces
- Exceptions
- Domain services
These are just some of the examples of what we could define in the Domain layer. We can be more or less strict, depending on our needs. We have to realize that everything is a tradeoff in software engineering.
Let’s start by looking at the entity classes Owner
and Account
, under the Entities
folder:
public class Owner { public Guid Id { get; set; } public string Name { get; set; } public DateTime DateOfBirth { get; set; } public string Address { get; set; } public ICollection<Account> Accounts { get; set; } }
public class Account { public Guid Id { get; set; } public DateTime DateCreated { get; set; } public string AccountType { get; set; } public Guid OwnerId { get; set; } }
The entities defined in the Domain layer are going to capture the information that is important for describing the problem domain.
At this point, we should ask ourselves what about the behavior? Isn’t an anemic domain model a bad thing?
It depends. If you have very complex business logic, it would make sense to encapsulate it inside of our domain entities. But for most applications, it is usually easier to start with a simpler domain model, and only introduce complexity if it is required by the project.
Next, we are going to look at the IOwnerRepository
and IAccountRepository
interfaces inside of the Repositories
folder:
public interface IOwnerRepository { Task<IEnumerable<Owner>> GetAllAsync(CancellationToken cancellationToken = default); Task<Owner> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default); void Insert(Owner owner); void Remove(Owner owner); }
public interface IAccountRepository { Task<IEnumerable<Account>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default); Task<Account> GetByIdAsync(Guid accountId, CancellationToken cancellationToken = default); void Insert(Account account); void Remove(Account account); }
To learn more about the Repository pattern and how to implement it asynchronously, be sure to check out Implementing Asynchronous Generic Repository in ASP.NET Core.
Inside the same folder, we can also find the IUnitOfWork
interface:
public interface IUnitOfWork { Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); }
Notice that we are setting the CancellationToken
argument as an optional value, and giving it the default
value. With this approach, if we don’t provide an actual CancellationToken
value a CancellationToken.None
will be provided for us. By doing this, we can ensure that our asynchronous calls that use the CancellationToken
will always work.
Domain Exceptions
Now, let’s look at some of the custom exceptions that we have inside the Exceptions
folder.
There is an abstract BadRequestException
class:
public abstract class BadRequestException : Exception { protected BadRequestException(string message) : base(message) { } }
And the abstract NotFoundException
class:
public abstract class NotFoundException : Exception { protected NotFoundException(string message) : base(message) { } }
There are also a couple of exception classes that inherit from the abstract exceptions to describe specific scenarios that can occur in the application:
public sealed class AccountDoesNotBelongToOwnerException : BadRequestException { public AccountDoesNotBelongToOwnerException(Guid ownerId, Guid accountId) : base($"The account with the identifier {accountId} does not belong to the owner with the identifier {ownerId}") { } }
public sealed class OwnerNotFoundException : NotFoundException { public OwnerNotFoundException(Guid ownerId) : base($"The owner with the identifier {ownerId} was not found.") { } }
public sealed class AccountNotFoundException : NotFoundException { public AccountNotFoundException(Guid accountId) : base($"The account with the identifier {accountId} was not found.") { } }
These exceptions will be handled by the higher layers of our architecture. We are going to use them in a global exception handler that will return the proper HTTP status code based on the type of exception that was thrown.
If you’re interested in learning more about how to implement global exception handling, be sure to take a look at Global Error Handling in ASP.NET Core Web API.
At this point, we know how to define the Domain layer. That said, we can move on to the Service layer and see how to use it to implement the actual business logic.
Service Layer
The Service layer sits right above the Domain layer, which means that it has a reference to the Domain layer. The Service layer is split into two projects, Services.Abstractions
and Services
.
In the Services.Abstractions
project you can find the definitions for the service interfaces that are going to encapsulate the main business logic. Also, we are using the Contracts
project to define the Data Transfer Objects (DTO) that we are going to consume with the service interfaces.
Let’s first look at the IOwnerService
and IAccountService
interfaces:
public interface IOwnerService { Task<IEnumerable<OwnerDto>> GetAllAsync(CancellationToken cancellationToken = default); Task<OwnerDto> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default); Task<OwnerDto> CreateAsync(OwnerForCreationDto ownerForCreationDto, CancellationToken cancellationToken = default); Task UpdateAsync(Guid ownerId, OwnerForUpdateDto ownerForUpdateDto, CancellationToken cancellationToken = default); Task DeleteAsync(Guid ownerId, CancellationToken cancellationToken = default); }
public interface IAccountService { Task<IEnumerable<AccountDto>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default); Task<AccountDto> GetByIdAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken); Task<AccountDto> CreateAsync(Guid ownerId, AccountForCreationDto accountForCreationDto, CancellationToken cancellationToken = default); Task DeleteAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken = default); }
Additionally, we can see that there is an IServiceManager
interface that acts as a wrapper around the two interfaces that we created previously:
public interface IServiceManager { IOwnerService OwnerService { get; } IAccountService AccountService { get; } }
Next, we are going to look at how to implement these interfaces inside of the Services
project.
Let’s start with the OwnerService
:
internal sealed class OwnerService : IOwnerService { private readonly IRepositoryManager _repositoryManager; public OwnerService(IRepositoryManager repositoryManager) => _repositoryManager = repositoryManager; public async Task<IEnumerable<OwnerDto>> GetAllAsync(CancellationToken cancellationToken = default) { var owners = await _repositoryManager.OwnerRepository.GetAllAsync(cancellationToken); var ownersDto = owners.Adapt<IEnumerable<OwnerDto>>(); return ownersDto; } public async Task<OwnerDto> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default) { var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken); if (owner is null) { throw new OwnerNotFoundException(ownerId); } var ownerDto = owner.Adapt<OwnerDto>(); return ownerDto; } public async Task<OwnerDto> CreateAsync(OwnerForCreationDto ownerForCreationDto, CancellationToken cancellationToken = default) { var owner = ownerForCreationDto.Adapt<Owner>(); _repositoryManager.OwnerRepository.Insert(owner); await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken); return owner.Adapt<OwnerDto>(); } public async Task UpdateAsync(Guid ownerId, OwnerForUpdateDto ownerForUpdateDto, CancellationToken cancellationToken = default) { var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken); if (owner is null) { throw new OwnerNotFoundException(ownerId); } owner.Name = ownerForUpdateDto.Name; owner.DateOfBirth = ownerForUpdateDto.DateOfBirth; owner.Address = ownerForUpdateDto.Address; await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken); } public async Task DeleteAsync(Guid ownerId, CancellationToken cancellationToken = default) { var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken); if (owner is null) { throw new OwnerNotFoundException(ownerId); } _repositoryManager.OwnerRepository.Remove(owner); await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken); } }
Then let’s inspect the AccountService
class:
internal sealed class AccountService : IAccountService { private readonly IRepositoryManager _repositoryManager; public AccountService(IRepositoryManager repositoryManager) => _repositoryManager = repositoryManager; public async Task<IEnumerable<AccountDto>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default) { var accounts = await _repositoryManager.AccountRepository.GetAllByOwnerIdAsync(ownerId, cancellationToken); var accountsDto = accounts.Adapt<IEnumerable<AccountDto>>(); return accountsDto; } public async Task<AccountDto> GetByIdAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken) { var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken); if (owner is null) { throw new OwnerNotFoundException(ownerId); } var account = await _repositoryManager.AccountRepository.GetByIdAsync(accountId, cancellationToken); if (account is null) { throw new AccountNotFoundException(accountId); } if (account.OwnerId != owner.Id) { throw new AccountDoesNotBelongToOwnerException(owner.Id, account.Id); } var accountDto = account.Adapt<AccountDto>(); return accountDto; } public async Task<AccountDto> CreateAsync(Guid ownerId, AccountForCreationDto accountForCreationDto, CancellationToken cancellationToken = default) { var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken); if (owner is null) { throw new OwnerNotFoundException(ownerId); } var account = accountForCreationDto.Adapt<Account>(); account.OwnerId = owner.Id; _repositoryManager.AccountRepository.Insert(account); await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken); return account.Adapt<AccountDto>(); } public async Task DeleteAsync(Guid ownerId, Guid accountId, CancellationToken cancellationToken = default) { var owner = await _repositoryManager.OwnerRepository.GetByIdAsync(ownerId, cancellationToken); if (owner is null) { throw new OwnerNotFoundException(ownerId); } var account = await _repositoryManager.AccountRepository.GetByIdAsync(accountId, cancellationToken); if (account is null) { throw new AccountNotFoundException(accountId); } if (account.OwnerId != owner.Id) { throw new AccountDoesNotBelongToOwnerException(owner.Id, account.Id); } _repositoryManager.AccountRepository.Remove(account); await _repositoryManager.UnitOfWork.SaveChangesAsync(cancellationToken); } }
And finally the ServiceManager
:
public sealed class ServiceManager : IServiceManager { private readonly Lazy<IOwnerService> _lazyOwnerService; private readonly Lazy<IAccountService> _lazyAccountService; public ServiceManager(IRepositoryManager repositoryManager) { _lazyOwnerService = new Lazy<IOwnerService>(() => new OwnerService(repositoryManager)); _lazyAccountService = new Lazy<IAccountService>(() => new AccountService(repositoryManager)); } public IOwnerService OwnerService => _lazyOwnerService.Value; public IAccountService AccountService => _lazyAccountService.Value; }
The interesting part with the ServiceManager
implementation is that we are leveraging the power of the Lazy
class to ensure the lazy initialization of our services. This means that our service instances are only going to be created when we access them for the first time, and not before that.
What is the motivation for splitting the Service layer?
Why are we going through so much trouble to split our service interfaces and implementations into two separate projects?
As you can see, we mark the service implementations with the internal
keyword, which means they will not be publicly available outside of the Services
project. On the other hand, the service interfaces are public.
Do you remember what we said about the flow of dependencies?
With this approach, we are being very explicit about what the higher layers of the Onion can and can not do. It is easy to miss here that the Services.Abstractions
project does not have a reference to the Domain
project.
This means that when a higher layer references the Services.Abstractions
project it will only be able to call methods that are exposed by this project. We are going to see why this is very useful later on when we get to the Presentation layer.
Infrastructure Layer
The Infrastructure layer should be concerned with encapsulating anything related to external systems or services that our application is interacting with. These external services can be:
- Database
- Identity provider
- Messaging queue
- Email service
There are more examples, but hopefully, you get the idea. We are hiding all the implementation details in the Infrastructure layer because it is at the top of the Onion architecture, while all of the lower layers depend on the interfaces (abstractions).
First, we are going to look at the Entity Framework database context in the RepositoryDbConext
class:
public sealed class RepositoryDbContext : DbContext { public RepositoryDbContext(DbContextOptions options) : base(options) { } public DbSet<Owner> Owners { get; set; } public DbSet<Account> Accounts { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.ApplyConfigurationsFromAssembly(typeof(RepositoryDbContext).Assembly); }
As you can see, the implementation is extremely simple. However, in the OnModelCreating
method, we are configuring our database context based on the entity configurations from the same assembly.
Next, we are going to look at the entity configurations that are implementing the IEntityTypeConfiguration<T>
interface. We can find them inside of the Configurations
folder:
internal sealed class OwnerConfiguration : IEntityTypeConfiguration<Owner> { public void Configure(EntityTypeBuilder<Owner> builder) { builder.ToTable(nameof(Owner)); builder.HasKey(owner => owner.Id); builder.Property(account => account.Id).ValueGeneratedOnAdd(); builder.Property(owner => owner.Name).HasMaxLength(60); builder.Property(owner => owner.DateOfBirth).IsRequired(); builder.Property(owner => owner.Address).HasMaxLength(100); builder.HasMany(owner => owner.Accounts) .WithOne() .HasForeignKey(account => account.OwnerId) .OnDelete(DeleteBehavior.Cascade); } }
internal sealed class AccountConfiguration : IEntityTypeConfiguration<Account> { public void Configure(EntityTypeBuilder<Account> builder) { builder.ToTable(nameof(Account)); builder.HasKey(account => account.Id); builder.Property(account => account.Id).ValueGeneratedOnAdd(); builder.Property(account => account.AccountType).HasMaxLength(50); builder.Property(account => account.DateCreated).IsRequired(); } }
Great, now that the database context is configured, we can move on to the repositories.
We are going to look at the repository implementations inside of the Repositories
folder. The repositories are implementing the interfaces that we defined in the Domain
project:
internal sealed class OwnerRepository : IOwnerRepository { private readonly RepositoryDbContext _dbContext; public OwnerRepository(RepositoryDbContext dbContext) => _dbContext = dbContext; public async Task<IEnumerable<Owner>> GetAllAsync(CancellationToken cancellationToken = default) => await _dbContext.Owners.Include(x => x.Accounts).ToListAsync(cancellationToken); public async Task<Owner> GetByIdAsync(Guid ownerId, CancellationToken cancellationToken = default) => await _dbContext.Owners.Include(x => x.Accounts).FirstOrDefaultAsync(x => x.Id == ownerId, cancellationToken); public void Insert(Owner owner) => _dbContext.Owners.Add(owner); public void Remove(Owner owner) => _dbContext.Owners.Remove(owner); }
internal sealed class AccountRepository : IAccountRepository { private readonly RepositoryDbContext _dbContext; public AccountRepository(RepositoryDbContext dbContext) => _dbContext = dbContext; public async Task<IEnumerable<Account>> GetAllByOwnerIdAsync(Guid ownerId, CancellationToken cancellationToken = default) => await _dbContext.Accounts.Where(x => x.OwnerId == ownerId).ToListAsync(cancellationToken); public async Task<Account> GetByIdAsync(Guid accountId, CancellationToken cancellationToken = default) => await _dbContext.Accounts.FirstOrDefaultAsync(x => x.Id == accountId, cancellationToken); public void Insert(Account account) => _dbContext.Accounts.Add(account); public void Remove(Account account) => _dbContext.Accounts.Remove(account); }
To learn how to implement the repository pattern with Entity Framework Core you can check out this article ASP.NET Core Web API – Repository Pattern.
Great, we are done with the Infrastructure layer. Now we only have one more layer left to complete our Onion architecture implementation.
Presentation Layer
The purpose of the Presentation layer is to represent the entry point to our system so that consumers can interact with the data. We can implement this layer in many ways, for example creating a REST API, gRPC, etc.
We are using a Web API built with ASP.NET Core to create a set of RESTful API endpoints for modifying the domain entities and allowing consumers to get back the data.
However, we are going to do something different from what you are normally used to when creating Web APIs. By convention, the controllers are defined in the Controllers
folder inside of the Web application.
Why is this a problem? Because ASP.NET Core uses Dependency Injection everywhere, we need to have a reference to all of the projects in the solution from the Web application project. This allows us to configure our services inside of the Startup
class.
While this is exactly what we want to do, it introduces a big design flaw. What is preventing our controllers from injecting anything they want inside the constructor? Nothing!
Clean Controllers
With the standard ASP.NET Core approach, we can’t prevent anyone from injecting whatever they need inside a controller. So how can we impose some more strict rules about what the controllers can do?
Do you remember how we split the Service layer into the Services.Abstractions
and Services
projects? That was one piece of the puzzle.
We are creating a project called Presentation
and giving it a reference to the Microsoft.AspNetCore.Mvc.Core
NuGet package so that it has access to the ControllerBase
class. Then we can create our controllers inside this project.
Let’s look at the OwnersController
inside the project’s Controllers
folder:
[ApiController] [Route("api/owners")] public class OwnersController : ControllerBase { private readonly IServiceManager _serviceManager; public OwnersController(IServiceManager serviceManager) => _serviceManager = serviceManager; [HttpGet] public async Task<IActionResult> GetOwners(CancellationToken cancellationToken) { var owners = await _serviceManager.OwnerService.GetAllAsync(cancellationToken); return Ok(owners); } [HttpGet("{ownerId:guid}")] public async Task<IActionResult> GetOwnerById(Guid ownerId, CancellationToken cancellationToken) { var ownerDto = await _serviceManager.OwnerService.GetByIdAsync(ownerId, cancellationToken); return Ok(ownerDto); } [HttpPost] public async Task<IActionResult> CreateOwner([FromBody] OwnerForCreationDto ownerForCreationDto) { var ownerDto = await _serviceManager.OwnerService.CreateAsync(ownerForCreationDto); return CreatedAtAction(nameof(GetOwnerById), new { ownerId = ownerDto.Id }, ownerDto); } [HttpPut("{ownerId:guid}")] public async Task<IActionResult> UpdateOwner(Guid ownerId, [FromBody] OwnerForUpdateDto ownerForUpdateDto, CancellationToken cancellationToken) { await _serviceManager.OwnerService.UpdateAsync(ownerId, ownerForUpdateDto, cancellationToken); return NoContent(); } [HttpDelete("{ownerId:guid}")] public async Task<IActionResult> DeleteOwner(Guid ownerId, CancellationToken cancellationToken) { await _serviceManager.OwnerService.DeleteAsync(ownerId, cancellationToken); return NoContent(); } }
And let’s also take a look at the AccountsController
:
[ApiController] [Route("api/owners/{ownerId:guid}/accounts")] public class AccountsController : ControllerBase { private readonly IServiceManager _serviceManager; public AccountsController(IServiceManager serviceManager) => _serviceManager = serviceManager; [HttpGet] public async Task<IActionResult> GetAccounts(Guid ownerId, CancellationToken cancellationToken) { var accountsDto = await _serviceManager.AccountService.GetAllByOwnerIdAsync(ownerId, cancellationToken); return Ok(accountsDto); } [HttpGet("{accountId:guid}")] public async Task<IActionResult> GetAccountById(Guid ownerId, Guid accountId, CancellationToken cancellationToken) { var accountDto = await _serviceManager.AccountService.GetByIdAsync(ownerId, accountId, cancellationToken); return Ok(accountDto); } [HttpPost] public async Task<IActionResult> CreateAccount(Guid ownerId, [FromBody] AccountForCreationDto accountForCreationDto, CancellationToken cancellationToken) { var response = await _serviceManager.AccountService.CreateAsync(ownerId, accountForCreationDto, cancellationToken); return CreatedAtAction(nameof(GetAccountById), new { ownerId = response.OwnerId, accountId = response.Id }, response); } [HttpDelete("{accountId:guid}")] public async Task<IActionResult> DeleteAccount(Guid ownerId, Guid accountId, CancellationToken cancellationToken) { await _serviceManager.AccountService.DeleteAsync(ownerId, accountId, cancellationToken); return NoContent(); } }
By now it should be obvious that the Presentation
project will only have a reference to the Services.Abstraction
project. And since the Services.Abstractions
project does not reference any other project, we have imposed a very strict set of methods that we can call inside of our controllers.
The obvious advantage of the Onion architecture is that our controller’s methods become very thin. Just a couple of lines of code at most. This is the true beauty of the Onion architecture. We moved all of the important business logic into the Service layer.
Great, we have seen how to implement the Presentation layer.
But how are we going to use the controller if it is not in the Web application? Well, let us move on to the next section to find out.
Constructing the Onion
Congratulations if you made it this far. We’ve shown you how to implement the Domain layer, Service layer, and Infrastructure layer. Also, we’ve shown you the Presentation layer implementation by decoupling the controllers from the main Web application.
There is only one little problem remaining. The application does not work at all! We did not see how to wire up any of our dependencies.
Configuring the Services
Let’s look at how all of the required service dependencies are registered inside of the Startup
class for .NET 5 in the Web
project. We are going to take a look at the ConfigureServices
method:
public void ConfigureServices(IServiceCollection services) { services.AddControllers() .AddApplicationPart(typeof(Presentation.AssemblyReference).Assembly); services.AddSwaggerGen(c => c.SwaggerDoc("v1", new OpenApiInfo { Title = "Web", Version = "v1" })); services.AddScoped<IServiceManager, ServiceManager>(); services.AddScoped<IRepositoryManager, RepositoryManager>(); services.AddDbContextPool<RepositoryDbContext>(builder => { var connectionString = Configuration.GetConnectionString("Database"); builder.UseNpgsql(connectionString); }); services.AddTransient<ExceptionHandlingMiddleware>(); }
For .NET 6, we would add a slightly modified code inside the Program class:
builder.Services.AddControllers() .AddApplicationPart(typeof(Presentation.AssemblyReference).Assembly); builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new OpenApiInfo { Title = "Web", Version = "v1" })); builder.Services.AddScoped<IServiceManager, ServiceManager>(); builder.Services.AddScoped<IRepositoryManager, RepositoryManager>(); builder.Services.AddDbContextPool<RepositoryDbContext>(builder => { var connectionString = Configuration.GetConnectionString("Database"); builder.UseNpgsql(connectionString); }); builder.Services.AddTransient<ExceptionHandlingMiddleware>();
The most important part of the code is:
builder.Services.AddControllers() .AddApplicationPart(typeof(Presentation.AssemblyReference).Assembly);
Without this line of code, the Web API would not work. This line of code will find all of the controllers inside of the Presentation
project and configure them with the framework. They are going to be treated the same as if they were defined conventionally.
Great, we saw how we wired up all of the dependencies of our application. However, there are still a couple of things to take care of.
Creating a Global Exception Handler
Remember that we have two abstract exception classes BadRequestException
and NotFoundException
inside of the Domain layer? Let’s see how we can make good use of them.
We are going to look at the global exception handler ExceptionHandlingMiddleware
class, which can be found inside of the Middlewares
folder:
internal sealed class ExceptionHandlingMiddleware : IMiddleware { private readonly ILogger<ExceptionHandlingMiddleware> _logger; public ExceptionHandlingMiddleware(ILogger<ExceptionHandlingMiddleware> logger) => _logger = logger; public async Task InvokeAsync(HttpContext context, RequestDelegate next) { try { await next(context); } catch (Exception e) { _logger.LogError(e, e.Message); await HandleExceptionAsync(context, e); } } private static async Task HandleExceptionAsync(HttpContext httpContext, Exception exception) { httpContext.Response.ContentType = "application/json"; httpContext.Response.StatusCode = exception switch { BadRequestException => StatusCodes.Status400BadRequest, NotFoundException => StatusCodes.Status404NotFound, _ => StatusCodes.Status500InternalServerError }; var response = new { error = exception.Message }; await httpContext.Response.WriteAsync(JsonSerializer.Serialize(response)); } }
Notice that we create a switch expression around the exception instance and then perform a pattern matching based on the exception type. Then, we are modifying the response HTTP status code depending on what the specific exception type is.
To learn more about the switch expression, and other useful C# features, be sure to check out C# Tips to Improve Code Quality and Performance.
Next, we have to register the ExceptionHandlingMiddleware
with the ASP.NET Core middleware pipeline for this to work correctly:
... app.UseMiddleware<ExceptionHandlingMiddleware>(); ...
We also have to register our middleware implementation inside of the ConfigureService
method of the Startup
class:
services.AddTransient<ExceptionHandlingMiddleware>();
Or in .NET 6:
builder.Services.AddTransient<ExceptionHandlingMiddleware>();
Without registering the ExceptionHandlingMiddleware
with the dependency container, we would get a runtime exception, and we do not want that to happen.
Taking Care of Database Migrations
We are going to look at one last improvement to the project, which makes it easier to use for everyone, and then we are done.
To make it straightforward to download the application code and be able to run the application locally we are using Docker. With Docker
we are wrapping our ASP.NET Core application inside of a Docker container. We are also using Docker Compose
to group our Web application container with a container running the PostgreSQL database image. That way, we won’t need to have PostgreSQL installed on our system.
However, since the Web application and the database server will be running inside of containers, how are we going to create the actual database for the application to use?
We could create an initialization script, connect to the Docker container while it is running the database server, and execute the script. But this is a lot of manual work, and it is error-prone. Luckily, there is a better way.
To do this elegantly, we are going to use Entity Framework Core migrations and we are going to execute the migrations from our code when the application starts. To see how we achieved this, take a look at the Program
class in the Web
project:
public class Program { public static async Task Main(string[] args) { var webHost = CreateHostBuilder(args).Build(); await ApplyMigrations(webHost.Services); await webHost.RunAsync(); } private static async Task ApplyMigrations(IServiceProvider serviceProvider) { using var scope = serviceProvider.CreateScope(); await using RepositoryDbContext dbContext = scope.ServiceProvider.GetRequiredService<RepositoryDbContext>(); await dbContext.Database.MigrateAsync(); } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>()); }
The great thing about this approach is that the migrations will be automatically applied when we create new migrations, further down the road. We do not have to think about it going forward. To learn more about migrations and how to seed data with EF Core in both .NET check out this article Migrations and Seed Data with Entity Framework Core.
Running the Application
Amazing job! We have connected all of our Onion architecture implementation layers, and our application is now ready for use.
We can start the application by clicking the Docker Compose
button from Visual Studio. Make sure the docker-compose
project is set as your startup project. This will automatically spin up the web application and database server containers for us:
We can then open the browser at the https://localhost:5001/swagger
address, where we can find the Swagger
user interface:
Here we can test out our API endpoints and check if everything is working properly.
Conclusion
In this article, we have learned about Onion architecture. We have explained our take on the architecture by splitting it into the Domain, Service, Infrastructure, and Presentation layers.
We started with the Domain layer, where we saw the definitions for our entities and repository interfaces and exceptions.
Then we saw how the Service layer was created, where we are encapsulating our business logic.
Next, we looked at the Infrastructure layer, where the implementations of the repository interfaces are placed, as well as the EF database context.
And finally, we saw how our Presentation layer is implemented as a separate project by decoupling the controllers from the main Web application. Then, we explained how we can connect all of the layers using an ASP.NET Core Web API.
Until the next article,
All the best.