Aidre Cabrera

Put logic in the right layer

Most code starts in the middle.

A ticket comes in. Somebody opens a file and writes a function that does the thing. It fetches data, computes a value, updates state, and triggers whatever side effects the feature needs. The function works. It ships. Months later, somebody wants to test the computation in isolation and discovers they cannot, because the logic is welded to state, IO, and event handling.

That is the layering problem.

The bug is not that the code is ugly. The bug is that nobody decided where each kind of logic belongs.

A simple way to think about this is to split logic into four layers.

Layer 0 is pure computation. Numbers in, numbers out. No state. No side effects.

Layer 1 is domain logic. It knows your domain types and rules, but it is still pure.

Layer 2 is state transition logic. It takes old state, applies domain logic, and produces new state.

Layer 3 is orchestration. This is where user input, time, IO, async work, and framework glue belong.

Those layers are not ceremony. They are what make code easy to test and easy to move.

Here is the kind of function that causes trouble. It handles a drag in a design tool by building a transform, updating selected nodes, rebuilding caches, and committing the new state.

function handleDragMove(ctx: ToolContext, dx: number, dy: number): void {
  const delta = matrixFromTranslation(dx, dy);

  const state = ctx.getState();
  const newRoot = mapNodes(state.root, (node) =>
    state.selectedIds.has(node.id)
      ? {
          ...node,
          localTransform: matrixMultiply(delta, node.localTransform),
        }
      : node
  );

  const nextState = {
    ...state,
    root: newRoot,
    worldTransformCache: collectWorldTransforms(newRoot, IDENTITY_MATRIX),
  };

  ctx.setState(nextState);
}

This works. It also mixes at least four different jobs. The event handler is doing math, domain rules, state transitions, and orchestration in one scope.

The first move is small: pull the pure math out of the handler.

interface Vec2 {
  readonly x: number;
  readonly y: number;
}

interface Matrix2D {
  readonly a: number; readonly b: number;
  readonly c: number; readonly d: number;
  readonly tx: number; readonly ty: number;
}

function matrixMultiply(a: Matrix2D, b: Matrix2D): Matrix2D {
  return {
    a: a.a * b.a + a.b * b.c,
    b: a.a * b.b + a.b * b.d,
    c: a.c * b.a + a.d * b.c,
    d: a.c * b.b + a.d * b.d,
    tx: a.a * b.tx + a.b * b.ty + a.tx,
    ty: a.c * b.tx + a.d * b.ty + a.ty,
  };
}

That alone does not fix the design. It just makes the first boundary visible. The math no longer knows anything about state or user input.

Next, move the domain logic out. Applying a transform to a node is not orchestration. It is a domain operation.

interface SceneNode {
  readonly id: string;
  readonly localTransform: Matrix2D;
  readonly children: readonly SceneNode[];
}

function applyTransformToNode(
  node: SceneNode,
  delta: Matrix2D
): SceneNode {
  return {
    ...node,
    localTransform: matrixMultiply(delta, node.localTransform),
  };
}

Now the state transition can own the state work without also owning the domain rule:

interface SceneState {
  readonly root: SceneNode;
  readonly selectedIds: ReadonlySet<string>;
  readonly worldTransformCache: ReadonlyMap<string, Matrix2D>;
}

function transformSelected(
  state: SceneState,
  delta: Matrix2D
): SceneState {
  const newRoot = mapNodes(state.root, (node) =>
    state.selectedIds.has(node.id)
      ? applyTransformToNode(node, delta)
      : node
  );

  return {
    ...state,
    root: newRoot,
    worldTransformCache: collectWorldTransforms(newRoot, IDENTITY_MATRIX),
  };
}

At that point the event handler gets smaller for the right reason. It is not “shorter code.” It is code that only does orchestration.

interface ToolContext {
  getState(): SceneState;
  setState(state: SceneState): void;
}

function handleDragMove(ctx: ToolContext, dx: number, dy: number): void {
  const delta = matrixFromTranslation(dx, dy);
  const next = transformSelected(ctx.getState(), delta);
  ctx.setState(next);
}

That is the full layered version:

// Layer 0: pure computation
function matrixMultiply(a: Matrix2D, b: Matrix2D): Matrix2D {
  return {
    a: a.a * b.a + a.b * b.c,
    b: a.a * b.b + a.b * b.d,
    c: a.c * b.a + a.d * b.c,
    d: a.c * b.b + a.d * b.d,
    tx: a.a * b.tx + a.b * b.ty + a.tx,
    ty: a.c * b.tx + a.d * b.ty + a.ty,
  };
}

// Layer 1: domain logic
function applyTransformToNode(
  node: SceneNode,
  delta: Matrix2D
): SceneNode {
  return {
    ...node,
    localTransform: matrixMultiply(delta, node.localTransform),
  };
}

// Layer 2: state transition
function transformSelected(
  state: SceneState,
  delta: Matrix2D
): SceneState {
  const newRoot = mapNodes(state.root, (node) =>
    state.selectedIds.has(node.id)
      ? applyTransformToNode(node, delta)
      : node
  );

  return {
    ...state,
    root: newRoot,
    worldTransformCache: collectWorldTransforms(newRoot, IDENTITY_MATRIX),
  };
}

// Layer 3: orchestration
function handleDragMove(ctx: ToolContext, dx: number, dy: number): void {
  const delta = matrixFromTranslation(dx, dy);
  const next = transformSelected(ctx.getState(), delta);
  ctx.setState(next);
}

What changed here is not style. The dependencies moved.

The math layer depends on nothing. The domain layer depends on math. The state transition depends on domain logic. The orchestration layer depends on all of them, but nothing below depends upward.

That direction matters because it shrinks the world each piece of code needs to care about.

A test for matrixMultiply() needs numbers. A test for applyTransformToNode() needs a node and a matrix. A test for transformSelected() needs a scene state. Only the orchestration layer needs a context or a mock.

That is the practical payoff. Fewer mocks. Smaller tests. Clearer ownership. Less code that mysteriously breaks when the UI framework changes.

There is no need to turn this into religion. Not every function deserves a grand layered decomposition. But when a single scope is doing pure work, domain work, state work, and orchestration all at once, the design is carrying too much weight in one place.

The easiest rule is still the best one: if a function needs IO, state, and domain logic at the same time, you probably have not decided where the logic belongs.