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.
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 typeis mechanical, on purpose. The base config turns on@typescript-eslint/consistent-type-importsas an error, so type-only imports are auto-fixable. The sanctioned cross-context seam stays low-friction precisely because the linter rewritesimporttoimport typefor 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.