Search

Sign in to launch Copilot/Codex from the palette.

Field note · 2026.11.12 · 10 min

Domain first

Start a new app by writing what it's allowed to do, in one file. That file becomes your tests, your Durable Objects, your API and your UI commands. Here's one real model, and you can play with it below.

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:

src/domain/tool.ts · the model ↗
Story testsrun the rules in memory, in milliseconds
Cloudflarethe same rules, one Durable Object per tool
Effect Clusterthe same rules, in Postgres, on runners you choose
HTTPan endpoint for every ask, with an OpenAPI document
Foldkita UI command for every ask, with typed answers

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:

Before merge
The stories check the rules

Ordinary tests, in memory, under a second. Run them while you edit, before you push, and in CI.

In production
The model enforces the rules

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:

loading the real model… src/domain/tool.ts · Domain.InMemory
You are
Tool("sass")
missing
nobody has built it
Ask it something
What the model said

Pick an agent and ask. Try: ann asks to build, then bob says it's built.

The same race from the post, run live. Every click is a fresh tool.

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:

The rules · click one to delete it
The 16 stories
all pass
  1. ✓ A new tool. Agent A asks for it. BuildStarted
  2. ✓ Sass is building. Agent B asks for it. AlreadyBuilding
  3. ✓ Sass is built. Agent B asks for it. AlreadyBuilt
  4. ✓ Agent A's build has hung for 5 minutes. Agent B asks. BuildStarted
  5. ✓ The build finishes and matches the real tool. Built
  6. ✓ The bundle prints something different from real sass. DoesNotMatchRealTool
  7. ✓ The bundle works in the container but fails in the isolate. DoesNotMatchRealTool
  8. ✓ B took over a hung build. Then A wakes up with a result. NotYourBuild
  9. ✓ A result arrives, but nobody was building. NotBuilding
  10. ✓ The build container crashes. BuildFailed
  11. ✓ A build failed earlier. Agent B asks. BuildStarted
  12. ✓ Someone hands accrete a finished tool that passes its check. Registered
  13. ✓ Someone hands accrete a finished tool that fails its check. DoesNotMatchRealTool
  14. ✓ Someone hands over a tool while a build is running. AlreadyBuilding
  15. ✓ Sass was built, then forgotten. Agent B asks. BuildStarted
  16. ✓ 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:

In memory
my laptop, in the test runner
1
build started, 19 told to wait
20 calls from one process · 22–23 ms
Cloudflare
workerd via alchemy dev, one Durable Object per tool
1
build started, 19 told to wait
20 HTTP calls · 56–75 ms
Effect Cluster
Docker: Postgres and 2 runners
1
build started, 19 told to wait
20 HTTP calls · 94–142 ms

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/domain is 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.

Read that again ↑

Those three points are the future of building.

  1. 01 · trust your code Behavior is one file, and it runs. 16 stories. Delete a rule, one breaks.
  2. 02 · trust your agents The one file you actually read. Agents change the how. You own the what.
  3. 03 · run it anywhere Same file. Every host. Same answers. Memory · Durable Objects · Effect Cluster: 1 build.

If you want to trust your code, trust your agents, no matter where things run…

you want

Domain