Updated on

The Onion architecture organizes an application as layers in concentric circles. The domain model sits at the center, and every dependency points inward, toward it.

We will build a RESTful API that follows the Onion architecture, with ASP.NET Core and .NET, and see what its advantages are.

The Onion architecture is closely related to the Clean architecture and to Ports and adapters, also known as the Hexagonal architecture. These architectural approaches are variations of the same theme.

Our Onion vs Clean Architecture comparison puts the first two side by side, and the Hexagonal Architecture article shows ports and adapters in C#.

To download the source code for this article, you can visit our GitHub repository.

Let’s get started!

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 in 2008, to overcome the issues of the traditional N-layered architecture approach.

In a traditional N-layered application, the business logic depends on the data access layer. The Onion architecture turns that dependency around. The domain model sits at the center and depends on nothing. The core defines interfaces for what it needs, and the Infrastructure layer on the outside implements them.

The main rule is about direction. Code in any layer can depend on the layers closer to the center, and never on the layers further out.

That way, the database becomes a detail on the edge of our application, and we can test the business logic without it. The price is a few extra projects and interfaces, which pays off in long-lived applications with real business rules.

In his first post on the pattern, The Onion Architecture: part 1, Palermo wrote: “The fundamental rule is that all code can depend on layers more central, but code cannot depend on layers further out from the core.”

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:

Onion Architecture diagram with three concentric rings. At the center, the Domain layer, the Banking.Domain project with Account and IAccountRepository. Around it, the Service layer, the Banking.Services project with IAccountService and AccountService. The outer ring is split into the Presentation layer, Banking.Api, and the Infrastructure layer, Banking.Infrastructure with EF Core and SQLite. An arrow runs from the outer edge to the center, labeled: no project reference points outward.

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 through the interfaces and types 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 the core depends on abstractions. The abstractions can be easily mocked with a mocking library such as NSubstitute, or replaced with a small hand-written fake, as we’ll do later in this article.

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.

These advantages aren’t free, though. We pay for them with extra projects and interfaces, and Palermo said so himself in that first post: “This architecture is not appropriate for small websites.”

For a small CRUD application, Vertical Slice Architecture is often a better fit, because it keeps a feature’s code together instead of spreading it over several projects.

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 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 layer below, which is the Domain layer.

Enough theory, let us see some code. We are going to build a small bank account API from an empty folder, one project at a time, and talk about how each project fits into the Onion architecture.

Our API has one entity, Account, and one business rule: an account can’t be overdrawn. It exposes two endpoints, one that returns an account and one that withdraws money from it.

Solution Structure

The steps below assume the .NET 10 SDK, since they use its noun-first commands, such as dotnet reference add.

Older SDKs use dotnet sln add, dotnet add reference and dotnet add package instead.

Let’s start by creating the solution structure we are going to be using:

dotnet new sln -n Banking
dotnet new classlib -n Banking.Domain
dotnet new classlib -n Banking.Services
dotnet new classlib -n Banking.Infrastructure
dotnet new web -n Banking.Api
dotnet solution add Banking.Domain Banking.Services Banking.Infrastructure Banking.Api

As we can see, it consists of the Banking.Api project, which is our ASP.NET Core application, and three class libraries. The Banking.Domain project will hold the Domain layer implementation. The Banking.Services project is going to be our Service layer implementation. The Banking.Infrastructure project will be our Infrastructure layer, and the Banking.Api project will be the Presentation layer implementation.

Each class library starts with a placeholder Class1.cs file. We don’t need them, so let’s delete all three.

You’ll also notice Banking.slnx. That’s the XML solution format the .NET 10 SDK creates by default.

Project references decide which layer can see which. These three commands set up our flow of dependencies:

dotnet reference add Banking.Domain --project Banking.Services
dotnet reference add Banking.Domain --project Banking.Infrastructure
dotnet reference add Banking.Services Banking.Infrastructure --project Banking.Api

Both Services and Infrastructure point at Domain, the API points at those two, and Domain points at nothing:

Four project boxes. Banking.Api at the top left and Banking.Infrastructure at the top right, Banking.Services under Banking.Api, and Banking.Domain at the bottom. Arrows show project references: Banking.Api to Banking.Services, Banking.Api to Banking.Infrastructure (labeled composition root, Program.cs only), Banking.Services to Banking.Domain, and Banking.Infrastructure to Banking.Domain. No arrow leaves Banking.Domain.

Why doesn’t Infrastructure reference Services? It doesn’t need anything from there. The interface it implements, IAccountRepository, lives in the Domain, next to the entity it loads and saves.

And isn’t the API reaching past the core when it references Infrastructure? It is, but only for one file. Program.cs has to register the EF Core classes behind the Domain’s interface, and that makes it the composition root: the single spot where every layer gets wired up. We’ll come back to this in the Presentation layer.

Finally, two projects get a package each:

dotnet package add Microsoft.Extensions.DependencyInjection.Abstractions --version 10.0.12 --project Banking.Services
dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project Banking.Infrastructure

The Services project only needs the dependency injection abstractions for its registration method. EF Core lands in Infrastructure, the one project that implements data access. We pin the versions to the ones this article was tested with.

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
  • Errors
  • 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 Account entity class. Create Banking.Domain/Account.cs:

namespace Banking.Domain;

public sealed class Account
{
    private Account(string owner, decimal balance)
    {
        Owner = owner;
        Balance = balance;
    }

    public int Id { get; private set; }
    public string Owner { get; private set; }
    public decimal Balance { get; private set; }

    public static Account Open(string owner, decimal initialDeposit)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(owner);
        ArgumentOutOfRangeException.ThrowIfNegative(initialDeposit);

        return new Account(owner, initialDeposit);
    }

    public Result Withdraw(decimal amount)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);

        if (amount > Balance)
            return AccountErrors.InsufficientFunds(Id);

        Balance -= amount;

        return Result.Success();
    }
}

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 complex business logic, it makes sense to encapsulate it inside of our domain entities. Our Account has only one rule, but that rule is the reason the application exists, so it lives inside the entity. All three setters are private, and money leaves the account only through Withdraw().

For many applications, it is usually easier to start with a simpler domain model, and only introduce complexity if it is required by the project.

Withdraw() returns a Result and uses AccountErrors. These are two small types we are going to create in the Domain Errors section below, so the project won’t build until they’re in place.

Next, the Domain needs a way to load and save an account without knowing anything about the database. So we define an interface for it. Create Banking.Domain/IAccountRepository.cs:

namespace Banking.Domain;

public interface IAccountRepository
{
    Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default);

    Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

Palermo placed repository interfaces right here, in the first layer around the domain model, and the class that talks to the database out on the edge. Clean Architecture projects often move these interfaces one layer out. Both placements keep the rule intact.

There’s no separate IUnitOfWork interface in our Domain. SaveChangesAsync() sits on the repository, which is fine while each operation works with one repository. Once an operation writes through two, say accounts and an audit log, the save needs its own interface, and the unit of work pattern provides one.

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 Errors

A withdrawal bigger than the balance isn’t a bug. Our customers will try it every day, and turning them down is the system working correctly.

If we threw an exception for it, we’d treat it like a crash. So our Domain returns a result that describes the failure, and we keep exceptions for bugs. Create Banking.Domain/Result.cs:

namespace Banking.Domain;

public enum ErrorType
{
    NotFound,
    Conflict
}

public sealed record Error(string Code, string Description, ErrorType Type);

public class Result
{
    protected Result(Error? error) => Error = error;

    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static Result Success() => new(null);

    public static implicit operator Result(Error error) => new(error);
}

public sealed class Result<TValue> : Result
{
    private readonly TValue? _value;

    private Result(TValue? value, Error? error)
        : base(error) => _value = value;

    public TValue Value => IsSuccess
        ? _value!
        : throw new InvalidOperationException("A failed result has no value.");

    public static implicit operator Result<TValue>(TValue value) =>
        new(value, null);

    public static implicit operator Result<TValue>(Error error) =>
        new(default, error);
}

An Error carries a code, a readable description and a type. Later, the Presentation layer turns the type into an HTTP status code, and the Domain stays unaware of what a 404 is.

A successful Result has no Error, and a failed one has exactly one. Result<TValue> also holds the value of a success. We read Value only after checking IsSuccess, because a failed result throws.

Thanks to the implicit operators, a method that returns a Result<TValue> can return a value or an Error directly, and the conversion happens on its own.

There are also a couple of errors that describe specific scenarios that can occur in the application. Create Banking.Domain/AccountErrors.cs:

namespace Banking.Domain;

public static class AccountErrors
{
    public static Error NotFound(int accountId) =>
        new("Account.NotFound", $"Account {accountId} was not found.", ErrorType.NotFound);

    public static Error InsufficientFunds(int accountId) =>
        new("Account.InsufficientFunds", $"Account {accountId} doesn't have enough money for this withdrawal.", ErrorType.Conflict);
}

The code, such as Account.InsufficientFunds, is meant for programs and stays stable. The description is meant for people and may be reworded, so your client code should branch on the code.

Now Withdraw() makes sense. It refuses in two different ways. A zero or negative amount means the caller has a bug, so ThrowIfNegativeOrZero() throws. Asking for more than the balance is a normal customer mistake, so the method returns AccountErrors.InsufficientFunds and leaves the balance alone.

These errors will be handled by the higher layers of our architecture. We are going to use them in the Presentation layer, which will return the proper HTTP status code based on the type of error that was returned.

If you prefer exceptions, that works too. Many Onion samples throw exceptions such as AccountNotFoundException, and a global exception handler turns them into status codes.

We prefer results because an expected failure then shows up in the method’s signature, where we and the compiler can both see it. Our Result pattern article goes deeper into the pattern.

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 operations of our application.

Service Layer

The Service layer sits right above the Domain layer, which means that it has a reference to the Domain layer. We keep the whole Service layer in one project, Banking.Services.

In it, we define the service interface that is going to encapsulate the operations on an account, and the Data Transfer Object (DTO) that we are going to consume with it.

Let’s first look at the IAccountService interface. Create Banking.Services/IAccountService.cs:

using Banking.Domain;

namespace Banking.Services;

public sealed record AccountDto(int Id, string Owner, decimal Balance);

public interface IAccountService
{
    Task<Result<AccountDto>> GetByIdAsync(int accountId, CancellationToken cancellationToken = default);

    Task<Result<AccountDto>> WithdrawAsync(int accountId, decimal amount, CancellationToken cancellationToken = default);
}

AccountDto is what the outside world sees of an account. Our endpoints return this record and never the Account entity, so the JSON stays the same when the entity changes.

Next, we are going to implement this interface inside of the Services project. Create Banking.Services/AccountService.cs:

using Banking.Domain;

namespace Banking.Services;

internal sealed class AccountService(IAccountRepository accounts) : IAccountService
{
    public async Task<Result<AccountDto>> GetByIdAsync(
        int accountId, CancellationToken cancellationToken = default)
    {
        var account = await accounts.GetByIdAsync(accountId, cancellationToken);
        if (account is null)
            return AccountErrors.NotFound(accountId);

        return ToDto(account);
    }

    public async Task<Result<AccountDto>> WithdrawAsync(
        int accountId, decimal amount, CancellationToken cancellationToken = default)
    {
        var account = await accounts.GetByIdAsync(accountId, cancellationToken);
        if (account is null)
            return AccountErrors.NotFound(accountId);

        var withdrawal = account.Withdraw(amount);
        if (!withdrawal.IsSuccess)
            return withdrawal.Error!;

        await accounts.SaveChangesAsync(cancellationToken);

        return ToDto(account);
    }

    private static AccountDto ToDto(Account account) =>
        new(account.Id, account.Owner, account.Balance);
}

WithdrawAsync() starts by fetching the account through the repository interface. An unknown ID ends right there, with Account.NotFound.

Then the account decides. If it refuses, the service hands that error back as it is. It saves only after a successful withdrawal, and it returns an AccountDto built by hand.

Can you spot the balance check in the service? There isn’t one. Account makes that call, and the service runs the steps in order.

What happens when the bank adds deposits? We add a Deposit() method to Account and a DepositAsync() method to this service. That’s how an Onion application grows: a new operation is a new method on an existing service.

And finally, the service registration, which stays inside the project too. Create Banking.Services/DependencyInjection.cs:

using Microsoft.Extensions.DependencyInjection;

namespace Banking.Services;

public static class DependencyInjection
{
    public static IServiceCollection AddServices(this IServiceCollection services)
    {
        services.AddScoped<IAccountService, AccountService>();

        return services;
    }
}

The API will call AddServices() and never learn which class implements IAccountService.

What is the motivation for splitting the Service layer?

Why are we going through so much trouble to split our service interface from its implementation?

As you can see, we mark the service implementation with the internal keyword, which means it will not be publicly available outside of the Services project. On the other hand, the service interface is 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. They get IAccountService, and AddServices() decides what stands behind it.

This means that when a higher layer references the Services project, it will only be able to reach the service through the methods that IAccountService exposes. We are going to see why this is very useful later on when we get to the Presentation layer.

Many Onion solutions go a step further and move the service interfaces into a separate Services.Abstractions project, and the endpoints into a Presentation project that references only that one. Then the compiler stops the endpoints from using Infrastructure, but it costs two more projects, and later in this article we’ll get the same guarantee from one test.

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 create the Entity Framework database context, the BankingDbContext class. Create Banking.Infrastructure/BankingDbContext.cs:

using Banking.Domain;
using Microsoft.EntityFrameworkCore;

namespace Banking.Infrastructure;

public sealed class BankingDbContext(DbContextOptions<BankingDbContext> options)
    : DbContext(options)
{
    public DbSet<Account> Accounts => Set<Account>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Account>(builder =>
        {
            builder.Property(a => a.Owner).HasMaxLength(100);

            builder.HasData(
                new { Id = 1, Owner = "Anna Smith", Balance = 100m },
                new { Id = 2, Owner = "Mark Jones", Balance = 20m });
        });
    }
}

As you can see, the implementation is extremely simple. In the OnModelCreating() method, we configure the Account entity and seed the two accounts our requests will use: Anna Smith, who has 100, and Mark Jones, who has 20.

There isn’t a single EF Core attribute on Account, so the Domain has no idea how it’s stored. EF Core copes with the private constructor and the private setters, so we don’t give up encapsulation to use it.

Great, now that the database context is configured, we can move on to the repository.

The repository implements the interface that we defined in the Domain project. Create Banking.Infrastructure/AccountRepository.cs:

using Banking.Domain;
using Microsoft.EntityFrameworkCore;

namespace Banking.Infrastructure;

internal sealed class AccountRepository(BankingDbContext dbContext) : IAccountRepository
{
    public Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default) =>
        dbContext.Accounts.FirstOrDefaultAsync(a => a.Id == accountId, cancellationToken);

    public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
        dbContext.SaveChangesAsync(cancellationToken);
}

The class stays internal, like AccountService. Because GetByIdAsync() returns a tracked entity, EF Core sees the new balance after Withdraw(), and SaveChangesAsync() turns it into a single UPDATE.

Do we need a generic IRepository<T> as well? We don’t think so. Microsoft’s DbContext reference describes DbContext as the repository and unit of work patterns rolled into one, so a generic wrapper would mostly re-expose DbSet<T>.

Like the Service layer, the Infrastructure layer registers its own classes. Create Banking.Infrastructure/DependencyInjection.cs:

using Banking.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace Banking.Infrastructure;

public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services, string connectionString)
    {
        services.AddDbContext<BankingDbContext>(options => options.UseSqlite(connectionString));
        services.AddScoped<IAccountRepository, AccountRepository>();

        return services;
    }
}

AddInfrastructure() receives the connection string from its caller. Where the string is stored is the host’s business, and Infrastructure doesn’t need to know.

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.

By convention, the endpoints are defined inside of the Web application project, and ours are no different.

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 Program class.

While this is exactly what we want to do, it introduces a big design flaw. What is preventing our endpoints from injecting anything they want, like the BankingDbContext? Nothing!

Clean Endpoints

With the standard ASP.NET Core approach, we can’t prevent anyone from injecting whatever they need inside an endpoint. So how can we impose some more strict rules about what the endpoints can do?

Do you remember how we split the service interface from its internal implementation? That was one piece of the puzzle. The other piece is an architecture test, and we’ll write it once the application runs.

For now, let’s create the endpoints. Create Banking.Api/AccountEndpoints.cs:

using System.ComponentModel.DataAnnotations;
using Banking.Services;

namespace Banking.Api;

public sealed record WithdrawRequest([property: Range(typeof(decimal), "1", "1000")] decimal Amount);

public static class AccountEndpoints
{
    public static void MapAccountEndpoints(this IEndpointRouteBuilder app)
    {
        var accounts = app.MapGroup("/api/accounts");

        accounts.MapGet("/{accountId:int}", async (
            int accountId,
            IAccountService service,
            CancellationToken cancellationToken) =>
        {
            var result = await service.GetByIdAsync(accountId, cancellationToken);

            return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
        });

        accounts.MapPost("/{accountId:int}/withdrawals", async (
            int accountId,
            WithdrawRequest request,
            IAccountService service,
            CancellationToken cancellationToken) =>
        {
            var result = await service.WithdrawAsync(accountId, request.Amount, cancellationToken);

            return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
        });
    }
}

Both endpoints do the same thing. They call one service method, return 200 with the DTO on success, and hand a failure to ToProblem(), an extension method we’re going to write in a moment.

WithdrawRequest describes the HTTP body. Its [Range] attribute accepts 1 to 1,000 per withdrawal, and the typeof(decimal) form makes it compare amounts as decimals, so 0.99 and 1000.01 are both rejected.

By now it should be obvious that our endpoints only talk to IAccountService. That’s a strict set of methods, but for now only by convention. The compiler would still let an endpoint take the BankingDbContext.

The obvious advantage of the Onion architecture is that our endpoints 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 Domain and Service layers.

We wrote the endpoints as minimal APIs, which Microsoft’s APIs overview recommends for new projects. Prefer controllers? They would live in the same layer, and the core wouldn’t notice the difference.

Great, we have seen how to implement the Presentation layer.

But how is the application going to find our endpoints and services? 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, with thin endpoints that only talk to the Service layer.

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 Program class in the Banking.Api project. Replace Banking.Api/Program.cs with:

using Banking.Api;
using Banking.Infrastructure;
using Banking.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddServices();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Banking")!);

builder.Services.AddProblemDetails();
builder.Services.AddValidation();

var app = builder.Build();

app.UseExceptionHandler();

using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<BankingDbContext>();
    dbContext.Database.EnsureCreated();
}

app.MapAccountEndpoints();

app.Run();

The most important part of the code is:

builder.Services.AddServices();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Banking")!);

Without these two lines of code, the Web API would not work, because nothing would know which classes stand behind IAccountService and IAccountRepository. Each call pulls in one project’s registrations, so this file knows about every layer, and no other file has to.

Our ASP.NET Core dependency injection guide explains the registrations behind these calls.

Minimal APIs gained built-in validation in .NET 10, and AddValidation() turns it on, so the [Range] attribute on WithdrawRequest gets checked. MapAccountEndpoints() maps the endpoints we wrote in the Presentation layer.

Program.cs reads the connection string named Banking from the configuration and hands it to AddInfrastructure(), so let’s add it. Replace Banking.Api/appsettings.json with:

{
  "ConnectionStrings": {
    "Banking": "Data Source=banking.db"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

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.

Handling Errors and Exceptions

Remember that we have the ErrorType enum, with NotFound and Conflict, inside of the Domain layer? Let’s see how we can make good use of it.

We are going to write the ToProblem() extension method, which turns a failed Result into an HTTP response. Create Banking.Api/ResultExtensions.cs:

using Banking.Domain;

namespace Banking.Api;

public static class ResultExtensions
{
    public static IResult ToProblem(this Result result)
    {
        var error = result.Error
            ?? throw new InvalidOperationException("A successful result is not a problem.");

        var statusCode = error.Type switch
        {
            ErrorType.NotFound => StatusCodes.Status404NotFound,
            ErrorType.Conflict => StatusCodes.Status409Conflict,
            _ => StatusCodes.Status500InternalServerError
        };

        return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
    }
}

Notice that we create a switch expression around the error type and then perform a pattern matching based on it. Then, we are picking the response HTTP status code depending on what the specific error type is.

This is the only place where an ErrorType becomes a status code. The core talks about missing accounts and conflicts, and the Presentation layer decides that those mean 404 and 409.

TypedResults.Problem() answers in the problem details format that HTTP APIs use for errors, with our error code as the title. ProblemDetails in ASP.NET Core explains every field of that body.

That covers the errors we expect. For the ones we don’t, like a bug that throws, we use the global exception handler that ASP.NET Core already has. These two lines in Program.cs turn it on:

builder.Services.AddProblemDetails();

app.UseExceptionHandler();

With both in place, an unhandled exception comes back as a 500, in the same problem details format as our own errors. We don’t have to write any middleware for it.

Taking Care of the Database

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 SQLite. SQLite keeps the whole database in one file. That way, we won’t need to have a database server installed on our system.

However, how are we going to create the actual database for the application to use?

We could create an initialization script and execute it by hand. 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 let EF Core create the database from our code when the application starts. To see how we achieved this, take a look at these lines in the Program class:

using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<BankingDbContext>();
    dbContext.Database.EnsureCreated();
}

The great thing about this approach is that EnsureCreated() builds the database and seeds it the first time the application starts. We do not have to think about it going forward.

It’s a demo shortcut, though. In production, we’d evolve the schema with migrations instead. To learn more about migrations and how to seed data, check out our article on EF Core migrations.

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 from the solution folder. This command pins the API to port 5000, which our requests expect:

dotnet run --project Banking.Api -- --urls http://localhost:5000

Requests will work as soon as the console reports Now listening on: http://localhost:5000.

On that first start, EnsureCreated() writes Banking.Api/banking.db with Anna’s and Mark’s accounts. Deleting the file while the app is stopped gives you fresh balances.

Now we can test out our API endpoints and check if everything is working properly. Visual Studio and Rider can run the five requests below from an .http file without add-ons, and the REST Client extension adds the same to VS Code. Create Banking.Api/Banking.Api.http:

GET http://localhost:5000/api/accounts/1

###

POST http://localhost:5000/api/accounts/1/withdrawals
Content-Type: application/json

{ "amount": 30 }

###

POST http://localhost:5000/api/accounts/2/withdrawals
Content-Type: application/json

{ "amount": 50 }

###

POST http://localhost:5000/api/accounts/1/withdrawals
Content-Type: application/json

{ "amount": 0 }

###

GET http://localhost:5000/api/accounts/99

The first request reads Anna’s account:

{"id":1,"owner":"Anna Smith","balance":100.0}

Why 100.0 and not 100? SQLite has no decimal type, so EF Core stores the balance as text. The seed went in as '100.0', and that’s what comes back.

Withdrawing 30 returns the new balance:

{"id":1,"owner":"Anna Smith","balance":70.0}

Mark has 20, so asking his account for 50 breaks the rule. The API answers 409 Conflict, in the problem details format:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
  "title": "Account.InsufficientFunds",
  "status": 409,
  "detail": "Account 2 doesn't have enough money for this withdrawal.",
  "traceId": "00-db25fb61f96fd9ddb2b745cfa9a14d7f-f94d89bc50637b5d-00"
}

That refusal started in Account.Withdraw() and reached the endpoint as an ordinary return value, passing through the service untouched. No exception was thrown along the way, and EF Core’s log for this request shows one SELECT and no UPDATE.

A withdrawal of 0 doesn’t get that far. The [Range] check turns it away with a 400 before the endpoint runs:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Amount": [
      "The field Amount must be between 1 and 1000."
    ]
  },
  "traceId": "00-1c896064c9118396ad2f15f2a1ea022e-ffb5ca8ce2ad8b34-00"
}

Finally, account 99 doesn’t exist, which the service reports as Account.NotFound and the API as a 404:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Account.NotFound",
  "status": 404,
  "detail": "Account 99 was not found.",
  "traceId": "00-3720b229336004fb467b8f140ad65dac-90ae76f1a1c85453-00"
}

Everything works as expected. Next, let’s make sure the layers stay the way we built them.

Testing the Onion Architecture

Remember how we said that testability is very high with the Onion architecture? Palermo made it one of the tenets of the pattern: all the application core code can be compiled and run separately from the infrastructure.

Let’s check if ours can.

Testing the Core Without a Database

We need a test project:

dotnet new classlib -n Banking.UnitTests
dotnet solution add Banking.UnitTests

Nothing here needs Class1.cs, so delete it. The project file needs the test packages and a reference to Services, so replace Banking.UnitTests/Banking.UnitTests.csproj with:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Banking.Services\Banking.Services.csproj" />
  </ItemGroup>

</Project>

Its only project reference is Banking.Services, and the Domain comes along with it. Infrastructure, EF Core and ASP.NET Core are nowhere in sight.

We pick xunit.v3.mtp-off so xUnit v3 runs on the classic VSTest runner, which lets a plain dotnet test in the solution folder run the tests without any configuration.

AccountService is internal, so the test project needs permission to see it. Replace Banking.Services/Banking.Services.csproj with:

<Project Sdk="Microsoft.NET.Sdk">

  <ItemGroup>
    <ProjectReference Include="..\Banking.Domain\Banking.Domain.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.12" />
  </ItemGroup>

  <ItemGroup>
    <InternalsVisibleTo Include="Banking.UnitTests" />
  </ItemGroup>

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

The InternalsVisibleTo item opens the project’s internals to one assembly, our tests, and to nothing else.

The domain tests only need Account. Create Banking.UnitTests/AccountTests.cs:

using Banking.Domain;

namespace Banking.UnitTests;

public class AccountTests
{
    [Fact]
    public void Withdraw_WhenTheBalanceCoversIt_TakesTheMoney()
    {
        var account = Account.Open("Anna Smith", initialDeposit: 100m);

        var result = account.Withdraw(30m);

        Assert.True(result.IsSuccess);
        Assert.Equal(70m, account.Balance);
    }

    [Fact]
    public void Withdraw_WhenTheBalanceIsTooLow_ReturnsAnErrorAndKeepsTheMoney()
    {
        var account = Account.Open("Mark Jones", initialDeposit: 20m);

        var result = account.Withdraw(50m);

        Assert.False(result.IsSuccess);
        Assert.Equal("Account.InsufficientFunds", result.Error!.Code);
        Assert.Equal(20m, account.Balance);
    }
}

Each test opens an account and calls Withdraw(), with no database and no setup code.

For the service, we replace the repository with a fake: a hand-written class that implements IAccountRepository in a few lines. Create Banking.UnitTests/AccountServiceTests.cs:

using Banking.Domain;
using Banking.Services;

namespace Banking.UnitTests;

public class AccountServiceTests
{
    [Fact]
    public async Task WithdrawAsync_WhenTheBalanceCoversIt_SavesOnce()
    {
        var accounts = new FakeAccountRepository(Account.Open("Anna Smith", initialDeposit: 100m));
        var service = new AccountService(accounts);

        var result = await service.WithdrawAsync(
            accountId: 1, amount: 30m, TestContext.Current.CancellationToken);

        Assert.True(result.IsSuccess);
        Assert.Equal(70m, result.Value.Balance);
        Assert.Equal(1, accounts.SaveCount);
    }

    [Fact]
    public async Task WithdrawAsync_WhenTheBalanceIsTooLow_SavesNothing()
    {
        var accounts = new FakeAccountRepository(Account.Open("Mark Jones", initialDeposit: 20m));
        var service = new AccountService(accounts);

        var result = await service.WithdrawAsync(
            accountId: 2, amount: 50m, TestContext.Current.CancellationToken);

        Assert.False(result.IsSuccess);
        Assert.Equal("Account.InsufficientFunds", result.Error!.Code);
        Assert.Equal(0, accounts.SaveCount);
    }
}

internal sealed class FakeAccountRepository(Account? account) : IAccountRepository
{
    public int SaveCount { get; private set; }

    public Task<Account?> GetByIdAsync(int accountId, CancellationToken cancellationToken = default) =>
        Task.FromResult(account);

    public Task SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        SaveCount++;
        return Task.CompletedTask;
    }
}

The fake returns the account we give it and counts the saves. That lets the first test assert that a 30 withdrawal from 100 saved exactly once, and the second that a 50 withdrawal from 20 saved nothing.

dotnet test in the solution folder runs all four:

dotnet test

The output ends with the test summary:

Test summary: total: 4, failed: 0, succeeded: 4, skipped: 0, duration: 2.8s
Build succeeded in 4.6s

The whole command took 4.6 seconds on our machine, build included, and neither a database nor a web server was involved.

What these tests can’t show is whether the layers fit together at runtime, such as the EF Core mapping and the DI registrations. Integration tests cover that, and we leave them out here.

Keeping the Endpoints Clean With Architecture Tests

For the inner layers, the compiler is already on guard. To see it, add this using line to the top of Account.cs. It points outward, so you’ll undo it right after:

using Banking.Services;

The build fails:

Account.cs(1,15): error CS0234: The type or namespace name 'Services' does not exist in the namespace 'Banking' (are you missing an assembly reference?)

The Domain project doesn’t reference Services, so as far as the compiler knows, that namespace doesn’t exist. For the inner layers, breaking the rule is a compile error, as long as no one adds a project reference that points outward. Delete the line again.

The Presentation layer is another story. Remember the question from the Clean Endpoints section? What stops an endpoint from taking a BankingDbContext and skipping the service? At compile time, nothing.

Here’s an endpoint that does exactly that. It’s the kind of code we don’t want, so add it only to watch it get caught, then remove it. Create Banking.Api/SneakyEndpoints.cs:

using Banking.Infrastructure;

namespace Banking.Api;

public static class SneakyEndpoints
{
    public static void MapSneakyEndpoints(this IEndpointRouteBuilder app) =>
        app.MapGet("/api/sneaky/{accountId:int}", async (int accountId, BankingDbContext dbContext) =>
            await dbContext.Accounts.FindAsync(accountId));
}

The build succeeds with no warnings. As far as the compiler can tell, nothing is wrong.

That’s the gap an architecture test fills. Add one more test project:

dotnet new classlib -n Banking.ArchitectureTests
dotnet solution add Banking.ArchitectureTests

Like the unit test project, this one starts with a Class1.cs you can delete. It needs NetArchTest as well as the test packages, so replace Banking.ArchitectureTests/Banking.ArchitectureTests.csproj with:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
    <PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Banking.Api\Banking.Api.csproj" />
  </ItemGroup>

</Project>

This project references the API, so it can see the assemblies of all four layers. The package is NetArchTest.Rules, which inspects compiled assemblies and tells you which types depend on what. Create Banking.ArchitectureTests/LayerTests.cs:

using Banking.Api;
using Banking.Domain;
using Banking.Services;
using NetArchTest.Rules;

namespace Banking.ArchitectureTests;

public class LayerTests
{
    [Fact]
    public void Domain_DependsOnNoOtherLayer()
    {
        var result = Types.InAssembly(typeof(Account).Assembly)
            .ShouldNot()
            .HaveDependencyOnAny("Banking.Services", "Banking.Infrastructure", "Banking.Api")
            .GetResult();

        Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
    }

    [Fact]
    public void Services_DoNotDependOnTheOuterRing()
    {
        var result = Types.InAssembly(typeof(IAccountService).Assembly)
            .ShouldNot()
            .HaveDependencyOnAny("Banking.Infrastructure", "Banking.Api",
                "Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
            .GetResult();

        Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
    }

    [Fact]
    public void Endpoints_GoThroughTheServices()
    {
        var result = Types.InAssembly(typeof(AccountEndpoints).Assembly)
            .That()
            .ResideInNamespace("Banking.Api")
            .ShouldNot()
            .HaveDependencyOnAny("Banking.Infrastructure", "Banking.Domain.IAccountRepository")
            .GetResult();

        Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
    }
}

Two of the tests guard the inner layers. The Domain uses no other layer, and the Services use neither the outer layers nor EF Core and ASP.NET Core.

The third test is the one we promised in the Clean Endpoints section. Types in the Banking.Api namespace may use the services, but never Infrastructure or the repository interface. Program.cs isn’t in that namespace, so our composition root stays free to register everything.

Let’s run the tests with the sneaky endpoint still in place:

dotnet test

The unit tests pass, and one architecture test fails and names the culprit:

  Banking.ArchitectureTests test net10.0 failed with 1 error(s) (3.2s)
    ...\Banking.ArchitectureTests\LayerTests.cs(43): error TESTERROR:
      Banking.ArchitectureTests.LayerTests.Endpoints_GoThroughTheServices (40ms): Error Message: Offending types: Banking.Api.SneakyEndpoints
      ...

Test summary: total: 7, failed: 1, succeeded: 6, skipped: 0, duration: 4.0s
Build failed with 1 error(s) in 6.1s

Delete SneakyEndpoints.cs, and all seven tests pass again. Run them in CI, and every pull request gets that check, whether or not a reviewer spots the leak.

A note on the library before you depend on it: its latest version, 1.3.2, is from May 2021. It handled our .NET 10 assemblies without trouble.

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.

Here is the finished solution, one project per layer plus the two test projects:

Banking.slnx
Banking.Domain/
    Account.cs, AccountErrors.cs, IAccountRepository.cs, Result.cs
Banking.Services/
    AccountService.cs, DependencyInjection.cs, IAccountService.cs
Banking.Infrastructure/
    AccountRepository.cs, BankingDbContext.cs, DependencyInjection.cs
Banking.Api/
    AccountEndpoints.cs, Banking.Api.http, Program.cs, ResultExtensions.cs, appsettings.json
Banking.UnitTests/
    AccountServiceTests.cs, AccountTests.cs
Banking.ArchitectureTests/
    LayerTests.cs

We started with the Domain layer, where we saw the definitions for our entity, the repository interface and the errors.

Then we saw how the Service layer was created, where we are encapsulating the operations of our application.

Next, we looked at the Infrastructure layer, where the implementation of the repository interface is placed, as well as the EF database context.

And finally, we saw how our Presentation layer stays thin. Then, we explained how we can connect all of the layers using an ASP.NET Core Web API.

On top of that, we tested the core without a database, and wrote an architecture test that keeps the endpoints away from the Infrastructure layer.

As your domain grows, the layers stay where they are. New entities and repository interfaces go in the Domain, new operations become service methods, and new infrastructure implements interfaces the core already declares.

The same inward rule runs through our Clean Architecture in .NET course: a complete event ticketing app with four full test suites, architecture tests among them.

If you’re moving an existing app to the Onion architecture, write the architecture tests first. Each failure names the types that depend the wrong way today.

Until the next article,

All the best.

Tested with .NET 10.0.10, EF Core 10.0.12, xUnit v3 4.0.1 and NetArchTest.Rules 1.3.2.