Why Senior Engineers Write Code That Looks Junior
On this page
I have written about event sourcing on this blog. I have written about the outbox pattern and about building domain-specific languages. I like these ideas. I think every backend engineer should understand them.
And yet, the most valuable thing I have learned about all of them is when not to use them.
Picture a team building a wallet service for a fintech startup. Someone proposes event sourcing for the ledger: every credit and debit stored as an immutable event, balances rebuilt by replaying the stream, projections feeding read models. It sounds rigorous. It sounds like what serious engineers do.
The product has a few thousand users. The team has four engineers. Nobody on it has run an event-sourced system in production.
For a system at this stage, the senior answer is much less exciting: a conventional double-entry model where every transfer posts balanced debit and credit entries, balances updated atomically inside a database transaction, idempotency keys on every request, and an immutable audit trail. Boring. Readable. Debuggable at 2am. Replaceable later if the business truly needs more.
That answer can look junior. It is not. And the gap between those two things is what this article is about.
The three stages of complexity
Most engineers move through three stages in their relationship with complexity.
Stage one: simple because you don’t know better. You write a long method with nested if statements. You put the SQL in the controller. It works, and it is simple, but only because you haven’t met the alternatives yet.
Stage two: complex because you finally do. You discover design patterns, clean architecture, CQRS, message brokers, and generics that take three type parameters. Every problem looks like a chance to use them. Your pull requests get longer, your diagrams get more boxes, and you feel like a real engineer for the first time.
Stage three: simple again, on purpose. You know every pattern from stage two. You have also maintained the systems built with them. So you reach for them only when the problem demands it, and the rest of the time you write code that looks almost naive.
The tragedy is that stage two often gets mistaken for seniority. It is louder, it looks impressive in a design review, and it is easy to measure. Stage three is quiet. From the outside, it can look exactly like stage one.
The code is the same. The reasoning is completely different. Junior simplicity comes from not knowing the options. Senior simplicity comes from knowing all of them and choosing restraint.
Simple is not the same as easy
Before going further, it helps to be precise about the word. In his talk Simple Made Easy, Rich Hickey draws a line that every engineer should know.
Simple means few things tangled together. A simple system has parts you can understand one at a time, without holding the whole thing in your head.
Easy means familiar or close at hand. The framework you already know is easy. The library you can install in one command is easy. Easy is about you; simple is about the system.
The two often pull in opposite directions. Adding a new dependency is easy, but it makes the system less simple. Writing a plain SQL query instead of reaching for a query builder might take a few more minutes, but the result is simpler.
Simple also does not mean fewer lines. Sometimes the simple version is longer, because it is explicit. Five clear if statements can be simpler than one clever abstraction that routes behaviour through reflection and annotations. Line count is a proxy. What you are really measuring is how much a reader has to understand before they can safely change the code.
Why complexity is the default
It would be easy to write this article as a lecture. But engineers don’t over-engineer because they are foolish. They do it because almost every incentive pushes them there.
Complexity feels like progress. Adding a layer, a queue or a new service produces visible output. Deleting an abstraction feels like doing nothing, even when it is the most valuable change of the week.
Complexity signals skill. In design reviews and interviews, the engineer who mentions sagas, circuit breakers and event-driven architecture sounds more senior than the one who says “a cron job will do.” We reward vocabulary more than judgement.
Complexity pads the CV. “Built an event-driven microservices platform on Kafka” reads better than “kept the monolith boring and it never went down.” Résumé-driven development is real, and most of us have been guilty of it.
Complexity hedges against an imagined future. “What if we need to support ten payment providers?” “What if we hit a million users?” Building for those futures feels responsible. Usually it means paying today for a problem you may never have, in a shape you cannot yet predict.
Understanding these pressures matters, because resisting them is the actual skill. Knowing the patterns is table stakes. Saying no to them, and being able to explain why, is where seniority shows.
What senior simplicity looks like in practice
Principles are cheap. Here is what this looks like in the kind of systems I work on: payments, core banking and fintech infrastructure.
The fee engine nobody needed
A microfinance bank charges transfer fees. There are three rules: a flat fee below one amount, a different flat fee above it, and no fee for internal transfers. A stage-two engineer sees a future full of configurable rules and builds something like this:
public interface FeeRule {
boolean appliesTo(TransferContext ctx);
BigDecimal calculate(TransferContext ctx);
int priority();
}
@Component
public class FeeRuleEngine {
private final List<FeeRule> rules;
public BigDecimal evaluate(TransferContext ctx) {
return rules.stream()
.sorted(Comparator.comparingInt(FeeRule::priority))
.filter(rule -> rule.appliesTo(ctx))
.findFirst()
.map(rule -> rule.calculate(ctx))
.orElseThrow(NoApplicableFeeRuleException::new);
}
}
Plus three FeeRule implementations in three files, a priority scheme someone has to remember, and Spring wiring that decides the order at runtime. Here is the senior version:
public BigDecimal calculateFee(Transfer transfer) {
if (transfer.isInternal()) {
return BigDecimal.ZERO;
}
if (transfer.amount().compareTo(FEE_THRESHOLD) <= 0) {
return LOW_TIER_FEE;
}
return HIGH_TIER_FEE;
}
A new engineer can read it in ten seconds. An auditor can verify it. If the bank later needs twenty configurable rules per customer segment, you will know far more about what those rules look like than you do today, and you can design for the real requirement.
Requery with a scheduled job, not a saga framework
Anyone who has integrated with Nigeria’s NIBSS Instant Payment (NIP) network knows that some transfers come back with a pending or unknown status, and you must requery them later. It is tempting to model this as a distributed saga with an orchestrator, compensation steps and a state machine library.
Often, a status column, a next_requery_at timestamp and a scheduled job that picks up pending rows is all you need. It is transparent, easy to monitor, and easy to fix by hand when something unusual happens, which in payments it will.
Modular monolith before microservices
For a new core banking product, starting with a single Spring Boot application split into clear modules (accounts, loans, transfers, reporting) is usually the senior call. You get clean boundaries without network calls, distributed transactions or ten deployment pipelines. When a module genuinely needs to scale or deploy independently, the boundary is already there to cut along, provided those boundaries were actually enforced (no module reaching into another’s tables or internals) rather than merely represented by package names.
Boring retries over a new library
If an idempotent downstream call occasionally fails with a transient timeout, a small, bounded retry with backoff and jitter is often better than introducing a resilience framework the rest of the team has never used. In payments, “idempotent” is doing a lot of work in that sentence: never retry a debit or transfer unless the provider honours an idempotency key or you have confirmed the first attempt failed. When in doubt, requery before you retry. And still, the best dependency is the one you did not add.
The hidden cost of clever
Complexity is a loan, and the person who takes it out is rarely the one who repays it.
The engineer who builds the clever system gets the satisfaction of building it. The cost lands on everyone else: the new hire who needs three weeks instead of three days to become productive, the teammate afraid to touch a module because nobody is sure what depends on it, and the on-call engineer staring at a stack trace that passes through six layers of abstraction.
In payments, this cost is not abstract. When a transfer is stuck, a customer’s money is in limbo. The engineer investigating needs to answer one question fast: what happened to this transaction? In a simple system, that is a query and a log search. In an over-engineered one, it can mean replaying events, tracing messages across services and reading framework source code.
Clever code also ages badly. The person who understood it leaves. The patterns that were fashionable when it was written fall out of favour. What remains is a system that works until it doesn’t, and that nobody wants to own.
Simple code, by contrast, is generous. It assumes the next reader is tired, busy and new to the codebase. That assumption is almost always correct.
Simplicity is not an excuse
There is a lazy version of this argument, and it is worth rejecting clearly. “Keep it simple” is not permission to ignore hard problems.
Some problems are genuinely complex. Reconciliation between your ledger and a settlement file is complex. Guaranteeing that a debit and its reversal never both go missing in a distributed system is complex. Parsing and building ISO 8583 messages correctly is complex. Pretending otherwise does not remove the complexity. It just pushes it somewhere worse, usually into production incidents.
Fred Brooks gave us the right vocabulary decades ago in No Silver Bullet. Essential complexity belongs to the problem itself. Accidental complexity is what we add through our tools, structures and choices.
The senior engineer’s job is not to eliminate complexity. It is to eliminate the accidental kind, and to put the essential kind in one well-understood, well-tested place. Your reconciliation logic can be intricate. The code around it, the way it is triggered, logged and deployed, should be as plain as possible.
This is also why the event sourcing example at the start is not an argument against event sourcing. If regulators require you to reconstruct any account’s state at any past moment, or your domain genuinely revolves around a history of events, it may be exactly the right tool. The point is that you choose it because the problem demands it, not because it is interesting.
Six questions before I add another abstraction
Simplicity is a habit more than a rule. These are the questions I find myself asking before I add a layer, a service or a dependency.
- Are we solving a problem we have, or one we might have? If it is the second, write down the trigger that would make it real, and wait for it.
- What does the version with no abstraction look like? Write it, even mentally. Is it actually worse, or just less impressive?
- Could a new hire understand this in a day? If not, the complexity had better be essential.
- How would I debug this at 2am? Trace a failing request in your head. Count the places you would need to look.
- How hard is this to delete? Good code is easy to remove. If a design makes future deletion painful, it is taking on more commitment than it seems.
- Is the team ready to run it? A tool nobody on the team has operated in production is a risk, however elegant it looks on a whiteboard.
None of these questions forbid complexity. They just make it earn its place.
Knowing what not to build
Back to that wallet service. The team that chose a plain double-entry ledger did not skip the hard thinking. They did it. They considered event sourcing, weighed it against their scale, their team and their deadlines, and decided it was not yet worth the cost. They also knew exactly what would change their minds.
That is what seniority looks like. Building complex systems is a skill. Knowing when you don’t need one is another skill entirely, and often the rarer one. It takes judgement to see when a simple system is enough, and the confidence to defend that choice in a room full of people who want something more impressive.
So the next time you review a pull request that looks almost too plain, a single table, a few if statements, no new dependencies, pause before you judge it. It might have been written by someone who does not know better.
Or it might have been written by someone who knows far more than it shows.