Clean Architecture
By the end of this lesson
Keep business rules independent of frameworks and databases.
Clean architecture arranges an application as rings. Business rules sit at the centre. Use cases wrap them. Adapters for the web, the database and external services sit outside that. The host application, with its framework and its wiring, is the outermost ring.
There is one rule: source-code dependencies point inwards only. An inner ring never names a type from an outer ring. Everything else — the number of rings, what you call them, how many projects you use — is local convention. The same idea appears under other names, notably hexagonal architecture or ports and adapters, and the rule is the same in each.
What goes in each ring, using an invoicing application as the example:
- Entities and domain rules
- Invoice, Payment, Employee, and the rules that hold regardless of delivery: an invoice cannot be overpaid, an order needs a line. This project references the base class library and nothing else. No attributes from your ORM, no framework types, no interfaces to HTTP clients.
- Use cases
- One class per thing the application does: IssueInvoice, RecordPayment, ArchiveEmployee. Each coordinates entities, decides the transaction boundary, and declares the ports it needs. It knows the domain; it does not know it is being called over HTTP.
- Ports
- The interfaces the use cases declare — IInvoiceRepository, IPaymentGateway, IClock. A port is named for what the use case needs, not for the technology that will satisfy it. Ports live with the use cases, which is what lets the dependency arrow point inwards.
- Adapters
- The implementations: an EF Core repository, an HTTP payment client, a controller that turns a request into a use-case call. Adapters reference inwards to use the ports and entities. Nothing inward references them.
- Host and composition root
- Program.cs and configuration. This is the only place that knows both the port and the adapter, because it is the place that connects them. It is deliberately the outermost, most replaceable ring.
// Domain project — no package references at all
public sealed class Invoice(int customerId, decimal total, DateTime issuedUtc)
{
private readonly List<Payment> _payments = [];
public int CustomerId { get; } = customerId;
public decimal Total { get; } = total;
public DateTime IssuedUtc { get; } = issuedUtc;
public decimal Paid => _payments.Sum(payment => payment.Amount);
public decimal Outstanding => Total - Paid;
public bool IsSettled => Outstanding == 0m;
public void ApplyPayment(decimal amount, DateTime receivedUtc)
{
if (amount <= 0m)
throw new DomainRuleException("A payment must be greater than zero.");
if (amount > Outstanding)
throw new DomainRuleException("A payment cannot exceed the outstanding balance.");
_payments.Add(new Payment(amount, receivedUtc));
}
}
// Test project — references the domain project only
[Fact]
public void ApplyPayment_AboveOutstandingBalance_IsRejected()
{
var invoice = new Invoice(customerId: 7, total: 500m, issuedUtc: new DateTime(2025, 1, 10));
invoice.ApplyPayment(300m, new DateTime(2025, 1, 20));
Assert.Throws<DomainRuleException>(
() => invoice.ApplyPayment(250m, new DateTime(2025, 2, 1)));
Assert.Equal(200m, invoice.Outstanding);
}- The payments list is private and there is no public setter for the totals, so the only route to changing an invoice's state is a method that enforces the rules. A caller cannot leave the invoice in a state the business would reject.
- Outstanding and IsSettled are computed rather than stored, so they cannot disagree with the payments that produced them.
- The test constructs an invoice and calls two methods. No database, no host, no configuration file. On a normal machine it finishes in well under a millisecond, and a suite of three hundred like it finishes before you have moved your hand off the keyboard.
- That speed is what changes behaviour. Tests that run instantly get run constantly, so a broken rule is found while you still remember why you changed it.
Summary
- Rings with one rule: source-code dependencies point inwards only
- Ports are declared by the use cases that need them, and adapters implement them from outside
- The payoff is business rules tested with no infrastructure running, which makes those tests actually get written
- The cost is more files, more mapping and more indirection per feature
- For an application with few real rules the centre is empty, and a simpler structure is the better choice
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Check the centre
Open the domain or core project of an application you work on and look at its package references, then its using directives.
For every reference to a framework, ORM or client library, decide what it would take to remove it — and whether removing it would buy you anything here.
Show solution
The most common finding is ORM attributes or a base entity type from a data-access library. Removing those usually means moving mapping into configuration classes in the infrastructure project, which is mechanical work.
The second most common is a reference pulled in for one small thing, often a validation attribute or a JSON attribute. These are the cheapest to remove and the easiest to justify leaving, so decide deliberately rather than by default.
The honest answer is sometimes "removing it buys nothing here". If the project has three rules and no test suite, a purer centre changes nothing about your day. Knowing that is better than a half-finished migration that leaves the codebase in two styles.
Think about it
When the rings are the wrong answer
You are asked to build an internal tool for the finance team: eight screens over eight tables, a search box, CSV export, and no business rules beyond required fields. Expected life, three years. One developer.
How would you structure it, and what would you say to a colleague who insists on four projects with ports and adapters?
Show solution
Two layers is proportionate: endpoints or pages, and a data layer. One project, organised by feature folder. The structure should make it easy to add a ninth screen, because that is what will actually be asked for.
To the colleague: name what the rings protect. They protect business rules from infrastructure. This application has no business rules, so there is nothing at the centre. The cost — four projects, mapping between them, a use-case class per screen — is paid in full and returns nothing.
Keep the habits that are cheap and pay off anyway: keep data access out of the pages, keep the CSV export separate from the query that feeds it, and do not put the connection string in code. Those cost almost nothing and leave the door open.
If one screen later grows a real rule — a threshold, an approval, a calculation — extract that piece. Growing structure where it turns out to be needed is normal and cheap. Guessing at it up front is what goes wrong.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.