← All posts

Clean Architecture and SOLID After Five Years: What I'd Keep and What I'd Delete

2026-09-28 · dotnet · clean-architecture · solid · csharp

I want to start with a fact that took me a while to sit with.

Steve Smith — who wrote the canonical .NET Clean Architecture template, who coined “vertical slices” in this context, who has shaped how thousands of .NET teams structure their code — now ships a second, officially sanctioned template. It’s called min-clean. It’s a single project, not an onion.

And the template documentation states its recommended ceiling out loud:

Ideal Scenarios: MVPs and Prototypes; Small to Medium Applications — 5-50 endpoints, 5-20 domain entities, 1-5 developers; Learning Clean Architecture; Vertical Slice Architecture Preference.

Not Recommended For: Large Enterprise Applications — complex domain requiring extensive DDD patterns, multiple teams needing strict boundaries, long-term evolution expected; Microservices from Start; Regulatory/Compliance Heavy.

Read that again. The architect of the .NET Clean Architecture template says: if you have between one and five developers and fewer than fifty endpoints, the four-project onion is too much, and a single project with folder conventions is the better call.

That’s the honest centre of this post. I’m not going to argue you into or out of Clean Architecture. I’m going to tell you what the rule actually says, what it costs, and how each SOLID principle holds up against real C# — including the two that I’d argue are mostly noise and the one that does all the work.

The Dependency Rule is the whole thing

Robert Martin’s original statement is worth quoting properly, because the version most people learned is a softened paraphrase:

“Source code dependencies can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle. In particular, the name of something declared in an outer circle must not be mentioned by the code in an inner circle. That includes functions, classes, variables, or any other named software entity. By the same token, data formats used in an outer circle should not be used by an inner circle, especially if those formats are generated by a framework in an outer circle.” — The Clean Architecture

Two corrections to the common version floating around:

The famous “and only to an abstraction defined in an inner circle” clause is not in the original. The original rule is about names, and it’s stricter: a name from an outer circle must not be mentioned, full stop. The abstraction-only formulation is a later restatement — the one Microsoft uses.

And the rule extends to data formats, not just source references. That sentence is the entire justification for “no EF Core in the Domain layer.” DbContext, LINQ expression trees, anonymous projection types — those are outer-circle data formats. “No EF in Domain” isn’t a style preference someone invented; it’s a direct consequence.

One more thing from the same article, and it’s the most useful line for people who feel they need four projects:

“There’s no rule that says you must always have just these four. However, The Dependency Rule always applies.”

The rule is the constraint. The layer count is a convention. That’s the entire theoretical case for min-clean existing.

What “inverting” actually buys you

Microsoft’s framing of Dependency Inversion is precise, and it contains the sentence that keeps me honest:

“Applying the dependency inversion principle allows A to call methods on an abstraction that B implements, making it possible for A to call B at run time, but for B to depend on an interface controlled by A at compile time (thus, inverting the typical compile-time dependency). At run time, the flow of program execution remains unchanged, but the introduction of interfaces means that different implementations of these interfaces can easily be plugged in.”

At runtime, the flow of program execution is unchanged — the cost of the inversion is paid in interface dispatch and a DI registration, not in meaningful runtime overhead. What you buy is a compile-time guarantee that the centre of your system doesn’t know what the outside is. Testability, swappability, and the rest are downstream consequences of that one property — not separate benefits to be claimed.

And the realistic test for whether the rule is doing anything is not “does the project have zero dependencies.” It’s “can I unit-test this without a database.” Those are different, and the second is the one you actually want. A Domain project that references a logging abstraction is fine. A Domain project that references Microsoft.EntityFrameworkCore has quietly surrendered the benefit while technically still obeying the rule as people usually state it.

I found a perfect live example of that failure while researching this. Microsoft’s own dotnet/eShop reference app has <PackageReference Include="MediatR" /> in src/Ordering.Domain/ — the innermost project — with global using MediatR; in its global usings. Nothing in that domain layer actually sends a MediatR message. It looks like scaffolding residue. It’s also a commercially-licensed package sitting in the layer that’s supposed to be dependency-free. If the canonical reference app can drift, your codebase can too.

What it costs

The author admits the redundancy

From the Clean Architecture template’s own design decisions document:

“Having validation occur both within the API endpoints and then again at the use case level may be considered redundant. There are tradeoffs to adding essentially the same validation in two places… it often makes sense to add validation in both places, as the overhead is minimal and the peace of mind of greater application robustness is often worth it.”

“May be considered redundant,” from the person whose template causes it. And this generalizes: you validate at the API boundary, and again in the domain, because the domain can’t know it was reached through an API.

The performance cost nobody mentions

The layering decision has a correctness consequence, not just a bookkeeping one. EF Core’s published benchmark, 10 blogs × 20 posts:

QueryMeanAllocated
AsTracking1,414.7 µs380.11 KB
AsNoTracking993.3 µs232.89 KB

AsNoTracking is ~30% faster and allocates ~39% less — but note what that benchmark actually measures: tracking versus not tracking. Tracking is what keeps your entities attached to the context, and it’s the default. A separate axis is lazy loading, which fires additional round-trips as navigation properties are touched.

In a layered architecture where Application returns entities and Infrastructure owns the DbContext, the two problems compound. Returning tracked entities means the Web layer can trigger queries at an arbitrary time — after, or outside, the scope you designed them to run in. That’s a correctness bug caused by a layering decision, and it’s a much stronger argument for DTOs at the boundary than “purity” ever was.

Microsoft’s own warning on the round-trips themselves:

“Note that since these methods aren’t eager, they require additional roundtrips to the database, which is source of slowdown; depending on your specific scenario, it may be more efficient to just always load all Posts, rather than to execute the additional roundtrips and select the ones you need.”

Worth knowing: EF Core’s compiled models explicitly do not support lazy loading and change-tracking proxies. So the lazy-loading-across-layers pattern and the startup-performance pattern are mutually exclusive. You get to pick.

The boundary is a convention, not a compiler guarantee

Microsoft’s own documentation contains the escape hatch:

“In order to wire up dependency injection during app startup, the UI layer project may need to reference the Infrastructure project… developers should limit actual references to types in the Infrastructure project to the app’s composition root.”

Your outermost project references your innermost project, by necessity, at Program.cs. The rule is enforced by a code review convention and a comment that says “don’t touch Infrastructure from here.” That’s it. Every Clean Architecture codebase has that comment, and it is not a compile error to ignore it.

SOLID, honestly, one at a time

Single Responsibility: ignore “one reason to change”

The famous formulation — “one reason to change” — is unmeasurable. Any change is a reason to change if you phrase it loosely enough. Microsoft drops the reformulation the moment it gets practical and replaces it with something genuinely useful, under “Explicit Dependencies”:

“If you define classes that can be constructed and called, but that will only function properly if certain global or infrastructure components are in place, these classes are being dishonest with their clients. The constructor contract is telling the client that it only needs the things specified… but then at runtime it turns out the object really did need something else.”

That’s the test I’d actually use: can you construct this, call it with only what the signature shows, and have it work? It’s mechanically checkable. “One reason to change” isn’t.

A real example — three unrelated change axes in one method:

// BEFORE: three reasons to change — tax rules, PDF layout, storage location.
public sealed class InvoiceService(
    AppDbContext db,
    IEmailSender mailer,
    IPdfRenderer renderer)
{
    public async Task SendAsync(int invoiceId)
    {
        var invoice = await db.Invoices.FindAsync(invoiceId)
            ?? throw new InvalidOperationException($"No invoice {invoiceId}");

        var tax = 0.20m * invoice.Subtotal;                        // tax rules changed
        var pdf = renderer.Render(invoice, tax);                    // new template
        await mailer.SendAsync(invoice.CustomerEmail, pdf, $"Invoice {invoiceId}");
        invoice.SentAt = DateTimeOffset.UtcNow;
        await db.SaveChangesAsync();
    }
}

You cannot test the tax calculation without a DbContext and a renderer. That’s the dishonesty.

// AFTER: each type has one change axis and is constructible from its signature alone.
public sealed record Invoice(int Id, string CustomerEmail, decimal Subtotal);

public interface ITaxCalculator
{
    decimal TaxFor(decimal subtotal);
}

public sealed class VatTaxCalculator : ITaxCalculator   // reason to change: tax rules
{
    public decimal TaxFor(decimal subtotal) => 0.20m * subtotal;
}

public sealed record RenderedInvoice(byte[] Pdf, string FileName);

public sealed class InvoiceSender(
    ITaxCalculator tax,
    IPdfRenderer renderer,
    IEmailSender mailer)                                    // reasons to change: PDF, email provider
{
    public RenderedInvoice Prepare(Invoice invoice)
    {
        var pdf = renderer.Render(InvoiceDocument.From(invoice, tax.TaxFor(invoice.Subtotal)));
        return new RenderedInvoice(pdf, $"invoice-{invoice.Id}.pdf");
    }

    public Task SendAsync(Invoice invoice, RenderedInvoice rendered, CancellationToken ct)
        => mailer.SendAsync(invoice.CustomerEmail, rendered.Pdf, $"Invoice {invoice.Id}", ct);
}

public sealed class InvoiceDeliveryJob(AppDbContext db, InvoiceSender sender)
{
    public async Task RunAsync(int invoiceId, CancellationToken ct)
    {
        var invoice = await db.Invoices.FindAsync([invoiceId], ct)
            ?? throw new InvalidOperationException($"No invoice {invoiceId}");
        var rendered = sender.Prepare(invoice);
        await sender.SendAsync(invoice, rendered, ct);
        invoice.SentAt = DateTimeOffset.UtcNow;
        await db.SaveChangesAsync(ct);
    }
}

VatTaxCalculator is now testable with zero infrastructure. The PDF library is swappable without touching tax logic.

One correction to the common advice: SRP does not mean splitting on change axis only. Splitting on stability — what changes rarely versus what changes often — is equally valid, and it’s the actual mechanism behind the repository pattern.

Open/Closed: a change-isolation principle, not a purity principle

The reason a switch on payment method is bad isn’t the four lines of new case. It’s that the concept “add a provider” becomes an edit to an existing working class — so you re-review the whole function, re-run every test that touched it, and merge-conflict in whatever file everyone else is editing that week.

// BEFORE: adding "Klarna" means editing this method. This file is now a conflict magnet.
public sealed class PaymentProcessor(AppDbContext db, ILogger<PaymentProcessor> log)
{
    public Task<PaymentResult> ChargeAsync(Order order, string method, decimal amount)
    {
        return method switch
        {
            "card"    => ChargeCardAsync(order, amount),
            "paypal"  => ChargePaypalAsync(order, amount),
            "bank"    => ChargeBankAsync(order, amount),
            _         => Task.FromResult(PaymentResult.Failed($"Unknown method {method}")),
        };
    }
}
// AFTER: the compiler enforces what the switch could never express.
public interface IPaymentProvider
{
    // Provider-specific config, so you can't pass a card number to a bank transfer.
    string Method { get; }
    Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct);
}

public sealed record PaymentRequest(int OrderId, decimal Amount, string CustomerId);

public sealed class PaymentProcessor(
    IEnumerable<IPaymentProvider> providers,   // DI supplies the set
    ILogger<PaymentProcessor> log)
{
    public Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct)
        => (providers.FirstOrDefault(p => p.Method == request.Method)
            ?? throw new PaymentMethodNotSupportedException(request.Method))
           .ChargeAsync(request, ct);
}

The subtle win: ChargeAsync takes a PaymentRequest, not (Order, decimal), so each provider only ever receives what it plausibly needs.

The honest counter-argument: OCP is the most-abused of the five. A switch over an enum you own, handled with a discard arm, is correct and cheap. What you get is a total match at compile time (an unhandled value falls to _ rather than escaping), which is close to but not the same as exhaustiveness enforcement — C# won’t warn you if you later add an enum member. Abstracting the switch trades that for a runtime FirstOrDefault that can return null.

My rule: apply OCP when the set changes for reasons outside your code — payment rails, jurisdictions, tax regimes, notification channels. Keep the switch when you own the set and it genuinely never grows.

Liskov: the failure you’ll actually hit is your test double

The Rectangle/Square example still works, but it’s not the version that costs you weekends. Here’s the one from real .NET test suites:

// Production implementation — the DbSet IS the query engine.
public sealed class EfOrderStore(AppDbContext db) : IOrderStore
{
    public async Task<IReadOnlyList<Order>> PendingAsync(CancellationToken ct)
        => await db.Orders.Where(o => o.Status == OrderStatus.Pending).ToListAsync(ct);

    public async Task AddAsync(Order order, CancellationToken ct)
        => await db.Orders.AddAsync(order, ct);
}

// Test double — compiles, passes the happy path, wrong.
public sealed class InMemoryOrderStore : IOrderStore
{
    private readonly List<Order> _orders = [];
    public Task<IReadOnlyList<Order>> PendingAsync(CancellationToken ct)
        => Task.FromResult<IReadOnlyList<Order>>(
            _orders.Where(o => o.Status == OrderStatus.Pending).ToList());
    public Task AddAsync(Order order, CancellationToken ct) { _orders.Add(order); return Task.CompletedTask; }
}

Where is the substitution broken? Not in any single method. In the queryable surface. DbSet<T> is IQueryable<T> — it supports Include, AsNoTracking, composed predicates, server-side projection. IOrderStore supports none of it. The moment Application code needs Include(o => o.Lines), the double silently can’t stand in — and because the interface was satisfiable, the compiler never warned you.

Two honest fixes:

Narrow the abstraction to the genuinely shared capability. PendingAsync is fine. Don’t add a Queryable() escape hatch.

Use the real thing in tests. EF Core with SQLite in-memory or Testcontainers usually runs faster than a hand-rolled fake, and it tests your actual LINQ translation.

The practical tell: if you write a test double and add NotSupportedException to a method, you have an LSP violation, not a missing feature. That exception is the tell.

Interface Segregation: .NET usually solves it with extension methods

The canonical fat interface:

// BEFORE — Sleep() exists because one caller needs it.
public interface IWorker
{
    void Work();
    void Sleep(int minutes);
}

public sealed class Miner : IWorker
{
    public void Work() { }
    public void Sleep(int minutes) => throw new NotSupportedException("Miners don't sleep.");
}

That NotSupportedException is a compile-clean, runtime-fatal LSP violation created purely by interface design. The fix is to name the capability:

public interface IWorkable { void Work(); }
public interface IRestable { void Sleep(int minutes); }

public sealed class Miner : IWorkable     // no Sleep, no exception, no dead member
{
    public void Work() { }
}

But here’s the .NET-specific insight that I’d argue matters more than interface inheritance: the BCL already solves this with extension methods. IEnumerable<T> does not have a Count property — it has Count() as an extension method, precisely so that ICollection<T>’s O(1) fast path doesn’t become a mandatory O(n) contract on every enumerable.

In .NET, Interface Segregation is frequently implemented as an extension method over a minimal interface rather than as interface splitting. Cheaper, more idiomatic, and it doesn’t multiply the number of types consumers must know about.

The real cost of ISP: every split interface multiplies constructor parameters. A type needing four of your interfaces needs four constructor parameters, or a composite interface that reintroduces the fat one. Worth paying when implementations genuinely differ. Not worth paying for theoretical purity.

Dependency Inversion: the one that does all the work

This is the only one of the five that changes the shape of the system rather than the shape of a class. SRP, OCP, LSP, and ISP are all local, within-type concerns — you can apply all four in a single file. DIP is the only one that says this type must not know about that type, and it’s the only one that makes the other four enforceable across a codebase.

The dependency rule is DIP applied at the level of assemblies. DIP is the principle; the rule is its architectural projection. They’re not two different ideas.

// BEFORE — the Application layer knows the filesystem.
public sealed class DocumentArchiver
{
    public async Task<string> ArchiveAsync(Stream content, CancellationToken ct)
    {
        var path = Path.Combine("/var/data/archives", Guid.NewGuid().ToString("N"));
        await using var fs = File.Create(path);
        await content.CopyToAsync(fs, ct);
        return path;
    }
}
// AFTER — the abstraction is owned by the consumer (that's the inversion).
public interface IBlobStore            // declared by Application, not by Infrastructure
{
    Task<Uri> PutAsync(string key, Stream content, CancellationToken ct);
    Task<Stream?> GetAsync(string key, CancellationToken ct);
}

public sealed class DocumentArchiver(IBlobStore blobs)
{
    public Task<Uri> ArchiveAsync(Stream content, CancellationToken ct)
        => blobs.PutAsync(Guid.NewGuid().ToString("N"), content, ct);
}

"/var/data/archives" is gone from the Application layer entirely — it never belonged there. The test is now a three-line fake. And note that ISP and DIP end up aligned: the interface has exactly the members the consumer uses.

The honest cost: every inverted dependency is an interface, a DI registration, and a fake in your test project. A string path parameter costs nothing. The test is whether you have two implementations that differ in a way that matters — two stores, two time sources, two payment gateways. If you have exactly one and no plans for another, the inversion is speculative and you paid for it in complexity.

Where SOLID is actively harmful

The IWhatever trap

An interface with exactly one implementation, no second implementer, no test double, and a name that is the class name with an I glued on. It provides zero testability benefit — you can’t substitute something that has no substitute — while costing you the freedom to change the class’s API.

Where the trap comes from: conflating “depend on abstractions” with “abstract everything.” DIP says depend on the abstraction where the dependency is volatile. A UserRepository interface is defensible — storage is volatile. An IUserNameFormatter wrapping one pure static function is not.

The template author removed Autofac for the same reason

The Clean Architecture template shipped with Autofac and deleted it, documenting why:

“Based on community feedback and a review of our project’s requirements, we have decided to remove Autofac from this template and transition to using .NET’s built-in dependency injection infrastructure. This decision aligns with the goal of simplifying the codebase and reducing external dependencies.” — ADR 001: Replace Autofac with .NET DI

That’s a container framework removed from a Clean Architecture template for being too much abstraction. If you’re weighing up a DI container with decorators and module scanning, that ADR is worth reading first.

The performance angle everyone omits

MediatR’s DI registration is transient by default, and its own documentation flags this as a performance note recommending Singleton instead. That’s an allocation-per-message cost in the dispatch layer of every request.

And the reason source-generated mediators exist at all is that MediatR’s runtime dispatch uses reflection. That’s a real constraint under Native AOT and aggressive trimming, where reflected members can be trimmed away or need explicit preservation — not an absolute blocker, but a friction you have to plan around rather than a style preference. If you’re cold-start-sensitive, dispatch cost is worth measuring.

The MediatR licensing change you need to know about

This is the part that has genuinely surprised people, and I want to get it exactly right.

MediatR moved from jbogard/MediatR to LuckyPennySoftware/MediatR. Version 13.0.0, released 2025-07-02, is the boundary. I verified the licenses directly in the repository: v12.4.1 ships the Apache License 2.0, v13.0.0 ships RPL-1.5 or commercial. Current version is 14.2.0.

RPL-1.5 is reciprocal: if you ship a product containing it, you must release that product’s source under RPL-compatible terms. For a commercial product that means buying a license. The FAQ states it plainly — AutoMapper 15.0.0+ and MediatR 13.0.0+ require it; earlier versions remain open source and free.

A license key is expected in production, resolved from explicit configuration, then MEDIATR_LICENSE_KEY, then LUCKYPENNY_LICENSE_KEY. The important nuance: a missing key does not throw. Unlicensed use logs a warning under the LuckyPennySoftware.MediatR.License category, and that warning is suppressible via a logging filter. So the failure mode is a quiet one — which is exactly the kind worth watching for in your logs, and worth fixing deliberately rather than discovering during an audit.

The Community tier is free below $5,000,000 USD annual revenue, self-service. Licensing is team-based, not per-seat. Paid tiers exist above that; I’d link to mediatr.io rather than quote figures, because the site displays prices under a bundle toggle and the per-tier numbers are easy to misread.

What the ecosystem did about it: both of Steve Smith’s own projects moved. eShopOnWeb’s ApplicationCore and the Clean Architecture template’s Core project now reference Mediator.Abstractions instead. The template’s v10→v11 migration guide says it directly:

“One of the most noticeable changes in recent versions is the removal of the MediatR library for handling commands and queries inside the UseCases project, favoring alternative implementations or source generators.”

Meanwhile dotnet/eShop still pins MediatR 13.0.0 in its Domain project. So the reference implementations disagree with each other — which is itself a useful signal about how settled this is.

Your options:

OptionLicenseDispatchNotes
MediatR 12.4.1Apache-2.0ReflectionLast free version. Frozen since 2024-09. Pin deliberately if you want OSS.
MediatR 13+RPL-1.5 or commercialReflectionKey required at runtime. Free under $5M revenue.
Mediator (martinothamar)MITSource generatorsFull Native AOT support, no reflection. Drop-in IMediator-compatible. Mediator.Abstractions is the contracts-only package both Smith projects use.
Wolverine (JasperFx)MIT (support contracts paid)Code-first, no IHandler<T> requiredAlso a message bus: sagas, transactional outbox.
Hand-rolled—DirectA Func<T, CancellationToken, Task<TResponse>> delegate, or a static abstract interface member. Zero dependencies, AOT-clean. Loses pipeline behaviours unless you build them.

If you’re on MediatR 12 today and happy, pinning 12.4.1 is a legitimate strategy. If you’re starting fresh, the source-generated options remove both the licensing question and the reflection cost.

The .NET-native middle ground

Vertical slice architecture is just “organize by feature, not by layer” — one folder per feature containing its handler, its DTOs, its endpoint, its tests. min-clean is that plus a thinner set of domain patterns:

Its own ADR lists the trade honestly: faster builds, easier refactoring, no project-boundary concerns — against “developers must respect folder boundaries (not enforced by compiler).”

That last bullet is the real trade. Multi-project boundaries are enforced by the build. Folder boundaries are enforced by code review. Clean Architecture buys you a compiler-enforced dependency rule and pays for it with ceremony; the middle ground buys you speed and pays with discipline. For most teams, one developer, and fifty endpoints, that’s the better deal — which is exactly what the template author concluded.

What I’d actually do

If you have one to five developers and fewer than fifty endpoints: single project, folders by feature, minimal APIs, an Application folder if you want one seam, and DTOs at the HTTP boundary. Pin MediatR 12.4.1 or use a source-generated mediator. Resist the four-project onion; you can add it later, and the dependency rule is the part you’d actually keep.

If you have multiple teams, compliance requirements, or a long expected life: the full onion. But enforce it — with a test that asserts the Domain project has no forbidden package references, not a comment.

Regardless of structure: apply DIP at the point where the dependency is genuinely volatile, split capabilities when implementations genuinely differ, and use switch for sets you own. Ignore the parts of SOLID that make a two-minute change into a two-hour one.

The principle I keep coming back to: abstraction should be cheaper than the problem it solves. The dependency rule earns that, because it removes an entire class of bug rather than organizing code. An IWhatever with one implementation never does.


Sources for the claims in this post, all verified on 2026-09-28: Uncle Bob, The Clean Architecture · Microsoft, Common web application architectures · Microsoft, Architectural principles · ardalis/CleanArchitecture design decisions · ADR-001: .NET DI adoption · Minimal Clean Architecture · MediatR LICENSE.md · mediatr.io · EF Core efficient querying benchmarks