Layered Architecture
By the end of this lesson
Organise code into layers with a clear dependency direction.
A layer is a group of code with one kind of responsibility and a rule about what it is allowed to reference. The rule is the part that does the work. Without it you have named groups of files, which is filing, not architecture.
The four-layer arrangement below is the most common one in business applications. The names vary between teams — "service layer" for application, "data access layer" for infrastructure — so agree on your own vocabulary early and write it down.
The four layers, and what each is allowed to know about:
- Presentation
- Controllers, pages, minimal API endpoints. Turns an HTTP request into a call on the application layer, and a result into a response. It knows about HTTP and about the application layer. It knows nothing about the database.
- Application
- One class per use case: place an order, approve an invoice, archive an employee. It coordinates — load, call domain logic, save, publish — and owns the transaction boundary. It knows the domain layer and the interfaces it needs, not their implementations.
- Domain
- The entities and the rules that are true regardless of how the application is delivered: an invoice cannot be paid twice, an order needs at least one line. It references nothing outside itself.
- Infrastructure
- Everything that talks to the outside world: EF Core repositories, HTTP clients, file storage, email. It implements interfaces defined further in, and nothing depends on it except the composition root that wires the application together.
Dependencies point inwards. Presentation depends on application, application depends on domain, and domain depends on nothing. Infrastructure also points inwards, which surprises people the first time: it sits at the outer edge but it depends on the layers inside it, because it implements interfaces that they define.
In .NET you can make this real rather than aspirational. One project per layer, and project references only in the permitted direction. The Domain project has no reference to Microsoft.EntityFrameworkCore. If someone writes a query in it, the build fails. That is the difference between a boundary and a folder.
// Web/Controllers/InvoicesController.cs
[HttpPost("{id:int}/approve")]
public async Task<IActionResult> Approve(int id)
{
// Reaching straight past the application and domain layers.
var invoice = await _db.Invoices
.Include(i => i.Lines)
.FirstOrDefaultAsync(i => i.Id == id);
if (invoice is null) return NotFound();
// A business rule, living in a controller.
if (invoice.Status == InvoiceStatus.Paid)
return BadRequest("Paid invoices cannot be approved again.");
if (invoice.Lines.Sum(l => l.Amount) > 10_000m && !User.IsInRole("Finance"))
return Forbid();
invoice.Status = InvoiceStatus.Approved;
invoice.ApprovedUtc = DateTime.UtcNow;
await _db.SaveChangesAsync();
return NoContent();
}- The solution has four folders named after the four layers, so a diagram of it looks correct.
- This controller queries the database directly, which means the Web project references EF Core and the DbContext. The infrastructure boundary does not exist.
- Two business rules are in it: paid invoices cannot be re-approved, and large invoices need the Finance role. Neither can be tested without spinning up a web request, and neither is available to any other entry point — a background job approving invoices will have to repeat them, and the two copies will drift.
- Nothing here is broken today. It works, and for a small application it may be a reasonable choice. The point is that calling it layered is inaccurate, and the benefits people expect from layering are not present.
// Domain/Invoice.cs — no framework references in this project
public sealed class Invoice
{
public InvoiceStatus Status { get; private set; }
public DateTime? ApprovedUtc { get; private set; }
private readonly List<InvoiceLine> _lines = [];
public decimal Total => _lines.Sum(line => line.Amount);
public bool NeedsFinanceApproval => Total > 10_000m;
public void Approve(DateTime whenUtc)
{
if (Status is InvoiceStatus.Paid or InvoiceStatus.Approved)
throw new InvoiceStateException("This invoice has already been approved.");
Status = InvoiceStatus.Approved;
ApprovedUtc = whenUtc;
}
}
// Application/ApproveInvoiceHandler.cs
public sealed class ApproveInvoiceHandler(IInvoiceRepository invoices, IClock clock)
{
public async Task<ApproveResult> HandleAsync(int invoiceId, bool callerIsFinance, CancellationToken token)
{
Invoice? invoice = await invoices.FindAsync(invoiceId, token);
if (invoice is null) return ApproveResult.NotFound;
if (invoice.NeedsFinanceApproval && !callerIsFinance) return ApproveResult.NotPermitted;
invoice.Approve(clock.UtcNow);
await invoices.SaveChangesAsync(token);
return ApproveResult.Approved;
}
}- The rule about re-approval now lives on the entity that owns the state, so it holds no matter who calls it. The entity also refuses to be in an invalid state rather than trusting callers to check first.
- The threshold is expressed as a domain property, NeedsFinanceApproval. The application layer decides what to do about it; the domain decides what the number is.
- The handler returns a result value rather than an IActionResult, because it does not know it is being called over HTTP. The controller translates ApproveResult into a status code — that translation is the whole of its job.
- IClock is an interface over the current time. Injecting it keeps the domain deterministic in tests, which is worth the small ceremony anywhere a date affects a rule.
How to tell whether your layers are real, in about five minutes:
- Open the Domain project file. If it references EF Core, ASP.NET Core or an HTTP client library, the inner layer is not protected.
- Search the presentation layer for DbContext and for the name of your ORM. Any hit is a layer being bypassed.
- Pick a business rule and search for it. If it appears in more than one place, no layer owns it.
- Look for a Common or Shared project that every layer references. Anything put in it is effectively global, and dependency rules do not apply to it.
- Try to write a test for one rule with no database. If you cannot, the boundary that was supposed to allow that does not exist.
Summary
- A layer is a responsibility plus an enforced rule about what it may reference
- Dependencies point inwards; infrastructure implements interfaces defined further in
- Project references make the rule checkable, which is what separates a boundary from a folder
- The common failure is layer-shaped folders with controllers querying the database and rules in the wrong place
- Fewer layers is a legitimate choice when there are few business rules to protect
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Find the bypass
In an application you have worked on, search the presentation layer for your ORM's context type and for the word Include.
For each hit, write down which layer should have owned that query, and whether any business rule is sitting next to it.
Show solution
Most applications that describe themselves as layered have several hits. That is worth knowing plainly rather than treating as a failure — it usually happened one urgent fix at a time.
The hits matter in proportion to the rules next to them. A controller reading a lookup list for a dropdown is a minor leak. A controller applying an approval threshold is a rule that now exists in one entry point only, and the next entry point will get its own copy.
The cheapest useful repair is rarely a full restructure. Move the rule inward first, leave the query where it is, and you have removed the duplication risk without a large change.
Think about it
Why does infrastructure depend inwards?
Infrastructure sits at the outside edge of the diagram, yet its project references point inwards to the domain and application layers. Why is that arrangement possible at all, and what would break if you reversed it?
Show solution
It is possible because the interface and the implementation can live in different projects. The application layer declares IInvoiceRepository; the infrastructure project references the application project and provides an EF Core class that implements it. The compiler is satisfied and the dependency arrow points inwards.
Reverse it and the application layer must reference the infrastructure project to name the concrete repository. Now the inner layers depend on EF Core transitively, the domain project pulls in a database driver, and a test of one rule drags the whole stack with it.
This is dependency inversion, which is the next module's subject. Layering gives you the shape; inversion is the mechanism that makes the inward arrow possible.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.