Aidre Cabrera

Temporal coupling is a design bug

Temporal coupling is when one piece of code must run before another, but the code does not enforce that order.

The dependency is real. It just is not visible.

That kind of design is fragile because the caller has to remember a rule the API never states. New people miss it. Old people forget it. Tests skip it accidentally. Then the system fails in a way that feels arbitrary until someone remembers the hidden order.

Here is a small rendering pipeline with exactly that problem:

class RenderPipeline {
  private dirtyRegions: Rect[] = [];
  private composited: LayerBuffer[] = [];

  computeDirtyRegions(layers: Layer[]): void {
    this.dirtyRegions = layers
      .filter((layer) => layer.needsRepaint)
      .map((layer) => layer.bounds);
  }

  compositeLayers(layers: Layer[]): void {
    // Hidden dependency: this only works after computeDirtyRegions().
    this.composited = layers
      .filter((layer) =>
        this.dirtyRegions.some((region) => rectsOverlap(region, layer.bounds))
      )
      .map((layer) => rasterizeLayer(layer));
  }

  present(): void {
    // Hidden dependency: this only works after compositeLayers().
    blitToScreen(this.composited);
  }
}

The bug is not that the code uses a class. The bug is hidden state. Each step writes data into a field, and the next step silently depends on it.

The first repair is modest. Make the dependency explicit in the function signature.

function computeDirtyRegions(layers: readonly Layer[]): readonly Rect[] {
  return layers
    .filter((layer) => layer.needsRepaint)
    .map((layer) => layer.bounds);
}

function compositeLayers(
  layers: readonly Layer[],
  dirtyRegions: readonly Rect[]
): readonly LayerBuffer[] {
  return layers
    .filter((layer) =>
      dirtyRegions.some((region) => rectsOverlap(region, layer.bounds))
    )
    .map((layer) => rasterizeLayer(layer));
}

That is already an improvement. compositeLayers() no longer relies on a hidden field. You can see what it needs just by reading the signature.

But the sequence is still only a convention. A caller can still pass the wrong array, skip the first step, or call the functions in a nonsense order.

So take the next step and turn the process into explicit data flow. The output of one step becomes the required input of the next.

interface DirtyRegionResult {
  readonly layers: readonly Layer[];
  readonly dirtyRegions: readonly Rect[];
}

interface CompositeResult {
  readonly buffers: readonly LayerBuffer[];
}

function computeDirtyRegions(layers: readonly Layer[]): DirtyRegionResult {
  return {
    layers,
    dirtyRegions: layers
      .filter((layer) => layer.needsRepaint)
      .map((layer) => layer.bounds),
  };
}

function compositeLayers(input: DirtyRegionResult): CompositeResult {
  return {
    buffers: input.layers
      .filter((layer) =>
        input.dirtyRegions.some((region) => rectsOverlap(region, layer.bounds))
      )
      .map((layer) => rasterizeLayer(layer)),
  };
}

function present(input: CompositeResult): void {
  blitToScreen(input.buffers);
}

Now the order is not a rumor anymore. It is encoded in the structure.

The final pipeline reads like a process:

function renderFrame(layers: readonly Layer[]): void {
  const dirty = computeDirtyRegions(layers);
  const composited = compositeLayers(dirty);
  present(composited);
}

The shift looks small in code, but it changes the failure mode completely.

In the original version, the class let you call methods in nonsense orders because the sequencing rule lived in comments and memory. In the final version, you cannot call compositeLayers() without a DirtyRegionResult, and you cannot get one without calling computeDirtyRegions().

That is the real move. Hidden call order became visible data flow.

There are a few common places where this problem shows up. Rendering pipelines. Parsers. Build steps. Validation and persistence flows. Request pipelines. The details differ, but the pattern is the same: step B only works after step A, and the first version hides that fact in mutable fields or shared context.

The fix is also the same: make the output of one stage the input of the next.

Sometimes that means introducing a small result type. Sometimes it means replacing methods on a mutable object with plain functions. Sometimes it means introducing a state machine. The exact tool matters less than the direction of the change.

Do not ask the caller to remember sequencing rules that the code can express itself.

That is the rule worth keeping.

When step B depends on step A, the output of A should become the input of B.