Encoding Architectural Constraints and Module Boundaries for Agents
Architecture is invisible to anyone reading one file at a time. An agent reads one file at a time — and infers your conventions from whichever files it happened to open.
Architecture is invisible to a reader
Open any file in a well-structured codebase and you see a file. You do not see the rule that this directory may not import from that one, or that dependencies point inward, or that the adapter layer exists specifically so the domain layer stays testable. Those rules live in the heads of the people who designed the system and, if you are fortunate, in an architecture decision record nobody has read since it was merged.
An agent reads files. It infers conventions from what it sees. If it opens
three files in domain/ that happen to import a database client,
it concludes that is normal — and it will not know those three files are
the violations you have been meaning to clean up.
Boundaries, stated as rules
The useful form is a direction of dependency, not a description of layers.
## Architecture
Dependencies point inward. Outer layers may import inner; never reverse.
adapters/ → application/ → domain/
- `domain/` imports nothing from this repo except other `domain/` code.
No database, no HTTP, no framework imports. Pure functions and types.
- `application/` orchestrates. May import `domain/`. May NOT import
`adapters/` directly — it declares interfaces, adapters implement them.
- `adapters/` is the only place I/O lives: DB, HTTP clients, queues, files.
If you need I/O in `domain/`, that is a design error. Declare an
interface in `application/` and implement it in `adapters/` instead.
The final paragraph is doing the most work. Without it, an agent that needs a database call inside domain logic will add one, because the alternative is not obvious. With it, the agent has somewhere to go when it hits the constraint. A rule that forbids without redirecting produces either violation or paralysis.
Every boundary rule should end with "if you need X, do Y instead". The rule tells the agent what not to do; the escape hatch tells it what to do. Only the second one lets it finish the task.
Naming the pattern is not enough
"This project follows hexagonal architecture" is a weak instruction. Models know what hexagonal architecture is in the abstract, and every codebase implements it slightly differently. The abstract knowledge will fill in your gaps with someone else's conventions.
| Weak | Strong |
|---|---|
| "We use hexagonal architecture" | "domain/ imports nothing from this repo except domain/" |
| "Follow DDD principles" | "Aggregates live in domain/<context>/. One aggregate root per file." |
| "Keep components small" | "Over ~150 lines, split. Extract hooks before extracting subcomponents." |
| "Use dependency injection" | "Constructors take interfaces, never concrete types. Wiring happens only in main.go." |
The right column shares a property: each entry is checkable. You could write a lint rule for most of them. That is the standard to aim for — if you cannot imagine how you would verify a rule mechanically, it is probably too vague to steer behaviour.
Which of these should be lint rules instead
That observation leads somewhere important. If a boundary rule is mechanically checkable, the context file is the weaker place to enforce it.
Import boundaries in particular have mature tooling —
import-linter for Python, depguard in
golangci-lint, ESLint's no-restricted-imports, and equivalents
elsewhere. A rule encoded there fails the build. A rule in
AGENTS.md reduces the probability of violation.
This does not mean removing the rule from the context file. It means the two serve different purposes:
- The lint rule makes the violation impossible to merge.
- The context file stops the agent writing the violation in the first place, and — crucially — tells it what to do instead when it hits the wall.
Without the context file, the agent writes the import, the lint fails, and it now has to guess at a fix. It may guess "add an exclusion to the lint config", which is worse than the original problem. With both, it does the right thing first time and the lint is a backstop that rarely fires.
An agent blocked by a lint rule will sometimes edit the lint configuration rather than the code. If your architecture is enforced by tooling, add the config files to your "do not touch" list explicitly. It is not an obvious thing to write down until the first time it happens.
Constraints that exist for reasons nobody remembers
Every mature codebase has rules that look arbitrary and are not. A field that must stay nullable because a downstream consumer breaks otherwise. A module that cannot be split because a vendor's licence is keyed to its path. A retry count that looks too low and is that way because the upstream service rate-limits aggressively.
These are the constraints an agent is most likely to violate, because they are invisible in the code and they look like mistakes. A capable agent encountering a retry count of 2 with no comment will helpfully raise it to 5.
## Non-obvious constraints
- `User.legacy_id` must stay nullable. The 2019 billing export
reads this column and treats NOT NULL as a schema change.
- Retries against `partner-api` are capped at 2 deliberately.
They rate-limit at 3/min per key and will block us for an hour.
- `src/vendor/acme/` path is fixed by our licence terms. Do not
move or rename, even during refactors.
Two properties make these entries work. Each states the constraint and the reason, because a constraint without a reason invites an agent to reason its way around it. And each is written at the moment someone discovered it — which is the only time anyone knows it, and the moment before it gets forgotten.
This section grows organically. When an agent does something that makes you say "no, you can't do that, because—", the words after "because" are the entry. Write them down in that moment; you will not reconstruct them later.
Folder structure logic
Where a new file goes is a decision an agent makes several times per session, and it is easy to get wrong in a way that is annoying to unpick later. State the organising principle, not the tree — the tree is visible, the principle is not.
## Where things go
- Organised by feature, not by type. A new "billing" feature creates
`src/billing/` containing its component, hook, tests and types —
NOT files scattered into `components/`, `hooks/`, `types/`.
- Shared code moves to `src/shared/` only after a second consumer
exists. Do not pre-emptively generalise.
- Tests live beside the code: `billing/invoice.ts` →
`billing/invoice.test.ts`. No separate `__tests__` directory.
The middle rule is the interesting one. "Do not pre-emptively generalise" is a judgement most agents will not make on their own — the pull toward extracting a shared abstraction on first sight of similarity is strong, and it produces the wrong abstraction more often than not. Stating the threshold explicitly gives it something concrete to apply.
State dependency directions rather than pattern names. Make every rule checkable. Always pair a prohibition with an alternative, or the agent will either violate the rule or stall. And where a boundary can be enforced by a linter, do both — the linter for correctness, the context file so the agent does not have to fail first to learn.