Skip to content

When NOT to Apply a Pattern

Key Points

  • Every pattern has a cost. Indirection, ceremony, mental load, harder debugging. Junior engineers read pattern books and reach for the named pattern; senior engineers ask "what does it cost me, and what am I getting back?"
  • The pattern is the solution to a problem. If you don't have the problem, you don't need the solution. CQRS solves asymmetric read/write needs; if your reads and writes are symmetric, CQRS is just ceremony.
  • Misapplied patterns are worse than no pattern. They add cost without paying off, and they signal "this code is well-architected" to future readers, who then build on a foundation that wasn't load-bearing.
  • The senior heuristic: prefer the simplest thing that solves today's problem, with a plausible escape hatch for tomorrow's. Patterns are tomorrow's escape hatches — don't pre-spend them.
  • Patterns interact. Three patterns layered together can quadruple cognitive overhead. Adopt one at a time; measure the pain it removes against the pain it adds.
  • "Pattern fluency" is identifying when not to use one. The fluency reveals itself in subtractive PRs — "let's just call the method directly" beats "let's introduce a Mediator."

Concepts (deep dive)

The cost ledger of any pattern

Every pattern adds at least some of:

  • Indirection — the call path now goes through one or more extra hops. Stack traces lengthen; "go to definition" stops being a useful tool.
  • Ceremony — boilerplate the pattern requires (request/response types, handlers, validators, mappers).
  • Cognitive load — readers need to know the pattern before they can follow the code.
  • Onboarding cost — every new engineer learns it; if they don't, they fight it.
  • Debugging cost — a bug that would have been "step into the function" is now "trace through the mediator pipeline behavior."
  • Test cost — abstractions need test doubles; doubles drift from production behavior.

These costs are tolerated in exchange for benefit. The check before adopting any pattern is: what specific pain am I removing, and is removing it worth the costs above?

If you can't articulate the pain in one sentence — "the same code touches four layers when a feature changes" or "we can't write integration tests because the DB is hard-wired into the controllers" — you don't have the pain yet. Wait.

Heuristics for "don't adopt this pattern (yet)"

  • You can't name the problem you're solving. "It's cleaner" isn't a problem.
  • The pattern's name appears in your PR description but not in the actual user-visible behavior. That's ceremony shipping.
  • You're adopting it because the codebase you came from used it. Different codebase, different forces.
  • You're adopting it because a conference talk recommended it. Conference talks describe the upside; you have to evaluate the downside in your context.
  • The current "ugly" code works, is tested, and changes rarely. Refactor before rewrite; rewrite before re-architecting.

Specific patterns and their misuse modes

The rest of this section walks through patterns the guide covers, and the situations in which adopting them is a net loss.

CQRS without asymmetric reads/writes

CQRS is justified when your read path and write path have different shapes: writes go through aggregates with validation; reads are denormalized projections for performance. Different models, different infrastructure, possibly different databases.

If your reads are "select by id" and your writes are "update by id," CQRS gives you nothing and costs you:

  • Two types per operation (Command + Result, or Query + Result).
  • A handler class per operation.
  • A mediator pipeline to dispatch through.
  • "Go to definition" lands on the interface, not the handler.

The smell: 200 commands in the codebase, none of them with bespoke read or write logic — every handler is db.Set<T>().Update(x); await db.SaveChangesAsync();. That's a Repository with extra steps.

Repository over EF Core's DbContext

EF Core's DbContext is already a Unit of Work, and DbSet<T> is already a Repository. Wrapping it in IOrderRepository typically buys you nothing except:

  • One more layer in every test setup.
  • A method like GetByIdAsync that does await _db.Orders.FindAsync(id).
  • A new place for Include calls to hide.

The Repository pattern was canonical when ORMs were primitive (early NHibernate, hand-rolled DAL). In modern EF Core, wrap only when you have a specific reason: enforcing query rules across an aggregate, swapping out the persistence mechanism behind a stable interface, or testing without a database. "I read it in a clean architecture book" isn't a reason.

MediatR for a one-handler-deep call

// What it looks like with MediatR for a single handler:
var result = await _mediator.Send(new PlaceOrderCommand(...));

// What's actually happening:
var handler = _serviceProvider.GetRequiredService<IRequestHandler<PlaceOrderCommand, Result>>();
return await handler.Handle(new PlaceOrderCommand(...), ct);

If there are no pipeline behaviors (validation, logging, transactions, retries) running between Send and Handle, MediatR is just service-locator-with-extra-steps. The handler exists either way; the indirection only earns its keep if the pipeline behaviors do.

The senior call: keep MediatR if you're using it for pipeline behaviors. Drop it (or never adopt it) if you're not.

Result pattern for simple CRUD

Result pattern is excellent when (a) errors are expected control flow (validation failure, business rule violation), (b) there's a small, bounded set of failure modes, and © the call site genuinely branches on them.

For straight CRUD with happy-path returns and "throw on something weird," Result<T, E> is overhead. Every method now returns a wrapper; every caller match-es; nullable annotation rules get fuzzy. ASP.NET Core's ProblemDetails + thrown exceptions is fine for the 80% case.

The senior call: use Result where the value is large (input validation, domain rule violations, fallback strategies). Don't use it for "what if the DB is down" — that's an exception.

Domain events for every state change

Domain events are a great pattern when another bounded context needs to react to a state change. They're a terrible pattern when the only handler is the same module that raised them — that's a method call wearing a costume.

// ❌ Pointless event:
public class OrderService
{
    public async Task PlaceOrder(...)
    {
        order.AddDomainEvent(new OrderPlaced(order.Id));   // raises an event
        await _publisher.PublishAsync(...);                 // handled in the same module
    }
}

// ✅ Just call the method:
public class OrderService(EmailSender email)
{
    public async Task PlaceOrder(...)
    {
        // ...
        await email.SendOrderConfirmation(order.CustomerId, ct);
    }
}

Use events when the consumer is genuinely separate (other module, other service, different bounded context). Otherwise it's the same call with extra debugging surface.

Microservices for a small team

See the dedicated discussion in Monolith, Modular Monolith, Microservices. Short version: don't pay the distributed-systems tax (network, observability, sagas, contract evolution) without a specific reason — scale profile, release cadence, or team ownership — that earns it. A team of five does not have the bandwidth to operate fifteen services well.

Microservice-per-feature in a modular monolith

A modular monolith already gives you bounded contexts at near-zero cost. Splitting each module into its own service multiplies operational cost without the corresponding scale or autonomy benefit. The signal that a module should become a service: it has its own SLO, scale curve, or release cadence — not "it's a separate folder."

Saga for two operations in the same database

Sagas exist because you can't BEGIN; ...; COMMIT; across two services with different databases. If your two operations are in one database, a saga is a distributed-transaction simulator for a problem you don't have. Use a regular DB transaction.

Event sourcing as a default

Event sourcing is a powerful pattern for audit-heavy domains (financial systems, regulated industries, anything that asks "how did the state get to be this way"). It's a terrible default for CRUD apps:

  • Every query becomes a projection rebuild story.
  • Schema evolution becomes "how do we handle events from version 1 in version 3" forever.
  • Tooling (debugging, ad-hoc queries) is worse than relational.

Adopt it when audit / replay is the requirement. Otherwise normal persistence with an audit_log table covers 95% of real "what happened" questions.

Hexagonal / Clean / Onion for a 200-line CLI tool

Clean Architecture is valuable when you have multiple delivery mechanisms (HTTP, gRPC, queue consumer, CLI) and want to keep the domain pure. For a single-purpose tool that reads a JSON file and writes another JSON file, the four-project ceremony (Domain, Application, Infrastructure, Presentation) is overhead.

The senior call: scale your architecture to the surface area of the app. A CLI tool doesn't need an Application layer.

Premature abstraction (the speculative interface)

// ❌ Hypothetical second implementation, never materialized:
public interface IEmailSender { Task SendAsync(string to, string body, CancellationToken ct); }
public class SmtpEmailSender : IEmailSender { /* the only implementation */ }

If there is exactly one implementation and there isn't a specific named second one coming ("we'll need a Mailgun version for the EU market in Q3"), the interface is speculation. Speculative interfaces are easy to remove later; the cost of having them now (extra type, extra DI registration, fake in tests) is non-zero.

The senior call: introduce the interface when the second implementation appears, or when you actually need to fake it in a test. YAGNI applies.

"Pipeline behavior for everything"

MediatR pipeline behaviors, ASP.NET Core middleware, and EF Core interceptors are all powerful. They're also globally-applied. Adding a "transaction" behavior that wraps every command in a DB transaction sounds clean until you have a GetWeatherForecast query making three HTTP calls, all wrapped in a transaction that holds locks unnecessarily.

The senior call: think about each cross-cutting concern as "is this actually every-request, or every-some-requests-but-not-all?" Behaviors and middleware are right for the former; explicit decoration is right for the latter.

Strategy/Visitor/Decorator before you have variations

GoF patterns reward you when variations show up. Adopting a Strategy pattern with one strategy is just an awkwardly-named class. Wait for the second variation to appear, then introduce the pattern with two real concrete strategies — the abstraction is then validated against two cases instead of speculated for one.

How patterns compound (and explode)

Three patterns layered together can multiply cognitive overhead far past their individual costs. A canonical example:

Controller
  → MediatR.Send(Command)
    → ValidationBehavior (FluentValidation)
      → LoggingBehavior
        → TransactionBehavior
          → CommandHandler
            → IRepository<Order>
              → Repository<Order>
                → DbContext
                  → EF Core
                    → ADO.NET
                      → SQL Server

To follow what happens when a request arrives, a new engineer has to know: Minimal APIs, MediatR, FluentValidation, ASP.NET Core middleware order, the Repository pattern, EF Core change tracking, and SQL Server. Each is fine in isolation; together they're a wall.

The senior heuristic: adopt patterns one at a time. Live with each long enough to see whether the pain it removes is bigger than the pain it adds. If the team can't sketch the call path from controller to DB on a whiteboard, that's a layering signal.


How it works under the hood

This topic doesn't have a runtime / CLR angle — it's a judgment topic, not a mechanics topic.


Code: correct vs wrong

❌ Wrong: pattern applied because "that's how we do it"

// CQRS, MediatR, Repository, Result — all for a single-row select.
public record GetUserByIdQuery(int Id) : IRequest<Result<UserDto, NotFoundError>>;

public class GetUserByIdHandler(IUserRepository repo)
    : IRequestHandler<GetUserByIdQuery, Result<UserDto, NotFoundError>>
{
    public async Task<Result<UserDto, NotFoundError>> Handle(
        GetUserByIdQuery q, CancellationToken ct)
    {
        var u = await repo.GetByIdAsync(q.Id, ct);
        return u is null ? new NotFoundError() : new UserDto(u);
    }
}

// Repository:
public class UserRepository(AppDb db) : IUserRepository
{
    public Task<User?> GetByIdAsync(int id, CancellationToken ct)
        => db.Users.FindAsync(new object?[] { id }, ct).AsTask();
}

Five types, three abstractions, ~30 lines — to do db.Users.FindAsync(id).

✅ Correct: the simplest thing that works

app.MapGet("/users/{id:int}", async (int id, AppDb db, CancellationToken ct) =>
    await db.Users.FindAsync(new object?[] { id }, ct) is { } u
        ? Results.Ok(new UserDto(u))
        : Results.NotFound());

When GetUser grows real business rules — caching, projection, multi-tenant filtering — then extract a handler. Today: don't.

❌ Wrong: speculative interface for one implementation

public interface ITaxCalculator { decimal Calculate(Order o); }

public class UsSalesTaxCalculator : ITaxCalculator
{
    public decimal Calculate(Order o) => o.Total * 0.0875m;
}

// In DI:
services.AddSingleton<ITaxCalculator, UsSalesTaxCalculator>();

One implementation, no test fake, no second variant planned. Delete the interface.

✅ Correct: introduce the interface when there are two

public class TaxCalculator
{
    public decimal Calculate(Order o) => /* US logic for now */;
}

// When EU launches and the rules diverge:
public abstract class TaxCalculator { public abstract decimal Calculate(Order o); }
public class UsTaxCalculator : TaxCalculator { /* ... */ }
public class EuTaxCalculator : TaxCalculator { /* ... */ }

The abstraction is validated by two real cases, not speculated by one.

❌ Wrong: pipeline behavior that punishes most callers

public class TransactionBehavior<TReq, TResp> : IPipelineBehavior<TReq, TResp>
{
    public async Task<TResp> Handle(TReq req, RequestHandlerDelegate<TResp> next, CancellationToken ct)
    {
        await using var tx = await _db.Database.BeginTransactionAsync(ct);
        var r = await next();
        await tx.CommitAsync(ct);
        return r;
    }
}
// Registered globally — every read query now opens a transaction it doesn't need.

✅ Correct: behavior runs only on commands, or call sites opt in

public class TransactionBehavior<TReq, TResp> : IPipelineBehavior<TReq, TResp>
    where TReq : ICommand              // ← scoped by marker interface
{
    public async Task<TResp> Handle(TReq req, RequestHandlerDelegate<TResp> next, CancellationToken ct)
    {
        await using var tx = await _db.Database.BeginTransactionAsync(ct);
        var r = await next();
        await tx.CommitAsync(ct);
        return r;
    }
}

Reads bypass the behavior; commands get the transaction. Cross-cutting and scoped.


Design patterns for this topic

Pattern 1 — "Name the pain before the pattern"

  • Intent: force articulation of the problem; reject patterns adopted for vibes.

Pattern 2 — "Rule of three before extracting"

  • Intent: wait for the third occurrence; two could be coincidence.

Pattern 3 — "Add one pattern at a time"

  • Intent: measure the pain removed vs added before stacking another.

Pattern 4 — "Speculative interfaces are deletable"

  • Intent: the cost of removing them later is small; the cost of having them all over the codebase is constant.

Pattern 5 — "Pattern fluency = subtractive PRs"

  • Intent: the senior contribution is often "let's drop this layer," not "let's add one."

Pattern 6 — "Scope cross-cutting concerns by marker"

  • Intent: behaviors / middleware / interceptors apply where they belong, not everywhere.

Pros & cons / trade-offs

Decision Pros Cons
Adopt a pattern proactively Code "looks" architected Pays cost before benefit; misapplications spread
Adopt a pattern reactively (when pain appears) Pays cost only when justified Tempts "we'll refactor later" debt
Reject patterns by default Lean, easy to read Risks reinventing what a pattern would solve
Stack patterns liberally Each is independently defensible Compound cognitive load
Single-pattern minimum Easy to onboard May miss real wins

When to use / when to avoid

  • Use a pattern when you have a specific, named pain and the pattern is the canonical fix.
  • Use a pattern when you've hit the third occurrence of the same shape.
  • Avoid a pattern when the only justification is "it's a best practice."
  • Avoid stacking more than two patterns at once into a fresh codebase — measure each before adding the next.
  • Avoid premature abstractions (interface-per-class, speculative strategy, dehydrated handlers).
  • Avoid event-driven when the only consumer is the producer.
  • Avoid microservices / event sourcing / CQRS as defaults — they're powerful tools for specific problems, not universal architecture.

Interview Q&A

Q1. How do you decide whether to adopt a pattern? Name the specific pain it removes; name the costs (indirection, ceremony, cognitive load); decide whether the trade is positive in this codebase. If the pain can't be named in one sentence, the pattern isn't load-bearing yet.

Q2. What's wrong with Repository over EF Core? EF Core's DbContext is a Unit of Work and DbSet<T> is a Repository. Wrapping them again adds a layer that mostly forwards calls. Wrap only for a specific reason — enforcing query rules, swapping persistence, testing without a DB.

Q3. When is CQRS overkill? When reads and writes are symmetric — same model, same shape, same database, no projection. CQRS pays off when the two paths have different shapes; otherwise it's just two types per operation.

Q4. When is MediatR not pulling its weight? When there are no pipeline behaviors. Without behaviors, MediatR is service-locator-with-extra-steps; the handler exists either way and the dispatch is pure ceremony.

Q5. What's wrong with the Result pattern everywhere? Result is excellent for expected error flow (validation, business rule violations). For "what if the DB is down" (truly exceptional), exceptions are cleaner. Wrapping every return wrapper costs ceremony for no business benefit.

Q6. Should every aggregate raise domain events on state changes? Only when there's an actual subscriber in a different bounded context. If the producer and consumer live in the same module, a method call is simpler and easier to debug.

Q7. What's the "stacked patterns" smell? A simple feature change requires understanding five patterns at once (CQRS + MediatR + Repository + Specification + Result). The cognitive load grows multiplicatively, not additively.

Q8. Microservices for a five-engineer team — yes or no? Almost always no. The distributed-systems tax (network, observability, sagas, contract evolution) costs the same regardless of team size; small teams can't afford it. Modular monolith first; extract when a specific module has independent scale or cadence.

Q9. What's "premature abstraction"? Introducing an interface for an imagined second implementation that never arrives. The cost is real (extra type, extra DI registration, fake in tests); the benefit is hypothetical. Introduce abstractions when the second concrete case appears.

Q10. How do you push back when a teammate wants to "clean architecture" everything? Ask which specific pain the pattern removes — broken tests? Hard-coded dependencies? Multiple delivery mechanisms? If they can name one, the pattern is justified. If they can't, the pattern is a vibe, not a fix.

Q11. When is event sourcing the wrong default? For CRUD apps where audit / replay isn't the central requirement. Event sourcing is heavy on projection rebuilds, schema evolution, and tooling overhead. A normal relational schema plus an audit-log table covers most "what happened" questions.

Q12. What's the senior heuristic for pattern adoption? Subtractive PRs reveal pattern fluency: "let's just call the method" beats "let's introduce a Mediator." Patterns earn their place by removing pain that exists today, not by creating optionality for pain that might exist tomorrow.


Gotchas / common mistakes

  • ⚠️ "It's a best practice" — that's an assertion, not a justification. Ask which problem it solves in this code.
  • ⚠️ Pattern stacking without measurement — five patterns adopted on day one; nobody knows which one is actually paying off.
  • ⚠️ Interface-per-class — a speculative seam that nobody uses; deletes harmless, but accumulation isn't.
  • ⚠️ "We'll need it eventually" — YAGNI applies. Patterns are easier to add than to remove (interfaces, mediator pipelines, abstract base classes leave fingerprints everywhere).
  • ⚠️ Reading a book and applying it whole — Clean Architecture, DDD Blue Book, Implementing DDD. Mine them for ideas; don't transplant them whole.
  • ⚠️ Following the framework's own template too literally — the eShop reference architecture is one shape; not the only valid shape for .NET apps.
  • ⚠️ Pattern as a tribal identity — "we're a CQRS shop" / "we're a hexagonal shop." Patterns are tools; the codebase isn't a banner.

Further reading