Aidre Cabrera

Keep the domain away from infrastructure

The domain is the part of the system that would still exist if you replaced the framework.

That sounds obvious until you look at real code.

In a design tool, the rule that a group’s bounds are the union of its children’s bounds is domain logic. Measuring text by calling the canvas API is infrastructure. One is a rule of the product. The other is a way to satisfy a need.

Bad code mixes them.

Here is a bounds function that reaches straight into a canvas context:

function computeNodeBounds(
  node: SceneNode,
  ctx: CanvasRenderingContext2D
): Rect {
  switch (node.type) {
    case 'rect':
      return {
        x: node.x,
        y: node.y,
        width: node.width,
        height: node.height,
      };

    case 'text':
      ctx.font = `${node.fontSize}px ${node.fontFamily}`;
      const metrics = ctx.measureText(node.content);
      return {
        x: node.x,
        y: node.y,
        width: metrics.width,
        height: node.fontSize * 1.2,
      };

    case 'group':
      return unionRects(node.children.map((child) => computeNodeBounds(child, ctx)));
  }
}

This is a problem even if it works perfectly.

The domain rule for groups is now tied to the browser. To test group bounds properly, you need a canvas context. To swap canvas for another rendering backend, you have to touch a function that should have been pure domain logic all along.

The fix is not “mock the canvas harder.” The fix is to move the boundary to the right place.

The domain should describe what it needs in domain terms.

interface TextMeasurer {
  measure(
    content: string,
    fontSize: number,
    fontFamily: string
  ): { width: number; height: number };
}

That interface says nothing about canvas, browser APIs, or DOM objects. Good. It should not.

Now the domain function depends on the capability, not the implementation:

function computeNodeBounds(
  node: SceneNode,
  measureText: TextMeasurer
): Rect {
  switch (node.type) {
    case 'rect':
      return {
        x: node.x,
        y: node.y,
        width: node.width,
        height: node.height,
      };

    case 'text': {
      const size = measureText.measure(
        node.content,
        node.fontSize,
        node.fontFamily
      );

      return {
        x: node.x,
        y: node.y,
        width: size.width,
        height: size.height,
      };
    }

    case 'group':
      return unionRects(
        node.children.map((child) => computeNodeBounds(child, measureText))
      );
  }
}

The difference is subtle but important. The domain no longer asks for a canvas. It asks for text measurement.

That means tests can stay cheap:

const stubMeasurer: TextMeasurer = {
  measure(content, fontSize) {
    return {
      width: content.length * fontSize * 0.6,
      height: fontSize * 1.2,
    };
  },
};

And production can still use the real browser API:

function canvasMeasurer(ctx: CanvasRenderingContext2D): TextMeasurer {
  return {
    measure(content, fontSize, fontFamily) {
      ctx.font = `${fontSize}px ${fontFamily}`;
      const metrics = ctx.measureText(content);

      return {
        width: metrics.width,
        height: fontSize * 1.2,
      };
    },
  };
}

Once that seam exists, the rest of the design gets healthier almost by accident.

The domain becomes easier to test because it no longer pulls infrastructure in behind it. Infrastructure becomes easier to replace because it is now one implementation of a boundary instead of something entangled with the core rules. The dependency graph gets cleaner because the center of the system stops importing outward.

This does not mean every external dependency needs an interface. That is how you end up with abstractions no one needs. The point is more specific. You introduce a boundary where domain logic is reaching directly into infrastructure and dragging it inward.

That is the smell to watch for.

Can you run the core logic of the feature without the framework, the network client, the database adapter, or the canvas context? If the answer is no, ask whether the domain is depending on a tool when it should be depending on a capability.

The best boundaries are boring. They just let the domain stay itself.

That is enough.