Rules eliminate entire bug classes permanently. But rules alone aren’t enough. You need the three-legged stool: structural constraints, behavioral verification, and generative scaffolding.
Author: James Phoenix | Date: March 2026
Summary
This article documents a real production harness across a TypeScript monorepo (tx-agent-kit: 15 packages, 234 integration tests, ~1200 lines of domain invariants). It covers: the six layers from compiler flags to structural scripts, the override strategy for generated code and tests, real bugs found during hardening that exposed config traps in ESLint, an honest ROI assessment of each rule category, and the “three-legged stool” framework for what actually works when scaling with coding agents.
The Core Idea
If a bug class can be caught mechanically, it should never reach code review.
Humans are inconsistent at spotting structural violations. Machines are perfect at it. You encode your architectural decisions as lint rules and compiler flags. Every pnpm lint run becomes a mechanical proof that the architecture hasn’t drifted.
This is constraint-first development applied to lint infrastructure.
The Six Layers
From innermost (language-level) to outermost (architecture-level):
Layer 1: TypeScript Compiler ← Zero cost, catches type errors
Layer 2: strictTypeChecked Preset ← Type-aware lint, catches null/error bugs
Layer 3: Cherry-Picked Type Safety ← Exhaustive switches, nullish coalescing
Layer 4: Code Quality ← No nested ternary, no require, no void
Layer 5: Domain Invariants ← DDD boundaries, schema isolation, determinism
Layer 6: Structural Scripts ← Cross-file invariants ESLint can't express
Each layer catches a different class of problem. Together they form a harness that prevents architectural drift across unlimited agent sessions.
Layer 1: TypeScript Compiler
File: packages/tooling/typescript-config/base.json
| Flag | What it prevents |
|---|---|
strict: true |
Baseline: strictNullChecks, noImplicitAny, strictFunctionTypes. Without this, TypeScript is just JavaScript with extra syntax. |
noUncheckedIndexedAccess |
array[0] returns T | undefined instead of T. Forces handling of missing indices. Agents frequently write users[0].name without checking. |
noImplicitReturns |
Every code path must explicitly return. Prevents accidentally returning undefined by falling off the end. |
noFallthroughCasesInSwitch |
Every case must end with break, return, or throw. Agents regularly forget break. |
Why this layer matters: Zero runtime cost, zero maintenance. The compiler checks them on every build. They catch the bugs that are embarrassing to find in production.
Layer 2: strictTypeChecked Preset
File: packages/tooling/eslint-config/base.js
The upgrade from recommendedTypeChecked to strictTypeChecked added ~20 type-aware rules. Key categories:
Null/Undefined Safety
| Rule | What it catches | Example |
|---|---|---|
no-unnecessary-condition |
Conditions that are always true/false | if (x) where x: string (never null) |
restrict-template-expressions |
Non-string values silently stringified | `value: ${undefined}` becomes "value: undefined" |
no-confusing-void-expression |
Using void values where real values expected | return arr.forEach(...) |
Error Handling
| Rule | What it catches | Example |
|---|---|---|
only-throw-error |
Throwing non-Error values | throw 'something broke' loses stack trace |
prefer-promise-reject-errors |
Rejecting with non-Error values | reject('failed') loses stack trace |
use-unknown-in-catch-callback-variables |
.catch(err) where err is typed as any |
Forces unknown type, requiring explicit checks |
Code Precision
| Rule | What it catches |
|---|---|
no-unnecessary-type-parameters |
Generics that add complexity without value |
unified-signatures |
Overloads that could be a single signature |
no-dynamic-delete |
delete obj[key] mutations via dynamic keys |
Layer 3: Cherry-Picked Type Safety
File: packages/tooling/eslint-config/type-safety.js
Four rules not in any preset but high-value for catching agent mistakes:
switch-exhaustiveness-check (the most important rule for DDD)
type Status = 'pending' | 'active' | 'suspended'
function getLabel(status: Status): string {
switch (status) {
case 'pending': return 'Pending'
case 'active': return 'Active'
// ERROR: 'suspended' not handled
}
}
When you add a new value to a union type, every switch statement that handles that type fails lint until you add the new case. Domain model changes ripple through the codebase mechanically. Without this rule, adding a new enum value is a game of “did I find all the places?”
prefer-nullish-coalescing
const port = config.port || 3000 // BUG: port 0 is valid but falsy
const port = config.port ?? 3000 // CORRECT: only null/undefined trigger fallback
The || operator treats 0, '', false, and NaN as falsy. The ?? operator only triggers on null/undefined.
prefer-optional-chain
if (user && user.address && user.address.city) // verbose, easy to mistype
if (user?.address?.city) // same semantics, less error-prone
consistent-type-exports
export { type User, type Team } // CORRECT: signals "types only" to bundlers
export { User, Team } // ERROR: may confuse tree-shaking
Layer 4: Code Quality
File: packages/tooling/eslint-config/code-quality.js
| Rule | What it prevents | Why |
|---|---|---|
no-nested-ternary |
a ? b ? c : d : e |
Unreadable. Use if/else or extracted variables. |
no-require-imports |
require('x') in TypeScript |
Forces ESM imports so tree-shaking works and types flow. |
no-void |
void doSomething() |
Silently swallows rejected promises. Use await, .catch(), or Effect.runFork. |
Layer 5: Domain Invariants (The Real Power)
File: packages/tooling/eslint-config/domain-invariants.js (~1200 lines)
This is where constraint-driven development shines. These aren’t TypeScript rules. They’re architecture rules encoded as lint constraints. They enforce DDD layer boundaries, hexagonal architecture, and operational contracts mechanically. See ESLint Rules and Enforcement Overview for the full rule reference.
5a. Dependency Direction
DDD layer structure: domain -> ports -> application -> adapters -> runtime. Dependencies must point inward only. See DDD Construction Pattern and Dependency Flow for the full architecture.
| Rule | What it enforces |
|---|---|
enforce-layer-boundaries |
Domain can’t import ports, ports can’t import application, etc. |
pure-domain-no-infra-imports |
Domain can’t import @tx-agent-kit/db, node:fs, etc. |
pure-domain-no-effect-imports |
Domain entities must be pure TypeScript, no Effect dependency. |
adapters-must-import-port |
Adapters must reference the port they implement. |
ports-no-layer-providers |
Ports can’t import Effect Layer wiring. |
Why this matters: If an agent adds a database call inside a domain entity, lint fails instantly. You don’t need a human to catch “you put infrastructure in the domain layer.”
5b. Schema Isolation
// Ban ALL competing schema libraries
'no-restricted-imports': ['error', {
paths: [
{ name: 'zod', message: 'Use effect/Schema only' },
{ name: 'valibot', ... },
{ name: 'yup', ... },
{ name: 'joi', ... },
{ name: 'superstruct', ... }
]
}]
One schema library per codebase. An agent trained on Stack Overflow will happily import { z } from 'zod'. This rule stops it. See Schema Contracts for how Effect Schema is used throughout.
5c. ORM Isolation
Only packages/infra/db may import drizzle-orm. Routes, services, domain code can’t import { eq } from 'drizzle-orm'. All persistence goes through repositories exposed as Effect Services via the Ports and Adapters pattern.
5d. Client App Isolation
Web and mobile apps are “dumb” API consumers with no domain knowledge:
apps/web can't import:
- @tx-agent-kit/db (no direct DB access)
- drizzle-orm (no ORM)
- effect (no Effect runtime)
- next/server (no server-side code)
Single entry points enforced:
localStorage -> only via lib/auth-token.ts
process.env -> only via lib/env.ts
notifications -> only via lib/notify.tsx
URL state -> only via lib/url-state.tsx
HTTP client -> only via lib/axios.ts
Why single entry points? If you need to change how auth tokens are stored (localStorage to cookies), you change exactly one file. Without this rule, 47 components each reading localStorage directly creates a refactoring nightmare.
5e. Domain Determinism
In domain/ports/application layers, ban Date.now(), new Date(), Math.random(). Inject via ports (ClockPort, RandomPort).
Domain logic must be deterministic. If your billing calculation uses new Date() internally, you can’t test it reliably. By injecting time through ports, tests provide fixed values.
5f. Temporal Workflow Determinism
In workflow files, ban Date.now(), setTimeout, process.env, @tx-agent-kit/db, @tx-agent-kit/logging.
Temporal workflows replay from event history. If a workflow uses Date.now(), it returns a different value on replay than on original execution, causing non-determinism bugs that are extremely hard to debug. See Worker (Temporal) and Adding Workflows for the full workflow setup.
5g. Outbox Pattern Enforcement
API must NOT import @temporalio/*. API writes to an outbox table, worker reads from it. This guarantees at-least-once delivery without distributed transactions.
5h. Effect Runtime Boundaries
Ban Effect.run* in source modules except explicit boundaries. Ban new Promise() in core/API (use Effect.promise/tryPromise). Effect code stays declarative until the edge of the system.
Layer 6: Structural Scripts
Directory: scripts/lint/
Some invariants can’t be expressed as ESLint rules because they span multiple files or require custom parsing. See Structural Checks and Shell Invariants for the full enforcement suite.
| Script | What it enforces |
|---|---|
enforce-domain-invariants.mjs |
Table-to-schema parity, table-to-factory parity, route/repository kind markers |
enforce-web-client-contracts.mjs |
Every web page uses 'use client' directive |
enforce-route-kind-contracts.mjs |
CRUD routes expose full CRUD surface, custom routes don’t |
enforce-source-type-safety.mjs |
No as any, no chained assertions, no suppression directives |
enforce-domain-event-contracts.mjs |
Every domain event has registered type, payload, schema, and exhaustive switch default |
enforce-tsconfig-alignment.mjs |
All tsconfigs extend the shared base |
enforce-compose-runtime-contracts.mjs |
Docker compose and K8s configs are consistent |
The Override Strategy
Not every file needs every rule. The config has a deliberate hierarchy:
| Override | Why | Rules disabled |
|---|---|---|
| Generated code (Orval output) | Codegen output can’t conform to hand-authored rules. If the generator is correct, the output is correct. | All no-unsafe-*, no-redundant-type-constituents, no-misused-spread, consistent-type-exports |
| Orval mutators (hand-authored) | Transport adapters that work with Axios internals and any-typed HTTP responses. Need no-unsafe-* relaxed but keep safety rules enabled. |
no-unsafe-assignment, no-unsafe-member-access, no-unsafe-argument, no-misused-spread |
| Test files | Tests need ! assertions, void expressions, dynamic deletes, and template interpolation of undefined values. |
no-non-null-assertion, restrict-template-expressions, no-confusing-void-expression, no-dynamic-delete, no-unnecessary-condition |
| DB schema | Drizzle’s pgTable API is marked deprecated in newer TS types but there’s no migration path yet. |
no-deprecated |
Key lesson learned: Config ordering matters. If type-safety.js loads after base.js in the ESLint config chain, rules in type-safety.js override the overrides from base.js. Generated file overrides must exist in every config file that enables the relevant rule.
Real Bugs Found During Hardening
These were discovered by running agent swarms (code reviewers, smoke testers, regression auditors) against the harness itself:
1. The restrict-template-expressions Config Reset Trap
Setting { allowNumber: true } alone resets all other options to their defaults (all true), silently undoing strictTypeChecked. You must explicitly set every option:
// BAD: silently allows boolean, nullish, any, regexp, never
{ allowNumber: true }
// GOOD: explicit about what's allowed
{
allowNumber: true,
allowBoolean: false,
allowNullish: false,
allowAny: false,
allowRegExp: false,
allowNever: false
}
2. The prefer-nullish-coalescing Semantic Risk
The rule auto-fixes '' || fallback to '' ?? fallback, changing behavior for empty strings. Fixed with ignorePrimitives: { string: true } because '' || fallback is often intentional.
3. The Orval Mutator Over-Permission
Hand-authored transport adapters were grouped with generated code overrides, disabling safety rules unnecessarily. Split into separate override with only the minimum rules disabled.
4. The Config Ordering Bug
type-safety.js came after base.js in the config chain, so consistent-type-exports: error from type-safety.js overrode the off in generated file overrides in base.js. Fix: add the generated file override directly in type-safety.js.
5. The Mutable Guard Pattern
TypeScript narrows guard.active through async boundaries even though the value can change after await. Fix: use a function accessor isActive() to prevent incorrect narrowing.
ROI Assessment: Not All Rules Are Equal
High ROI (invest heavily here)
| Rule | Bug prevented | Agent frequency | ROI |
|---|---|---|---|
switch-exhaustiveness-check |
Missing case in union handler | Very common | Extremely high |
no-restricted-imports (drizzle isolation) |
ORM leaking outside DB layer | Common | Very high |
| DDD layer boundaries | Architecture drift | Common | Very high |
no-unnecessary-condition |
Dead code or wrong type | Moderate | High |
restrict-template-expressions |
undefined in strings |
Common | High |
Low ROI (keep but don’t over-invest)
| Rule | What it catches | Frequency | ROI |
|---|---|---|---|
no-dynamic-delete |
delete obj[key] |
Rare | Low |
unified-signatures |
Overloads that could merge | Very rare | Low |
no-meaningless-void-operator |
void on void expressions |
Almost never | Negligible |
Practical advice: Invest most heavily in rules that catch the bugs agents actually make (exhaustive switches, null safety, layer boundary violations). Be skeptical of rules that mostly catch things humans do but agents don’t.
The Three-Legged Stool
Constraint-driven development (the lint harness) is necessary but not sufficient. It’s one leg of a three-legged stool:
┌────────────────────────┬──────────────────────────┬──────────────────────┐
│ Structural Constraints │ Behavioral Verification │ Generative │
│ (Lint Rules) │ (Integration Tests) │ Scaffolding │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ Prevent known │ Verify the system works │ Produce correct │
│ anti-patterns │ correctly end-to-end │ structures by │
│ │ │ default │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ ~1200 lines of domain │ 234 integration tests │ pnpm scaffold:crud │
│ invariants, 6 layers │ against real Postgres │ generates 15+ files │
│ of rules │ and Temporal │ conforming to DDD │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ Can check structure, │ Can verify behavior, │ Can produce correct │
│ NOT logic │ NOT structure │ starting points │
├────────────────────────┼──────────────────────────┼──────────────────────┤
│ Alone: structurally │ Alone: behaviorally │ Alone: correct │
│ correct but potentially │ correct but structurally │ starts that diverge │
│ behaviorally broken │ drifting │ over time │
└────────────────────────┴──────────────────────────┴──────────────────────┘
Leg 1: Structural Constraints (this article)
Rules check structure, not logic. This code passes every lint rule:
export const calculateDiscount = (price: number, tier: string): number => {
if (tier === 'premium') return price * 0.5
if (tier === 'standard') return price * 0.2
return price * 0.1
}
// Structurally perfect. Logically wrong.
// Business requirement: premium = 20% off, standard = 10% off, free = none
Leg 2: Behavioral Verification
Integration tests against real infrastructure verify behavior. Run real HTTP requests against a real database. Verify that sign-up, sign-in, token refresh, and organization creation all work. No lint rule can do that. See Testing Strategy for the full approach.
Leg 3: Generative Scaffolding
Code generators produce correct structures by default. pnpm scaffold:crud generates the entire DDD structure: domain types, ports, application layer, adapters, routes, tests. Instead of writing 15 files and hoping they conform, the scaffold produces a correct skeleton that the agent fills in. See Adding a Domain for the scaffold walkthrough.
OpenAPI spec to Orval-generated typed clients is the same idea. The agent never writes API client code. It’s generated from the contract.
Where Constraints Fall Short
Rules can only prevent what you’ve anticipated
Every rule exists because someone thought of that failure mode. Novel architectural drift slips through. The rules are reactive: discover a pattern, encode a rule, prevent recurrence. They don’t prevent the first occurrence.
What fills this gap: Code review by experienced humans or AI reviewers. The review agents in this session found the restrict-template-expressions config bug, the orval-mutator scope problem, and the isActive() guard pattern.
Rule maintenance is a real cost
When the architecture changes, you update the rules too. Rules that don’t evolve become either too permissive (allowing violations of the new architecture) or too restrictive (blocking legitimate new patterns, leading to exception requests).
The config ordering bug in this session is a rule maintenance bug. The system enforcing correctness had its own correctness problems.
Over-constraining creates friction without proportional safety
Low-ROI rules add cognitive overhead (developers/agents must learn them, understand the override system, work around false positives) without proportional safety benefit.
The Full Verification Suite
pnpm lint
├── ESLint (14 packages)
│ ├── base.js -- strictTypeChecked + explicit overrides
│ ├── type-safety.js -- exhaustive switches, ??, ?., type exports
│ ├── code-quality.js -- no nested ternary, no require, no void
│ ├── effect-consistency.js -- no new Promise in Effect code
│ ├── domain-invariants.js -- 1200+ lines of architecture rules
│ ├── boundaries.js -- import boundary constraints
│ ├── promise.js -- promise handling rules
│ └── testing.js -- test-specific rules
├── Structural invariants (7 scripts)
├── Shell invariants
└── CI env validation
Every run is a mechanical proof that:
- Types are sound (compiler)
- Null safety is maintained (strictTypeChecked)
- Switch statements are exhaustive (type-safety)
- DDD layers aren’t violated (domain-invariants)
- Client apps don’t know about the server (domain-invariants)
- Workflows are deterministic (domain-invariants)
- Events flow through the outbox (domain-invariants)
- Tables have schemas and factories (structural scripts)
- No code smells are hiding (code-quality)
The key insight: The rules ARE the architecture documentation. If someone asks “can the web app import from the database layer?” the answer isn’t in a wiki page that might be outdated. It’s in a lint rule that fails the build if violated.
External
- tx-agent-kit Documentation – Full docs for the monorepo this harness is built for
- Enforcement Overview – Complete enforcement suite reference
- DDD Construction Pattern – The architecture these rules enforce
Related
- Constraint-First Development – The philosophy: define constraints first, code is implementation detail
- Custom ESLint Rules for Determinism – How to build custom ESLint plugins with teaching error messages
- Constraint Escalation Ladder – Choosing the right prevention layer (types > primitives > invariants > tests > lint)
- Building the Harness – The four-layer harness around Claude Code
- Quality Gates as Information Filters – Gates that filter signal from noise
- Compounding Effects of Quality Gates – Multiplicative benefits over time
- Boundary Enforcement – Architectural boundaries via lint
- Integration Testing Patterns – End-to-end testing (leg 2 of the stool)
- Agent-Native Architecture – Designing software where agents are first-class citizens

