Skip to content
Cybrotrix
  1. Home
  2. Insights
  3. .NET
.NET 9 min read Cybrotrix Engineering

Clean architecture in .NET that survives year three

Most architecture looks clean at the first demo. The test is whether it still makes sense after three years of features, hires and pivots. Practical rules we apply.

.NET Architecture Maintainability

Every .NET codebase looks clean at the first demo. Projects are neatly named, dependencies point the right way, the domain layer has no framework references. The real test comes around year three, after two reorganizations, a dozen urgent features, several new hires and at least one pivot. The architectures that survive that period share a handful of practical habits. This is our list.

Start with the boundary, not the folders

Clean architecture is often taught as a project layout: Domain, Application, Infrastructure, Web. The layout is a consequence, not the point. The point is a boundary: business rules must not depend on delivery mechanisms or persistence. If you can swap Entity Framework for something else, or replace a controller with a message consumer, without touching the rules, the boundary exists. If you cannot, the folders are decoration.

We check the boundary mechanically. An architecture test asserts that the Domain assembly references nothing from Microsoft.EntityFrameworkCore or Microsoft.AspNetCore. It runs in CI. It fails the build. That single test has prevented more drift than any code review guideline.

Model the domain in the language of the business

Year-three codebases become hard to change when the code stops matching how the business talks. An entity called OrderRecord with forty nullable properties is a sign that several concepts have been merged for convenience. Split them. Use value objects for things with rules (money, email addresses, identifiers). Make invalid states unrepresentable where you can, and validate at the boundary where you cannot.

This is not purity for its own sake. A new engineer should be able to read the domain layer and learn how the business works. That is the onboarding document that never goes stale.

Use cases as the unit of application logic

We structure the Application layer around use cases rather than around entities. ApproveRelease, MergePullRequest, AssignWorkItem. Each is a small class with one public method, its own request and response types, and its own validation. Controllers, message handlers and background jobs call use cases; they do not contain logic.

The benefit shows up when requirements change. A change to how releases are approved touches one use case and its tests. Nobody has to trace through a service class with thirty methods to find the relevant branch.

Persistence is a detail, but a detail you must design

Entity Framework Core is excellent and it will happily let you leak persistence concerns into the domain: navigation properties everywhere, lazy loading surprises, queries scattered through controllers. Our rules:

  • Aggregates are loaded and saved through repositories with explicit include strategies. No lazy loading.
  • Read models for screens and reports are separate from write models. A query for a dashboard is a projection, not a graph of entities.
  • Migrations are reviewed like code, and every migration is reversible or documented as not.

Put cross-cutting behaviour in the pipeline

Logging, validation, authorization, transactions and idempotency are not use case logic. They belong in a pipeline that wraps every use case uniformly. Whether you use a mediator library or a hand-written decorator chain matters less than the discipline: a use case should read like the business rule and nothing else.

Test the rules, not the framework

Unit tests target the domain and use cases with in-memory fakes. Integration tests target the real database and the HTTP surface with a test host. We avoid the middle ground where tests mock EF Core: it produces brittle tests that pass while the system is broken.

A practical heuristic: if a test needs more than three mocks, the code under test is doing too much. Split the use case, or move behaviour into the domain.

Plan for the second system

Year three is when the second big feature area arrives and someone proposes a microservice. Sometimes that is right. More often, the correct move is a modular monolith: separate assemblies per bounded context with enforced dependencies, a shared host, and a single deployment. Extracting a service later is straightforward when the boundary already exists in code. Extracting one when it does not is a rewrite.

What "survives" looks like

A codebase that survives year three is not one that never changed. It is one where change stayed proportional to the request. A small requirement is still a small diff. A new engineer can ship in their first week. The architecture tests still pass. If those three things hold, the architecture did its job, whatever it was called.

Written by Cybrotrix Engineering. Opinions reflect our engineering practice at the time of writing. Discuss this with us
Next step

Let's Build What Comes Next.

Have a technology challenge, modernization initiative, or product idea? Let's engineer the solution.