I think this is what Clean is

I feel a bit ridiculous even writing this. In an era where we’re building AI that can reason and autonomous systems that can code, spending an afternoon debating the correct place to inject a DbContext feels like a massive step backward.

It’s not that the old patterns were wrong, they were built for a different time, a time when our tools were less capable and our databases were harder to manage. But in 2026, we’ve reached a point where our “best practices” have become our biggest bottlenecks.

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

If a task is small, the implementation should be visible at a glance. We should fear context fragmentation more, it should not be buried under layers of architectural ceremony.

Controller for small orchestration

We have been conditioned to believe that putting logic in a controller is a “sin”. This fear has led to the rise of the “pass-through” service or the unnecessary MediatR handler. Most people create these wrappers by default, even when they add zero value.

If an abstraction has only a single usage, you probably don’t need it. Creating a service just to call a repository is not architecture, it is just extra typing. If a feature just needs to validate a request, call a domain method, and save the changes, a separate service layer is just noise in my opinion. This “small orchestration” is exactly what a controller is for.

“But my controller will be too big..”

You might say. We’ve had the partial keyword for decades. If you’re using MediatR just to split your code into separate files like CreateOrder.cs and UpdateOrder.cs, you’re over-engineering a file-system problem. You can achieve the same “one-file-per-endpoint” structure with partial controllers, keeping the logic close to the route.

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

If you are mocking a repository interface just to test if a LINQ query filters by IsActive, you aren’t testing your business, you’re testing your ability to setup a mock. With Testcontainers, we can now run our tests against a real, isolated database in seconds.

The “unit” of your test should be the feature itself, not a single class. Testing the controller endpoint directly gives you the highest confidence.

Don’t build a service for a “maybe”. Build it when the duplication actually hurts. And even then, ask yourself: do I need an interface, or do I just need a class?

An interface with one implementation is a lie. A concrete class is an honest piece of code.

The anemic model

The reason we feel forced to create “Services” is that we have stripped our entities of all behavior. We create pure data classes just bags of properties and call it Domain-Driven Design (DDD). In reality, we waste the potential of OOP by keeping our entities “dumb”.

When your domain model is rich and contains the business rules, the orchestration in your controller becomes naturally thin. If the business logic is heavy, it belongs in your domain. A rich model means your entity handles 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));
    }
}

By looking at the class definition, you can see this isn’t a “dumb” POCO. It inherits from TrashableAggregateRoot and implements IAuditable. It handles its own deletion logic, its own 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 actually provide value, not as a wrapper for your logic, but as a dispatch mechanism. When you call SaveChangesAsync(), your infrastructure (the DbContext or a Unit of Work decorator) picks up those domain events and dispatches them. The “Email Service” can live in its own isolated handler, listening for OrderCompleted.

This is true decoupling. The controller doesn’t know about emails. The Order entity doesn’t know about SMTP servers. They both stay pure. We’ve achieved the separation of concerns that clean architecture promises, but without the 15 layers of interfaces.

If the logic is heavy, it belongs in your domain. If the side effect is asynchronous, it belongs in an event handler. Everything else is just noise.

Source generators & minimal APIs

Previously, I talked about using controllers and partial classes to organize files like a typical Mediatr project. While that works, I’ll admit it has constructor bloat. When a single controller manages 20 endpoints, you end up with a constructor pulling in every dependency for every slice, even if a specific method only needs one of them.

To solve this, I built a small engine using source generators and minimal APIs. Instead of fighting the framework to force a vertical slice, I made the “one-file-per-endpoint” style the default, compile-time enforced standard.

1. The endpoint as unit

Instead of a giant controller class, every endpoint is a static class. It’s a self-contained unit of work. By using a custom [Endpoint] attribute, the class defines its own metadata: routes, tags, success types, error codes, etc. This is the shipping container for your business logic.

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);
    }
}

Because it’s a static method, you don’t have a constructor. You inject dependencies directly into the Callback parameters. You only ask for what you actually use.

2. The generator

At compile-time, a source generator scans the project, finds those [Endpoint] attributes, and writes the .MapEndpoints() extension method for you.

Whether you have 10 endpoints or 1000, your Program.cs never grows.

Codegen/EndpointGenerator.cs
[Generator(LanguageNames.CSharp)]
public sealed class EndpointGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // ...
        foreach (var endpoint in group)
        {
            sb.AppendLine($"        {var}");
            sb.AppendLine($"             .Map{endpoint.method}(\"{endpoint.route}\", {endpoint.type}.Callback)");
            sb.AppendLine($"             .WithTags(\"{endpoint.tag}\")");
            
            if (!string.IsNullOrWhiteSpace(endpoint.auth))
            {
                sb.AppendLine($"             .RequireAuthorization(\"{endpoint.auth}\")");
            }
                
            if (endpoint.reqtype != null)
            {
                sb.AppendLine($"             .AddEndpointFilter<ValidationFilter<{endpoint.reqtype}>>()");
            }
        }
        // ...
    }
}

3. The analyzer

Architecture shouldn’t rely on “hope” or documentation, it should be enforced by the compiler. The analyzer scans the code as it is written. If a class is not static, or if a Results<T> doesn’t match the attribute’s SuccessType, the build fails.

Codegen/EndpointAnalyzer.cs
public class EndpointAnalyzer : DiagnosticAnalyzer
{
    private static readonly DiagnosticDescriptor StaticRule = new(
        id: "EP0001",
        title: "Endpoint class must be static",
        messageFormat: "The class '{0}' decorated with [Endpoint] must be static",
        category: "Design",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    // more descriptors ...

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
    [
        StaticRule,
        // more descriptors ...
    ];

    public override void Initialize(AnalysisContext context)
    {
        // ...
        context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
    }

    // AnalyzeSymbol checks these rules at compile-time ...
    private static void AnalyzeSymbol(SymbolAnalysisContext context)
    {
        var symbol = (INamedTypeSymbol)context.Symbol;

        if (!symbol.GetAttributes().Any(ad => ad.AttributeClass?.Name is "EndpointAttribute"))
        {
            return;
        }

        if (!symbol.IsStatic)
        {
            context.ReportDiagnostic(Diagnostic.Create(StaticRule, symbol.Locations[0]));
        }

        // ...
    }
}

This replaced human nag-ware with compiler errors. If the code doesn’t fit the architectural unit, it doesn’t leave the developer’s machine. This automation is what allows the folder structure to stay flat and focused on the business rather than the plumbing.

The module as a bounded context

When a project reaches a certain level of complexity, the answer isn’t to add more technical layers, it’s to tighten the business boundaries.

Instead of one giant context where any developer can join an Order to a MarketingCampaign just because they can, we use isolated DbContexts. If you are inside the Orders module, your context should only see Orders, Items, and Buyers. It shouldn’t even know the Marketing or Inventory tables exist. This isn’t just about being tidy, it’s about preventing the big ball of mud before it starts.

“But I need to join tables for reports!”

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

This module act like a silent observer. When the Orders module fires an OrderCompleted event, it catches, it and updates its own optimized tables. Your transactional code stays fast and focused, and the heavy-duty SQL is someone else’s problem.

“It’s ready to split..”

I’ll be honest: Most projects don’t need the distributed headache, the network latency, or the “where are my logs?”. But I do like options. By organizing by business boundaries instead of technical layers, you are always one copy-paste away from a separate service. You don’t build a microservice today, but you make the system ready for it.

ONE BILLION DOLLAR PROJECT STRUCTURE
src
├── X.Codegen (the automation)
├── 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
    └── ...

Because each module carries its own endpoint definitions, its own rich domain, and its own database schema, it is a self-contained fortress. If the traffic to the Orders module suddenly explodes, or if you somehow actually become the next Salesforce, you don’t have to perform open-heart surgery on the entire monolith.

Yes, I think this is what “Clean” is

“Clean” shouldn’t mean more files, it should mean more certainty.

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

Just inject that DbContext, enforce the boundaries, and go home early.