Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More
Spec-Driven Development: Enforcing Architectural Contracts for Coding Agents
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Architectural drift describes the gradual, undetected divergence between a system's intended design and its actual implementation. Spec-driven development addresses that drift by turning the intended design into machine-readable, enforceable rules. When autonomous coding agents operate with commit-level autonomy, the problem takes on a fundamentally different character—one that demands machine-readable contracts, AST-level enforcement, and automated test boundaries.

Table of Contents

The Architectural Drift Problem with Autonomous Coding Agents

How Agent-Generated Code Silently Degrades Structure

Architectural drift describes the gradual, undetected divergence between a system's intended design and its actual implementation. In the context of autonomous coding agents like Copilot Workspace, Devin, Claude Code, and GPT Engineer, this problem takes on a fundamentally different character. These agents, in autonomous or minimally supervised modes, operate with commit-level autonomy, generating entire modules, services, and integration layers without the institutional memory that guides human developers toward established patterns.

The concrete manifestations are predictable and recurring. An agent tasked with adding a repository implementation introduces a direct import from the domain layer into an infrastructure adapter, violating hexagonal architecture boundaries. Another agent flattens a carefully designed module hierarchy into a single directory because its context window lacks visibility into the project's structural conventions. Circular dependencies emerge when agents generate cross-module utility functions that reference each other. Domain types leak into infrastructure layers when an agent, optimizing for the shortest path to a working implementation, imports an entity directly into a database mapper rather than working through a port interface.

What makes agent-introduced drift qualitatively different from human-introduced drift is volume, speed, and the complete absence of design awareness.

What makes agent-introduced drift qualitatively different from human-introduced drift is volume, speed, and the complete absence of design awareness. On a typical team, a human developer might introduce one boundary violation per sprint. In our testing, an autonomous agent introduced 10 to 50 in a single session, each individually plausible, each compounding into dependency graph corruption that surfaced only weeks later.

Why Code Review and Linting Rules Alone Fail

Standard linting tools like ESLint and Biome operate primarily at the syntactic level. They enforce naming conventions, formatting rules, and per-file code quality heuristics. While ESLint's import/no-restricted-paths rule provides partial enforcement of module boundaries, it requires manual configuration per boundary and lacks schema-driven composability. These tools do not natively understand that a module named infrastructure/persistence should never import from domain/entities directly. They lack a holistic model of architectural intent.

Human reviewers fare somewhat better but cannot scale. When an agent generates a pull request touching 40 files across 8 modules, a reviewer must mentally reconstruct the dependency graph to detect cross-module violations. At the velocity agents produce code, this becomes untenable. Worse, agents generate code that looks structurally compliant on the surface. Imports are clean, types are correct, tests pass. The violation is not in any single file but in the relationship between files, and that relationship is invisible without tooling that understands the architecture.

What Is Spec-Driven Development?

From Implicit Conventions to Explicit Machine-Readable Contracts

Spec-driven development is the practice of encoding architectural decisions as enforceable, machine-readable schemas that both humans and automated agents consume. It moves architectural intent from implicit team conventions, wiki pages, and onboarding documents into versioned, testable artifacts that live alongside the code they govern.

This stands in contrast to documentation-driven approaches, where architectural rules exist as prose that agents never read, and convention-driven approaches, where rules exist only in the habits of senior developers who happen to be available for code review. In spec-driven development, the contract is a first-class artifact: it lives in the repository, it is versioned with the code, it is validated in CI, and it produces typed, structured errors when violated.

The Three Layers of an Architectural Contract

Architectural contracts operate across three enforcement layers. Structural contracts define module boundaries and dependency direction: which modules may import from which, and which imports are categorically forbidden. Type-shape contracts declare the public API surface of each module, specifying the exact shapes of exported symbols so that agents cannot silently alter interfaces that other modules depend on. Behavioral contracts round out the system by encoding test invariants that validate, at runtime, whether the system's actual structure matches its declared structure.

Each layer targets a different failure mode. Structural contracts prevent dependency graph corruption. Type-shape contracts stop API surface drift before downstream consumers break. Behavioral contracts act as a catch-all, running inside the test suite to flag anything the first two layers miss.

Defining Architectural Contracts in TypeScript with Zod

Prerequisites

Before working through the examples in this article, ensure the following:

  • Node.js ≥ 18 (required for consistent ESM/CJS behavior)
  • All source modules must be direct children of the configured rootDir (e.g., src/domain/, src/application/, src/infrastructure/). Deeper nesting (e.g., src/domain/entities/) requires adjusted path resolution in the AST linter.
  • A tsconfig.json must exist at the project root with an include field covering src/.
  • Install the following dependencies with the specified versions:
{
  "dependencies": {
    "zod": "^3.22.0",
    "minimatch": "^7.4.6"
  },
  "devDependencies": {
    "typescript": "^5.4.0",
    "vitest": "^1.6.0"
  }
}

Note on minimatch: Version 7 uses CommonJS and its default export is the minimatch function itself. Use import minimatch from "minimatch" (default import), not a named destructured import. Version 8+ is ESM-only, exports a named minimatch, and requires "type": "module" in package.json or an ESM-compatible bundler.

Note on typescript: The TypeScript Compiler API is used at runtime for AST analysis. If you run contract checks in pre-commit hooks or CI (outside the normal build toolchain), add typescript to dependencies rather than devDependencies.

Modeling Module Boundaries as Zod Schemas

The foundation of contract enforcement is a schema file that declares the architectural rules of the system in a format that is both human-readable and machine-parseable. Using Zod, a TypeScript-first schema validation library, these contracts become type-safe, composable, and directly executable.

The contract registry file, typically named arch.contracts.ts, defines each module's name, its allowed dependencies, its forbidden imports, and the shapes of its public exports.

import { z } from "zod";

const ExportedSymbolSchema = z.object({
  name: z.string(),
  kind: z.enum(["function", "class", "type", "const", "interface"]),
});

const ModuleContractSchema = z.object({
  moduleName: z.string(),
  allowedDependencies: z.array(z.string()),
  forbiddenImports: z.array(z.string()),
  publicExports: z.array(ExportedSymbolSchema),
});

export type ModuleContract = z.infer<typeof ModuleContractSchema>;

export const contractRegistry: ModuleContract[] = [
  {
    moduleName: "domain",
    allowedDependencies: [],
    forbiddenImports: ["infrastructure/**", "application/**"],
    publicExports: [
      { name: "User", kind: "interface" },
      { name: "UserRepository", kind: "interface" },
    ],
  },
  {
    moduleName: "application",
    allowedDependencies: ["domain"],
    forbiddenImports: ["infrastructure/**"],
    publicExports: [
      { name: "CreateUserUseCase", kind: "class" },
      { name: "GetUserUseCase", kind: "class" },
    ],
  },
  {
    moduleName: "infrastructure",
    allowedDependencies: ["domain", "application"],
    forbiddenImports: [],
    publicExports: [
      { name: "PostgresUserRepository", kind: "class" },
      { name: "createAppRouter", kind: "function" },
    ],
  },
];

This registry encodes a classic layered architecture: the domain layer has no dependencies, the application layer depends only on domain, and infrastructure may depend on both. The forbiddenImports field uses glob patterns matched against project-relative resolved paths (e.g., infrastructure/db/connection) to catch any import that crosses a boundary.

Validating Agent Output Against Contract Schemas

With the contract registry in place, validation becomes a matter of parsing each agent-generated file, extracting its import declarations, resolving them to project-relative paths, and checking them against the contract for the module that file belongs to.

import { readFileSync } from "fs";
import * as path from "path";
import { contractRegistry, type ModuleContract } from "./arch.contracts";
import minimatch from "minimatch";

/**
 * Resolves the module contract for a given file path by extracting
 * the first directory segment after rootDir and matching it against
 * the contract registry.
 *
 * Returns undefined if the file is not under rootDir or has no
 * matching contract (which is not an error — the file simply has
 * no architectural contract to enforce).
 */
function resolveModule(
  filePath: string,
  rootDir: string = "src"
): ModuleContract | undefined {
  const normalized = filePath.split(path.sep).join("/");
  const prefix = rootDir.endsWith("/") ? rootDir : `${rootDir}/`;
  const withoutRoot = normalized.startsWith(prefix)
    ? normalized.slice(prefix.length)
    : normalized;
  const segment = withoutRoot.split("/")[0];
  if (!segment || segment === normalized) return undefined;
  return contractRegistry.find((c) => c.moduleName === segment);
}

/**
 * Extracts import specifiers from TypeScript/JavaScript source text.
 * Handles single-line imports, multi-line imports, type-only imports,
 * and re-exports (export { ... } from "...").
 */
function extractImports(source: string): string[] {
  const importRegex =
    /(?:import\s+(?:type\s+)?(?:[\s\S]*?\s+from\s+)?|export\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"]([^'"]+)['"]/g;
  const imports: string[] = [];
  let match: RegExpExecArray | null;
  while ((match = importRegex.exec(source)) !== null) {
    imports.push(match[1]);
  }
  return imports;
}

export function validateModuleContract(
  filePath: string,
  projectRoot: string = process.cwd(),
  rootDir: string = "src"
): { applicable: boolean; errors: string[] } {
  const contract = resolveModule(filePath, rootDir);
  if (!contract) return { applicable: false, errors: [] };

  let source: string;
  try {
    source = readFileSync(path.resolve(projectRoot, filePath), "utf-8");
  } catch (e: unknown) {
    const message = e instanceof Error ? e.message : String(e);
    return {
      applicable: true,
      errors: [`Cannot read file: ${filePath}: ${message}`],
    };
  }

  const imports = extractImports(source);
  const errors: string[] = [];

  for (const imp of imports) {
    // Resolve the raw import specifier to a project-relative path
    // so that glob patterns like "infrastructure/**" can match correctly.
    const resolvedImp = imp.startsWith(".")
      ? path
          .relative(
            projectRoot,
            path.resolve(
              path.dirname(path.resolve(projectRoot, filePath)),
              imp
            )
          )
          .split(path.sep)
          .join("/")
      : imp;

    for (const pattern of contract.forbiddenImports) {
      if (minimatch(resolvedImp, pattern, { dot: true })) {
        errors.push(
          `[${contract.moduleName}] Forbidden import "${imp}" (resolved: "${resolvedImp}") in ${filePath} (matches ${pattern})`
        );
      }
    }
  }
  return { applicable: true, errors };
}

// Usage:
// Passing: validateModuleContract("src/application/CreateUserUseCase.ts")
//   => { applicable: true, errors: [] }
// Failing: validateModuleContract("src/domain/User.ts") with import from
//          "../infrastructure/db"
//   => { applicable: true, errors: ['[domain] Forbidden import ... (matches infrastructure/**)'] }
// Non-contracted file: validateModuleContract("scripts/build.ts")
//   => { applicable: false, errors: [] }

This function serves as the core validation primitive. It slots into a pre-commit hook or CI gate. When applicable is true and errors is empty, the file is compliant. When applicable is false, the file has no contract to enforce (which is not an error). Import specifiers are resolved to project-relative paths before glob matching, ensuring that patterns like infrastructure/** correctly match resolved paths such as infrastructure/db/connection.

The extractImports function handles single-line imports, multi-line imports, type-only imports, and re-exports. Dynamic imports (await import('./module')) are not covered by this regex; see the AST-level enforcement section for full coverage.

AST-Level Contract Enforcement with the TypeScript Compiler API

Building a Custom AST Schema Linter

Regex-based import extraction breaks down in real-world codebases. Dynamic imports (await import('./module')) and re-exports (export { Foo } from "./bar") evade regex matching. Type-only imports (import type { Foo } from "./bar") and barrel files that aggregate and re-export from subdirectories add further complexity. Reliable contract enforcement requires AST-level analysis.

Note: The AST implementation below covers ImportDeclaration nodes only. Dynamic imports (import() expressions) are CallExpression nodes in the TypeScript AST and require an additional ts.isCallExpression handler to capture. Re-exports via ExportDeclaration similarly require their own handler. A production implementation should include both; the code here focuses on the most common case to illustrate the approach.

The TypeScript Compiler API provides the necessary machinery. Using ts.createProgram to load the project and ts.forEachChild to traverse each source file's AST, the linter can extract every static import declaration and resolve module specifiers to their actual file paths.

This implementation assumes all modules are direct children of the rootDir (e.g., src//). Files at deeper nesting levels like src/domain/entities/User.ts will have their module resolved to domain based on the first directory segment after rootDir. The resolveModuleSegment helper strips the configured rootDir prefix before extracting the module name, ensuring correct results regardless of path depth.

import * as ts from "typescript";
import * as path from "path";

/**
 * Extracts the module name from a project-relative path by stripping
 * the rootDir prefix and returning the first path segment.
 * Returns undefined if the path is not under rootDir.
 */
function resolveModuleSegment(
  relPath: string,
  rootDir: string
): string | undefined {
  const normalized = relPath.split(path.sep).join("/");
  const prefix = rootDir.endsWith("/") ? rootDir : `${rootDir}/`;
  const withoutRoot = normalized.startsWith(prefix)
    ? normalized.slice(prefix.length)
    : undefined;
  if (withoutRoot === undefined) return undefined;
  const segment = withoutRoot.split("/")[0];
  return segment && segment !== "" ? segment : undefined;
}

export function extractDependencyGraph(
  projectPath: string,
  rootDir: string = "src"
): Map<string, Set<string>> {
  const configPath = ts.findConfigFile(projectPath, ts.sys.fileExists);
  if (!configPath) throw new Error("tsconfig.json not found");

  const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
  if (configFile.error) {
    const message = ts.flattenDiagnosticMessageText(
      configFile.error.messageText,
      "
"
    );
    throw new Error(`Failed to read tsconfig.json: ${message}`);
  }

  const parsed = ts.parseJsonConfigFileContent(
    configFile.config,
    ts.sys,
    path.dirname(configPath)
  );

  const program = ts.createProgram(parsed.fileNames, parsed.options);
  const graph = new Map<string, Set<string>>();

  for (const sourceFile of program.getSourceFiles()) {
    if (sourceFile.isDeclarationFile) continue;
    const filePath = path
      .relative(projectPath, sourceFile.fileName)
      .split(path.sep)
      .join("/");
    if (filePath.startsWith("node_modules")) continue;

    const sourceModule = resolveModuleSegment(filePath, rootDir);
    if (!sourceModule) continue;

    const deps = new Set<string>();

    function visit(node: ts.Node): void {
      if (
        ts.isImportDeclaration(node) &&
        ts.isStringLiteral(node.moduleSpecifier)
      ) {
        const specifier = node.moduleSpecifier.text;
        if (specifier.startsWith(".")) {
          const resolved = path.resolve(
            path.dirname(sourceFile.fileName),
            specifier
          );
          const relResolved = path
            .relative(projectPath, resolved)
            .split(path.sep)
            .join("/");
          const targetModule = resolveModuleSegment(relResolved, rootDir);
          if (targetModule) deps.add(targetModule);
        } else {
          // Non-relative specifiers include npm packages and TypeScript
          // path aliases (e.g., @domain/...). Path aliases require
          // tsconfig-paths or manual resolution to enforce boundaries.
          console.warn(
            `[ast-linter] Skipping non-relative specifier "${specifier}" in ${filePath}. ` +
              `If this is a path alias, configure alias resolution.`
          );
        }
      }
      ts.forEachChild(node, visit);
    }
    visit(sourceFile);

    const existing = graph.get(sourceModule) ?? new Set<string>();
    deps.forEach((d) => existing.add(d));
    graph.set(sourceModule, existing);
  }

  return graph;
}

This function produces a Map> representing the actual module-to-module dependency graph of the project. It filters out declaration files and node_modules, focusing exclusively on internal project modules. The resolveModuleSegment helper strips the configured rootDir prefix and extracts the first directory segment as the module name, ensuring correct behavior regardless of file nesting depth. Path separators are normalized to forward slashes to ensure consistent behavior across operating systems.

Limitation: Projects using TypeScript paths aliases (e.g., @domain/...) will have specifiers that don't start with "." and will be logged with a warning and skipped. Handling path aliases requires tsconfig-paths or manual resolution against the tsconfig.json paths configuration.

Detecting Contract Violations Programmatically

With the actual dependency graph extracted, detecting violations becomes a comparison operation against the declared contracts. This implementation uses an allowlist model: any dependency not explicitly listed in allowedDependencies is treated as a violation. This is stricter than the forbiddenImports blocklist in validateModuleContract, which only flags specific patterns. The two approaches are complementary — use allowedDependencies for graph-level enforcement and forbiddenImports for file-level granularity.

import { contractRegistry, type ModuleContract } from "./arch.contracts";

interface ArchitecturalViolation {
  module: string;
  forbiddenDependency: string;
  message: string;
}

export function detectViolations(
  graph: Map<string, Set<string>>
): ArchitecturalViolation[] {
  const violations: ArchitecturalViolation[] = [];

  for (const contract of contractRegistry) {
    const actualDeps = graph.get(contract.moduleName);
    if (!actualDeps) continue;

    for (const dep of actualDeps) {
      if (dep === contract.moduleName) continue;
      if (!contract.allowedDependencies.includes(dep)) {
        violations.push({
          module: contract.moduleName,
          forbiddenDependency: dep,
          message: `Module "${contract.moduleName}" depends on "${dep}", which is not in its allowed dependencies [${contract.allowedDependencies.join(", ")}]`,
        });
      }
    }
  }
  return violations;
}

// Sample output:
// [
//   { module: "domain", forbiddenDependency: "infrastructure",
//     message: 'Module "domain" depends on "infrastructure", which is not in its allowed dependencies []' },
//   { module: "application", forbiddenDependency: "infrastructure",
//     message: 'Module "application" depends on "infrastructure", which is not in its allowed dependencies [domain]' }
// ]

Edge cases require deliberate handling. Type-only imports may be acceptable in some architectures where the concern is runtime coupling rather than compile-time awareness; the AST walker can check node.importClause?.isTypeOnly to filter these:

if (ts.isImportDeclaration(node) && node.importClause?.isTypeOnly) return;

Test files should be excluded from enforcement unless the team explicitly wants to constrain test dependencies. Generated code directories (such as Prisma client output or protobuf stubs) should be added to an exclusion list in the configuration.

Automated Test Boundaries as Behavioral Contracts

Writing Vitest Architectural Tests

Treating architectural rules as test cases brings them into the same CI pipeline that validates functional correctness. This approach is particularly effective because it produces familiar pass/fail output that both developers and agents can interpret.

Note: extractDependencyGraph requires a tsconfig.json at the resolved project root. If Vitest is invoked from a different working directory (e.g., a monorepo root), use an explicit path such as path.resolve(__dirname, "../..") instead of process.cwd().

import { describe, it, expect, beforeAll } from "vitest";
import { extractDependencyGraph } from "./ast-linter";
import { contractRegistry } from "./arch.contracts";
import { detectViolations } from "./detect-violations";
import * as path from "path";

function formatViolations(vs: { message: string }[]): string {
  return vs.map((v) => `  - ${v.message}`).join("
");
}

describe("Architectural Contracts", () => {
  let graph: Map<string, Set<string>>;

  beforeAll(() => {
    graph = extractDependencyGraph(
      path.resolve(__dirname, "../.."),
      "src"
    );
  });

  it("enforces dependency direction for all modules", () => {
    const violations = detectViolations(graph);
    expect(violations, formatViolations(violations)).toHaveLength(0);
  });

  it("detects no direct mutual dependencies (A↔B)", () => {
    // This check detects direct mutual dependencies only.
    // Transitive cycle detection (A→B→C→A) requires a DFS-based algorithm
    // such as Tarjan's or Kahn's algorithm and is not implemented here.
    for (const [mod, deps] of graph) {
      for (const dep of deps) {
        const reverse = graph.get(dep);
        expect(
          reverse?.has(mod) ?? false,
          `Direct mutual dependency detected: ${mod} <-> ${dep}`
        ).toBe(false);
      }
    }
  });

  it("validates public exports declarations are present in contracts", () => {
    for (const contract of contractRegistry) {
      expect(
        contract.publicExports.length,
        `Contract for "${contract.moduleName}" declares no public exports`
      ).toBeGreaterThan(0);
      for (const exp of contract.publicExports) {
        expect(exp.name).toBeTruthy();
        expect(["function", "class", "type", "const", "interface"]).toContain(
          exp.kind
        );
      }
    }
  });

  it("ensures agent-generated files land in correct directories", () => {
    const moduleNames = contractRegistry.map((c) => c.moduleName);
    for (const [fileMod] of graph) {
      if (!fileMod.startsWith(".")) {
        expect(
          moduleNames.includes(fileMod) || fileMod === "shared",
          `File module "${fileMod}" is not declared in any contract`
        ).toBeTruthy();
      }
    }
  });
});

This test suite covers critical architectural invariants: dependency direction enforcement, direct mutual dependency detection, and module membership validation. The formatViolations helper ensures that failure messages are actionable, pointing directly to the offending module and its forbidden dependency. The graph is constructed inside beforeAll so that any failure (such as a missing tsconfig.json) produces a clear test-level error rather than a cryptic crash at module scope.

Integrating Contract Tests into Agent Workflows

Contract verification integrates into agent workflows at three points. A pre-commit hook prevents violations from entering version control entirely. A CI gate blocks pull requests that fail architectural tests. At the third level, the pipeline feeds violation reports back to the agent as corrective context, allowing it to self-remediate before a human ever reviews the change.

When an agent receives a structured violation report stating that module "domain" depends on "infrastructure" in a specific file, it has sufficient information to restructure the import.

The feedback loop is critical. When an agent receives a structured violation report stating that module "domain" depends on "infrastructure" in a specific file, it has sufficient information to restructure the import. Teams configure the failure mode via the enforcement field in arch.config.ts: "error" blocks the merge entirely, "warn" annotates the PR without blocking, and "off" disables enforcement for a given module during migration periods.

The Architectural Contract Linter and Agent Spec Verification Suite

What the Suite Includes

The following package structure illustrates the intended organization. These packages are not currently published to npm. The code examples in this article serve as a reference implementation that you can adapt to your own project.

The arch-contracts/ directory contains Zod schema templates for common architectural patterns including layered, hexagonal, and modular monolith structures. The ast-linter/ package provides a configurable AST-based dependency analyzer built on the TypeScript Compiler API. The vitest-arch/ package offers a pre-built Vitest architectural test harness that excludes test files and node_modules by default. The agent-gate/ package provides a pre-commit hook and a CI GitHub Action specifically designed for gating agent-generated pull requests.

Quick-Start: Adding Contract Enforcement to an Existing Repo

Adding contract enforcement to an existing repository requires a configuration file, a first audit run, and CI integration. The following example illustrates the target developer experience for a configuration-driven contract linter. Adapt the code samples from the earlier sections to build this workflow in your own project.

// arch.config.ts — illustrative configuration format
// Key names must match the ModuleContractSchema fields (e.g., "allowedDependencies").
// Adapt using the Zod schemas and AST linter from the sections above.

export default {
  rootDir: "src",
  modules: {
    domain: { allowedDependencies: [], strict: true },
    application: { allowedDependencies: ["domain"], strict: true },
    infrastructure: {
      allowedDependencies: ["domain", "application"],
      strict: false, // strict: false treats violations as warnings
    },
    shared: { allowedDependencies: [], strict: false },
  },
  exclude: ["**/*.test.ts", "**/*.spec.ts", "**/generated/**"],
  enforcement: "error" as "error" | "warn" | "off",
};

// Illustrative audit output — run the Vitest architectural tests
// or build a CLI runner using extractDependencyGraph + detectViolations:
//
// ┌────────────────┬────────────┬──────────────────────────┐
// │ Module         │ Violations │ Forbidden Dependency     │
// ├────────────────┼────────────┼──────────────────────────┤
// │ domain         │ 2          │ infrastructure, shared   │
// │ application    │ 1          │ infrastructure           │
// │ infrastructure │ 0          │ —                        │
// └────────────────┴────────────┴──────────────────────────┘
// Total: 3 violations in 3 files

The audit workflow scans the project, extracts the dependency graph via AST analysis, compares it against the declared configuration, and produces a summary table. Each violation includes the offending file, the forbidden dependency, and the line number. Teams can start with enforcement set to "warn" during adoption and switch to "error" once existing violations are resolved.

Preventing Architectural Drift at Scale: Patterns and Pitfalls

Patterns That Work

Contract-per-module ownership aligns naturally with team boundaries. The team responsible for the domain module owns its contract, declares its public API surface, and controls which modules may depend on it. This distributes the maintenance burden and ensures contracts evolve alongside the code they govern.

Start with dependency direction contracts alone; they catch the highest-impact violations with the least setup cost. Teams can add type-shape contracts later, once they trust the enforcement pipeline. Contract diffing in pull requests then shows what architectural changes an agent's PR introduces compared to the baseline, giving reviewers a structural summary far more useful than scanning individual file changes.

Common Pitfalls

Over-constraining contracts creates a different problem: agents cannot produce anything that passes validation, leading teams to disable enforcement entirely. Contracts should reflect the architecture's actual intent, not an idealized version that no implementation can satisfy.

If the architectural test suite runs only on developer machines, it will inevitably fall out of sync with the codebase. Contract tests that never execute in CI are decorative.

The subtlest failure mode is treating contracts as static. Architectures evolve. New modules appear, boundaries shift, and previously forbidden dependencies become necessary. Teams must update contracts as part of any architectural decision rather than writing them once and forgetting them.

Contracts as the Missing Interface Between Architects and Agents

Autonomous coding agents need machine-readable architectural intent, not just natural language prompts. Prompts describe what to build; contracts describe the structural boundaries within which to build it.

Autonomous coding agents need machine-readable architectural intent, not just natural language prompts. Prompts describe what to build; contracts describe the structural boundaries within which to build it. Without that structural layer, every agent interaction risks introducing drift that compounds silently across hundreds of generated files.

Architectural contracts fill this gap. They sit between human design decisions and autonomous code generation, encoding the invariants that keep a codebase maintainable and structurally sound. The tooling described here, Zod schemas for contract definition, TypeScript Compiler API for AST-level enforcement, Vitest for behavioral validation, provides a concrete, reproducible system that teams can adopt incrementally.

The starting point is a single module boundary. Define it, enforce it, and expand from there. As coding agents become standard development infrastructure, teams should treat contracts as a standard input format alongside prompts, giving agents not just instructions for what to build but constraints on how to build it.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.