A while ago I wrote about pricing logic and why it deserves to be treated as infrastructure instead of a pile of UI code. This is the same lesson from a different subsystem: undo/redo in a real-time, event-driven editor. The difference is that this time the payoff is visible, and it showed up a year later in a place we weren't looking.
Features and invariants are different objects
Features have a definition of done you can see. Ship the button, click the button, close the ticket. When a feature half-works, someone notices, because the feature is the thing people look at.
Infrastructure is different. Its job is to hold invariants that other code relies on. For undo/redo the invariant is brutal: every state mutation, from every code path, present and future, must be reversible in chronological order. That invariant doesn't live in any one component. You can't screenshot it. When it erodes, it erodes silently, one unmigrated code path at a time.
Pricing had the same shape. The invariant there was "money math is exact and happens in exactly one place." Every shortcut that looked harmless locally — a parseFloat here, a float multiplication there — chipped at an invariant nobody owned. It was boring until it was wrong.
The thing that decides whether you get this right is a question asked early: is this a feature, or is it infrastructure wearing a feature costume?
The tell: closures vs commands
You can often tell which mental model produced a piece of code by looking at what the abstraction knows about itself.
The obvious way to build undo support is callbacks. Every widget that wants to participate registers a pair of closures: here's how to undo me, here's how to redo me. It works, and it's the version most editors start with.
The problem is that a callback-based undo entry knows nothing. It's two anonymous closures. You can't ask it what it does, which block it touches, whether it succeeded, or when it happened. You can only fire it and hope. Debugging means reading captured variables in a debugger. Testing means reconstructing the exact closure environment.
The command version is self-describing:
class DeleteBlockCommand {
readonly scope = 'block';
readonly timestamp: string;
getCurrentBlockId(): string;
async undo(): Promise<boolean>;
async redo(): Promise<boolean>;
}
That difference looks cosmetic. It is not. Because commands carry identity, the system built on them can do things the closure version never could:
- One chronological stack across text edits, block moves, and table changes, because everything is comparable.
- Retry and failure semantics, because
undo()returns a result instead of throwing into the void. - Batching, because commands compose.
- Analytics at a handful of seams instead of everywhere.
When architecture pays off
That last bullet deserves its own section, because it's where the decision paid for itself.
When we wrote the RFC for the command architecture, one of its stated purposes was to reify editor actions as discrete, self-describing objects. Text edit, block move, style change, table mutation — each one individually addressable, with its own scope, timestamp, payload, and success or failure resolution, all landing on one chronological stack. Granular, per-event control over what happens in the editor. The justification at the time was correctness: reliable undo, reliable ordering, reliable failure handling.
Then product came asking for interaction tracking. Which actions do users perform, how often, in what order.
Look at what an analytics event needs: a name, a subject, a timestamp, structured properties, and confirmation that the thing actually happened. That is a command object. The grain analytics needed was the grain the architecture already had.
Without the command layer, per-action tracking means scattering emit calls across dozens of UI components and hoping every future component remembers to do the same. It also means every one of those call sites independently deciding what counts as success, which in practice means some of them fire on intent rather than outcome and your funnel quietly overcounts.
With the command layer, tracking attaches at a handful of well-defined seams and inherits success-gating for free. No event fires for an action that failed or got rejected, because the command already had to know the difference in order to be undoable.
We didn't design the command pattern for Mixpanel. We designed it for control over events, and analytics turned out to be just another consumer of that control. That's the signature of an infrastructure decision: consumers it wasn't designed for show up later and find what they need already there.
How to spot infrastructure wearing a feature costume
Four questions that would have flagged both pricing and undo/redo early:
- Does it cut across features? If every new widget has to remember to participate, it's infrastructure, and "remember to" is not a mechanism.
- Does it fail silently? Features fail loudly, in someone's face. Invariants fail quietly, in aggregate, and the first report is a metric that looks slightly off.
- Is correctness invisible? If you can't tell it's broken by looking at the screen, you need the architecture to make wrongness impossible, not code review to catch it.
- Is "mostly done" meaningfully different from done? For a feature, eighty percent shipped is roughly eighty percent of the value. For an invariant it's zero: a single unmigrated code path is enough to make the guarantee untrue, and untrue guarantees are worse than absent ones because everything downstream is built on them.
The uncomfortable part is that infrastructure decisions are hard to justify during planning, because the strongest argument for one is a consumer you can't name yet. You're asking for extra work now against a payoff you can only describe in the abstract.
A year later, ours had a name.
Top comments (0)