Setting Technical Direction
Under every build is a second set of decisions the product fields never capture, and most of them are small: extend a service or stand up a new one, unify API a and API b or leave them apart, fold an existing migration into this initiative or run it separately, buy a capability or build it. Problem, Solution, Release Note gets written at the top of the ticket. These get written nowhere, so they get made by whoever reaches the code first, under a deadline, for the feature in front of them and nothing past it.
This page is the standard each of those calls has to meet, with an example of each. It is for whoever leads a build: a tech lead, an EM, or the DRI on one initiative.
They are usually small. Nobody convenes a review to decide whether to extend a service or add a second endpoint, so the default decides it, and small is exactly where most direction hides. Decide these by reflex and the team inherits an architecture nobody chose, one reasonable local call at a time. Decide them out loud and the tenth feature that needs to notify a user is as cheap as the first.
This is the craft page for those calls. They are made in Define, where the engineer works out the how. The product fields say what should change for the user; these say what should be true about how we build it, so the cost does not land again on every feature after.
The decision made by default#
The failure is rarely choosing wrong. It is not noticing you chose. An invented case, the shape of which everyone ships under deadline:
A feature needs to notify a user, so it writes its own send against the email provider. Reasonable, it works, it passes review. The next feature needs to notify someone too, and copies it. By the third, “notify a user” is three code paths and nobody ever decided it should be one. The day the wording changes it changes in three files, and production goes out with the old copy in the one that got missed.
No bad call was made here. Each step was the fastest correct thing for that one feature. What was missing is that the second feature was standing at a fork, one home or many, and nobody saw it as a decision, so the default won: copy what is there.
So the first move is not deciding well. It is seeing that a decision is in front of you at all. Two questions separate a choice the team inherits from a detail that is yours alone:
- Reach. How many future features inherit this? One, and it is a detail, change it freely. Many, and it is direction.
- Reversibility. If it is wrong, can you walk it back, a flag, a revert, a swap, or is it a one-way door: a schema everything reads, a public contract, a vendor wired through the code.
Where a call lands sets how much ceremony it earns. The top-right, wide and irreversible, already has a gate: the solid lines in How We Ship, which take a named signer before they land. The ones that slip are the bottom-right, wide reach but individually reversible, where no gate fires and the call still compounds. Those are the subject of this page. The three forks below usually live there, but not always: when one turns out to be a one-way door, a vendor you cannot cheaply exit, two public APIs you cannot un-merge, treat it as a solid line and get a signer too.
Test: if you cannot name who inherits a choice, it is a detail, make it and move on. If you can name them, it is direction, and it meets one of the three standards below.
1. Build, buy, or reuse#
The standard: reach for what already exists first, buy the solved problem next, build only the part that is the product itself. The test is not “can we build this”, you usually can, it is “would a competitor who bought the same tools still lack it”. If not, it is plumbing: buy or reuse it, and spend the time on the part that is not. Most of the time the answer is the least work, extend a service rather than stand up a new one. “New” is the expensive default that feels like progress.
Bad, built what exists: a feature needs a CSV export of orders, so it stands up an exports service, when the reporting service already renders every one of those tables. Now two systems read the same orders two ways, and they disagree the first time a column is renamed in one and not the other.
Bad, built what’s bought: “We’ll build our own auth, we understand our own needs best.” Six weeks on, login works, and the team owns password reset, email verification, session expiry, SSO and account recovery forever. Four months later a session-fixation bug ships, because nobody on the team does security for a living.
Good: add the CSV format to the reporting service that already has the orders; buy auth and put it behind our own users table; build the pricing engine, the thing a competitor on the same stack still would not have. Reuse, then buy, then build.
- “New” is a cost, not a clean slate. A second service that does what one already does is not fresh, it is a fork you maintain twice. Extend first, and split only when the existing thing cannot hold the new case.
- Price the exit on anything bought. The subscription is not the cost, the forty files wired to the vendor two years on is. Buy behind an adapter and commit to the open standard, not the vendor (OTLP, not one backend’s SDK), so a swap is config. Constraints & Cost has the accounting; the stack is shaped this way on purpose.
- “We understand our needs best” is the tell you are about to rebuild a solved problem. Usually true, usually not the point.
2. One home or many#
The standard: if it is the same concept as something that already exists, or a second real caller now needs it, give it one home and move both behind it. If it is new, keep it separate. This is the everyday “unify API a and b, or leave them” call and the premature “build a platform for one caller” call, which are the same question from opposite ends.
Bad, split that should be one: API a and API b both return a customer, shaped differently because they grew on different teams, so every client branches on which one it called, and a field fixed in one stays wrong in the other.
Bad, one that should be split: caller one needs to send a single receipt and builds a NotificationEngine, a channel registry, a template system, a scheduler and a plugin interface, to serve callers that do not exist. Every real caller after bends to fit the guess.
Good: caller one ships the one send, no abstraction. At the second real caller, name “notify a user” as a capability with one home and move both behind it. When a and b turn out to be the same customer, unify them and migrate the existing callers onto the one endpoint, rather than adding a third alongside.
- The second real caller is the trigger, never the imagined one. One caller is a function. Keep It Simple is the case against generalising for callers you invented.
- Fold existing work in, do not run it parallel. When an initiative touches something that already exists, migrate the existing one into it. A parallel second copy is the duplication you were trying to avoid, shipped on purpose. One Change, One Place owns why that hurts.
- One home needs one owner. The shared thing has a single place its definition lives, and the rest read it.
3. Standardise or let it vary#
The standard: standardise what crosses a boundary between people or systems; let the leaf choices vary. A standard earns its constraint where divergence makes two things fail to fit, the event one service emits and another reads, the error shape the client parses. Past that boundary, uniformity buys nothing and costs autonomy.
Bad, too loose: three services name the same moment three ways, user_signup, SignUpCompleted, signup_done, so every consumer carries a translation table and the funnel dashboard is hand-maintained and quietly wrong after the next rename.
Bad, too tight: a rule that every screen use one shared Button, which freezes every team on the first one’s API, so a team that needs a loading state opens a PR against a component forty screens depend on and waits a week for it.
Good: what crosses a boundary is standardised and owned in one place, the stack, the event vocabulary, auth, the API error shape. Inside a module, how you name a local or structure a component is yours, change it freely.
- Ask where the choice is read. Read across a boundary: standardise. Read only inside one unit: leave it free.
- Standardise the interface, not the implementation. Everyone emits the same event shape; how each service produces it is its own business.
- A standard with no owner rots. If it crosses a boundary, one place owns its definition and the rest read it.
Decide it, then write it down#
A direction decision that is not written where the next person meets it will be unmade by them. This is the technical mirror of the three product fields: a few lines in the definition doc for the build, next to the problem and solution they serve. Four lines, no heavier:
- The fork. Which of the three, and the specific choice.
- The call. What you chose.
- Why. The reach and the reversibility that decided it.
- The way back. What undoing it would cost, so the next person knows whether they may.
Take a small one all the way through. This initiative adds search, and there is already a catalog-search service that indexes products. The fork is build, buy, or reuse, and the first question is reuse: can that service hold this too. Reach: every list view will query it, so this is direction, not a detail. The call, written down:
Fork: Search, build / buy / reuse.
Call: Extended catalog-search, did not stand up a new service.
Added an `orders` index behind the same query API.
Why: Reach, every list view queries it. Reusing keeps one
search service, not two that drift. Reversibility, two-way:
a new index is additive and drops on its own.
The way back: Split out a dedicated service only if orders search needs
ranking the shared service can't carry without slowing
product search.
Keep it to the fork, not the implementation. How the index is built lives in the code. That orders search went into the existing service rather than a new one is the direction, and that is the line worth leaving behind.
A decision is current, not permanent. The way-back line names the assumption that would reopen it, so it reopens when the world moves and not on a calendar. A call with no way back is either irreversible, in which case say so and get a signer, or you have not yet found the assumption it rests on.
Before you commit#
Everyone ships the default under deadline. The work is not trying harder on every line: most of these calls are details, a reach of one, so decide them and move on. It is catching the few that reach past the feature in front of you. When you are at one:
- You can name who inherits the choice. If you cannot, it is a detail, decide it and move on.
- Build, buy, or reuse: you reached for the existing thing first; anything bought sits behind an adapter with the exit priced.
- One home or many: the same concept has one home, not two, and no shared abstraction exists before the second real caller.
- Standardise or vary: what you standardised crosses a boundary, what you left free does not.
- The call is four lines in the definition doc, including the way back.
The signal that this is working is not filled-in definition docs. It is, as How We Ship puts it, the crossings not the compliance: the tenth feature to need a capability finds one home for it instead of writing the eleventh copy.
Related: Problem, Solution, Release Note · How We Ship · Constraints & Cost · One Change, One Place · Keep It Simple · Our Stack · Define