Back to all posts
Architecture Monorepo TypeScript Design Patterns

Contracts, Core, and Enforced Dependency Direction

September 6, 2026
3 min read
Junior Oliveira

Every growing monorepo hits the same failure mode: a low-level utility package quietly starts importing a high-level orchestration package, because it was the fastest way to ship one feature. Nobody planned it. Six months later, refactoring the “low-level” package breaks half the app, and nobody remembers why they’re connected.

graph LR
    ui[ui] --> core[dashboard-core]
    core --> data[data]
    data --> ui

    style ui fill:#04110d,stroke:#00ffc2,stroke-width:2px,stroke-dasharray:5 5,color:#00ffc2
    style core fill:#04110d,stroke:#00ffc2,stroke-width:2px,stroke-dasharray:5 5,color:#00ffc2
    style data fill:#04110d,stroke:#00ffc2,stroke-width:2px,stroke-dasharray:5 5,color:#00ffc2

Nobody drew this on a whiteboard. It happened one PR at a time, and now nothing here can be changed in isolation.

The fix is a layered dependency graph with one non-negotiable rule: dependencies only point one direction, and that direction is enforced by a script, not a wiki page.

🚀 Going straightforward to the shape of it:

A zero-dependency vocabulary layer – one package (call it contracts) holds shared types and schemas. It depends on nothing. Everything else is allowed to depend on it.

Explicit, derived allow-lists – which package may import which is written down once, and it’s checked against each package’s real dependencies — not maintained by hand somewhere it can quietly go stale.

A failing build, not a code review comment – the rule lives in CI. A violation doesn’t wait for a human to notice it in a diff.

Here’s the whole mechanism, stripped to its essentials:

const ALLOWED_DEPENDENCIES = {
  contracts: [],
  ui: ['contracts'],
  data: ['contracts'],
  widgets: ['contracts', 'ui'],
  'dashboard-core': ['contracts', 'widgets']
};

function validateImport(packageName, importedPackage) {
  const allowed = new Set([packageName, ...ALLOWED_DEPENDENCIES[packageName]]);

  if (!allowed.has(importedPackage)) {
    throw new Error(
      `'${packageName}' imports '${importedPackage}', which is not in ` +
      `its allowed dependencies. Update ALLOWED_DEPENDENCIES (and the ` +
      `real package.json) if this is a legitimate new relationship.`
    );
  }
}

// Run over every import statement in every package at CI time —
// not a lint suggestion, a failing build.

That map is a graph — the same one enforced above, just drawn instead of written:

graph BT
    contracts((contracts))
    ui[ui] --> contracts
    data[data] --> contracts
    widgets[widgets] --> contracts
    widgets --> ui
    core["dashboard-core"] --> contracts
    core --> widgets

    style contracts fill:#00251d,stroke:#00ffc2,stroke-width:3px,color:#00ffc2
    style ui fill:#04110d,stroke:#0aa88a,stroke-width:1.5px,color:#8effe0
    style data fill:#04110d,stroke:#0aa88a,stroke-width:1.5px,color:#8effe0
    style widgets fill:#04110d,stroke:#0aa88a,stroke-width:1.5px,color:#8effe0
    style core fill:#04110d,stroke:#0aa88a,stroke-width:1.5px,color:#8effe0

Every arrow points down, toward contracts. Nothing points back up. That’s the whole rule, in one picture.

Notice what contracts buys you: it’s the one package every other layer is allowed to speak, but it never has to speak back. A chart-rendering package and a data-fetching package can share a DashboardWidget type without ever depending on each other directly — they both just depend on contracts.

The map itself is small on purpose. If dashboard-core starts needing something from data directly instead of going through contracts, that’s not a bug in the checker — it’s a real architectural decision that now has to be made on purpose, by editing one line everyone can see in review, instead of happening by accident in a 400-line PR.

Note: this is the actual dependency-boundary check we run in CI for a production dashboard monorepo — a contracts package with zero dependencies, every other package’s allow-list derived straight from its own package.json, and a script that fails the build the moment an import crosses a line nobody approved.