← All essays

Ordering Is the API

Five load-bearing constraints in one wiring file, three different mechanisms, and not one of them expressible in a type system

There is a file in my project called main.js. It imports about forty things and registers them with an engine. On first read it is the most boring file in the codebase:

engine.use(new CameraHandler(config));
engine.use(new InteractionHandler(config, engine));
engine.use(new ActionStepHandler(config));
// … thirty more

Then you notice that several of those lines have a paragraph of prose above them, and every paragraph is some variant of the same sentence: this must be registered after that one, and here is what breaks otherwise.

There are five of these. They are enforced by nothing except the order of statements in a file, and they are all genuinely load-bearing. What makes them worth writing about is that they come from three completely different mechanisms and look identical at the call site.

Mechanism one: read-after-write, within a frame

The engine dispatches onFrame to every registered handler in registration order, once per frame, inside the host application's own update. That means two handlers in the same frame are not concurrent — they are sequential, and one can read what the other just wrote.

The time control writes the current playback time to a shared global each frame. The trace player reads it to decide which telemetry sample to display. The comment:

Must register AFTER TimeControlHandler — it reads window.__timelineElapsed written earlier in the same frame, the same t the engine consumes via the override buffer, so the HUD and the on-mesh readouts resolve the same sample per frame.

The consequence of getting it wrong is not a crash. It is a one-frame lag between the number on the heads-up display and the number rendered on the instrument inside the 3D scene. Two readouts of the same quantity, disagreeing by 16 milliseconds, forever. Nobody files that bug. They just quietly stop trusting the numbers.

A second handler pair has the same shape for the same reason — a fluid-volume producer that must also see the time written this frame, so the volume in the interface and the liquid level in the scene stay locked together.

This is a genuine data dependency. In a system with a real dataflow graph it would be an edge. Here it is a line number.

Mechanism two: last write wins

The handlers write object poses into a shared buffer that the 3D runtime reads at the end of the frame. Two handlers can write the same object. The runtime sees whichever wrote last.

That is not a bug to be avoided. It is the resolution mechanism, and one feature depends on it entirely.

The step orchestrator writes each object's authored pose — where the script says it should be. The undo cache writes each object's actual pose from the start of the step — where it really was, including things the script never anticipated, like an object knocked onto the floor two steps ago.

When undoing, the cache must win. It wins because it is registered second:

const orchestrator = new StepOrchestratorHandler(config, engine);
engine.use(orchestrator);

const stepStateCache = new StepStateCache(…);
engine.use(stepStateCache);

Swap those two lines and undo silently stops working for exactly the objects it exists to handle — the ones whose real position diverged from the authored one. Everything else keeps working, because for every other object both handlers write the same value.

That is the worst possible failure signature: correct in all the cases you would test, wrong only in the case the feature was built for.

Mechanism three: listener registration order

The third mechanism is not the engine's at all. It is the DOM's.

Several components are not engine handlers. They are plain objects that listen for a custom event announcing that the step changed. addEventListener fires listeners in the order they were attached, so construction order decides who reacts first.

Two constraints live here, and they point in opposite directions.

A display-noise controller must be constructed before the widget binder:

Both listen to orchestrator-step-changed, and the binder replays its cached readings on that event, so at an authored config boundary the new config must be armed before the replay renders.

The binder redraws instruments from cached values when the step changes. If the noise configuration for the new step has not been installed yet, that redraw paints the old step's configuration for one cycle. Arm first, then replay.

And a toggle handler must be constructed after the same binder:

On a step change the binder must release the shared side dock before the toggle can mount into it; the reverse order would let the binder wipe the toggle's fresh mount.

Both want the same piece of screen. The binder's step-change reaction includes a reset that clears that dock. If the toggle mounts first, the binder's reset erases it. The toggle has to go second so it mounts into a dock that has already been vacated.

Same event, same file, opposite ordering requirements, for reasons that have nothing to do with each other. One is about state being armed; the other is about a resource being released.

What they have in common

Three mechanisms — a same-frame data dependency, a last-write-wins buffer, and listener registration order — and at the call site every one of them looks like this:

engine.use(new SomeHandler(config));

There is no type that expresses "must run after". No annotation, no priority field, no declared dependency. The constraint lives entirely in the position of a statement, and the reason lives entirely in a comment above it.

That is what I mean by ordering being the API. The public interface of this wiring file is not the set of handlers it registers. It is the sequence.

Why I have not fixed it

I have thought about this more than the code suggests. Three options, and I rejected all of them.

Explicit priority numbers. Give each handler a priority and sort. This looks like a solution and is mostly a worse one: you replace a readable sequence with a set of magic integers, and the moment you need to insert something between 10 and 20 you start renumbering or inventing 15. Priorities also express the what while discarding the why. The current file at least has the why sitting right there.

A declared dependency graph. Have handlers name what they must follow and topologically sort. This is the technically correct answer and I would build it if the count were larger. At five constraints across forty registrations, the machinery — declarations, cycle detection, an error path, a way to debug the resolved order — is more code and more concepts than the problem. It would also lie slightly: two of my constraints are not really "A depends on B", they are "A and B contend for a resource and B must lose".

Named phases. Group handlers into buckets (input, simulation, presentation) that run in a fixed order. Genuinely appealing, and it would capture maybe three of the five. The other two are intra-bucket. So I would still need ordering within a phase, and I would have added a taxonomy on top of the thing I still have to get right.

Each alternative buys enforcement and costs legibility. At this size, legibility is winning.

What I do instead

The rule I actually follow is: an ordering constraint without a comment is a bug, even when the code is correct.

Every one of the five carries a paragraph explaining the mechanism and the observable failure. Not "must be after X" — that is just restating the line number. What breaks, and what it looks like when it breaks. "The HUD and the on-mesh readout disagree by one frame." "The binder wipes the toggle's fresh mount."

That is the difference between a comment and documentation. A future reader tidying this file will not be stopped by // must be after TimeControlHandler; that reads like a leftover note. They will be stopped by a sentence describing a bug they do not want to cause.

It is also why I am fine with the constraint being invisible to tooling. The failure mode of an unenforced convention is that someone violates it without noticing. The mitigation is making it hard not to notice — and a paragraph is harder to miss than a decorator.

The honest risk

This works because the file has one author and forty lines of registration. It does not scale, and I know roughly where it stops.

It stops when someone who has not read those comments reorders the file for aesthetic reasons and ships it, because four of the five constraints fail silently and the fifth fails only in the case nobody tests. There is no compile error waiting for them and no test that catches it — my test suite covers pure logic classes, and this is wiring.

If a second front-end developer joins, the declared-dependency-graph option stops being over-engineering and becomes the obvious call. Until then I have a file where the architecture is the sequence, the sequence is documented, and the documentation is written in terms of symptoms rather than rules.

There is one small thing I would recommend regardless of size, because it costs nothing: when order is load-bearing, say so at the top of the file, not only next to each case. A reader who knows the file is order-sensitive before they start editing behaves completely differently from one who finds out afterwards.