I Think This Is What Clean Is

I feel a bit ridiculous even writing this. Spending an afternoon debating where to inject a DbContext feels like a massive step backward. We should have settled this years ago and moved on.

The old patterns weren’t necessarily wrong. They were built for a different time, when our tools were less capable and our databases were harder to manage. But in 2026, our “best practices” have become the biggest bottlenecks in a lot of the projects I work on.

If adding a simple YES/NO toggle requires a tour of the entire project, something is probably off. You open the controller, then the service, then the interface, and finally the repository. By the time you find the actual logic, you’ve lost your flow.

For a small task, the implementation should be visible at a glance. I’ve started to fear context fragmentation more than “unclean” code. The logic shouldn’t be buried under layers of architectural ceremony.

Controller for small orchestration

We’ve been taught that putting logic in a controller is a “sin.” That fear has produced “pass-through” services and unnecessary MediatR handlers. Many teams create these wrappers by default, even when they don’t add much.

If an abstraction has only one use, it’s worth asking whether you need it at all. Creating a service just to call a repository isn’t architecture in my book. It’s mostly extra typing. If a feature only needs to validate a request, call a domain method, and save the changes, a separate service layer can add more noise than structure. That’s exactly the kind of “small orchestration” a controller can handle.

“But my controller will be too big..”

Maybe. We’ve had the partial keyword for decades. If you’re using MediatR only to split your code into files like CreateOrder.cs and UpdateOrder.cs, you’re solving a file-system problem with an architectural tool. partial controllers give you the same “one-file-per-endpoint” structure while keeping the logic close to the route.

“But I can’t unit test a controller..”

If you’re mocking a repository interface just to check whether a LINQ query filters by IsActive, you’re probably testing your mock setup more than your business logic. With Testcontainers, we can run tests against a real, isolated database in seconds.

I’d argue that the “unit” of a test should be the feature, not a single class. For this kind of code, testing the controller endpoint directly gives me the most confidence.

Don’t build a service for a “maybe.” Build one when duplication starts to hurt. Even then, ask yourself whether you need an interface or just a class.

An interface with one implementation is often indirection without a payoff. Until there’s a reason to add one, a concrete class is usually the more honest piece of code.

The anemic model

Part of the pressure to create “Services” comes from stripping all behavior out of our entities. We make pure data classes, bags of properties, and call it Domain-Driven Design (DDD). In the process, we waste much of what OOP gives us by keeping our entities “dumb.”

When the domain model contains the business rules, the controller’s orchestration gets thinner on its own. Heavy business logic belongs in the domain. A rich model lets an entity handle its own state transitions and validation.

Orders/Domain/Order.cs
public sealed class Order : TrashableAggregateRoot, IAuditable
{
    public string? LastTransactionId { get; private set; }
    // other properties and state ...

    public void Complete(DateTime now)
    {
        GuardOrderConfirmed();

        var points = 0;
        // reward calculation logic ...

        RewardSummary = new(points);
        Status = OrderStatus.Completed;
        ModifiedAt = now;

        RaiseEvent(new OrderCompleted(Id, LastTransactionId!, now));
    }
}

The class definition makes it clear that this isn’t a “dumb” POCO. It inherits from TrashableAggregateRoot and implements IAuditable. It handles deletion, auditing, and, most importantly, its own business completion logic.

“But what about side effects? Who sends the email? Who updates the warehouse?”

This is where tools like Wolverine help. They don’t wrap the logic. They dispatch it. When you call SaveChangesAsync(), your infrastructure, whether that’s the DbContext or a Unit of Work decorator, picks up the domain events and dispatches them. The “Email Service” can live in its own handler and listen for OrderCompleted.

That’s real decoupling to me. The controller doesn’t know about emails, and the Order entity doesn’t know about SMTP servers. Both stay focused. You get close to the separation of concerns promised by clean architecture without building 15 layers of interfaces.

Heavy logic belongs in the domain. An asynchronous side effect belongs in an event handler. In my experience, everything else tends to become noise.

Source generators & minimal APIs

I previously wrote about using controllers and partial classes to organize files like a typical MediatR project. That works, but it also creates constructor bloat. When one controller manages 20 endpoints, its constructor pulls in every dependency for every slice, even when a method needs only one of them.

I tried solving that with a small engine of my own, using source generators, an analyzer, and minimal APIs. It was basically a compile-time-enforced version of “one file per endpoint.” Each endpoint was a static class with a custom [Endpoint] attribute containing its metadata. Dependencies were injected into the static method instead of a constructor.

Gamification/Endpoints/SpinWheel/Spin.cs
[Endpoint(
    tag: "Customer - Spin Wheel",
    route: "customer/spin-wheel",
    method: EndpointMethod.Post,
    Authorization = KnownPolicyNames.Customer,
    SuccessType = typeof(Response)
)]
public static class Spin
{
    public sealed record Response(int Points, int Balance);

    /// <summary>Execute Spin Wheel</summary>
    /// <remarks>
    /// Executes a spin for the current authenticated customer.
    /// Deducts 1 from the customer's spin balance and returns the reward points.
    /// </remarks>
    public static async Task<Results<JsonHttpResult<Response>, JsonHttpResult<Error>>> Callback(
        GamificationDbContext db,
        IIdGenerator idgen,
        ITimeProvider time,
        ClaimsPrincipal claims,
        CancellationToken ct)
    {
        var membership = claims.GetMembership()!;
        var scheme = await db.GetOrCreateDefaultSpinWheelSchemeAsync(ct);
        var balance = await db.SpinWheelBalances.FirstAsync(p => p.Membership == membership, ct);
        var reward = balance.Spin(scheme, Random.Shared, time.UtcNow);

        await db.SaveChangesAsync(ct);
        return TypedResults.Json(new Response(reward.Points, balance.Balance), statusCode: 200);
    }
}

Behind the scenes, the source generator picked up the [Endpoint] attributes at compile time and wrote the route registrations. The analyzer enforced the convention at build time, so a non-static class or mismatched Results<T> would fail the build.

Libraries like FastEndpoints already solve much of this problem. It uses the REPR pattern, one class per endpoint, built-in validation, and auto-generated Swagger docs.

I don’t claim that my version is better than using something that already exists and is maintained by more than one person. I mostly built it to explore the problem hands-on. In hindsight, maintaining a custom analyzer and generator is a real cost, and FastEndpoints already takes that cost on for you.

Module as a bounded context

When a project gets complicated enough, the answer isn’t always more technical layers. Sometimes the answer is tighter business boundaries.

Instead of one giant context where anyone can join an Order to a MarketingCampaign, we use isolated DbContexts. If you’re working inside the Orders module, its context should see only Orders, Items, and Buyers. It shouldn’t even know that the Marketing or Inventory tables exist. This isn’t just tidiness. It makes the big ball of mud harder to build by accident.

“But I need to join tables for reports!”

Build a Reporting Module. Use shadow properties to keep the “foreign key noise” out of the domain entities. The database stays relational and the reports still work, but the Order entity doesn’t need to know about the marketing module just to satisfy a SQL join.

The reporting module watches for events. When the Orders module fires an OrderCompleted event, it catches it and updates its own optimized tables. The transactional code stays fast and focused, while the heavy SQL becomes someone else’s problem.

“It’s ready to split..”

Most projects don’t need the distributed headache, network latency, or “where are my logs?” problem that comes with splitting into services. But I do like having the option. Organizing by business boundaries instead of technical layers gets you closer to a separate service if you ever need one. You don’t have to build a microservice today. You can leave the system ready for that option if it ever becomes necessary.

src
├── X.Host (the shell)
│   ├── Program.cs
├── modules (the business)
│   ├── config.nsdepcop (the policeman)
│   ├── X.Modules.Orders
│   ├── X.Modules.Orders.Contracts (cross-module definition)
│   ├── X.Modules.Products
│   ├── X.Modules.Reporting
│   └── ...
├── services (the non-business infrastructure)
│   ├── X.Services.Common
│   ├── X.Services.Files
│   └── ...
└── shared (the plumbing)
    ├── X.Shared
    ├── X.Shared.EntityFramework
    ├── X.Shared.Web
    └── ...

Each module carries its own endpoint definitions, rich domain, and database schema, so it stays fairly self-contained. If traffic to the Orders module suddenly explodes, or if you somehow actually become the next Salesforce, you hopefully won’t have to perform open-heart surgery on the entire monolith.

Yes, I think this is what “Clean” is

To me, “clean” shouldn’t mean more files. It should mean knowing where things are and why.

We’ve moved the complexity out of the layers and into the tooling. What’s left is the code that actually makes money. This isn’t about following a diagram from a book. It’s about building a system that’s “at least easier to change” and honest about what it’s doing.

Inject that DbContext, enforce the boundaries, and go home early.