Mert Özen Further With Every Line
Software Architecture Clean, Onion and Hexagonal Architecture — #03

Onion Architecture: Domain Model at the Center, Infrastructure on the Outer Ring

Mert Özen Nov 7, 2026 12 min 5 views
Onion Architecture: Domain Model at the Center, Infrastructure on the Outer Ring

Onion Architecture (Palermo, 2008) builds the application around an independent domain model: inner rings define interfaces, outer rings implement them, all coupling points toward the center, and the core can be compiled and run without infrastructure. We place Bookstore's ordering code into Domain Model, Domain Services and Application Services rings and show how it differs from layered and Hexagonal, how rings relate to projects, and the ASP.NET Core Identity example.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore's ordering side has two endpoints. One places an order and calls an application service to do it. The other shows a single order and uses the repository directly, with no service in between:

// The UI ring calls the application service...
app.MapPost("/orders", async (PlaceOrderCommand command, PlaceOrderService service, CancellationToken ct) =>
{
    Order order = await service.PlaceAsync(command, ct);
    return Results.Created($"/orders/{order.Id}", new { order.Id, order.Subtotal, order.ShippingFee, order.Total });
});

// ...but for reads it may also use the repository in the domain services ring directly
app.MapGet("/orders/{id:int}", async (int id, IOrderRepository orders, CancellationToken ct) =>
    await orders.FindAsync(id, ct) is { } order
        ? Results.Ok(new { order.Id, order.CustomerEmail, Items = order.Items.Count, order.Total })
        : Results.NotFound());

In a classic, strict (closed) layered architecture the presentation layer may only call the business layer directly beneath it; there the second endpoint counts as a rule violation. Here the IOrderRepository interface lives in a ring right next to the domain model, and its implementation lives in the outermost Infrastructure project. Does Onion Architecture allow this GET endpoint? And which ring should hold the rule that makes shipping free once the order passes 500 TL: inside Order, in the application service, or somewhere between the two?

Palermo's 2008 Series

Jeffrey Palermo named "Onion Architecture" in a post published on 29 July 2008; the series continued in four parts that summer. The very first paragraph of part 1 pins down the context: this architecture is not appropriate for small websites; it is appropriate for long-lived business applications and applications with complex behavior. Palermo does not claim to have invented anything either; he gathers known techniques and gives them a shared name, because the biggest benefit of a pattern is giving a team a common vocabulary. In the same post he notes that Cockburn's Hexagonal Architecture rests on the same premise: push infrastructure out and connect it through adapter code.

The structure Palermo criticizes is the classic layered architecture we saw in the first post of this series: each layer depends on those beneath it, and all of them on shared infrastructure. The most common and most harmful coupling is UI (user interface) and business logic depending on data access. Palermo stresses transitive dependencies: even if the UI never calls data access directly, it cannot work without business logic, which cannot work without data access. His reasoning is practical: the industry changes data access techniques at least every three years, and systems that cannot keep up fall into disrepair and are eventually rewritten.

Onion's core rule is one sentence. Code may depend on rings more central than itself, never on rings further out. In Palermo's own words, "all coupling is toward the center". He does not hide that the architecture is openly biased toward object-oriented programming (OOP): objects sit at the center.

The Rings: From Domain Model to Infrastructure

In Palermo's diagram the Domain Model sits at the center, Domain Services around it, and Application Services around that; UI, Infrastructure and Tests share the outermost ring. The application core consists of the first three rings, and Palermo says explicitly that the number of rings in the core may vary by application. Let us place Bookstore's ordering code into these rings.

Domain Model

At the center, state and behavior that represent what is "true" for the organization live together. The center depends only on itself:

namespace Bookstore.Ordering.DomainModel;

public sealed record OrderItem(int BookId, string Title, decimal UnitPrice, int Quantity)
{
    public decimal LineTotal => UnitPrice * Quantity;
}

public sealed class Order(string customerEmail, string postalCode)
{
    private readonly List<OrderItem> _items = [];

    public int Id { get; set; }
    public string CustomerEmail { get; } = customerEmail;
    public string PostalCode { get; } = postalCode;
    public IReadOnlyList<OrderItem> Items => _items;
    public decimal ShippingFee { get; private set; }
    public decimal Subtotal => _items.Sum(i => i.LineTotal);
    public decimal Total => Subtotal + ShippingFee;

    public void AddItem(Book book, int quantity)
    {
        if (!book.TryReserve(quantity))
            throw new InvalidOperationException($"Only {book.Stock} copies of '{book.Title}' left.");
        _items.Add(new OrderItem(book.Id, book.Title, book.Price, quantity));
    }

    public void SetShippingFee(decimal fee) => ShippingFee = fee;
}

Book is in the same ring; it carries the stock rule with the TryReserve method from the earlier posts. Aggregate boundaries, value objects and how this model grows richer are topics for the upcoming Domain-Driven Design series; what matters here is that this ring imports nothing.

Domain Services

According to Palermo, the ring right around the domain model is the typical home of interfaces that describe saving and retrieving objects, the repository interfaces. Saving itself is not in the core, because it needs a database; only the interface is in the core. In Bookstore the interface that asks for the shipping fee lives here too. The answer to the second opening question is also in this ring: the free shipping threshold does not belong to a single entity, yet it is still a pure business rule. Rules like that go into a domain service:

// The inner ring defines the interface, the outer ring implements it
public interface IOrderRepository
{
    Task<Order?> FindAsync(int orderId, CancellationToken ct);
    Task AddAsync(Order order, CancellationToken ct);
    Task SaveChangesAsync(CancellationToken ct);
}

public interface IShippingRates
{
    Task<decimal> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct);
}

// Domain service: a business rule that belongs to no single entity
public sealed class ShippingFeePolicy(IShippingRates rates)
{
    public const decimal FreeShippingThreshold = 500m;
    private const int GramsPerBook = 400;

    public async Task<decimal> FeeForAsync(Order order, CancellationToken ct)
    {
        if (order.Subtotal >= FreeShippingThreshold) return 0m;
        int weight = order.Items.Sum(i => i.Quantity) * GramsPerBook;
        return await rates.QuoteAsync(order.PostalCode, weight, ct);
    }
}

ShippingFeePolicy is an inner-ring class and reaches the outside world only through IShippingRates, an interface in its own ring. Testing the threshold rule needs neither a database nor HTTP (Hypertext Transfer Protocol):

[Theory]
[InlineData(1, 40)]   // 250 TL: shipping fee applies
[InlineData(2, 0)]    // 500 TL: threshold reached, shipping is free
public async Task Free_shipping_starts_at_threshold(int quantity, decimal expectedFee)
{
    Order order = new("ada@example.com", "06100");
    order.AddItem(new Book(1, "Dune", 250m, stock: 5), quantity);

    decimal fee = await new ShippingFeePolicy(new FixedRates(40m)).FeeForAsync(order, CancellationToken.None);

    Assert.Equal(expectedFee, fee);
}

Application Services

The next ring manages use cases: what to read from which repository, which domain method to call, when to save the result. The rules themselves live in the inner rings; the application service puts them in order:

// Application service: manages the use case flow, leaves the rules to the inner rings
public sealed class PlaceOrderService(IBookRepository books, IOrderRepository orders, ShippingFeePolicy shipping)
{
    public async Task<Order> PlaceAsync(PlaceOrderCommand command, CancellationToken ct)
    {
        Book book = await books.FindAsync(command.BookId, ct)
            ?? throw new InvalidOperationException($"Book {command.BookId} not found.");

        Order order = new(command.CustomerEmail, command.PostalCode);
        order.AddItem(book, command.Quantity);
        order.SetShippingFee(await shipping.FeeForAsync(order, ct));

        await orders.AddAsync(order, ct);
        await orders.SaveChangesAsync(ct);
        return order;
    }
}

The outer ring: UI, Infrastructure, Tests

The things that change often live at the edge. EfOrderRepository depends on a specific data access technique (EF Core, Entity Framework Core), so it sits outside the core and attaches to it by implementing IOrderRepository. The Minimal API (Application Programming Interface) endpoints and the xUnit tests are in this ring too. Tests sitting in the outer ring is an important detail: the core does not depend on the tests, the tests depend on the core. To hand implementations to the core at runtime, Palermo recommends an IoC (Inversion of Control) container; he does not list it as a tenet but writes that in a language like C# it makes the job much easier. In ASP.NET Core that container is built in.

Four Tenets

Part 3 of the series, dated 4 August 2008, describes with four tenets which architectures the name Onion Architecture should be used for. In my own words:

  • The application is built around an independent object model.
  • Inner rings define interfaces, outer rings implement them.
  • The direction of coupling is toward the center.
  • All application core code can be compiled and run separately from infrastructure.

The fourth is the easiest tenet to measure. I wrote a test that asks which assemblies the core assembly references:

[Fact]
public void Core_assembly_references_only_the_base_library()
{
    string[] references = typeof(Order).Assembly.GetReferencedAssemblies()
        .Select(a => a.Name!).Order().ToArray();

    Assert.Equal(["System.Collections", "System.Linq", "System.Runtime"], references);
}
Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3, Duration: 6 ms - Bookstore.Ordering.Tests.dll (net10.0)

The core sees only the base class library. None of the three tests opened a database file, a port or an HTTP server. The test itself is arguably brittle (if you decide to use System.Text.Json in the core you have to update it), but it turns the fourth tenet into a concrete check. We will see a durable and more flexible way, architecture tests written with ArchUnitNET, in the 8th post of this series.

How It Differs from Layered: The Outer Ring Can See Every Inner Ring

When Palermo compares Onion with classic layered architecture in part 3, he puts the key difference here: in Onion an outer ring may directly call any ring inside it, whereas in traditional layered architecture a layer may call only the one directly beneath it. That answers the first opening question: the GET endpoint using the repository directly is not a violation in Onion. There is only one rule: the arrow always points inward.

I ran the same application, ordered 1 and then 2 copies of Dune, and then read the second order:

{"id":1,"subtotal":250.0,"shippingFee":40,"total":290.0}
{"id":2,"subtotal":500.0,"shippingFee":0,"total":500.0}
{"id":2,"customerEmail":"ada@example.com","items":1,"total":500.0}

The second order reached the threshold, so its shipping fee is zero; the GET request read the same domain object without ever visiting the application service. That has a cost: if the read side needs a business rule (for example, only the owner may see an order), that rule must live in an inner ring, not in the endpoint; otherwise a console or another UI will skip it. The freedom of "the outer ring may skip the inside" is not a licence to move rules to the outer ring.

Another of Palermo's observations is the place of the database. When you flatten the onion into a layer drawing, Data Access sits not at the bottom but at the top, next to the UI. In his words the database is not the center but external; there are no "database applications", only applications that use a database as a storage service.

Is a Ring a Project or a Folder?

Palermo does not describe rings as projects. In this example the three rings of the core live in a single Bookstore.Ordering project, in three folders (and three namespaces); Infrastructure, Web and the tests are separate projects. The consequence shows up in the compiler right away. When I added a file to the innermost DomainModel folder that uses a type from the outermost ring of the core, the build passed without complaint:

using Bookstore.Ordering.ApplicationServices;

namespace Bookstore.Ordering.DomainModel;

// The inner ring reaches for a type in an outer ring: within one project the compiler stays silent
public static class Leak
{
    public static PlaceOrderCommand Copy(Order order) => new(order.CustomerEmail, order.PostalCode, 0, 0);
}

When I put a file in the same folder that tries to use something from EF Core, the compiler stopped it, because the core project does not reference the EF Core package:

Leak.cs(1,17): error CS0234: The type or namespace name 'EntityFrameworkCore' does not exist in the namespace 'Microsoft' (are you missing an assembly reference?)

The rule is simple: the compiler polices project boundaries; only people police folder boundaries. The boundary between core and infrastructure is where violations are most expensive, so drawing it with a project is almost always right. Splitting the rings inside the core into separate projects is a choice: tighter checking in exchange for more projects and more references to manage. We covered the general criteria for splitting a solution into projects in the SOLID (the initials of Robert C. Martin's five class design principles) and Design Principles series post "Component Principles: Splitting a .NET Solution into Projects"; in the 5th post of this series we will build the common four-project layout for Clean Architecture.

The boundary also has a cost on the mapping side. When I tried to map the domain classes directly with EF Core, the application stopped at first start with this exception:

System.InvalidOperationException: No suitable constructor was found for the type 'Book'. The following constructors had parameters that could not be bound to properties of the type:
    Cannot bind 'title', 'price' in 'Book(int id, string title, decimal price, int stock)'

EF Core does not map the setter-less Title and Price properties by convention, so it cannot bind the constructor parameters. The fix is not to add setters to the domain but to map these properties explicitly in the Infrastructure configuration with e.Property(x => x.Title). We will look at the limits of persistence ignorance and how to organize this mapping in detail in this series' "Persistence Ignorance and Infrastructure Adapters" post.

An Onion in .NET: ASP.NET Core Identity

ASP.NET Core Identity is the framework-level example of "repository interface in the inner ring, implementation outside". When I asked via reflection which assembly three types live in, and whether the core assembly references Entity Framework:

UserManager`1    Microsoft.Extensions.Identity.Core
IUserStore`1     Microsoft.Extensions.Identity.Core
UserStore`1      Microsoft.AspNetCore.Identity.EntityFrameworkCore
no EF reference

UserManager<TUser>, which manages rules such as creating users, validating passwords and lockout, asks for storage through the IUserStore<TUser> interface, and both sit in the same core assembly. The EF Core implementation, UserStore, is in a separate package whose .nuspec depends on Microsoft.EntityFrameworkCore.Relational. When you write a store for your own database, UserManager does not change, just as PlaceOrderService does not change when you replace EfOrderRepository with another implementation in Bookstore.

How It Differs from Hexagonal

The two architectures share the same premise, and Palermo says so himself. The differences are in perspective:

QuestionHexagonal (Cockburn, 2005)Onion (Palermo, 2008)
Main distinctionInside and outside; ports on the boundaryRings from the center outward
Inner structureSays nothing about itDescribes it as Domain Model, Domain Services, Application Services
What is at the center"The application"; how it is modelled is openAn object-oriented domain model
Where interfaces liveAs ports on the application's boundaryIn the inner ring that uses them
TestsAn adapter on the driving sideExplicitly part of the outermost ring

In practice an Onion core behaves like a Hexagonal application from the outside: IOrderRepository and IShippingRates are driven ports, the endpoints are driving adapters. What Onion adds is drawing a map inside the hexagon.

Common Pitfalls

Turning the rings into pass-through layers. One application service per endpoint, one repository method per service method, each calling the one below with a single line: that is not Onion, it is the same layered architecture drawn as rings. If an application service is not really managing a flow, the outer ring is allowed to use the inner ring directly.

Leaving the Domain Model anemic. If the rules live in application services and entities are just bags of properties, there is nothing left at the center to protect. The Design Patterns series post "Anti-Patterns: God Object, Service Locator, Singleton Abuse, Anemic Domain Model and Patternitis" covers this symptom in detail.

Designing repository interfaces after database tables. Opening an interface per table, IOrderItemRepository, IBookRepository, ICustomerRepository, shapes the inner ring after the database schema, which is an indirect way of binding the center to the outside. Interfaces are opened for what the core needs.

Putting a "Common" or "Shared" project at the very center and filling it with everything. The center should hold only the domain model; as logging helpers, extension methods and DTOs (Data Transfer Objects) move into it, every change ripples through every ring.

When Not to Use It

Palermo's warning "not appropriate for small websites" is not a modest aside but a cost calculation. Onion means interfaces, DI (Dependency Injection) registrations, mapping between domain and persistence, and more projects. That cost pays for itself when there is a long life and complex behavior: the data access technology will change a few times, the business rules will grow, and the team will want to test those rules independently of infrastructure. For a site made of a few forms and lists, with almost no business rules and a lifespan as short as a campaign, the same structure only adds indirection. Bookstore's ordering side is long-lived with growing rules, so Onion's ideas are worth it in this series; a simple "author list" screen in an admin panel may not be worth it.

A Small Experiment

★☆☆ In your own project, list the assemblies referenced by the "innermost" project with the test from this post. Is there an ORM (Object-Relational Mapper), an HTTP library or a logging framework in the list?

★★☆ Add the rule "shipping is free on a customer's first order" to Bookstore. Decide which ring the rule belongs to; show which ring defines the interface needed to find the customer's previous orders and how you would write its test without a database.

★★★ Split the core into three projects (DomainModel, DomainServices, ApplicationServices) and add this post's Leak file again. Which error code do you get this time? Note the effect of the split on build time and on how readable the solution is.

Onion described the rings but said little about which data travels between rings and how. In the next post we move to Clean Architecture, written by Robert C. Martin in 2012: the Dependency Rule, use cases, and why data crossing a boundary should be a simple data structure rather than an entity.