software engineering6 min read
Bounded contexts and monadic composition: how I structure code for AI coding agents
My short answer: an AI coding agent needs each area of the codebase to be small, named and fenced, and every step to say in its type what it can fail with. Bounded contexts give the agent the first. Monadic composition gives it the second. In practice that means one package per domain, a one-way dependency direction that tests enforce, errors as typed values rather than thrown exceptions, and effects kept at the edges.
I posted this on X: "Bounded contexts and monadic composition. That's all you need to write code with AI." The line is also quoted on my founder page at Comonad. I'm Alex Vakhitov, an AI and software architect in London, and this post shows what that line looks like in real code, using a multi-tenant AI knowledge platform I architect and build. I've written separately about how the line relates to the company's name in Why I named my company Comonad.
What is a bounded context for a coding agent?
The term comes from domain-driven design: an area of a system with its own model and its own language, and clear edges. For a coding agent, a bounded context is an area small enough to hold in its working context, with edges it can't cross by accident.
On the platform, a bounded context is a package in a monorepo, one per domain: knowledge base, retrieval, ingestion, generation and so on. There is also one runtime package, and one foundation package that holds the error types.
The layers sit in a fixed order, and a package may depend only on layers to its left:
foundation → infrastructure → domain → content → runtime → apps
A domain package can depend on infrastructure, never the other way round. An app can use the runtime, but a domain package can't reach up into an app.
How do I mark the edges?
Three ways.
- The package boundary itself. A package has a name, a public entry point and a list of dependencies. That is the first thing an agent sees.
- A glossary file, so every package uses the same words for the same things.
- Architecture tests that fail the build. This is the one that matters most.
The architecture tests turn conventions into checks. On the platform they say, among other things, that the functional effects library may only be imported inside the domain and runtime packages, that the web front end may never import it, that AI clients may only be built in three named files, and that the package which renders exports may not touch the database, the network or the domain.
A boundary test is short:
test("AI provider clients are only reachable through the AI package", () => {
const violations = readImports()
.filter(({ file, specifier }) => isAiProviderImport(specifier) && !isAiPackageFile(file))
.map(({ file, specifier }) => `${file} imports ${specifier}`);
expect(violations).toEqual([]);
});
If an agent adds an import that crosses a line, the build fails with a message naming the file and the import. It doesn't have to know the rule in advance.
What does monadic composition look like in TypeScript?
In Monads 101 I described bind as a way to chain steps while the context handles the extra work: possible failure, state, I/O. TypeScript has no built-in monads, so on this platform I use Effect, a library that gives you that kind of composition with typed errors and managed effects.
Errors are values in the type signature, not thrown exceptions. A database error, a quota error, a provider error, and an "all providers failed" error that carries both causes are each a tagged type. Callers handle them by tag. The compiler also checks that the list of provider-error tags matches the union, so adding a new provider error without handling it fails the build:
export class ProviderCallError extends Data.TaggedError("ProviderCallError")<{ cause: unknown; model: string; provider: string }> {}
export class AllProvidersFailed extends Data.TaggedError("AllProvidersFailed")<{ primary: ProviderError; fallback: ProviderError | null }> {}
// A Record keyed on the union's tags: forget a member and the build fails.
const TAGS: Record<AllProvidersFailed["_tag"] | ProviderError["_tag"], true> = { /* … */ };
Effects live at the edges. The apps run programs through a single managed runtime. The web front end never touches Effect at all; it calls the API. This is the approach from my post on side effects and I/O: you can't remove effects, but you can push them to the boundary.
Where something should be pure, it stays pure. The classifier that decides whether a failed model call should go to another provider has no Effect imports at all. It's a plain function that I test with a table of cases.
Each domain package can be tested on its own by swapping a fake layer in for the real one.
The result is that each step can be checked in isolation. An agent changing one function can see from its type what it may fail with, and the compiler tells it when a caller no longer handles every case.
What should an agent find in the repo?
An agent needs to find the rules, the commands and the traps quickly. My AGENTS.md is short on purpose. It sends every coding agent, whichever vendor it comes from, to one operating manual, and it keeps only the operational facts that are easy to get wrong. In my own words, it covers:
- Which document wins. Decision records hold the reasoning, and the manual follows them. If the two drift apart, it's the manual that gets corrected. Where the docs and the actual config disagree, believe the config.
- The commands. Lint, typecheck and tests, run before every pull request.
- The test database. Tests use a real Postgres database built from the same migrations as production, never a hand-written schema. On CI, no database means a failed run.
- What unit tests can't see. Layout problems, such as sideways scrolling or a clipped popover, only show up in the browser tests.
- Boundaries that folder names don't show. The one-way dependency direction, where the effects library may be used, and the rule that model calls go through one service.
The longer operating manual carries the hard rules.
Do agents run the tests before they say they're finished?
Yes. Lint, typecheck and tests run before a pull request opens, and a red check means the change doesn't get merged. On larger changes a review-and-fix loop runs first. The deterministic checks decide, the AI reviewers only advise, and the agent has to show the command output before it can claim a fix. It can't weaken the checks to get a pass, and it stops and reports when it isn't converging. I've written up the full set of gates in Quality gates for AI-written code.
Where does it fall apart?
Where a change crosses a boundary the tests can't see across. Shared state and hidden side effects do the same kind of damage: a change looks local, and isn't.
The clearest case I have crossed the line between TypeScript and SQL. A chain of fixes to one database function, each fixing the last, stayed green because the test harness only recorded SQL and never ran it. One of those fixes produced SQL that Postgres would reject, and nothing in the tests could see it. The fix was structural: tests now run against a real Postgres database built from the real migrations.
Commit history gives a rough view of the other side, though timing muddies it. The AI package, where every model call sits behind one service and an architecture test, has needed almost no review-driven fixes. The rework clusters where changes cross into the database and the API.
Does it depend on which coding agent I use?
The setup assumes it shouldn't. The operating manual says outright that it applies to every coding agent, not one vendor's. My pull requests are reviewed by agents from three different vendors, with written review instructions kept in the repository.
Get new notes by email.