Skip to content
Goatfied

refactoring

Teaching agents to respect architectural boundaries

Teaching AI coding agents to enforce layered architecture and dependency rules during code generation, not after, to prevent violations of established module boundaries.

2026-08-288 min readBy Goatfied
Teaching agents to respect architectural boundaries

When an agent proposes changing a domain model class to call an external HTTP client directly, it's solving a problem—just not the one you wanted solved. Most AI coding tools will happily make that edit if it passes local type checks. The real challenge is teaching agents to recognize and respect the layered architecture, dependency flow, and module boundaries your team spent months establishing.

Enforcing architecture boundaries during automated refactors means programming the constraints before the agent writes code. Traditional linters catch violations after the fact; by then you've already spent tokens generating a diff that violates your three-tier separation or introduces a circular dependency. The agent loop needs architectural rules baked into the planning and constraint phases, not discovered during post-hoc validation.

Why agents ignore boundaries by default

Language models are trained on vast public codebases where architectural discipline varies wildly. An LLM has seen thousands of examples where services call databases directly, where UI components import backend logic, where utilities reach across package boundaries. When you ask an agent to "add caching to the user profile endpoint," it defaults to the statistically common solution—often the simplest path through the dependency graph, regardless of your layering rules.

The agent doesn't distinguish between "this import is technically possible" and "this import violates our hexagonal architecture." Without explicit constraints, it optimizes for making tests pass, not for preserving the boundaries between your core domain and infrastructure adapters.

This is compounded during multi-file refactors. An agent might correctly identify that function calculateDiscount needs to move from orders.ts to pricing.ts, but then blithely add an import from pricing.ts back to orders.ts to wire up a helper, creating a cycle your module bundler will reject. The agent solved the immediate type errors; it didn't model the directed acyclic graph your build system requires.

Encoding boundaries as compile-time constraints

The most reliable approach is making architectural violations impossible to compile. If your domain layer cannot import from your infrastructure layer because the module system forbids it, the agent cannot propose that change and pass validation.

Goatfied's constraint phase happens before code generation. You can specify import rules, dependency direction, and allowed call patterns as part of the task definition:


constraints:

  - no_imports:

      from: "src/domain/**"

      to: ["src/infrastructure/**", "src/api/**"]

  - layer_order:

      - presentation

      - application

      - domain

      - infrastructure

The agent's planner sees these constraints and shapes its approach accordingly. Instead of generating a diff that adds a database call to a domain entity, it will propose moving the query to a repository interface in the domain, then implementing that interface in the infrastructure layer—because that's the only shape that satisfies both the type checker and the import rules.

This front-loads the architectural enforcement. The agent isn't guessing at your preferences or relying on a linter plugin you might forget to run; it's working within the same compile-first boundaries your CI pipeline enforces.

Dependency inversion at the agent level

Agents often need to wire up new dependencies: a service suddenly requires a cache, or a module needs access to feature flags. Without guidance, the agent will reach for the concrete implementation—new RedisCache() instantiated wherever it's convenient.

Teaching agents to respect dependency inversion means providing them with examples of how your codebase handles this pattern. If your team uses constructor injection and interface types, show the agent a small reference snippet:


// ✓ Agent learns this pattern

class OrderService {

  constructor(

    private readonly cache: CacheProvider,

    private readonly eventBus: EventPublisher

  ) {}

}



// ✗ Agent avoids this

class OrderService {

  private cache = new RedisCache();

  private eventBus = new RabbitMQPublisher();

}

Goatfied allows you to attach reference code blocks to refactor tasks. The agent's planner considers these examples as strong signals about acceptable solutions. If it sees constructor injection in the references and DI container registration in your test fixtures, it will propose diffs that follow that pattern rather than scattering new keywords throughout your domain.

The validate step catches violations that slip through. If the agent generates code that instantiates a concrete HTTP client inside a use case, your unit tests (which mock that dependency via an interface) will fail. The retry logic sees the test failure, traces it to the architectural violation, and generates a revised diff that introduces an interface and accepts the client as a constructor parameter.

Guarding package boundaries in monorepos

Monorepo architectures amplify the boundary problem. You might have twenty packages in packages/, each with its own package.json, and strict rules about which packages can depend on which others. An agent refactoring @company/billing should never add @company/admin-ui as a dependency, even if both packages expose utilities that look superficially helpful.

Module federation and workspace constraints (via tools like Nx or Lerna) provide the enforcement layer. Configure your workspace to make invalid cross-package imports fail at module resolution:


// packages/billing/package.json

{

  "dependencies": {

    "@company/shared-types": "workspace:*",

    "@company/payment-gateway": "workspace:*"

  }

  // No @company/admin-ui here

}

When the agent tries to import from an unlisted package, the TypeScript compiler or bundler rejects it. The agent's validate phase sees the error, and its retry logic explores alternative solutions—maybe extracting the shared utility to @company/shared-utils, or recognizing that the seemingly helpful function doesn't belong in this package's dependency tree at all.

For complex monorepos, you can also define a directed graph of allowed dependencies in your constraint config. Goatfied's planner will refuse to propose edits that introduce edges not present in that graph, preventing the agent from creating circular dependencies between packages that should remain decoupled.

Boundary checks in the self-review loop

Even with constraints and compile gates, some architectural violations are semantic rather than syntactic. An agent might correctly place code in the application layer but choose the wrong service boundary—merging user authentication logic into the billing service because both touch user records.

This is where Goatfied's validation step becomes a miniature code review. After generating a diff, the agent runs:

1. Compile check: Does it type-check and build?

2. Lint pass: Does it satisfy import rules, naming conventions, cyclomatic limits?

3. Test suite: Do unit and integration tests pass?

4. Boundary lint (custom): A script that checks module-level invariants—no cross-context calls, no leaking internal types into public APIs.

If your project includes a script like npm run check-boundaries, Goatfied can invoke it as part of the validate step. When the script exits non-zero, the agent sees the error output (often precise: "Violation: src/billing/service.ts imports from src/users/internals.ts, which is not a public export") and plans a corrective diff.

The key is making boundaries explicit and checkable. Prose guidelines like "keep contexts separate" are hard for agents to operationalize; a script that parses import graphs and compares them against a manifest is straightforward.

Human oversight for fuzzy boundaries

Not all architectural decisions reduce to linter rules. Sometimes the agent needs to choose between two valid placements, and the "right" answer depends on strategic direction—are we splitting the monolith into services next quarter, or consolidating for simpler deployments?

For these cases, Goatfied's agent loop surfaces the decision to you before committing. The agent generates two candidate diffs: one that keeps shared logic in a central module, another that duplicates it across service boundaries to reduce coupling. You review both options in the editor, see the tradeoffs annotated, and approve the one that aligns with your roadmap.

This human-in-the-loop step is especially valuable during large refactors that touch module boundaries. The agent handles the tedious work—updating imports, renaming files, fixing type errors—but defers boundary-crossing moves to a reviewer who understands the broader context.

Automated enforcement handles 90% of boundary violations; human judgment handles the remaining 10% where architectural principles conflict or evolve.

Related posts

Teaching agents to respect architectural boundaries | Goatfied Blog