dolek.dev

back to /blog

Architecture

Clean Architecture for a .NET 10 LoB Service

 //  9 min read

Clean Architecture is one of those patterns that is easy to summarize and surprisingly hard to apply without a concrete map. This post is the map I wish I had the first time I wired it up in a real line-of-business service: where the layers go, what the dependency rule actually means in C# and Microsoft.Extensions.DependencyInjection, and which library belongs where.

If you are coming from another stack — Spring, Rails, Express — the names will be new but the shape will be familiar. The payoff is the same one Clean Architecture always promises: business rules that do not depend on frameworks, databases, or the web.

The problem this solves

A line-of-business service has a long life. Regulations change. Source systems get swapped. The team that ships version one is rarely the team that owns version five. Three things tend to rot first:

  • Business logic drifts into controllers. Validation, branching, and orchestration end up in HTTP handlers because that is where the request lands. The code is hard to test without spinning up the web host.
  • Domain rules leak into the data layer. A SQL query starts to enforce “an order cannot ship before payment clears” because that is where the data is. The rule is now unreachable from a unit test.
  • Tests touch the database. Every “unit” test runs migrations, opens connections, and slows the suite to a crawl. People stop running it.

Clean Architecture’s promise is small: keep the business rules where they can be reached without any of the above, and let everything else be a detail that can be replaced.

The four layers, in plain language

The vocabulary is older than the implementation, so it helps to anchor each layer to what is not allowed to know about the world outside it.

  • Domain — entities, value objects, and the rules that govern them. No references to a database, a framework, a serializer, or the web. The domain project in your solution should compile with only the BCL.
  • Application — use cases. “Create an order,” “reconcile a payment,” “render a report.” These orchestrate the domain, own input/output DTOs, and define the ports the outer layers must implement.
  • Infrastructure — adapters. Dapper repositories, Serilog sinks, file or email senders, clock implementations, third-party SDKs. Anything that talks to the outside world lives here.
  • API / Host — the ASP.NET Core project. This is the only place that knows about HTTP, cookies, routing, OpenAPI, or Scalar. It is also the composition root: the one place where every layer is wired together.

The rule is dependencies point inward. The API can reference Application and Infrastructure. Infrastructure can reference Application. Application can reference Domain. Domain references nothing.

How the dependency rule plays out in .NET

In a C# solution, the rule is enforced by project references. A trimmed solution layout looks like this:

MyService.sln
  src/
    MyService.Domain/         (classlib, net10.0)
    MyService.Application/    (classlib, net10.0)
    MyService.Infrastructure/ (classlib, net10.0)
    MyService.Api/            (web, net10.0)
  tests/
    MyService.Domain.Tests/
    MyService.Application.Tests/
    MyService.Infrastructure.Tests/
    MyService.Api.Tests/

The project-reference graph is exactly the layer graph:

graph LR

The composition root — the Program.cs of the API project — is where every IServiceCollection.Add* call lives. It is the one place where the inward-pointing dependency rule is allowed to be violated at runtime, because the rule is about source code, not execution order.

At request time, a single inbound call walks the layers in the other direction. The host is the only layer that knows HTTP exists; the application layer orchestrates the use case; the infrastructure layer reaches out to the database, the message bus, and the third-party APIs:

sequenceDiagram

The whole trip stays inside the project-reference graph above. The HTTP request is a host concern; the persistence is an infrastructure concern; the rule that “an order cannot ship before payment clears” lives in the domain and is reachable from a unit test that has no web host and no database.

Where each library in the stack lives

The reason to commit to this layout is that it makes the answer to “where does X go?” obvious. A few examples from a stack I reach for.

Dapper — Infrastructure, and only Infrastructure

Dapper is a thin micro-ORM that lets you write the SQL you would have written anyway, and get typed results back. That is exactly what Clean Architecture wants from a data layer: a repository that returns domain types, never DataTable or anonymous rows.

A repository belongs in MyService.Infrastructure/Persistence/. Its interface belongs in MyService.Application/Abstractions/. The application layer never sees Dapper; it sees IOrderRepository and a domain Order.

FluentValidation — Application, on the way in

Validation is part of “is this request well-formed enough to act on?”, which is a use-case concern. Validators belong in the application layer, next to the handler that consumes them. The pipeline runs them before the handler executes, so a failing validator short-circuits the use case.

A side benefit: the same validator can run from a unit test, a controller, a hosted background job, or a CLI — without dragging the web host along.

Serilog — configured in the Host, injected everywhere

Serilog’s setup is genuinely host-level: structured sinks, enrichers, JSON formatters, and rolling-file policies all live in Program.cs and appsettings.json. The application code receives ILogger<T> through constructor injection and never references Serilog directly.

A typical setup uses the console sink in development, a rolling file sink in production, and a couple of enrichers to make multi-instance logs correlatable:

{
  "Serilog": {
    "MinimumLevel": "Information",
    "WriteTo": [
      { "Name": "Console" },
      {
        "Name": "File",
        "Args": {
          "path": "logs/myservice-.log",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 14
        }
      }
    ],
    "Enrich": [ "WithMachineName", "WithThreadId" ]
  }
}

The point of the layered setup is that nothing in the application layer has to know which sinks are wired up, what the rolling policy is, or whether logs are being shipped to a central store. That is a host concern.

Microsoft.AspNetCore.OpenApi (built-in) — Host

.NET 10 ships an OpenAPI generator as a first-class citizen. AddOpenApi() and MapOpenApi() are enough to emit the spec from minimal-API routes and controllers. There is no need for Swashbuckle or any third-party package for the spec itself.

Scalar.AspNetCore — Host, in development

Scalar is a modern, interactive API reference UI that renders the .NET 10 built-in OpenAPI document. It is mounted in the dev environment and replaces Swagger UI. It supports a built-in request tester that handles cookie auth, which matters for services that authenticate via session rather than bearer tokens.

A small detail that pays off: because the spec comes from AddOpenApi(), every endpoint documented in Scalar is an endpoint that actually exists in the route table. There is no drift between docs and behavior.

The testing pyramid, mapped to layers

A clean layout makes the test layout fall out naturally. The rule of thumb: a test should fail because of the layer it is testing, not because of a layer it is dragging along.

graph TB
  • Domain — pure unit tests. No fakes, no testcontainers, no in-memory databases. The domain has no dependencies, so its tests should not either.
  • Application — handler tests with mocked repositories and real FluentValidation validators. Mocking the data layer here is correct: this layer is supposed to be ignorant of the data layer. Reaching for an in-memory database to “test the handler” usually means the handler knows too much about persistence.
  • Infrastructure — integration tests against a real database (SQL Server, PostgreSQL, or SQLite for fast loops). Dapper queries are SQL; they deserve to be tested against a real engine. Containers per test class or per test method are normal here.
  • API — WebApplicationFactory tests for routing, auth, and OpenAPI generation. These are the only tests that know the application is a web app.
  • End-to-end — kept thin on purpose. A handful of smoke tests against a deployed environment catch the bugs that integration layers cannot (TLS, DNS, auth cookies, real third-party APIs). Full coverage at this layer is rarely worth its cost.

The xUnit / FluentAssertions / Moq combination is a good fit. FluentAssertions reads like English at the assertion site, which makes failures self-explanatory. Moq is fine for the application layer; for the API layer, lean on WebApplicationFactory and let the real composition root wire things up.

A clean solution layout, in one screen

The actual dotnet commands to bootstrap a new service are short. Roughly:

dotnet new sln -n MyService
dotnet new classlib  -n MyService.Domain         -o src/MyService.Domain         -f net10.0
dotnet new classlib  -n MyService.Application    -o src/MyService.Application    -f net10.0
dotnet new classlib  -n MyService.Infrastructure -o src/MyService.Infrastructure -f net10.0
dotnet new web       -n MyService.Api            -o src/MyService.Api            -f net10.0
dotnet sln add src/**/*.csproj
dotnet add src/MyService.Infrastructure/MyService.Infrastructure.csproj reference src/MyService.Application/MyService.Application.csproj
dotnet add src/MyService.Api/MyService.Api.csproj            reference src/MyService.Application/MyService.Application.csproj
dotnet add src/MyService.Api/MyService.Api.csproj            reference src/MyService.Infrastructure/MyService.Infrastructure.csproj
dotnet add src/MyService.Application/MyService.Application.csproj reference src/MyService.Domain/MyService.Domain.csproj

The reference graph is the architecture. If a future change tries to add a reference the wrong way — Infrastructure to API, Application to Infrastructure — the code review question is just: “is this layer leaking?”

What C# 13 buys you here

A few recent language features make the layered style feel less ceremonious than it used to.

  • Primary constructors keep handlers and validators small. A use case that takes a repository, a validator, and a logger is one constructor line, not three.
  • File-scoped namespaces remove a level of indentation across the whole codebase, which matters more in a multi-project solution than in a single project.
  • Collection expressions (["a", "b", "c"]) read closer to JSON and reduce the friction of writing immutable collections in domain code.
  • Nullable reference types are a compile-time boundary check. A string? parameter in an application-layer DTO is a contract that the validator must satisfy before the handler is allowed to see the value. The compiler enforces that the boundary is real.

Where this pays off — and where it does not

Clean Architecture is not free. Four projects, four test projects, careful reference management, and the discipline to keep use cases out of controllers. For a five-endpoint CRUD over a single table, that is over-engineering.

It pays off when the service is:

  • Long-lived. Regulation, source-system churn, and feature creep are the norm, not the exception.
  • Integration-heavy. The business value is in the way data is shaped across systems, not in any one of them.
  • Tested seriously. Compliance, audit, and operational confidence depend on tests that actually exercise the business rules.

A line-of-business service for corporate or legal research is exactly that shape. So is anything where the team that owns it now is not the team that will own it in three years.

What to do on Monday morning

If you have a service to write or to clean up, the smallest useful first step is:

  1. Create the four projects in the layout above.
  2. Move one existing handler into MyService.Application/UseCases/, with its dependencies injected through the constructor.
  3. Move the existing data access for that handler into MyService.Infrastructure/, behind an interface in MyService.Application/Abstractions/.
  4. Wire it all up in Program.cs.
  5. Write one test for the handler that does not touch the database, and one test for the repository that does. Both should be fast.

If those five steps work for one handler, they work for the rest. The architecture is not in the diagrams — it is in the discipline of putting a class in the right project and a reference on the right edge.