The Problem Story
Picture this. It is a Tuesday afternoon. Your product manager says: “We just need to add an email field. Should be quick, right?”
You open the codebase. The User entity imports SqlConnection. Business logic calls the database in the same method. No interfaces. No tests. Everything wired to everything else.
You add the field. The ORM breaks. You fix the ORM, and three unrelated features stop working. Four hours for a twenty-minute task.
“What if your business logic never had to care what database you used, what UI framework you picked, or what cloud you deployed to?”
That is Onion Architecture — your business rules at the centre, infrastructure as swappable details at the edge.
Why Traditional Layered Architecture Fails
Most developers start with the classic three-tier model: UI → Business Logic → Data Layer. On small projects, it works. On large ones, it becomes a trap in four specific ways:
- Tight Coupling — Business logic directly instantiates database classes. Swap the ORM, and every service class needs surgery.
- Difficult Testing — To test one business rule, you need a real database running. Not because the rule touches it, but because the class containing it does.
- Slow Feature Changes — Adding a feature in the business layer requires understanding the data layer. Teams constantly step on each other.
- High Maintenance Cost — After 18 months, no one wants to touch the code. This is the layered architecture death spiral.
// The classic anti-pattern: business logic importing infrastructure
public class OrderService
{
public void PlaceOrder(Order order)
{
using var connection = new SqlConnection("Server=...");
// business rules mixed with database calls
}
}

The Core Insight: Inward Dependency Rule
Jeffrey Palermo introduced Onion Architecture in 2008. The single governing rule: dependencies always point inward.
Inner layers know nothing about outer layers. Outer layers adapt to inner layers — never the reverse.
Think of a company. The CEO (Domain) sets the rules. Management (Application) executes them. Operations (Infrastructure) handles the tools. Front-of-house (Presentation) faces the world. When a new CRM is adopted, the CEO’s strategy does not change.

Fig 2
This works via the Dependency Inversion Principle: the Application layer defines an interface (IOrderRepository), and Infrastructure implements it. The interface lives in the inner layer. The implementation lives in the outer layer.
The Four Layers Explained for Everyone
Using a restaurant analogy: Domain = recipe book, Application = kitchen manager, Infrastructure = ingredients supplier, Presentation = waiter.
Domain Layer (Innermost)
The heart of your application: entities, value objects, domain events, pure business rules. No NuGet packages. No framework references.
- Lives here: Entities, Value Objects, Domain Events, Business Rules
- Never here: Entity Framework, HttpClient, SqlConnection
Application Layer
The use-case layer. Orchestrates the domain. Contains use cases, service interfaces, and DTOs.
- Lives here: Use Cases, IRepository interfaces, DTOs, Application Services
- Never here: EF DbContext, HTTP Controllers
Infrastructure Layer
The plumbing. Implements the interfaces defined in Application. Databases, email, file system, APIs. Completely swappable.
- Lives here: SqlOrderRepository, AppDbContext, SmtpEmailService, S3FileStorage
- Never here: Business rules
Presentation Layer (Outermost)
The entry point. Controllers, Minimal API, gRPC, Worker Services. Takes the order, passes it in, returns results. Never contains business logic.
The 7 Key Principles Beyond the Definition
- Domain-Centric Design — Business rules first. Database shaped to match the domain, not vice versa.
- Dependency Inward Rule — A
usingstatement in Domain pointing to Infrastructure is a violation. - Separation of Concerns — Each layer, one job. Application orchestrates; it does not calculate.
- Infrastructure as External Detail — Application defines
IOrderRepository; Infrastructure deliversSqlOrderRepository. - Testability — Use cases testable with nothing but mocks. No database, no HTTP, no test containers.
- Maintainability — Swapping ORM from EF Core to Dapper: one-project change. Application and Domain untouched.
- Independent Domain Layer — Open your domain entity. Any
[Key]or EF attribute? Remove them.
Self-Assessment Checklist
- Domain entities have zero infrastructure using statements
- All interfaces live in the Application layer
- Infrastructure references Application — not the reverse
- Domain has no third-party NuGet dependencies
- Business rules testable without a database
- Swapping the ORM only affects Infrastructure
- Presentation calls Application services, never Infrastructure directly
Visualising Onion Architecture
Fig 3: The Onion rings annotated with real class examples — Domain at centre, through Application, Infrastructure, to Presentation.

Fig 3
Fig 4: A single HTTP POST flowing through all four layers: Controller → UseCase → Domain → Repository → SQL Server → 201 response.

Fig 4
Fig 5: Recommended .NET solution structure — each layer as a separate project for enforced dependency rules.

Fig 5
Implementing It: From Concept to Code
Step 1 — Domain Entity
public class Order
{
public Guid Id { get; private set; }
public decimal TotalAmount { get; private set; }
private Order() { }
public static Order Create(Guid customerId, decimal amount)
{
if (amount <= 0) throw new DomainException("Amount must be positive.");
return new Order { Id = Guid.NewGuid(), TotalAmount = amount };
}
}
Step 2 — Use Case in Application Layer
public class PlaceOrderUseCase
{
private readonly IOrderRepository _orders;
private readonly IEmailService _email;
public async Task<Guid> ExecuteAsync(CreateOrderDto dto)
{
var order = Order.Create(dto.CustomerId, dto.Amount);
await _orders.AddAsync(order);
await _orders.SaveChangesAsync();
await _email.SendOrderConfirmationAsync(dto.CustomerEmail, order.Id);
return order.Id;
}
}
Step 3 — Controller in Presentation
[HttpPost]
public async Task<IActionResult> Create(CreateOrderDto dto)
{
var orderId = await _placeOrder.ExecuteAsync(dto);
return CreatedAtAction(nameof(Create), new { id = orderId }, orderId);
}
Step 4 — DI Wiring (Program.cs)
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>(); builder.Services.AddScoped<IEmailService, SmtpEmailService>(); builder.Services.AddScoped<PlaceOrderUseCase>();
Real-World Use Cases
“Your business logic should outlive your framework. Onion Architecture is the insurance policy.”
E-Commerce
Discount rules, inventory, order lifecycle independent of storefront or database. Migrate from SQL Server to Cosmos DB — Domain and Application untouched.
Banking / FinTech
Compliance rules testable, auditable, independent of vendor SDKs. Business rules live once in the domain — regardless of web, mobile, API, or batch delivery.
Multi-Channel Delivery
Same Domain and Application layers powering Web API, background worker, CLI tool, and gRPC service. Only Presentation changes per channel.

Fig 6
Legacy Migration
Use the strangler fig pattern — wrap legacy code with clean interfaces, replace internals gradually. No big-bang rewrite required.
Onion vs Clean Architecture: The Honest Comparison
Clean Architecture (Uncle Bob, 2012): conceptual principles — Entities, Use Cases, Interface Adapters, Frameworks.
Onion Architecture (Palermo, 2008): structural implementation — Domain, Application, Infrastructure, Presentation with inward dependency rule.
| Dimension | Onion Architecture | Clean Architecture |
|---|---|---|
| Nature | Structural | Conceptual |
| Focus | Domain-centric | Use-case-driven |
| Dependency Rule | Inward | Inward (same) |
| Flexibility | Flexible naming | Prescriptive naming |
| Coexist? | Yes — many teams blend both successfully | |
Myth-buster: They are not the same thing. Onion is structural; Clean is philosophical. A well-implemented Onion Architecture satisfies most Clean Architecture principles automatically.
Things I Wish I Knew Before Implementing Onion Architecture
Anti-Pattern 1 — Infrastructure Leaking into Domain
[Table], [Column], or EF navigation properties on domain entities. Technically compiles. Architecturally fatal.
// WRONG
[Table("orders")] public class Order { [Key] public Guid Id { get; set; } }
// RIGHT — EF mapping in Infrastructure, domain stays clean
public class Order { public Guid Id { get; private set; } }
Anti-Pattern 2 — Anemic Domain Model
Entities with only getters and setters. Business rules pushed into services. Domain is hollow. Onion without a rich domain is just folder organisation.
Anti-Pattern 3 — Service Layer Overload
Use cases should be small: PlaceOrderUseCase, CancelOrderUseCase. An 800-line OrderService has too many jobs.
Anti-Pattern 4 — Interface for Everything
Interfaces earn their place when they enable mocking or multiple implementations. If a class will only ever have one implementation, the interface is noise.
Anti-Pattern 5 — Onion in Name Only
Check your .csproj files: Domain → nothing, Application → Domain only, Infrastructure → Application, API → Infrastructure + Application. Any other reference is a violation.
Testing Strategy in Onion Architecture

Fig 7
- Domain layer: Pure unit tests — no mocks needed, business rules in complete isolation
- Application layer: Unit tests with mocked repositories — no database, no HTTP
- Infrastructure layer: Integration tests with real DB (test containers)
- Presentation layer: End-to-end / contract tests
// No SQL Server started. Pure business logic test.
[Fact]
public async Task Execute_NegativeAmount_ThrowsDomainException()
{
var useCase = new PlaceOrderUseCase(_orderRepo.Object, _email.Object);
var dto = new CreateOrderDto { Amount = -50m };
await Assert.ThrowsAsync<DomainException>(() => useCase.ExecuteAsync(dto));
}
Architecture Tests
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot().HaveDependencyOn("YourApp.Infrastructure").GetResult();
result.IsSuccessful.Should().BeTrue();
}
The Architect’s Mental Model
We traced the full journey: tight coupling pain → inward dependency insight → four layers → seven principles → implementation → use cases → comparison → anti-patterns → testing.
“Your domain should be able to run its tests with zero infrastructure. If it cannot, your architecture has a leak.”
Further Reading
- Jeffrey Palermo’s original 2008 blog post on Onion Architecture
- Clean Architecture — Robert C. Martin (2017)
- Domain-Driven Design — Eric Evans
- Patterns of Enterprise Application Architecture — Martin Fowler
Which layer do you think most teams get wrong first, and why? Share your experience in the comments.