Keep It Simple: essential versus accidental complexity, simple versus easy, readable code over clever code, and how to argue for simplicity in design reviews.
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.
"KISS means choosing the simplest design that meets the real requirements, so the next person can read, test and change it safely. Two distinctions make it practical. The first is essential vs accidental complexity (Fred Brooks): tax law or settlement rules are essentially complex and the code has to reflect that, while layers, patterns and frameworks we add are accidental complexity, and that's the part KISS attacks. The second is simple vs easy (Rich Hickey): simple means few concepts interleaved, one role per thing; easy means familiar or quick to start. Copying a 'clean architecture' template with a mediator, generic repositories and CQRS is easy, but for a six-endpoint admin API it isn't simple. In .NET that usually means a plain class before an abstraction,
DbContextbefore a generic repository, minimal APIs or a service before a mediator, a modular monolith before microservices, and a readable loop before clever LINQ. Simplicity still needs structure: a 3,000-lineProgram.csis easy, not simple. I add structure when a concrete need appears, and in reviews I argue with costs: files touched per change, onboarding time, test setup size."
| Essential (keep, model clearly) | Accidental (remove where it doesn't pay) |
|---|---|
| Tax rules, pricing, eligibility | Layers that only forward calls |
| Regulatory workflows | Patterns adopted by habit (mediator, CQRS, event bus) |
| Concurrency inherent in the domain | Distributed systems where one process would do |
| Real integration constraints | Custom frameworks, base classes, generic "engines" |
| Simple | Easy | |
|---|---|---|
| About | The artefact: few interleaved concepts | The person: familiar, near to hand |
| Example | A handler calling DbContext |
A template with 12 projects you've used before |
| Long-term cost | Low | Can be high (ceremony, indirection) |
| Question | How many things must I hold in mind to change this? | How quickly can I start? |
| Source | Simpler alternative |
|---|---|
| Generic repository + unit of work over EF Core | DbContext directly, or focused query classes |
| Mediator for a handful of endpoints | Minimal API handlers or a service class |
| Microservices for one small team | Modular monolith with clear module boundaries |
| CQRS with separate databases for simple CRUD | One model, maybe separate read queries |
| Custom base controllers/services with hooks | Composition, filters, middleware |
| AutoMapper for 3 DTOs | Explicit mapping methods |
Deep generic hierarchies (BaseService<TEntity, TKey, TDto, ...>) |
Concrete classes |
| Clever LINQ with side effects | A clear loop with named variables |
I inherited an [internal admin API] with [six] endpoints built on CQRS with separate read/write stores, a mediator, a generic repository with unit of work, and an event bus. Onboarding took [three weeks], adding a single field touched [eight] files, and people regularly couldn't find where logic lived. I listed every layer against the problem it actually solved for this app: most solved none. We collapsed it to minimal API endpoints using EF Core directly, keeping one service class where real business rules lived. The code shrank by [about 40%], change lead time went from days to hours, and we added a rule that architecture decision records must state the concrete problem a new layer solves.
Checks whether the candidate can resist over-engineering and articulate why.
Green flags
Red flags
| Idea | Source |
|---|---|
| KISS | Attributed to Kelly Johnson (Lockheed Skunk Works): designs should be repairable by an average mechanic in the field |
| Essential vs accidental complexity | Fred Brooks, No Silver Bullet (1986) |
| Simple vs easy | Rich Hickey, Simple Made Easy (2011) |
| "Make it work, make it right, make it fast" | Kent Beck |
Johnson's framing is useful: simplicity is about the people who maintain it under pressure, not elegance for its own sake.
| Signal | Typical cause |
|---|---|
| One behaviour change touches many files | Too many layers / pass-through classes |
| New starters need a diagram to add an endpoint | Framework ceremony |
| Many interfaces with one implementation | Speculative abstraction |
| Reviewers can't predict behaviour | Cleverness, hidden side effects, magic conventions |
| Debugging requires stepping through generic plumbing | Custom frameworks |
| Tests need elaborate setup | Tangled concerns |
| Too little structure | Appropriate | Too much |
|---|---|---|
One giant Program.cs / god class |
Feature folders/modules with clear names | 12 projects, 5 layers per feature |
| Inline SQL everywhere | DbContext with configurations |
Generic repository + specification framework |
| Copy-paste | Small focused helpers | Custom framework |
// Clever: one pass, but readers must decode tuple arithmetic
var r = xs.Aggregate((0, 0), (a, x) => x > 0 ? (a.Item1 + x, a.Item2) : (a.Item1, a.Item2 + 1));
// Simple: two passes, obvious intent; optimise only if a benchmark says so
var positiveTotal = xs.Where(x => x > 0).Sum();
var nonPositiveCount = xs.Count(x => x <= 0);
In large systems, KISS shows up as:
Some complexity is worth paying for: a resilience pipeline for a flaky dependency, an outbox for must-not-lose events, caching backed by measurements. The test is whether there's a concrete, current problem and evidence the complexity solves it.
// Over-engineered: a generic repository and unit of work wrapping EF Core,
// which already is a unit of work with repositories (DbSet<T>).
// public interface IRepository<T> { IQueryable<T> Query(); void Add(T e); }
// public interface IUnitOfWork { Task CommitAsync(); }
// Simple: a minimal API endpoint using the DbContext directly.
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AdminDb>(o => o.UseInMemoryDatabase("admin"));
var app = builder.Build();
app.MapGet("/users/{id:int}", async (int id, AdminDb db, CancellationToken ct) =>
await db.Users.FindAsync([id], ct) is { } user ? Results.Ok(user) : Results.NotFound());
app.MapPost("/users", async (User user, AdminDb db, CancellationToken ct) =>
{
db.Users.Add(user);
await db.SaveChangesAsync(ct);
return Results.Created($"/users/{user.Id}", user);
});
app.Run();
public sealed class User
{
public int Id { get; set; }
public required string Email { get; set; }
}
public sealed class AdminDb(DbContextOptions<AdminDb> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
}
Requires the Web SDK and Microsoft.EntityFrameworkCore.InMemory.
What happens: CQRS, mediator, microservices for a small app. Why it is wrong: accidental complexity slows every change. Do instead: start direct; add structure when a specific need appears.
What happens: "we always use this template". Why it is wrong: familiarity doesn't reduce the concepts a change requires. Do instead: judge by how much a reader must hold in mind to change it.
What happens: dense LINQ, nested ternaries, tuple tricks. Why it is wrong: the next reader pays in comprehension and bugs. Do instead: code that reads like the explanation you'd give.
What happens: everything in one file or method. Why it is wrong: that's complex, just concentrated. Do instead: the smallest structure that keeps responsibilities clear.
What happens: tax or settlement rules "simplified" by ignoring edge cases. Why it is wrong: the software becomes wrong. Do instead: model essential complexity explicitly and clearly.
What happens: a home-grown BaseService<T> engine for all features.
Why it is wrong: every developer must learn it; debugging goes through plumbing.
Do instead: plain classes and built-in framework features.
What happens: "we need a repository because that's clean architecture". Why it is wrong: no stated problem, no way to judge value. Do instead: state the concrete problem each layer solves.
What happens: complex caching or pooling without measurements. Why it is wrong: complexity without proven benefit. Do instead: measure first, then optimise the hot path.
What happens: five patterns for data access in one codebase. Why it is wrong: cognitive load multiplies. Do instead: agree on one simple default and document exceptions.
What happens: layers added for a past need remain forever. Why it is wrong: the cost persists after the benefit disappears. Do instead: periodically remove layers that no longer earn their keep.
Simplicity is a senior skill because it requires understanding the problem well enough to leave things out. I defend simple designs in reviews with concrete costs: files touched per change, onboarding time and test setup size.
| Proposal | Question I ask |
|---|---|
| Add a generic repository | What can it do that DbContext cannot? |
| Split into microservices | Which team or scaling need forces this? |
| Add a mediator | Which cross-cutting behaviour needs the pipeline? |
| Add an interface | Is there a second implementation or a test seam need? |
| Clever optimisation | Is there a benchmark showing the need? |
YAGNI (You Aren't Gonna Need It, from Extreme Programming) says do not build a capability until you actually need it. Speculative features cost time to build, delay what is needed now, add code to maintain, and are often wrong when the real requirement arrives. YAGNI does not mean skipping tests or refactoring; it is those practices that make adding the feature later cheap. I make an exception for decisions that are expensive to reverse, such as public API contracts, persisted data formats and security, where some upfront design pays off.
DRY (from The Pragmatic Programmer) says every piece of knowledge should have one authoritative representation in the system. It is about rules, not text: the GST rate, the definition of an overdue invoice or a validation rule should live in one place. Two blocks of code that look the same but change for different reasons are accidental duplication, and merging them couples unrelated features. I remove true duplication of business knowledge quickly, and I tolerate similar-looking code until a real shared concept appears, usually around the third repetition.
Separation of Concerns (Dijkstra's term) means each part of a system addresses one concern, so you can reason about and change it without understanding everything else. In an ASP.NET Core app the typical concerns are HTTP handling (routing, model binding, status codes), business rules, persistence, and cross-cutting concerns such as logging, authentication, validation and error handling. Cross-cutting concerns go into middleware, endpoint filters or decorators rather than every handler. Separation can be by technical layer or by feature (vertical slices), and over-separating is its own problem because one change then spans many files.
How to tell it (STAR)
I inherited a small admin API built with every pattern in the book, and a one-field change touched [eight] files. I listed each layer against the problem it actually solved and most solved nothing here. We collapsed it to minimal APIs and EF Core, the code shrank by about [40%], and changes went from days to hours.
Last updated 31 Oct 2026.