What finally clicked
I kept thinking of Domain as a testing framework, because its tests read so nicely. It's one step past that. A testing framework checks behavior that lives somewhere else. Domain makes the behavior the program, and everything else is built from it:
I did all five with one real model, from a small app of mine where agents build tools.
Why this matters with agents
Agents write code faster than I can read it. I can't review every line anymore. A model gives me one file I actually read.
What the app does lives in that file. Everything else is how. So if an agent's change doesn't touch the model, it didn't change the rules. If it does touch the model, the change is a few lines I can read in a minute.
When does it run?
This confused me at first. It's one file, used in two ways:
Ordinary tests, in memory, under a second. Run them while you edit, before you push, and in CI.
It isn't a check beside the app. It is the app. Every request goes through it, so a broken rule can't slip through at runtime.
One more line makes it a gate for agents: src/domain/ @me in CODEOWNERS. Agents can merge
anything else. Changing the rules needs me.
The model
The example: agents ask for a tool like sass.
The first one builds it, and everyone after uses the kept copy. I assumed a rule was there, don't build the same tool twice. Five agents asking at once built it five times.
So I wrote the rules down as a Domain model, in src/domain/tool.ts. A tool
can be asked five things. Each if is one way to say no. Here's the rule that was missing:
RequestBuild: (tool, ask) => {
if (tool.status === 'built') {
return new AlreadyBuilt({ hash: tool.hash });
}
if (buildInFlight(tool, ask.at)) {
return new AlreadyBuilding({ by: tool.builder });
}
return [new BuildStarted({ by: ask.by, at: ask.at })];
}, A yes returns an event, a fact that gets saved. A no returns a rejection, which says why and saves nothing. Writing the next one down turned up a rule I hadn't thought of: a builder that hung, and was taken over, can't finish later.
RecordBuilt: (tool, result) => {
if (tool.status !== 'building') {
return new NotBuilding({});
}
if (tool.builder !== result.by) {
return new NotYourBuild({ by: tool.builder });
}
const reason = mismatch(result);
if (reason !== null) {
return new DoesNotMatchRealTool({ reason });
}
return [new Built({ hash: result.hash, version: result.version })];
}, There's nothing here about storage, containers or HTTP. That's the point. It's the part that stays the same while everything else changes.
Play with it
This is the real model file, bundled and running in your browser on Domain.InMemory. Nothing is
faked. Be an agent and try to break the rules:
Pick an agent and ask. Try: ann asks to build, then bob says it's built.
Things to try: ann asks to build, then bob says it's built. Ask for a tool that's already built. Forget a tool nobody has.
Host 1: in memory, for tests
Stories run the real model with no server at all:
it.effect('a second agent waits while sass builds', () =>
story(
Story.given(sass, started),
Story.when(sass, new RequestBuild({ by: 'agent-b', at: 1_000 })),
Story.rejected(new AlreadyBuilding({ by: 'agent-a' })),
),
); The model has 16 stories, one per rule and outcome. Delete a rule here and watch which story breaks:
- ✓ A new tool. Agent A asks for it. BuildStarted
- ✓ Sass is building. Agent B asks for it. AlreadyBuilding
- ✓ Sass is built. Agent B asks for it. AlreadyBuilt
- ✓ Agent A's build has hung for 5 minutes. Agent B asks. BuildStarted
- ✓ The build finishes and matches the real tool. Built
- ✓ The bundle prints something different from real sass. DoesNotMatchRealTool
- ✓ The bundle works in the container but fails in the isolate. DoesNotMatchRealTool
- ✓ B took over a hung build. Then A wakes up with a result. NotYourBuild
- ✓ A result arrives, but nobody was building. NotBuilding
- ✓ The build container crashes. BuildFailed
- ✓ A build failed earlier. Agent B asks. BuildStarted
- ✓ Someone hands accrete a finished tool that passes its check. Registered
- ✓ Someone hands accrete a finished tool that fails its check. DoesNotMatchRealTool
- ✓ Someone hands over a tool while a build is running. AlreadyBuilding
- ✓ Sass was built, then forgotten. Agent B asks. BuildStarted
- ✓ Forget a tool nobody has. NothingToForget
Choosing where it runs
A model doesn't run by itself. You hand it a platform, and Domain hosts it there. All the hosting goes through this one function:
export const hostAccrete = <A, E, R>(platform: Layer.Layer<A, E, R>) =>
Accrete.layer(Layer.empty).pipe(Layer.provide(platform)); Then each host is one line, or close to it. In memory:
export default hostAccrete(Domain.InMemory); Cloudflare:
export default hostAccrete(DomainCloudflare.layerDurableObjects()); Effect Cluster:
export const Database = ClusterDocker.Database('AccreteCluster');
export const Pool = ClusterDocker.SwarmPool('Runners', {
replicas: 2,
}).pipe(Layer.provide(Database));
export default hostAccrete(
DomainCluster.layerEntities({
name: 'Accrete',
main: import.meta.url,
pool: Pool,
}),
); The model file is the same in all three, byte for byte.
What each host gives you
Cloudflare. layerDurableObjects() gives every tool its own Durable Object. Tool("sass") is one object, Tool("tsc") is
another. I didn't write a Durable Object class or a binding. A Durable Object handles one
request at a time, so the rule doesn't need a lock. The host already provides one.
Effect Cluster. The same idea on servers you choose: Postgres plus two runners, here on
Docker. I killed both runners mid-build, and the next agent still got AlreadyBuilding. The state lives in
Postgres, not in the process.
Same race, three hosts
20 agents ask for the same new tool at once. Three runs per host:
Every run gave the same answer: 1 started, 19 waiting. On Cloudflare and Effect Cluster I
also sent the next three asks over HTTP. The wrong agent finishing got NotYourBuild, the right one got Built, and a latecomer got AlreadyBuilt. In memory, the 16
stories cover those.
Foldkit: the UI comes from the model too
An Api picks which asks the outside world may send:
export class ToolApi extends Domain.Api('ToolApi', {
model: Accrete,
commands: [
'RequestBuild',
'RecordBuilt',
'RecordFailed',
'Register',
'Forget',
],
}) {} One line turns it into Foldkit commands:
export const Tools = DomainFoldkit.make(ToolApi); A build button sends one:
const askToBuild = (model: Model): Return => {
const next = DomainFoldkit.next(model.commandIds);
const { commandId, commandIds } = next;
const target = model.tool;
const input = { by: model.agent, at: Date.now() };
const send = Tools.RequestBuild({ target, input, commandId });
return {
model: modifyFields(model, {
commandIds: () => commandIds,
answer: () => 'asking…',
}),
commands: [
Command.mapMessage(send, (result) =>
Message.ReceivedAnswer({ result }),
),
],
};
}; And a refusal comes back as a normal, typed answer, not an error. If someone else is
building, the button shows AlreadyBuilding:
const refusal = (model: Model, failure: Refusal) =>
failure._tag === 'Rejected'
? answered(model, failure.rejection._tag)
: answered(model, failure._tag); I ran that update against the Cloudflare host with two agents clicking at once. Three runs,
and each time one showed Building and the other AlreadyBuilding.
Try it on your own app
Pick one thing in your app that has rules, and write it as a model. Good first picks:
- Anything where two requests at once could do something twice.
- Anything agents touch, so you have one file you actually read.
- Anything you might want to run somewhere else one day.
Start in memory with a few stories. Once they pass, the same file runs on Durable Objects or Effect Cluster with one line. That's the moment it clicked for me.
Fine print
How I know the tests can fail
I deleted each of the 7 rules in the model, one at a time, and each deletion failed exactly one story. With the "wait" rule deleted, the race test went back to 5 builds.
What this does not prove
- Everything ran on my laptop. Cloudflare was local workerd through
alchemy dev, not a deployed Worker. Effect Cluster was a local Docker swarm, not AWS or Fly. - On the cluster, the 20 calls came from inside the Docker network. My laptop couldn't reach the published port, a Colima networking issue.
- The live app doesn't use these hosts yet. It still runs the rules by hand inside its own Durable Object, and waiting agents poll every half second.
- The playground runs the real model, bundled with esbuild (about 120 KB gzipped, loaded only on this page). The "delete a rule" panel runs a plain copy of the rules, so you can switch each one off.
@alchemy.run/domainis a beta (2.0.0-beta.86).
Run it yourself
git clone https://github.com/acoyfellow/accrete cd accrete && npm install npm run test:domain # 16 stories npm run test:hosts # in memory + Foldkit cd examples/hosts bun x alchemy dev --config alchemy.cloudflare.ts docker swarm init && bun x alchemy deploy --config alchemy.cluster.ts
The model · the stories · the hosts, the race, and the receipts
What I got wrong first
My first draft called Domain a way to stop two requests from colliding. Then I called it a readable rules file. Both are true, and both miss it.
It's behavior as source code. You write what the app does once. Then the tests, the Durable Objects, the cluster entities, the endpoints and the UI commands are all generated from it, and they all give the same answers.
Next: move the live app onto layerDurableObjects() and drop the polling.