Notes on building software

September 11, 2026

A few engineering instincts I’ve developed over sixteen years of building systems — and the reasons I keep coming back to them.

I’ve been writing software for sixteen years, across startups, consulting, a coding school, and large engineering organizations. Different stacks, different domains, wildly different constraints. What surprised me is how much stays the same. The same handful of instincts keep earning their place, whether I’m shaping a rail-signalling platform, a classroom coding game, or the AI systems I work on now.

This is my attempt to write them down. Not as laws — as the things I reach for by default, and the reasons behind them. If you’ve worked with me, you’ll recognize these from discussions and code reviews.

01 — Boundaries are where correctness lives

Most critical bugs I’ve chased didn’t live inside a function. They lived at the seam between two of them — a type that quietly changed from string to int on the way to disk, a field name that resolved to the wrong table, a camelCase key that got helpfully “cleaned up” and broke every consumer downstream.

So I hold the line hard at boundaries. Types, names, identifiers, wire formats — none of them shift silently. A DTO mirrors what’s actually on the wire and nothing more; the moment it starts carrying orchestration bookkeeping or a field with a # fixme confessing it’s a lie, it stops being honest. Dishonest data structures cost you later.

Get the interface right and you’ve saved yourself a whole bunch of refactoring later on. Get it wrong and no amount of internal tidiness saves you.

02 — Fail loud, never default silently

The failure mode I trust least is the one that hides. A default time window that silently shrinks a result set. A top limit that caps a fetch without telling anyone. A blanket except Exception that swallows the one error you needed to see. A parameter that gets dropped when two are passed and only one can win.

Every one of these makes the code look like it’s working while it lies about what it did. I’d rather it break. Be honest about what is happening so you know what there is to fix.

Narrow your exceptions to the one you actually expect. Require the argument instead of guessing a default. If a token can’t be forwarded, fail the request and log why — don’t proceed and hope. Uncertainty and unnecessary complexity are not your friends; make behaviour explicit and simple, or make it fail.

03 — Delete first, add back when needed

The smallest change that solves the real problem in the right place is usually the right one. I’ve watched too many caches, health endpoints, parallel hardcoded lists, and “we might need this” state fields get added ahead of any caller that needed them — and then quietly rot, because nothing depended on them and nobody dared remove them.

If state can be derived from data you already have, derive it — don’t track it in parallel and pray the two stay in sync. If nothing calls it, delete it; you can add it back the day a real need shows up. Premature abstraction costs more than a little duplication, and it costs it for years.

04 — Put work where the data already is

Every concern has a natural home: the layer that already owns the state it touches. Exact-value filtering belongs in the database that holds the rows, not in a processor above it. Logging belongs where the payload is finalized. Config validation belongs at boot, so a missing variable fails on startup and not at 3am on first use. Models stay pure data unless a method genuinely belongs to the model.

When you push work to its natural layer, the layers above it stay focused and the whole system gets easier to reason about. When you don’t, concerns leak upward and everything above has to know too much.

05 — Reach for the standard before inventing one

If REST, OData, or the framework already defines the shape you need, use it. A full-state PUT over a pile of RPC-flavoured delta endpoints. $search as the spec defines it, not a bespoke search param you’ll be adjusting forever. The framework’s own navigation component over a hand-rolled one. A context manager for resource cleanup, because the language already solved deterministic teardown and your clever alternative probably didn’t.

Related: when you add code next to existing code, copy the shape of its nearest sibling. Consistency across a codebase is a feature. Deviation is technical debt and should be justified.

06 — Code should explain itself

I mostly don’t use comments, and I don’t like leaving #fixme or #TODO notes behind — I’d rather derive follow-up tasks from them so the work is tracked, not buried in the code. If a piece of code needs a comment to be understood, that’s usually a naming problem or a shape problem wearing a disguise, and the honest fix is to rename or restructure until the code says what it does on its own.

Name functions for their precise domain shape. Prefer explicit types over loose dictionaries, so misuse fails in your editor at dev time instead of in production at runtime. The type system and a good name are documentation that can’t go stale.

07 — Trust comes from tests that would actually catch the bug

A test that can pass while the bug is present isn’t testing anything. A substring check that proves nothing. A mock that returns a friendly default for input it should have rejected. An assertion of “no error” when what you needed to prove is “the data actually arrived.”

I favour component tests that boot the real application and exercise real flows, mocking only at the outermost boundary — the external client, the surrounding system. Restrict mocking to what you genuinely can’t exercise. That’s where the confidence-per-unit-of-effort is highest. Assert full equality over spot-checks. Make mocks raise on the unexpected. And when a human reads the failure, tell them what it means, not just that a number didn’t match.

08 — The trade-off is the job

There’s no “best” — only “best for this constraint.” Ephemeral pods change what you can hold in memory. A payload limit changes how you chunk an outbound call. A known set of callers changes whether you need a /v2/ route or can just update everyone in lockstep. Naming the constraint out loud, and choosing deliberately against it, is most of what senior engineering actually is. Not knowing the one true answer — knowing there isn’t one, and making the call anyway.

09 — Build it together

No one builds software alone. Not really.

Even when one person writes every line of code, the useful parts of the work come from somewhere else: understanding the problem, discussing the design, challenging assumptions, reviewing the implementation, deciding what not to build. Software is a shared endeavour long before it becomes a shared codebase.

That’s even more true now that AI can produce code at a speed no team could match manually. The bottleneck moves. Writing the code gets cheaper; deciding what should be built, why, and whether it is actually right becomes more important.

I want people around me to understand the direction well enough to challenge it. To contribute rather than just execute. To review each other’s work, change their minds when the evidence says they should, and take ownership of the result.

A good team doesn’t need one person with all the answers. It needs enough shared context and trust for the answers to emerge from the team.


If there’s a thread running through all of this, it’s that I’m trying to build systems — and teams — that can move with confidence and independence. Honest boundaries, loud failures, small changes, and code that explains itself aren’t ends in themselves. They’re what lets the next person (often me, six months later) change something without being afraid of it.

That’s the bar I’m always aiming for. These principles are just how I’ve learned to get there.

← All writing