I don’t meet this bar every day. I wrote it down because writing it down is how I hold myself to it. This series lays out the practices I try to work by as an engineer: patterns and anti-patterns across data modeling, delivery, code quality, code structure, architecture, and performance.
1. Make impossible states unrepresentable — and parse, don’t validate
This is the single highest-leverage type-level habit. Every time you reach for boolean, number, or string to represent a domain concept, ask: can this type contain values that are invalid in the domain?
- Multiple booleans → discriminated union
- Raw IDs → branded types
- Status codes → named enum or literal union
- Nullable timestamp pairs (
cancelledAt,completedAt) → state variants that carry their own timestamps
Alexis King articulated the deeper version of this in “Parse, Don’t Validate”. A validation function like isValidIBAN(s string) bool returns a boolean and discards the knowledge it just computed. The moment after you call it, the type system no longer knows you checked. Pass the string somewhere else two lines later, and nothing stops it from being invalid. You validated. You didn’t parse.
The fix is to change the return type so that success is the proof. Go has no branded-string types, but an unexported struct field does the same job: the only way to construct one is through the constructor that validated it, because code outside the package can’t set an unexported field directly.
// Validation: throws away the knowledge
func isValidIBAN(s string) bool { /* ... */ }
func initiateWireTransfer(to string) error { /* ... */ }
iban := req.Body.IBAN
if !isValidIBAN(iban) {
return errors.New("invalid IBAN")
}
initiateWireTransfer(iban) // string — compiler doesn't know it's valid
// Parsing: the type IS the proof
type IBAN struct {
value string // unexported — only ledger.ParseIBAN can construct one
}
func ParseIBAN(s string) (IBAN, error) {
if !ibanPattern.MatchString(s) {
return IBAN{}, fmt.Errorf("invalid IBAN: %q", s)
}
return IBAN{value: s}, nil
}
func initiateWireTransfer(to IBAN) error { /* ... */ }
iban, err := ledger.ParseIBAN(req.Body.IBAN)
if err != nil {
return err
}
initiateWireTransfer(iban) // IBAN — compiler enforces it everywhere, forever
Look at initiateWireTransfer’s signature in each version: it went from taking a string to taking an IBAN. The compiler enforces, at every call site in every file, for the lifetime of the codebase, that you can only pass a value that went through ParseIBAN. You can’t pass a raw string by mistake. The invalid state isn’t representable anymore.
King’s heuristic for finding parsing opportunities: if you call a validation function and then keep using the original value, you validated when you should have parsed. Write the types first, and let the shape of the type guide the implementation. If a type forces you to handle a case you know can’t happen, the type is too weak. Strengthen it.
Comments and validation functions rot. A compiler-enforced type doesn’t: it holds in every caller, at every call site, forever. (Go’s compiler enforces the unexported-field boundary at the package level, not the file level like TypeScript’s branded types — anything inside the ledger package can still construct an IBAN directly. The guarantee is “only code that went through the parser, or is trusted enough to live in the same package as it,” which is a slightly looser boundary worth knowing about.)
This doesn’t mean the shape is frozen forever. Say the domain grows: you now need to know whether an IBAN cleared sanctions screening. Add it to the parser and the type, not to the call sites:
type IBAN struct {
value string
sanctionsCheckedAt *time.Time
}
func ParseIBAN(s string) (IBAN, error) {
if !ibanPattern.MatchString(s) {
return IBAN{}, fmt.Errorf("invalid IBAN: %q", s)
}
return IBAN{value: s}, nil
}
Every existing caller of initiateWireTransfer(iban) still compiles, because iban still went through ParseIBAN — the compiler doesn’t care that the shape underneath grew a field. The places that do care (anything now reading .sanctionsCheckedAt) are exactly the places Go forces you to update, since the field’s zero value (nil) means “not yet checked” and any code branching on it has to handle that case explicitly. Compare that to the raw-string version: nothing would have told you which of the fifty call sites needed to change. The type isn’t a straitjacket — it’s the one place the shape actually lives, so it’s the one place you have to edit when the shape legitimately changes.
// One rule eliminates four anti-patterns.
// Go has no native sum type, so a sealed interface is the idiomatic stand-in:
// only types in this package implement transferStatus, so a switch below
// is exhaustive in practice even though the compiler won't enforce that for you.
type TransferStatus interface {
transferStatus()
}
type Pending struct{}
func (Pending) transferStatus() {}
type Settled struct {
SettledAt time.Time
}
func (Settled) transferStatus() {}
type Reversed struct {
Reason string
ReversedAt time.Time
}
func (Reversed) transferStatus() {}
type TransferID struct{ value string }
type CustomerID struct{ value string }
A switch over TransferStatus has to handle Pending, Settled, and Reversed by name — there’s no field to typo, no nil to forget. The tradeoff against TypeScript’s discriminated unions: Go won’t fail the build if you add a fourth type and forget to update a switch somewhere. The exhaustive linter closes that gap, but it’s an opt-in tool, not a language guarantee — worth wiring into CI the day you adopt this pattern, not after the first missed case ships.
2. Define done before starting; articulate trade-offs upfront
Most engineers do these two things once, badly, and then stop.
Define done concretely. “It works” isn’t a completion test. Write the specific, observable condition before you touch the keyboard, for features, refactors, performance work, bug fixes, all of it. Skip this and the work expands to fill whatever time exists.
Vague: "Add a fraud hold on large transfers."
Concrete: "A transfer over $10,000 to a payee the customer hasn't paid
before is held for manual review. The customer sees a 'pending review'
status within 2 seconds of submitting. A reviewer in the ops dashboard
approves or rejects within their queue; approval releases the transfer
within 5 seconds, rejection notifies the customer with a specific
reason, not a generic decline."
The first version has no edge in it — you can’t tell from reading it whether “done” includes the new-payee condition, the review-latency requirement, or what the customer actually sees while waiting. The second version is a checklist. Either the system does those specific things or it doesn’t, and there’s no argument to have about it.
State trade-offs before you build, not after. “This approach is faster, but it assumes X holds; if it doesn’t, we need Y” is a statement you make before writing the code. “If only we’d had more time” is the retrospective version, cover-your-ass engineering (CYAE) dressed up as regret. Say the constraint upfront and your manager has options: adjust scope, add time, accept the risk. Say it after delivery and all that’s left is damage control.
Do both and you avoid over-engineering, because you have a stopping condition, and under-engineering, because the trade-offs were never hidden from anyone.
None of this means the definition of done can’t change. Requirements shift mid-project all the time — the hold should also freeze the receiving account until reviewed, say, once someone points out that a fraud ring was cycling money through newly opened payee accounts. The discipline isn’t “never change the target.” It’s “never let it change silently.” When the completion test changes, that’s a visible, callable-out event: you rewrite it, you say out loud that the target moved and why, and everyone recalibrates against the new one. What you’re avoiding isn’t change — it’s the version where “done” quietly drifts because nobody ever wrote it down in the first place, so there was never a fixed thing to notice moving.
3. Deliver vertically, not horizontally
The most common delivery failure I’ve seen: build all of one layer before starting the next. Every database table, then every endpoint, then every screen. The cost stays hidden until the deadline, when integration reveals the data model was wrong, the API contract meant something different to the two people who built each side, or a requirement was ambiguous the whole time.
The fix: no layer counts as done until a user can do something end to end. Build that vertical slice first, before anything else on the feature.
The same slice breaks analysis paralysis. When a design won’t converge, stop discussing it and build the smallest slice that lets reality answer the open questions.
It also caps gold plating. If the completion test is “a user can do X,” and they can, the feature is done. Nothing left to argue about.
4. Build Plan B before Plan A
There’s an instinct to reach for the clever solution first. It signals confidence in a design meeting. It’s also the riskiest move you can make on a project with a deadline. Build the boring version first instead, as your primary deliverable, not a fallback.
Something running early gets you real feedback against real constraints instead of imagined ones. You find the actual hard parts instead of guessing at them. Often the boring version turns out to be fast enough, flexible enough, good enough on its own, and the clever version was never necessary.
A clever solution built after a working simple version answers to real constraints. A clever solution built instead of a simple version answers to whatever you imagined at the whiteboard.
Skipping Plan B is status signaling dressed up as confidence: it’s risk-taking with no hedge. Build Plan B and the schedule conversation changes shape. “We’re going to miss the deadline” becomes “Plan B already shipped, do we still want Plan A?”
5. Measure before optimizing, form hypotheses before debugging
Performance work and debugging fail the same way: you act without a model of what’s actually true.
For performance, profile first. Your intuition about the bottleneck is wrong more often than it’s right. The bottleneck is almost always database queries (especially N+1) or network round trips, and you won’t know which without data.
For debugging, form a falsifiable hypothesis before you read any code. State what must be true in the system for this behavior to occur, and what evidence would prove you wrong. That’s the discipline that stops you from closing an investigation on a fix that happened to work.
If you can’t explain why the fix works, it isn’t fixed.
6. Characterization tests before touching unfamiliar code
Before changing code you don’t own completely, capture what it currently does in tests. Not what it should do. What it does, including the weird edge cases that might be load-bearing.
// Not a spec — a snapshot that makes behavioral changes visible
func TestFormatCurrency_NilAmount(t *testing.T) {
got := FormatCurrency(nil) // current behavior: nil cents in, empty string out
if got != "" {
t.Errorf("FormatCurrency(nil) = %q, want empty string", got)
}
}
Then change one thing at a time: structure changes in one commit, behavior changes in another. Reviewable diffs, a git blame that still means something, and a regression you’ll actually see when it happens.
Characterize first. Then change one dimension at a time.
The underlying unity
All six come back to one idea: close the gap between action and consequence.
Impossible states make a type error fail at compile time instead of runtime. Defining done sets the stopping condition before the work starts, not after. Vertical slices make integration problems surface in week one instead of week six. Plan B makes schedule risk visible before the deadline, not at it. Measuring before optimizing sends your effort to a real bottleneck instead of an imagined one. Characterization tests make a behavioral regression visible the moment it happens.
Every expensive mistake I’ve seen shares a shape: an assumption went false, and a long stretch of time passed before anyone found out. These six principles shorten that stretch.
The rest of the series
The rest of the series works through each of these in more depth, plus a set of named patterns and anti-patterns, grouped by where you actually run into them:
Patterns & anti-patterns, by category
- Data & Domain: Get the Data Model Right, Get Everything Else for Free
- Delivery & Feedback: Ship Small, Ship Boring, Know When You’re Done
- Code Quality: Don’t Touch Code You Don’t Understand Yet
- Code Structure: Push Complexity to the Edges
- Architecture: Design for the System You Have, Not the One You Imagine
- Performance: Profile First, Optimize Second
By developer activity
- If Running Tests Takes Willpower, They’re Too Slow
- How to Learn a Codebase With No Docs
- Code Review Is Wasted on Style Comments
- Stuck Designing or Stuck Building?
The Highest-Leverage Work Is Never Feature Work. How to find and pitch the infrastructure improvements that multiply team effectiveness instead of just personal output.