Guardrails your agent can't write for itself: compiler-enforced DDD boundaries

Clean architecture survives contact with AI agents only when the boundaries are compiled in. Here are the exact ESLint rules UseDeploy uses to make DDD layering and cross-context walls something a coding agent can't cross by accident.

MC
Martin Coll

A CLAUDE.md or AGENTS.md file tells an AI coding agent how your project is supposed to be organized. It does not stop the agent from ignoring it. Clean architecture only survives contact with AI agents when the guardrails are enforced by the compiler and the linter — not written in prose the model can skim past on a long session. This post is about the second kind: the exact ESLint rules UseDeploy compiles into every module boundary, so that a domain layer reaching for Prisma, or one bounded context importing another's internals, fails at lint time instead of six months later.

A rules file is advice. A linter is a wall.

Shipping a rules file is table stakes now. As of July 2026, AGENTS.md is a cross-tool convention read by Claude Code, Cursor, Codex, Aider, Copilot and a couple dozen others — a blank repo forces the agent to invent architecture from scratch, and a rules file is the cheapest fix for that. Everyone ships one. So a rules file can't be the moat; it's the floor.

The reason prose isn't enough is the failure mode of AI-assisted code, and it has a name. A developer building an AI-ready boilerplate put it precisely (dev.to, "An AI-Ready NestJS + Next.js Boilerplate for 2026", as of July 2026):

"the project's shape would drift toward the AI's defaults — reasonable choices in isolation, stacked into something I wouldn't have designed."

Each edit is locally sensible. The agent sees a slice of the codebase per session and confidently mirrors the nearest pattern it can see. What it can't see is the CLAUDE.md line three folders up that said "the application layer never imports a Prisma repository." Prose lives outside the code path. The moment enforcement depends on the model remembering a rule, the rule is already lost.

UseDeploy's bet is to move the boundaries that matter out of prose and into ESLint, so crossing one is a failed build, not a code-review conversation. The rules live in packages/eslint-config/ddd.js and are wired into the server via a three-line flat config:

// apps/server/eslint.config.js
import ddd from '@app/eslint-config/ddd';

export default [
  { ignores: ['dist/**', 'node_modules/**'] },
  ...ddd,
];

Everything below is that ddd.js file, unedited.

What DDD layering forbids, in actual rules

Every bounded context under apps/server/src/modules/<context>/ has the same four folders — domain/, application/, infrastructure/, interfaces/http/ — and the dependency arrow only points one way: infrastructure knows application, the application layer knows the domain, and the domain knows nothing about either. (The bounded-contexts doc walks the eleven modules that ship this way.) Two ESLint blocks pin that arrow down.

domain/ may not touch a framework

{
  files: ['**/modules/*/domain/**/*.ts'],
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [
        {
          group: ['*/application/*', '*/infrastructure/*', '*/interfaces/*'],
          message: 'domain/ may not import from application, infrastructure, or interfaces. See ADR-001.',
        },
        {
          group: ['express', 'prisma', '@prisma/client', 'better-auth'],
          message: 'domain/ must remain framework-agnostic. See ADR-001.',
        },
      ],
    }],
  },
},

A value object under domain/ that reaches for @prisma/client — the single most tempting shortcut an agent takes when it needs to load a row — fails lint with the ADR-001 message, before it ever runs. The payoff is that the domain layer can only express interfaces. Here's a real repository contract that lives in domain/, importing nothing but types:

// modules/tenancy/domain/membership-repository.ts
import type { UserId } from '@app/shared';
import type { Membership } from './membership.js';
import type { MembershipId, OrganizationId } from './organization-id.js';

export interface IMembershipRepository {
  /** Org-scoped read: a cross-tenant id resolves to null. */
  findByIdForOrg(id: MembershipId, orgId: OrganizationId): Promise<Membership | null>;
  findByOrgAndUser(orgId: OrganizationId, userId: UserId): Promise<Membership | null>;
  listByOrganization(orgId: OrganizationId): Promise<ReadonlyArray<Membership>>;
  save(membership: Membership): Promise<void>;
  delete(id: MembershipId, orgId: OrganizationId): Promise<void>;
}

The PrismaMembershipRepository that implements this interface lives in infrastructure/ and is the only place @prisma/client appears. That's the dependency inversion the linter is protecting: the domain declares the shape, the outer layer supplies the Prisma. An agent physically cannot collapse the two, because the import that would let it do so is a lint error.

application/ talks to ports, not adapters

{
  files: ['**/modules/*/application/**/*.ts'],
  rules: {
    'no-restricted-imports': ['error', {
      patterns: [
        {
          group: ['*/infrastructure/*', '*/interfaces/*'],
          message: 'application/ may only depend on domain and ports. See ADR-001.',
        },
        {
          group: ['express', 'prisma', '@prisma/client', 'better-auth'],
          message: 'application/ may not import frameworks directly — use ports.',
        },
      ],
    }],
  },
},

Use cases receive their dependencies as interfaces through the constructor and never construct a concrete adapter themselves:

// modules/tenancy/application/use-cases/change-member-role.ts
export class ChangeMemberRoleUseCase
  implements UseCase<ChangeMemberRoleInput, void, DomainError>
{
  constructor(
    private readonly memberships: IMembershipRepository,
    private readonly roles: IRoleRepository,
    private readonly events: IEventBus,
  ) {}
  // ...
}

IMembershipRepository, IRoleRepository, IEventBus — three interfaces, zero adapters, no new PrismaMembershipRepository() anywhere in sight. If an agent tries to short-circuit the port and import the Prisma repo directly to "just get the data," the */infrastructure/* pattern rejects it.

The cross-context wall

Intra-module layering is the easy boundary. The one that actually rots large codebases is modules reaching into each other — tenancy newing up a billing service, iam importing a webhooks repository — until the eleven contexts are a single tangle. UseDeploy guards this with a stricter rule (fix-07), and it uses @typescript-eslint's variant of the plugin specifically because it supports one flag the core rule doesn't:

{
  files: ['**/modules/*/**/*.ts'],
  rules: {
    '@typescript-eslint/no-restricted-imports': ['error', {
      patterns: [{
        group: [
          'ai', 'audit', 'billing', 'feature-flags', 'iam', 'notifications',
          'platform', 'storage', 'tenancy', 'usage', 'webhooks',
        ].flatMap((m) => [
          `**/${m}/domain/**`,
          `**/${m}/application/**`,
          `**/${m}/infrastructure/**`,
        ]),
        allowTypeImports: true,
        message:
          'Cross-context runtime import: a module may reference another bounded context only via `import type` or its HTTP middleware, never a value/runtime import. See ADR-001.',
      }],
    }],
  },
},

allowTypeImports: true is the whole design. You can import another context's type — a shared shape carries no runtime dependency and disappears at compile time. You cannot import its value — that's a real edge in the dependency graph. So the sanctioned seam between modules is a type-only import:

// modules/tenancy/application/use-cases/invite-member.ts
import type { IUserRepository } from '../../../iam/domain/user-repository.js';

tenancy gets to name IAM's repository interface for dependency-injection wiring without taking a runtime dependency on IAM. The one deliberate exception is HTTP middleware: interfaces/ is absent from the pattern list, so importing another context's interfaces/http — the sanctioned shared kernel — is allowed. That's how a tenancy route can mount IAM's auth guard:

// modules/tenancy/interfaces/http/role.routes.ts
import { authMiddleware } from '../../../iam/interfaces/http/auth.middleware.js';

This is the "execution environment for agents" discipline in one rule. As Zeyu (zeyu2001) framed it in his widely-shared piece on harness engineering (dev.to, as of July 2026): "A repository should be treated less like a pile of code that can be executed, and more like an execution environment for agents." The walls are part of the environment, not a memo the agent has to hold in context.

The escape hatch is one greppable line

Sometimes a runtime cross-context import is genuinely the right call, and the rule doesn't pretend otherwise — it just refuses to let it be silent. When tenancy legitimately needs to construct IAM's Email value object at runtime, the crossing is an explicit, reviewable eslint-disable with a reason attached:

// modules/tenancy/application/use-cases/invite-member.ts
// eslint-disable-next-line @typescript-eslint/no-restricted-imports -- cross-context allowlist (fix-07): constructs IAM's Email VO at runtime; grandfathered pending shared-kernel/port extraction
import { Email } from '../../../iam/domain/email.js';

There are only a couple of these in the whole server, each carrying the same fix-07 ... grandfathered note. That mirrors UseDeploy's other guardrail pattern — the tenant-isolation guard's getSystemPrismaClient() escape hatch is greppable for the same reason. The wall has exactly one kind of door, it's labeled, and grep -rn "eslint-disable.*no-restricted-imports" audits every place anyone walked through it. An agent can't quietly add a twelfth; it would have to write the disable comment, and that shows up in the diff.

The honest limitations

This is a lint boundary, not a proof, and it's worth being exact about what it does and doesn't do:

  • The cross-context list is a maintenance surface. The config says so in a comment: "when you add a new module folder under modules/, add its name to the list below or its cross-context imports will not be guarded." It's a deliberate allowlist — the same tradeoff as the tenant guard's explicit set of scoped models. Adding a context means adding a string; forget it and that module's walls simply aren't built yet.
  • ESLint checks the import graph, not the design. It can prove a use case didn't import an adapter; it can't prove the port you did inject is the right one. Architecture is still on you — the rule catches the crossing, not the bad idea behind a legal one.
  • import type is mechanical, on purpose. The base config turns on @typescript-eslint/consistent-type-imports as an error, so type-only imports are auto-fixable. The sanctioned cross-context seam stays low-friction precisely because the linter rewrites import to import type for you when it can.

Why the compiler, not the prose, when an agent is in the loop

The lint walls don't stand alone; they pair with a typed contract between client and server. GitHub reported that 94% of LLM-generated compilation errors are type-check failures — the exact class of mistake type systems already solve (as of July 2026). UseDeploy leans into that: the shared Zod schemas in @app/contracts are the single source of truth for request and response shapes, so a form that drifts from the endpoint it calls is a red squiggle at author time, not a 400 in production. That's the whole story of the contracts doc — one schema, both sides, drift caught by the compiler.

Put the two together and the failure mode inverts. A rule an agent violates in a CLAUDE.md is drift you notice in review if you're paying attention, and in the month-six slowdown if you aren't. A rule an agent violates in ddd.js is a red X in the same terminal the agent is already reading — inside its own edit-test-edit loop, before a single line is committed. The agent gets the ADR-001 message, sees exactly which import to drop, and self-corrects in the same turn. The boundary lives in the code path, where a context-limited model actually encounters it.

That's the line between an "AI-ready" repo and one an agent genuinely can't break: not how good the rules file reads, but how many of the rules the compiler enforces without being asked. If you're evaluating a base to hand an agent, the walls it compiles in are the ones worth paying for — and they're the throughline of what UseDeploy builds for teams shipping with AI coding agents.