Signals of over-engineering (layers, patterns, microservices, configurability), metrics to prove it, communicating with the team, and incremental simplification.
Over-engineering is complexity that today's requirements do not justify. I detect it through symptoms with numbers behind them: a one-field change touching many files, interfaces with one implementation, configuration options nobody sets, patterns like CQRS or event sourcing without the problems they solve, and microservices that always deploy together. I reverse it incrementally rather than with a rewrite: inline single-use abstractions, merge layers that only pass data through, consolidate services that share a release cycle, and delete unused options, each step behind tests. I also address why it happened, because otherwise it grows back.
"Over-engineering is complexity that today's requirements don't justify: layers, patterns, services or configurability built for problems the system doesn't have. I detect it through symptoms I can measure, because 'this is over-engineered' as an opinion goes nowhere. How many files does a one-field change touch? How many interfaces have one implementation outside real boundaries? Which configuration options have never been set to a non-default value? Which microservices always deploy together? Is there CQRS with one database and identical models, or event sourcing with no audit or temporal needs? How long does a new starter take to ship a first change? I reverse it incrementally, not with a rewrite: share the evidence with the team without blame, agree a target shape, add characterisation tests where coverage is thin, then simplify one vertical slice at a time. That means inlining single-use abstractions, collapsing pass-through layers, merging lock-step services into modules of a modular monolith, and deleting unused options. Each step is a normal PR that keeps the system shippable. Finally I address why it happened (unclear requirements, copied big-company architecture, resume-driven design, fear of change) with ADRs and problem statements, or it grows back."
| Signal | Measurement |
|---|---|
| Shotgun changes | Average files touched per PR for small features |
| Pass-through layers | Methods that only forward the same arguments to the next layer (+ a mapper per hop) |
| Speculative interfaces | Interfaces with one implementation, not at a boundary |
| Unused configurability | Config keys never set to non-default values in any environment |
| Premature distribution | Services always deployed together; chatty synchronous chains; shared databases |
| Pattern without problem | CQRS with identical read/write models; event sourcing with no replay/audit use; mediator for 5 endpoints |
| Custom frameworks | In-house base classes/engines every feature must use |
| Slow onboarding | Days until a new starter ships a first change |
| Dead code | Never-executed paths (production coverage, feature usage telemetry, analysers) |
| Operational overhead | Deploy time, number of pipelines/dashboards per feature |
| Over-engineered element | Incremental reversal |
|---|---|
| Pass-through layers | Collapse per vertical slice; keep real rules in the domain |
| Lock-step microservices | Merge into modules of a modular monolith, one service per sprint |
| Generic repository over EF Core | Use DbContext per feature, then delete the repository |
| Single-use interfaces | Inline module by module; keep boundary ports |
| Unused configuration options | Delete the option and the dead branch |
| Mediator for a few endpoints | Call handlers/services directly; keep pipeline behaviours only if they pay |
| Event sourcing without need | Snapshot current state into tables; retire the event store |
A team of [five] engineers ran [14] microservices for one product. Each release deployed [nine] of them together, a single page load produced a [40-span] distributed trace, and deployments took [about 90 minutes]. I mapped deployment coupling, synchronous call chains and shared data, and showed the team that most services shared both a release cadence and a database. We agreed a target of a modular monolith with [four] modules, documented it in an ADR, and merged one service per sprint behind contract tests, with NetArchTest rules keeping module boundaries honest. Feature work continued throughout. After [two quarters], deployments took [about 12 minutes], p95 latency had roughly halved, and new services now require an ADR with team ownership and a scaling justification.
Checks diagnosis, evidence-based persuasion and safe change management.
Green flags
Red flags
| Cause | Countermeasure |
|---|---|
| Uncertain requirements → build for everything | Short feedback loops; YAGNI; reversible decisions |
| Copying big-company architecture | Fit architecture to team size, scale and change rate |
| Resume-driven development | Architecture reviews that require a problem statement |
| Fear of future change | Invest in tests and refactoring skills instead of speculative flexibility |
| Template/framework defaults | Question each layer the template adds |
| No feedback on cost | Measure files per change, lead time, deploy time |
Single deployable
├── Modules/Ordering (public API: IOrderingModule, events) own schema: ordering.*
├── Modules/Billing (public API: IBillingModule, events) own schema: billing.*
├── Modules/Catalogue ...
└── Host (composition root)
dotnet format analyzers (IDE0051/IDE0052) for unused private members.Simplification should keep the boundaries real change patterns need. Removing a payment port because "we only have one vendor" is wrong if a vendor change is on the roadmap; collapsing a module boundary between teams that genuinely change independently recreates coordination pain. Use change history and ownership, not aesthetics, to decide.
// Before: four pass-through layers for one read.
// Controller -> IOrderAppService -> IOrderDomainService -> IOrderRepository -> DbContext
// Each layer: same arguments in, same DTO out, plus a mapper per hop.
// After: one endpoint, one query, with the real rule kept in the domain.
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ShopDb>(o => o.UseInMemoryDatabase("shop"));
var app = builder.Build();
app.MapGet("/customers/{id:int}/orders", async (int id, ShopDb db, CancellationToken ct) =>
await db.Orders
.Where(o => o.CustomerId == id)
.OrderByDescending(o => o.PlacedAt)
.Select(o => new { o.Id, o.Total, Overdue = o.DueDate < DateOnly.FromDateTime(DateTime.UtcNow) && !o.Paid })
.ToListAsync(ct));
app.Run();
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
public DateTimeOffset PlacedAt { get; set; }
public DateOnly DueDate { get; set; }
public bool Paid { get; set; }
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
If the overdue rule is used elsewhere, move it to a named policy (see DRY) rather than reintroducing layers. Requires the Web SDK and Microsoft.EntityFrameworkCore.InMemory.
What happens: "let's start again, simply this time". Why it is wrong: rewrites overrun, freeze features and lose hidden behaviour. Do instead: incremental simplification behind tests.
What happens: opinion vs opinion. Why it is wrong: original authors get defensive; nothing changes. Do instead: measurements: files per change, deployment coupling, lead time.
What happens: all interfaces and modules collapsed. Why it is wrong: swings to under-engineering; the pain returns. Do instead: keep ports and modules that match actual change patterns.
What happens: complexity removed, habits unchanged. Why it is wrong: the same decisions rebuild it. Do instead: problem statements and ADRs for new layers/services.
What happens: months of refactoring with no features. Why it is wrong: loses stakeholder support; risky big-bang changes. Do instead: simplify alongside feature work, slice by slice.
What happens: layers removed with thin test coverage. Why it is wrong: behaviour changes go unnoticed. Do instead: characterisation and contract tests first.
What happens: "whoever built this didn't know what they were doing". Why it is wrong: damages trust; ignores context they had. Do instead: frame it as assumptions that changed.
What happens: unused configuration and branches remain. Why it is wrong: carry cost and test burden continue. Do instead: delete; version control remembers.
What happens: code merged but databases still shared chaotically. Why it is wrong: coupling just moves. Do instead: module-owned schemas and explicit module APIs.
What happens: simplification declared successful by feel. Why it is wrong: no evidence to justify continuing or to prevent regressions. Do instead: track the same metrics before and after.
Detecting over-engineering is easy; reversing it while the product keeps shipping is the senior skill. I make the cost visible, agree on a target shape, and simplify in small steps that each leave the system working.
| Over-engineered element | Incremental reversal |
|---|---|
| Pass-through layers | Collapse per vertical slice |
| Lock-step microservices | Merge into modules of a modular monolith |
| Generic repository | Use DbContext per feature, then delete |
| Unused config options | Delete option and dead branch |
| Event sourcing without need | Snapshot to state tables, retire the event store |
KISS (Keep It Simple) means choosing the simplest design that meets the actual requirements, so the code is easy to read, test and change. The useful distinction is between essential complexity (the problem really is hard, such as tax rules) and accidental complexity (layers, patterns and frameworks we added). I also separate simple (few interleaved concepts) from easy (familiar or quick to write): copying a framework template is easy but may not be simple. In practice it means a plain class before an abstraction, a direct EF Core query before a generic repository, and readable code over clever LINQ.
I do not abstract when there is no second variant, no boundary to protect and no testing need, because an abstraction costs indirection, a name, a contract and cognitive load. Typical examples are an IOrderService with one implementation used by one controller, a generic repository over EF Core, or a wrapper around HttpClient or ILogger<T>, which are already abstractions. I do abstract at architecture boundaries (ports for payment vendors, email, storage) and where tests need a seam for something slow or non-deterministic. If an abstraction has had one implementation and one caller for a long time and is not at a boundary, I inline it.
I treat a large refactoring as a series of small, behaviour-preserving steps merged to main continuously, not a big-bang branch. First I pin current behaviour with characterisation tests. Then I pick a technique: strangler fig to route traffic piece by piece to a new implementation, branch by abstraction to put an interface in front of the old code and swap implementations behind it, or parallel change (expand, migrate, contract) for APIs and database schemas. Feature flags and shadow comparisons let old and new paths run side by side. I prioritise hotspots (code that changes often and hurts), keep refactoring commits separate from feature commits, and define an end state so it does not drag on forever.
How to tell it (STAR)
We were five engineers running [14] microservices that always released together. I showed the team the deployment coupling and trace data, and we agreed to merge them into a modular monolith one service per sprint, with architecture tests protecting the module boundaries. Deployments went from [90] to [12] minutes and latency halved.
Last updated 31 Oct 2026.