Vertical slices, not horizontal layers
Here’s a failure mode I’ve watched play out on almost every large feature: the team builds all of one layer before starting the next. Every database table first, then every endpoint, then every screen. Progress looks real — the database layer is “done,” the API is 70% there — right up until week six, when integration starts. The data model was built on assumptions that turned out wrong. The API contract doesn’t match what the UI actually needs. Two weeks of rework follows.
Horizontal delivery looks efficient because each layer is independently completable. But integration is the actual work. Push it to the end and you’ve pushed all the risk to the one moment you have the least time left to absorb it.
Build one thin path through every layer instead — one form, one endpoint, one row — before filling out the rest of the feature. Ship it. Get feedback. Confirm the data model is right before you’ve built eight more tables on top of it.
// Horizontal delivery — nothing ships until week 6
Week 1-2: Design all 8 database tables
Week 3-4: Build all API endpoints
Week 5-6: Build all UI components
Week 6: Integrate everything → discover the data model was wrong
// Vertical delivery — first slice ships week 1
Week 1: Customer can open a checking account (one table, one endpoint, one form)
→ Ship. Get feedback. Validate data model.
Week 2: Customer can view their transaction history
Week 3: Customer can close their account
→ Each week reveals integration problems early and cheaply
This is exactly for when the plan needs to change — that’s the mechanism, not a failure of it. Say week 1’s account-opening slice reveals that “account” actually needs a jointHolderID you hadn’t modeled, because two spouses are supposed to share the account. You find that out from one small, real slice instead of from the schema you’d already built eight tables on top of. The rework is real, but it’s a day, not two weeks, and it happens in week 1 while the cost of being wrong is still cheap.
Spot it: sprint reviews where “the DB layer is done, the API is 70%, UI hasn’t started” repeats across tickets, “we’ll integrate everything at the end,” a feature “almost done” for three sprints with nothing shippable, a milestone called “backend complete” with no user-facing value attached to it.
Raise it: “Before we go deeper on the database layer, could we get one thin slice working end to end first? Even the simplest happy path. It’ll surface any mismatch between the layers before we’ve built everything out.”
Plan B before Plan A
There’s a familiar dynamic in design meetings: someone proposes something sophisticated. It’s elegant. It handles every edge case. It scales to ten million users. Everyone nods, the team commits, and three weeks before the deadline it’s 70% done with all the hard parts still in the remaining 30%.
Reaching for the clever solution first isn’t engineering, it’s status signaling. It feels like confidence. It skips the question nobody wants to ask out loud: what happens if this isn’t ready in time?
Build the simple version first instead, not as a contingency but as the primary deliverable.
Feature: real-time fraud scoring on outbound transfers
Plan A: ML risk model, trained on historical fraud cases, scoring every transfer
Plan B: rule-based checks — flag if amount > 3x the account's rolling average,
or payee is new and amount > $5,000
Build Plan B first. Ship it. Measure the false-positive rate against real transfers.
If acceptable, Plan A was unnecessary. If too noisy, you now know the exact
false-positive rate to beat — and you have a working fallback for launch.
Building Plan B first forces you to understand the problem: what data is involved, where the real bottlenecks sit, where the edge cases live. Whatever Plan A you design after shipping Plan B answers to constraints you’ve actually seen, not ones you imagined at the whiteboard. And often the simple version turns out to be good enough on its own — the clever solution was never necessary.
This also changes the shape of schedule risk. Instead of “we’re going to miss the deadline, we have nothing,” the conversation becomes: “Plan A won’t be ready in time, but Plan B already shipped and works. Want us to keep going on Plan A next release?”
Plan B doesn’t have to be temporary. Once you’ve measured, you might find the rule-based checks hold up fine against your actual fraud rate, and Plan A just never gets built — the “fallback” was the answer the whole time. Or Plan A ships and replaces it outright. Either way Plan B wasn’t wasted effort: it was in production catching real fraud the entire time you’d otherwise have spent still building Plan A, and it’s what told you whether Plan A was worth building at all.
Spot it: a design with no mention of a simpler fallback, “we’ll just revert the deploy” as the only rollback strategy, an approach where nothing ships until every piece works, a deadline approaching with no working simple version in reserve.
Raise it: “I like where this is headed. Before we commit fully, what’s our Plan B if we hit the deadline at 80%? Might be worth getting a simpler version working first, even as insurance. We’d learn a lot about the problem in the process.”
The completion test
Write the completion test before building the feature: the specific, observable condition that means you’re done. Not “fraud detection works” — “a transfer flagged as high-risk gets held within 500ms of submission, and the flagging decision matches the expected outcome for the 50 most recent confirmed-fraud cases from last quarter.” One is a feeling. The other is something you can run and get a yes or no from. Skip this and the natural pull is toward more: more polish, more edge cases, more robustness. Work expands to fill the time available, and available time is never zero.
Write it down as a sentence first — before any code exists, you often can’t automate it yet, and forcing yourself to state it in plain language is what catches the vague ones. But the sentence isn’t the finish line. Where it’s feasible, turn it into something that actually runs:
// The written completion test, encoded as a test
func TestFraudScoring_MatchesConfirmedCases(t *testing.T) {
for _, c := range confirmedFraudCases {
got := ScoreTransfer(c.Transfer)
if got.Flagged != c.ExpectedFlagged {
t.Errorf("ScoreTransfer(%s).Flagged = %v, want %v", c.Transfer.ID, got.Flagged, c.ExpectedFlagged)
}
}
}
A written completion test you never automate quietly becomes a memory: someone eyeballs it once at ship time, and six months later nobody’s checking it, because there’s nothing left to check it against. An automated one keeps answering “is this still done” every time CI runs, indefinitely, for free. It’s not always possible — some completion tests are inherently a human judgment call (“the onboarding flow feels fast”) — but the default should be code, not a document, whenever the condition is something a computer can actually evaluate. If you can write the sentence, you can usually write the assertion; the sentence is just the assertion before you’ve picked the syntax.
Applied to performance work specifically: set the target before you optimize. Profile, establish a baseline, fix the bottleneck, profile again. Without a target you optimize indefinitely, usually past the point where anyone benefits.
Gold plating is what happens with no completion test. PRs grow past ticket scope. “While I’m in here, I also…” shows up in descriptions. The engineer is optimizing for something other than the requirement — the requirement was already met.
Analysis paralysis is what happens when the completion test targets the design instead of the implementation, and the design keeps surfacing new questions. No code ships because the design isn’t “ready.” The design never gets ready because it’s never tested against reality.
The fix for both is the same: build something that runs. A working implementation runs into constraints a diagram never will. The running system answers design questions the whiteboard can’t.
Spot it, gold plating: PRs that keep growing past ticket scope, “while I’m in here” language in descriptions, tickets stuck “in progress” for sprints with no clear definition of done.
Spot it, analysis paralysis: architecture discussions that repeat without converging, “we need to resolve X before we start” where X keeps changing, sprint after sprint of diagrams with no code shipped.
Raise it: “We’ve surfaced a lot of good questions. Which one matters most? What’s the smallest thing we could build this sprint that gives us the most information? I’d rather be wrong with working code than uncertain with a perfect diagram.”