Mert Özen Further With Every Line
Software Architecture Clean, Onion and Hexagonal Architecture — #09

When Is Clean Architecture Too Much? Template Cargo Cults, Interface Explosion, and a Pragmatic Path

Mert Özen Nov 7, 2026 13 min 6 views
When Is Clean Architecture Too Much? Template Cargo Cults, Interface Explosion, and a Pragmatic Path

Clean Architecture is too much for CRUD-heavy, short-lived or single-adapter small services with no business rules to protect, and pays off in long-lived regions with rules and several adapters. The symptoms of template cargo cults and interface explosion, protecting the same Dependency Rule with a single project and ArchUnitNET tests, and migrating a layered project gradually with FreezingArchRule.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

An "update a book's price" feature is coming to Bookstore's admin panel. The team takes the structure we built for Ordering as the model and applies the same four-project layout to Catalog. In Application, a service and its interface:

using Bookstore.Domain.Catalog;

namespace Bookstore.Application.Catalog;

public interface IBookService
{
    Task<BookDto?> UpdatePriceAsync(UpdateBookPriceCommand command, CancellationToken ct);
}

public sealed class BookService(IBookRepository repository, IBookMapper mapper) : IBookService
{
    public async Task<BookDto?> UpdatePriceAsync(UpdateBookPriceCommand command, CancellationToken ct)
    {
        Book? book = await repository.GetAsync(command.BookId, ct);
        if (book is null)
            return null;
        book.Price = command.NewPrice;
        await repository.UpdateAsync(book, ct);
        return mapper.ToDto(book);
    }
}

Next to it a mapper and its interface, a repository interface, a DTO (Data Transfer Object) and a command. In Infrastructure the repository implementation; in Web the endpoint and the request and response records:

using Bookstore.Application.Catalog;

namespace Bookstore.Web.Catalog;

public sealed record UpdateBookPriceRequest(decimal Price);
public sealed record BookResponse(int Id, string Title, decimal Price);

public static class BookEndpoints
{
    public static void MapBookEndpoints(this IEndpointRouteBuilder app) =>
        app.MapPut("/admin/books/{id:int}/price", async (int id, UpdateBookPriceRequest request, IBookService service, CancellationToken ct) =>
        {
            BookDto? dto = await service.UpdatePriceAsync(new UpdateBookPriceCommand(id, request.Price), ct);
            return dto is null ? Results.NotFound() : Results.Ok(new BookResponse(dto.Id, dto.Title, dto.Price));
        });
}

Everything follows the rules of Clean Architecture: Domain depends on nothing, Application defines its own interfaces, Web sees Infrastructure only in the composition root. Tomorrow the product team will ask for two things: the price must not be zero or negative, and books need a currency. Guess: how many files and how many projects will you touch for these two changes? Where is the "price must be greater than zero" rule written today?

Nine Files for One Price

The files of this feature:

$ find src -name "*.cs" -not -path "*/obj/*" | sort | xargs wc -l
       4 src/Bookstore.Application/Catalog/BookDto.cs
      13 src/Bookstore.Application/Catalog/IBookMapper.cs
       9 src/Bookstore.Application/Catalog/IBookRepository.cs
      21 src/Bookstore.Application/Catalog/IBookService.cs
       8 src/Bookstore.Domain/Catalog/Book.cs
      20 src/Bookstore.Infrastructure/Catalog/BookRepository.cs
      17 src/Bookstore.Infrastructure/DependencyInjection.cs
      16 src/Bookstore.Web/Catalog/BookEndpoints.cs
      11 src/Bookstore.Web/Program.cs
     119 total

Nine files spread over four projects, 119 lines. Inside are three interfaces with a single implementation each (IBookService, IBookMapper, IBookRepository), five types carrying the same book's data in different shapes (Book, BookDto, UpdateBookPriceCommand, UpdateBookPriceRequest, BookResponse) and two mappings. Adding a currency means touching all five types and both mappings. The answer to the second question is more uncomfortable: the "price must be greater than zero" rule is nowhere. Book has public setters, and BookService writes the price as it is. All these layers are wrapped around a data update that has no business rule to protect.

This is the last question of the series: when is Clean Architecture too much? The previous eight posts covered the sources of the idea, how it is built in .NET 10, and how it is tested and protected. This post talks about the cost of the same structure, the cases where that cost doesn't pay off, and the ways to keep the rules while cutting the ceremony.

Symptoms: Architecture or Ceremony?

If you see several of the following at once, the structure has probably outgrown the problem:

  • Almost every interface has a single implementation, and no test writes a fake for it either. We discussed when a single-implementation interface is unnecessary in the Dependency Inversion Principle post of the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series.
  • A request changes shape four or five times before it comes back as a response, and every transition has its own mapping code.
  • Most handlers just call the repository and return the result. This is what Mark Richards and Neal Ford's "Fundamentals of Software Architecture" calls the architecture sinkhole anti-pattern; it is covered in detail in the Introduction to Architecture series post "Layered Architecture: The Real Responsibilities of Presentation, Business, and Data Layers".
  • Domain classes consist only of properties, and the rules live in services. It is the architectural-scale counterpart of the anemic domain model from the Design Patterns series post "Anti-Patterns: God Object, Service Locator, Singleton Abuse, Anemic Domain Model and Patternitis".
  • Every change spreads across all projects; adding a field means opening files in four projects.

None of these alone says "this architecture is wrong". But what they all have in common is that the layers only carry data instead of protecting anything.

The Template Cargo Cult

The most common cause of this is copying a solution template as is. I looked at the two most widely used templates in .NET as of November 2026. jasontaylordev/CleanArchitecture (v10.8.0, March 2026) contains seven projects under src: Domain, Application, Infrastructure, Web, Shared, ServiceDefaults and an AppHost for Aspire, plus six test projects. Its dependencies on the main branch include MediatR 14, AutoMapper 16 and FluentValidation 12. ardalis/CleanArchitecture (v11.1.1, April 2026) adds AspireHost and ServiceDefaults to its Core, UseCases, Infrastructure and Web projects, comes with four test projects, and uses FastEndpoints, the source-generator-based Mediator, Vogen and Ardalis.Specification.

Both are well thought out starting points that contain decisions their authors have defended for years. The problem is that copying the template means taking all those decisions too: which library to use, where validation lives, how mapping is done. Some of those decisions change over time as well. Starting with AutoMapper 15 and MediatR 13, both libraries moved on July 2, 2025 to Lucky Penny Software's dual RPL (Reciprocal Public License) 1.5 and commercial license; the ardalis template dropped MediatR in v11 for exactly this reason. A team that copied a template can keep carrying, without noticing, a decision its owner has since reconsidered.

Interestingly, ardalis offers a second template in the same repository, min-clean: a single Web project with Domain, Infrastructure and feature folders inside. The template's own ADR (architecture decision record) states the cost of this choice openly: developers have to respect the folder boundaries themselves, because the compiler doesn't enforce them. In post 08 we saw exactly the tool that fills that gap.

When It Pays Off, and When It Doesn't

All three of the series' sources speak to this question directly. Alistair Cockburn proposed Hexagonal Architecture in 2005 to let an application be driven equally by several different drivers (users, tests, batch scripts); in an application that is just one HTTP (Hypertext Transfer Protocol) API (Application Programming Interface) and one database, that need is weak. Jeffrey Palermo says it plainly in his first Onion post in 2008: "This architecture is not appropriate for small websites." According to the same post, it fits long-lived business applications and applications with complex behavior. Martin Fowler's 2007 "Design Stamina Hypothesis" frames the balance like this: past a certain point, the cost of good design comes back as speed, and according to Fowler that point usually comes after weeks, not months; but he also says himself that it is a hypothesis that can't be proven by measurement.

SituationFull Clean ArchitectureA lighter path
LifetimeWill live for years and pass through many teamsCampaign site, prototype, a tool that will live a few months
DomainRules, invariants, state transitions (orders, payments, stock)Mostly CRUD (create, read, update, delete); hardly any rules
Number of adaptersSeveral driving (API, worker, console) and driven (database, shipping, payment) adaptersOne API, one database
TeamSeveral teams in the same code baseOne small team, one small service
Testing needsTesting business rules quickly without a database is criticalA few end-to-end integration tests are enough

Bookstore itself falls into both columns of this table. Ordering sits in the middle column: stock, a copy limit, concurrency, a shipping company, several adapters. Catalog administration is almost entirely CRUD, which puts it in the right-hand column. That gives the real answer: the decision can be made per region, not per application.

Pragmatic Path 1: One Project, the Same Rule

Let's write the same feature for Catalog in a single project, with folders. This time Book in the Domain folder carries its own rule:

namespace Bookstore.Catalog.Domain;

public sealed class Book(int id, string title, decimal price)
{
    public int Id { get; } = id;
    public string Title { get; } = title;
    public decimal Price { get; private set; } = price;

    public void ChangePrice(decimal newPrice)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(newPrice);
        Price = newPrice;
    }
}

The feature itself is a single file. The endpoint uses DbContext directly, because there is no flow in between that needs protecting:

using System.ComponentModel.DataAnnotations;
using Bookstore.Catalog.Domain;
using Bookstore.Catalog.Infrastructure;

namespace Bookstore.Catalog.Features;

// One feature = one file: request, endpoint and flow side by side
public static class UpdatePrice
{
    public sealed record Request([Range(0.01, 10_000)] decimal Price);
    public sealed record Response(int Id, string Title, decimal Price);

    public static void Map(IEndpointRouteBuilder app) =>
        app.MapPut("/admin/books/{id:int}/price", async (int id, Request request, CatalogDbContext db, CancellationToken ct) =>
        {
            Book? book = await db.Books.FindAsync([id], ct);
            if (book is null)
                return Results.NotFound();

            book.ChangePrice(request.Price);
            await db.SaveChangesAsync(ct);
            return Results.Ok(new Response(book.Id, book.Title, book.Price));
        });
}
$ find src -name "*.cs" -not -path "*/obj/*" | sort | xargs wc -l
      14 src/Bookstore.Catalog/Domain/Book.cs
      24 src/Bookstore.Catalog/Features/UpdatePrice.cs
      17 src/Bookstore.Catalog/Infrastructure/CatalogDbContext.cs
      23 src/Bookstore.Catalog/Program.cs
      78 total

Four files, one project, zero interfaces. And two things are here that the four-project version didn't have: the rule that the price must be greater than zero, in the Domain, and input validation on the request. Microsoft's "Architect Modern Web Applications with ASP.NET Core and Azure" e-book also explains that the smallest possible architecture is a single project, with separation of concerns achieved through folders. Running it:

PUT /admin/books/1/price {"price":230}
HTTP 200
{"id":1,"title":"Dune","price":230}
PUT /admin/books/1/price {"price":0}
HTTP 400
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Price":["The field Price must be between 0.01 and 10000."]}}
PUT /admin/books/42/price {"price":10}
HTTP 404

The weak point of a single project is that, unlike the first violation in post 08, the compiler no longer protects any arrow. Adding a using for Infrastructure in the Domain folder is perfectly valid C#. We move the rule from the compiler to an architecture test:

using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnit;
using Bookstore.Catalog.Domain;
using static ArchUnitNET.Fluent.ArchRuleDefinition;

namespace Bookstore.Catalog.ArchitectureTests;

// One project, one assembly: these tests guard the boundary, not the compiler
public sealed class FolderRuleTests
{
    private static readonly Architecture Architecture =
        new ArchLoader().LoadAssemblies(typeof(Book).Assembly).Build();

    [Fact]
    public void Domain_folder_knows_nothing_else() =>
        Types().That().ResideInNamespaceOrBelow("Bookstore.Catalog.Domain").Should()
            .NotDependOnAny(Types(true).That()
                .ResideInNamespaceOrBelow("Bookstore.Catalog.Infrastructure")
                .Or().ResideInNamespaceOrBelow("Bookstore.Catalog.Features")
                .Or().ResideInNamespaceOrBelow("Microsoft.EntityFrameworkCore")
                .Or().ResideInNamespaceOrBelow("Microsoft.AspNetCore"))
            .Check(Architecture);

    [Fact]
    public void Infrastructure_does_not_reach_into_features() =>
        Types().That().ResideInNamespaceOrBelow("Bookstore.Catalog.Infrastructure").Should()
            .NotDependOnAny(Types().That().ResideInNamespaceOrBelow("Bookstore.Catalog.Features"))
            .Check(Architecture);
}

When someone adds a shortcut to Book so it can "find itself":

// in a single project the compiler doesn't object to this
public static ValueTask<Book?> FindAsync(CatalogDbContext db, int id) => db.Books.FindAsync(id);
Failed Bookstore.Catalog.ArchitectureTests.FolderRuleTests.Domain_folder_knows_nothing_else [76 ms]
ArchUnitNET.xUnit.FailedArchRuleException : "Types that reside in namespace with full name "Bookstore.Catalog.Domain" or below should not depend on any Types (including referenced) that reside in namespace with full name "Bookstore.Catalog.Infrastructure" or below or reside in nam..." failed:
	Bookstore.Catalog.Domain.Book does depend on "Bookstore.Catalog.Infrastructure.CatalogDbContext" and "Microsoft.EntityFrameworkCore.DbSet`1"

The compiler stayed quiet; the test didn't. The essence of Clean Architecture, the Dependency Rule, doesn't depend on the number of projects. Projects are one way to make the compiler enforce that rule; architecture tests are another. Which one is cheaper depends on the size of the code base.

Pragmatic Path 2: Mixing by Complexity

The "Partial Boundaries" chapter of Robert C. Martin's 2017 book "Clean Architecture" accepts that a full boundary is expensive and describes three middle paths: doing all the preparation for a boundary but still keeping the parts in the same component, a one-dimensional boundary that uses an interface on only one side, and hiding behind a facade. The book's message is that the architect should decide deliberately where boundaries are full and where they are partial; drawing full boundaries everywhere is as much a decision as drawing none.

For Bookstore this means: Ordering stays as in posts 05–08, with Domain, Application, Infrastructure and Web projects, handlers, ports and architecture tests. Catalog administration lives in a single project, with thin slices going from the endpoint straight to DbContext, but with the same Dependency Rule test. If real rules arrive in Catalog one day (price history, promotions, an approval flow), you move that region to Ordering's shape; the documentation of ardalis's single-project template describes the same path as "can be extracted into multiple projects later if needed".

This approach has a name too: Vertical Slice Architecture. The idea Jimmy Bogard described in 2018 is to slice code by feature rather than by technical layer, and let each slice use as much abstraction as it needs. Combining it with Clean Architecture's inner circle, the MediatR decision and sharing between slices are the subject of the upcoming Vertical Slice Architecture series.

Moving a Layered Project Gradually

Most of the time you are not starting from scratch; you have a layered project of the kind we described in the Introduction to Architecture series, where Business depends directly on Data. Trying to turn all of it into Clean Architecture in one sprint leaves a half-finished refactoring and a code base where two architectures live at once. A gradual plan:

  • First freeze today's state: write the direction rule as an architecture test, accept the existing violations, block new ones.
  • Start with the use case that changes most or breaks most often; define a port for it in Business and let the class in Data implement that port.
  • Move that use case's rules from the service into the entities and write core tests without a database.
  • Once enough ports and entities have accumulated, move them into separate Domain and Application projects; from then on the compiler guards the arrow with you.
  • Don't force the regions that remain CRUD to move; where there are no rules, no layers are needed.

ArchUnitNET's FreezingArchRule feature is a perfect fit for the first step. In the old code, the business rule depends directly on a concrete class in Data:

using Bookstore.Legacy.Data;

namespace Bookstore.Legacy.Business;

// Old code: the business rule depends directly on a concrete class in Data
public sealed class OrderService(OrderRepository repository)
{
    public void Place(Guid orderId, decimal total) => repository.Save(orderId, total);
}

The rule is written and frozen:

public sealed class FrozenRuleTests
{
    private static readonly Architecture Architecture =
        new ArchLoader().LoadAssemblies(typeof(OrderService).Assembly).Build();

    [Fact]
    public void Business_does_not_depend_on_data_no_new_violations()
    {
        IArchRule rule = Types().That().ResideInNamespaceOrBelow("Bookstore.Legacy.Business").Should()
            .NotDependOnAny(Types().That().ResideInNamespaceOrBelow("Bookstore.Legacy.Data"));

        // today's violations are written to a file; only new ones break the test
        FreezingArchRule.Freeze(rule).Check(Architecture);
    }
}

On the first run the test passes and writes today's violation to ArchUnitNET/Storage/FrozenRules.json in the test project. This file is committed to the repository:

[
  {
    "ArchRuleDescription": "Types that reside in namespace with full name \"Bookstore.Legacy.Business\" or below should not depend on any Types that reside in namespace with full name \"Bookstore.Legacy.Data\" or below",
    "Violations": [
      "Bookstore.Legacy.Business.OrderService"
    ]
  }
]

A week later someone writes a new class with the same old habit:

using Bookstore.Legacy.Data;

namespace Bookstore.Legacy.Business;

// New code, old habit
public sealed class InvoiceService
{
    public void Issue(Guid orderId) => SqlHelper.Execute($"INSERT invoice for {orderId}");
}
Failed Bookstore.Legacy.ArchitectureTests.FrozenRuleTests.Business_does_not_depend_on_data_no_new_violations [48 ms]
ArchUnitNET.xUnit.FailedArchRuleException : "Types that reside in namespace with full name "Bookstore.Legacy.Business" or below should not depend on any Types that reside in namespace with full name "Bookstore.Legacy.Data" or below" failed:
	Bookstore.Legacy.Business.InvoiceService does depend on "Bookstore.Legacy.Data.SqlHelper"

The test shows only the new violation; OrderService isn't on the list, because it is frozen. In a copy where I turned OrderService into a port and deleted InvoiceService, the test passed again and the file shrank on its own:

[
  {
    "ArchRuleDescription": "Types that reside in namespace with full name \"Bookstore.Legacy.Business\" or below should not depend on any Types that reside in namespace with full name \"Bookstore.Legacy.Data\" or below",
    "Violations": []
  }
]

The empty list is the goal itself: at that point you remove the Freeze call and leave the rule as a normal rule.

Common Pitfalls

Mistaking the architecture for the template's folder structure. Opening four projects named Domain, Application, Infrastructure and Web doesn't give you the Dependency Rule by itself; a repository returning IQueryable in Application or an Entity Framework Core (EF Core) attribute in Domain breaks the rule even when the names are right. What you check is the arrows, not the names.

The "just in case" interface. Putting an interface in front of every class because we might switch databases tomorrow is the exact opposite of YAGNI (You Aren't Gonna Need It), which we covered in detail in the SOLID series. Add the interface when a second implementation, a test fake or a boundary really needs it; the IDE's (integrated development environment) "Extract Interface" refactoring takes a minute.

Overdoing it the other way: drawing no boundaries at all. Jumping from "Clean Architecture is too much" to "let's write everything in the endpoint" is also a mistake. In a rule-heavy region like Ordering, that means going back to the half-finished order from the opening of post 06.

Leaving the migration half done. Moving one module to the new structure and leaving the others in the old layout is fine; the problem is when it isn't written down which region follows which rule. The frozen rule file and the architecture tests are the most concrete record of that.

Wrapping Up the Series

Over nine posts we followed three tellings of a single idea and its counterpart in .NET 10. We started where Layered Architecture gets stuck: when business rules get locked to the database and frameworks. Cockburn's ports and adapters isolated the application from the outside world, Palermo's rings also had something to say about the inner structure, and Martin's Dependency Rule reduced both to one sentence: source code dependencies point only inward. Then we built Bookstore's Ordering by that rule: single-purpose handlers and one transaction boundary, EF Core mapping that stays in Infrastructure and a typed HttpClient adapter, core tests without a database, adapter tests with Testcontainers, and ArchUnitNET tests that guard the rule in CI (continuous integration). In this last post we saw that the same rule sometimes takes four projects and sometimes one project and a test file. The sentence to remember: Clean Architecture is not a folder structure but a decision about the direction of dependencies; how much ceremony you apply that decision with depends on the size of the problem.

A Small Experiment

★☆☆ Add the "price must be greater than zero" rule to the four-project Catalog example. Which file did you put the rule in, and how many files did you touch? Count the same for the single-project version.

★★☆ Apply the symptoms list to one of your own projects: count the single-implementation interfaces and the pass-through handlers. Which would you delete and which would you keep, and why?

★★★ Add the rule "Business must not depend on Data" to a layered project with FreezingArchRule. Turn the first class in the frozen violation list into a port and watch FrozenRules.json shrink. Which step of the gradual migration plan have you completed?

This post ends the Clean, Onion and Hexagonal Architecture series. The road continues with the question we opened the door to several times in this post: slicing code by feature instead of by layer. The next series in the Software Architecture category, Vertical Slice Architecture, starts with slices with and without MediatR, and with where it meets and where it parts ways with Clean Architecture.