Overview
Over-architecture occurs when a design introduces more boundaries, abstractions, infrastructure, or indirection than the application's current requirements justify. The result may look sophisticated while making ordinary changes slower, behavior harder to trace, and the system more expensive to operate.
A simple application is not necessarily unimportant. It may handle valuable business data while having a small scope, modest scale, one deployment team, straightforward workflows, and limited integration needs. Such an application still needs clear code, validation, security, error handling, tests, and observability. It does not automatically need microservices, event sourcing, multiple architectural layers, or an interface for every class.
The correct goal is the simplest architecture that satisfies known functional and quality requirements while leaving reasonable options for likely change. Simplicity is not the absence of design. It is deliberate control of complexity.
This topic is important in interviews because candidates are expected to demonstrate judgment, not only pattern knowledge. A strong candidate can explain:
- Which problem an architectural pattern solves.
- What complexity the pattern introduces.
- What evidence justifies that trade-off.
- How to keep a simple design evolvable.
- Which signals should trigger a later architectural change.
Core Concepts
Essential Complexity and Accidental Complexity
Essential complexity comes from the problem itself:
- Business rules.
- Regulatory requirements.
- Security constraints.
- Required availability and performance.
- Data consistency needs.
- Integration behavior.
Accidental complexity comes from the chosen solution:
- Extra layers and mappings.
- Network boundaries.
- Messaging infrastructure.
- Framework-specific ceremony.
- Generic abstractions.
- Multiple deployment pipelines.
- Distributed transactions and eventual consistency.
Architecture cannot remove essential complexity, but it should avoid adding accidental complexity without a corresponding benefit.
What Counts as Over-Architecture?
An architectural choice is excessive when its cost is concrete but its expected benefit is speculative or irrelevant.
Common examples include:
- Splitting a small CRUD application into microservices.
- Applying CQRS with separate read and write stores when both paths use the same model and database.
- Introducing event sourcing without audit, temporal-query, or domain-reconstruction requirements.
- Creating an interface for every concrete class despite having one stable implementation.
- Wrapping EF Core in generic repositories that hide useful query capabilities without adding domain meaning.
- Adding a mediator to a handful of direct application-service calls when no pipeline behavior is needed.
- Building a plugin system for variants that do not exist.
- Creating many projects and mapping layers around simple data flow.
- Using a message broker for work that must complete synchronously in one process.
The same pattern can be appropriate in another context. The problem is not the pattern itself; it is applying it without the forces that make its trade-offs worthwhile.
Architecture Is a Trade-Off, Not a Maturity Ladder
Architectures are not levels where every application should eventually progress from monolith to microservices or from simple services to event sourcing.
Each style optimizes for different forces:
The interview-quality question is: Which requirement pays for this cost?
YAGNI, KISS, and Evolutionary Design
YAGNI, or "You Aren't Gonna Need It," advises against building functionality or flexibility before it is needed. It does not mean ignoring foreseeable risks. It means distinguishing evidence from imagination.
KISS, or "Keep It Simple," favors designs that are easy to understand and operate while still meeting requirements.
Evolutionary design accepts that architecture can change:
- Make current behavior clear.
- Protect important behavior with tests.
- Keep responsibilities cohesive.
- Isolate volatile external dependencies.
- Monitor explicit triggers for change.
- Refactor when evidence appears.
This is safer than paying permanent complexity costs for every possible future.
Simple Does Not Mean Unstructured
A simple application can have clear boundaries without adopting a full architectural template.
For example:
src/
Todo.Api/
Features/
Todos/
CreateTodo.cs
CompleteTodo.cs
GetTodos.cs
Data/
TodoDbContext.cs
Program.cs
tests/
Todo.Api.Tests/
This structure can support:
- Feature-local validation.
- Direct EF Core access in focused handlers.
- DTOs at the HTTP boundary.
- Centralized authentication and error handling.
- Unit tests for nontrivial rules.
- Integration tests against the API and database.
It avoids projects and abstractions that do not yet protect a real boundary.
Start With Requirements and Quality Attributes
Architecture should respond to requirements such as:
- Expected traffic and data volume.
- Latency targets.
- Availability and recovery objectives.
- Security and compliance boundaries.
- Consistency requirements.
- Number and autonomy of teams.
- Release frequency.
- Integration reliability.
- Expected rate and type of change.
A single-team internal application with hundreds of requests per day has different needs from a global payment platform. Applying the same architecture to both ignores the forces architecture is meant to address.
When requirements are uncertain:
- Record assumptions.
- Choose a reversible default.
- Measure actual behavior.
- Define thresholds that would force a different decision.
The Cost Model for Architectural Complexity
Architectural complexity has several dimensions.
Cognitive cost
- More concepts to learn.
- Longer navigation paths.
- Indirect control flow.
- More difficult debugging.
Development cost
- More files and mappings per feature.
- Additional tests and mocks.
- More cross-boundary contract work.
Runtime cost
- Serialization.
- Network latency.
- More failure modes.
- Synchronization and consistency work.
Operational cost
- More deployments and infrastructure.
- Monitoring, alerting, tracing, and on-call burden.
- Backup and recovery across multiple stores.
Change cost
- Contract versioning.
- Coordinated migrations.
- Boilerplate that must change with each feature.
A design should be evaluated by total lifecycle cost, not only by how clean its diagram appears.
Abstractions Must Earn Their Place
An abstraction is valuable when it represents a stable concept and protects callers from meaningful variation or volatility.
Good reasons to introduce an abstraction include:
- Multiple implementations exist now.
- An external service or vendor is volatile.
- The application owns a port that infrastructure must implement.
- Testing a nondeterministic boundary such as time is important.
- A policy must remain independent of a framework.
- Cross-cutting behavior needs a consistent extension point.
Weak reasons include:
- "We might replace the database someday."
- "Interfaces are always best practice."
- "Every service should be mockable."
- "The architecture template includes this layer."
Compare an unnecessary interface:
public interface ITodoService
{
Task<TodoDto> GetAsync(Guid id, CancellationToken cancellationToken);
}
public sealed class TodoService : ITodoService
{
// The only implementation simply forwards to one repository.
}
with a useful boundary:
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(
Money amount,
PaymentMethod paymentMethod,
CancellationToken cancellationToken);
}
The payment abstraction protects application policy from a remote vendor, failure behavior, credentials, and SDK changes. Its boundary has architectural meaning.
Avoid Interface-Per-Class Design
Dependency injection does not require every class to have an interface. Concrete classes can be constructor-injected.
Use an interface when:
- It is a port owned by higher-level policy.
- Multiple strategies are selected at runtime.
- A volatile dependency must be isolated.
- The contract is shared across a real module boundary.
Prefer a concrete class when:
- There is one stable in-process implementation.
- The class is an internal application operation.
- Tests can exercise behavior without replacing it.
- The interface would repeat the same members and add no semantic boundary.
Testing alone is not always sufficient justification. Excessive mocking often tests call choreography instead of observable behavior.
Avoid Layer-Per-Concern Ceremony
Extra layers are useful only when they contain distinct policy or protect a boundary.
An over-layered request can look like:
Controller
-> Application service
-> Domain service
-> Repository
-> Unit of work
-> EF Core DbContext
If each layer only forwards parameters, the design adds navigation without separation.
A simple use case can be direct:
app.MapPost("/todos", async (
CreateTodoRequest request,
TodoDbContext db,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Title))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["title"] = ["A title is required."]
});
}
var todo = new Todo(request.Title.Trim());
db.Todos.Add(todo);
await db.SaveChangesAsync(cancellationToken);
return Results.Created($"/todos/{todo.Id}", new TodoResponse(
todo.Id,
todo.Title,
todo.IsComplete));
});
As rules grow, extraction can be incremental:
- Move complex business rules into a domain type.
- Move repeated use-case orchestration into a handler.
- Introduce a port when an external dependency appears.
- Split a module when independent ownership becomes useful.
Generic Repository and Unit of Work in EF Core
EF Core's DbContext already provides unit-of-work behavior, and DbSet provides repository-like collection access. A generic repository that exposes only Add, Update, Delete, and GetAll may remove useful query composition while adding little domain value.
A custom repository is justified when it:
- Expresses aggregate-oriented operations.
- Hides complex persistence behavior.
- Protects domain code from infrastructure.
- Centralizes a query or concurrency rule with business meaning.
It is less useful when it only mirrors DbSet:
public interface IRepository<T>
{
IQueryable<T> GetAll();
Task AddAsync(T entity);
void Update(T entity);
void Delete(T entity);
}
For a simple data-centric application, using DbContext directly in focused handlers can be clearer.
CQRS and Mediator Trade-Offs
CQRS separates write operations from read operations. It is useful when:
- Read and write models differ materially.
- They scale independently.
- Writes enforce complex domain behavior.
- Read projections need specialized storage or shape.
Using command and query classes in one application can improve organization without requiring separate databases. However, creating duplicate models, handlers, buses, and mapping for trivial CRUD may be excessive.
A mediator is useful for:
- Pipeline behaviors such as validation, authorization, logging, or transactions.
- Dispatching requests without coupling callers to handlers.
- Consistent feature-slice organization.
It is unnecessary when it merely replaces an obvious method call and makes execution harder to follow.
Microservices and Messaging
Microservices are justified by needs such as:
- Independent deployments.
- Independent team ownership.
- Distinct scaling profiles.
- Fault or security isolation.
- Different technology lifecycles.
They also require:
- Network failure handling.
- Contract versioning.
- Distributed tracing.
- Deployment automation.
- Data ownership and eventual consistency.
- Retries and idempotency.
A small application rarely benefits from paying these costs before the relevant organizational or runtime pressures exist.
Similarly, messaging is useful for durable asynchronous workflows and decoupled notifications. It is not automatically better than an in-process method call. A queue adds delivery semantics, duplicate handling, monitoring, and delayed-failure behavior.
Event Sourcing
Event sourcing stores state changes as an append-only sequence of domain events. It can be valuable when the system needs:
- Complete historical reconstruction.
- Temporal queries.
- Auditability beyond ordinary change logs.
- Rich domain behavior expressed as events.
- Multiple projections from the same history.
It adds:
- Event schema evolution.
- Projection rebuilding.
- Eventual consistency.
- More difficult debugging and data correction.
- Specialized operational knowledge.
For ordinary CRUD with a current-state database, event sourcing is usually an unjustified default.
Duplication Versus Premature Generalization
DRY does not mean that every similar-looking block must immediately share one abstraction. Two pieces of code can look similar while representing concepts that will evolve differently.
A practical approach is:
- Tolerate small duplication while the concepts are unclear.
- Observe how each copy changes.
- Extract when the shared concept and stable variation points are understood.
- Keep separate code when changes occur for different reasons.
The "rule of three" is a heuristic, not a law: repeated use can provide enough evidence for a useful abstraction. The more important test is whether the abstraction has a coherent responsibility and makes future changes easier.
Reversible and Irreversible Decisions
Not every decision deserves the same amount of design effort.
Relatively reversible decisions include:
- Moving code into a feature folder.
- Extracting a class.
- Adding an interface at a clear boundary.
- Introducing a local application handler.
More expensive decisions include:
- Splitting data across services.
- Publishing a public API contract.
- Choosing event sourcing as the source of truth.
- Committing to a cloud-specific messaging topology.
Invest more analysis in decisions that are costly to reverse. For reversible decisions, choose a clear default and learn from implementation.
A Practical Decision Framework
Before adopting a pattern, ask:
- What current problem does it solve?
- Which requirement or measured risk demonstrates that problem?
- What new concepts and failure modes does it introduce?
- Is there a simpler design that satisfies the same requirement?
- How likely and costly is the predicted change?
- Can the pattern be introduced later without a rewrite?
- What observable trigger would tell us to evolve?
Examples of useful triggers:
- A module's release schedule repeatedly blocks another team.
- One workload consistently needs independent scaling.
- Query requirements diverge from the transactional model.
- A vendor integration changes frequently and leaks through the codebase.
- A class has accumulated several unrelated reasons to change.
- Duplicate business rules are producing inconsistent behavior.
Keeping a Simple Design Evolvable
Avoiding over-architecture does not mean creating a dead end.
Useful practices include:
- Organize by cohesive features.
- Keep business rules out of UI and infrastructure details.
- Use explicit request and response contracts at external boundaries.
- Centralize composition in the application entry point.
- Isolate genuinely volatile integrations.
- Keep dependencies acyclic.
- Write tests around important behavior.
- Record architecture decisions and assumptions briefly.
- Measure performance before optimizing.
- Refactor continuously in small steps.
These practices preserve options without implementing every possible future architecture.
Common Mistakes
- Equating more projects and interfaces with better separation.
- Choosing architecture from a diagram or template before understanding requirements.
- Building for hypothetical global scale.
- Using microservices to solve code organization problems.
- Treating every internal call as an event.
- Mocking every dependency and testing implementation details.
- Adding mappings that duplicate identical models without protecting a boundary.
- Hiding framework capabilities behind weaker generic abstractions.
- Refusing all structure in the name of YAGNI.
- Waiting until code is unmaintainable before refactoring.
Best Practices
- Begin with functional requirements, quality attributes, and team constraints.
- Choose the least complex design that meets those needs.
- Make the reason for every major pattern explicit.
- Separate business policy from volatile external details.
- Prefer cohesive feature organization over ceremonial global layers.
- Use direct calls when synchronous in-process collaboration is sufficient.
- Add abstractions around proven variation or meaningful boundaries.
- Define measurable triggers for architectural evolution.
- Treat operational and cognitive costs as first-class design concerns.
- Revisit decisions as evidence changes.