📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you detect over-engineering in a .NET system, and how do you reverse it without a rewrite?

Signals of over-engineering (layers, patterns, microservices, configurability), metrics to prove it, communicating with the team, and incremental simplification.

Senior Architecture Advanced architecture design-judgment over-engineering simplification

🎯 Key points

  • Over-engineering is complexity not justified by current requirements
  • Signals: many files per change, single-implementation abstractions, unused configurability, premature microservices
  • Measure: files touched per change, lead time, onboarding time, code that is never executed
  • Reverse incrementally: inline, merge layers, consolidate services, delete unused options
  • Use tests and characterisation tests as a safety net
  • Address the cause: unclear requirements, resume-driven design, or fear of change

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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."


1. Signals and how to measure them

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

2. Reversal strategy

  1. Gather evidence and present it as a team problem, not as blame on the original authors (who often had good reasons at the time).
  2. Agree the target shape (e.g. "vertical slices over a domain core; modular monolith with four modules") in an ADR.
  3. Add safety nets: characterisation tests, contract tests, good observability.
  4. Simplify one slice at a time: pick the highest-cost area; each step is a normal PR alongside feature work.
  5. Delete aggressively once a path is unused (options, layers, services).
  6. Measure again: files per change, lead time, deploy time, latency.
  7. Prevent recurrence: problem statements and ADRs required for new layers/services/patterns.

3. Typical reversals

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

Production example

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.

🔍 What the interviewer is checking

Checks diagnosis, evidence-based persuasion and safe change management.

Green flags

  • Lists concrete, measurable signals.
  • Proposes incremental simplification with tests.
  • Mentions modular monolith or merging services where appropriate.
  • Addresses root causes and team dynamics.

Red flags

  • "Rewrite it."
  • Simplifies by removing all structure.

📚 Learn in depth

1. Why it happens

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

2. Modular monolith as a landing zone

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)
  • Boundaries stay explicit (module public APIs, owned schemas, in-process events).
  • Network hops, distributed transactions, per-service pipelines disappear.
  • Architecture tests stop modules reaching into each other's internals.
  • A module can still be extracted into a service later if a real scaling or team need appears.

3. Merging a service into a module, step by step

  1. Move the service's code into a module project behind the same contract (interface or in-process handler).
  2. Route callers to the in-process implementation behind a feature flag; keep the HTTP endpoint temporarily.
  3. Move its data into the module's schema (or keep the database and change connection ownership first).
  4. Remove the network call path and the old deployment.
  5. Repeat for the next service.

4. Finding dead configurability and code

  • Search configuration across all environments for keys only ever at defaults.
  • Use feature-usage telemetry or production coverage tools to find never-executed paths.
  • IDE "unused member" analysis, dotnet format analyzers (IDE0051/IDE0052) for unused private members.

5. Avoiding the opposite failure

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.

6. Communicating it

  • Lead with outcomes the business cares about: lead time, incident rate, deploy time, onboarding time.
  • Avoid blame: earlier decisions were often reasonable for earlier assumptions.
  • Offer a reversible, incremental plan rather than a "big simplification project".

7. Try it yourself

  1. Count files touched in your last five small feature PRs; find layers present in every one.
  2. Remove one pass-through layer for one feature; compare the next change's diff.
  3. List config keys and check which ever differ from defaults.
  4. Map which services deploy together over the last 20 releases.
  5. Write an ADR template that requires a problem statement for new layers or services.

💻 Code

// 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.

⚠ Common mistakes

1. Responding with a full rewrite

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.

2. Calling it over-engineered without evidence

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.

3. Removing boundaries that protect real change

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.

4. Not addressing the cause

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.

5. A separate "simplification project"

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.

6. Simplifying without safety nets

What happens: layers removed with thin test coverage. Why it is wrong: behaviour changes go unnoticed. Do instead: characterisation and contract tests first.

7. Blaming the original authors

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.

8. Keeping dead options "just in case"

What happens: unused configuration and branches remain. Why it is wrong: carry cost and test burden continue. Do instead: delete; version control remembers.

9. Merging services without data ownership plans

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.

10. Not measuring the result

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate talks about accidental complexity Why: Checks KISS reasoning
    How to answer

    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.

  2. When: Candidate inlines single-use interfaces Why: Probes abstraction judgement
    How to answer

    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.

  3. When: Candidate simplifies incrementally Why: Moves to refactoring strategy
    How to answer

    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.

🛠 Real-world problems: how you handle them

Fourteen microservices for a team of five
Situation
A team of [five] ran [14] microservices for one product.
Signals
[Nine] services deployed together each release; [40]-span traces for a page; [90]-minute deployments.
Investigation
Mapped deployment coupling and synchronous call chains; most services shared data and release cadence.
Resolution
Merged into a modular monolith with [four] modules and architecture tests, migrating one service per sprint.
Prevention
ADR required for new services, with team ownership and scaling justification.

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.

↩ Usually asked after

More in Senior-Level Judgment

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me