← writing

Opinionated Takes on Writing Good Software

· 5 min

Good software is software you can keep changing quickly. Speed, quality, and the ability to hire people who care all depend on that. I believe the only thing that keeps software changeable is simplicity: how little you have to hold in your head to make a change safely.

All the takes here are based on this same belief, applied to the different places where complexity shows up. My post on work in progress is about the complexity that builds up over time when work stays unfinished. This one is about the complexity you design in from the start.

The best code is the code you don’t write

A lot of complexity comes from scope you never needed. Before building anything, the first question should be whether you actually need it, and the honest answer is usually no. Reduce the idea to the smallest thing that solves the real problem, then build that.

A feature you don’t ship has no bugs, needs no maintenance, and adds nothing people have to understand later. Removing scope is the easiest way to keep things simple, but it is often the last option people consider.

Slice vertically, not horizontally

A horizontal slice builds out one whole layer at once, all the things you could do on that level. A vertical slice connects both ends instead, so something works end to end. Only the vertical slice creates value, because value only exists once both ends are connected.

So build the smallest vertical slice you can. You can always add more slices later, but a very thin one has almost nothing to disagree with, can be merged quickly, and forces you to focus on the core value. And if a slice turns out not to create value, it was the wrong slice. That is also the fastest way to find out you are building the wrong thing.

Shorten the distance to the customer

Every handover between the engineer and the person with the problem makes the software worse. When information goes from customer to PM to ticket to engineer, some context gets lost every time. What reaches the engineer is often only a simplified version of the real need.

When an engineer talks to the customer directly, or at least listens to the call, they understand the urgency and direction in a way that a well-written ticket cannot provide. They prioritize better and move faster because they now own the problem. That’s why I want engineers to practice product engineering and stay close to customers.

Fewer moving parts

A simple system has fewer things that can interact with each other. Introduce boundaries as late as possible. If you split out a service or add another layer too early, every future change has to deal with that extra boundary.

I prefer one language, boring technologies with a low entry barrier and good AI support, and as few dependencies as possible. I am especially careful with settings. One option means two behaviors, two options mean four, and a system with four possible paths is already hard to hold in your head. Every setting is a decision you did not make and instead passed on to everyone using the system.

Abstractions are guilty until proven

The wrong abstraction is worse than no abstraction. Every future change then has to go through a bad abstraction, and it becomes expensive to remove once everything depends on it. That’s why I don’t abstract until the duplication actually starts to hurt.

When I do, I build small pieces that each do one thing and combine them, instead of building one large configurable thing that tries to do everything. If something is hard to test, it is usually too coupled. That’s why I design for testability from the start.

Write code to be read

Code is read much more often than it is written, so readability is the most important part of good code. If code doesn’t read easily, it is bad code.

Put related things together: tests next to the code they cover, all the logic for a feature in one folder, and the important parts up front instead of buried in utils and types. The things that make code hard to read are usually predictable: deep nesting, hidden side effects, closures that grow past twenty lines, vague names, and any types. Keep functions small and pure and you avoid most of these problems.

AI-generated code often has the same problems at scale: bloated tests that assert nothing useful, long files with no hierarchy, and code that is a little too clever to read quickly. Hold it to the same standard as code written by a person.

Make the wrong thing hard

As a team grows, opinions alone are not enough. Make the wrong thing difficult with strict types, linting, and framework constraints, so that the easiest path is also the correct one.

Build type safety into your abstractions from the start instead of adding it later. It is harder up front, but it forces you towards the right design. At Kombo, an endpoint has to declare its permissions, and an unauthenticated one gets an unsafe_ prefix. That means you have to make a conscious decision to ship something without authentication.

Finish before you start more

Another source of complexity is not in the code at all, but in the unfinished work around it. Open branches, stale PRs, and half-done initiatives all require the team to keep additional context in their heads. This topic is big enough for its own post.