Skip to content

what the hell is a domain contract?

A practical Seedfolk architecture note on turning farm-game rules, events, state shapes, and API promises into a small contract the whole project can lean on.

Stylized Seedfolk farm board on a design desk, surrounded by schema cards, seed packets, tools, and contract notes.

Generative concept art for the post: the farming domain as a physical board of rules, state, tools, and events.

I am developing a game called Seedfolk: a cozy, isometric farming game about tending a small plot of land, growing crops, and slowly turning a patch of authored tiles into a place with memory and rhythm.

That sounds soft and pastoral, which is the point. But even a cozy farm game quickly becomes a pile of hard rules. Seeds become crops. Crops become harvests. Water matters until it does not. Time passes while the player is away. The UI, game scene, save data, and server all need to agree on what happened.

The problem I hit was that those rules were starting to exist in too many places at once. The React UI had one idea of a crop. The Phaser scene had another. Save data needed its own shape. A future server or API would eventually need another version again. At first that feels manageable because the project is small and I know what I mean by a plot, a crop, a harvest, or an inventory item. But once the codebase grows, that shared understanding becomes fragile.

A domain contract is the thing you write when everyone sort of knows how the game works, but the code is starting to ask much meaner questions.

For Seedfolk, that means questions like:

  • What states can a plot be in?
  • Who owns the truth for crop growth: the scene, the store, or the rules module?
  • What data has to survive a save/load cycle?
  • Is a crop planted in a plot, on a tile, or in an inventory action?
  • Does watering belong to the crop, the soil, the player action, or the farm day?
  • When the player harvests wheat, what exactly changes?
  • Can a crop die while the scene is closed?
  • What does the server promise to return when the client asks for the farm?

If the answer is only “look at the implementation”, the project is going to get blurry fast. The UI can render from one state shape, Phaser can simulate from another, the save file can preserve only part of the truth, and tests can accidentally lock in behavior that was never deliberately designed.

A domain contract is a deliberately small document, schema, and testable agreement that says what the domain means before every layer invents its own version.

It is not magic architecture dust. It is just a promise with teeth.

A domain contract defines the shared rules, words, state shapes, events, and allowed transitions for a specific part of the product.

In Seedfolk, a contract might cover the farming domain:

  • the words the project uses: plot, prepared soil, planted crop, ripe crop, spent plot
  • the commands the domain accepts: place plot, till, plant, water, fertilize, harvest, clear
  • the events the domain emits: plot placed, crop planted, plot watered, crop harvested
  • the invariants that must always hold: a plot cannot contain two active crops
  • the data shapes that cross boundaries: saved farm state, API responses, client events
  • the failure cases that are normal: not enough seeds, wrong tool, crop not ready

That sounds close to Domain-Driven Design because it is standing on the same ground. Martin Fowler describes Domain-Driven Design as centering software on a domain model with a rich understanding of the domain’s processes and rules, and his notes on Ubiquitous Language and Bounded Context are useful here. The contract is where that shared language stops being a nice idea and starts becoming something the code can obey.

A Seedfolk contract board with farm plots in the center, surrounded by rule cards, arrows, and blueprint notes.

Another generative sketch: the domain contract as the thing tying rules, authored content, and runtime behavior back to the same little farm.

Seedfolk is exactly the kind of project where domain rules can leak everywhere.

The UI needs to know whether a tile is clickable. The Phaser scene needs to show hover state and progress bars. The farm simulation needs to mutate plots. The persistence layer needs to save and reload state. A worker or API layer may eventually need to validate offline progress. The content pipeline needs to know which authored tiles count as soil, crops, paths, or decorations.

Without a contract, each layer starts carrying little private opinions:

// UI opinion
const canHarvest = crop.stage === "ripe";
// Scene opinion
const canHarvest = tile.properties.includes("crop.ripe");
// Server opinion
const canHarvest = Date.now() >= crop.readyAt && !crop.harvestedAt;

Those are not automatically wrong. They are dangerous because they might all be right in slightly different ways.

The domain contract gives the project one place to say:

type CropLifecycle = "seeded" | "growing" | "ripe" | "spent" | "failed";
type PlotState = {
id: PlotId;
tile: TileCoord;
soil: "rough" | "prepared";
moisture: "dry" | "watered";
fertilizer: "none" | "compost" | "growth";
crop: ActiveCrop | null;
};

Now the UI can display it, the scene can render it, the server can validate it, and tests can protect it.

Mock Seedfolk debug screen showing a selected plot, command preconditions, domain state, and the resulting CropPlanted event.

A screen-grab style mockup of the kind of local debug view the contract makes possible: one selected plot, one command, one set of preconditions, one resulting event.

In that screen, the important part is not the particular UI. It is the shape of the inspection. The selected tile is not just “clickable”. It has a domain state. The next action is not just “button pressed”. It is a command with named preconditions and a named event on success.

An API schema is one kind of contract. It is not the whole domain contract.

OpenAPI is useful because it gives HTTP APIs a language-neutral way to describe endpoints and data structures. JSON Schema is useful because it can annotate and validate JSON documents. Tools like Pact go further and test whether a provider and consumer still agree about the interactions they actually use.

That is all good stuff. Seedfolk should absolutely use machine-checkable contracts at its boundaries.

But a domain contract sits one layer closer to the game. It answers the product-rule questions that an API schema cannot answer by itself.

An API schema can say this is valid JSON:

{
"plotId": "plot_042",
"action": "plant",
"seed": "carrot"
}

The domain contract says whether that command is allowed:

  • the plot must exist
  • the plot must be prepared
  • the plot must not already have an active crop
  • the player must have at least one carrot seed reserved for the action
  • planting emits CropPlanted
  • planting consumes the seed only if the action completes

That is the difference between “the message is shaped correctly” and “the game rule is true.”

A useful Seedfolk domain contract can stay small. I would start with five sections.

Diagram showing a Seedfolk domain contract between product rules, content data, React UI, Phaser scene, and worker/API layers.

The contract sits between the words people use and the code paths that need those words to mean the same thing.

This is the boring part that saves the most time.

Plot:
A farmable unit attached to one map tile. A plot may be rough or prepared.
Crop:
A plant lifecycle attached to a plot. A plot can have zero or one active crop.
Watered:
A soil moisture state on the plot, not a property of the crop.
Farm day:
The domain calendar boundary used for daily resets, chores, visitors, and offline progress.

The point is not dictionary theater. The point is that code, tests, docs, map properties, and UI labels should all use the same nouns.

This is where Fowler’s Ubiquitous Language idea becomes very practical. If the game says “plot” but the API says “field”, the map says “soil tile”, and the inventory says “garden bed”, the team now has four almost-terms where one would do.

Commands are things the player, system, or server asks the domain to do.

type FarmCommand =
| { type: "PlacePlot"; tile: TileCoord; plotKitId: ItemId }
| { type: "TillPlot"; plotId: PlotId; toolId: ToolId }
| { type: "PlantCrop"; plotId: PlotId; seedId: ItemId }
| { type: "WaterPlot"; plotId: PlotId; toolId: ToolId }
| { type: "FertilizePlot"; plotId: PlotId; itemId: ItemId }
| { type: "HarvestCrop"; plotId: PlotId }
| { type: "ClearPlot"; plotId: PlotId };

The contract should name who can issue each command and what must be true before it can run.

For example:

PlantCrop
Precondition:
- plot exists
- plot soil is prepared
- plot crop is null
- inventory has seed
Success:
- seed is consumed
- crop lifecycle becomes seeded
- CropPlanted event is emitted
Failure:
- no mutation happens
- caller receives a domain error

This borrows the useful spirit of Design by Contract: spell out what an operation expects, guarantees, and maintains. Eiffel Software’s overview of Design by Contract frames contracts as explicit expected behavior for software components; for Seedfolk, the same idea becomes less formal and more product-shaped.

Invariants are the rules that should always remain true.

For Seedfolk, examples might be:

  • a plot belongs to exactly one farm
  • a tile can hold at most one plot
  • a plot can hold at most one active crop
  • a crop cannot be ripe before its readyAt time
  • a harvested annual crop becomes spent
  • a water action changes plot moisture, not crop identity
  • inventory is not consumed until a queued action completes

Microsoft’s DDD guidance describes validation rules as invariants and says aggregates are responsible for enforcing invariants across state changes. That maps cleanly to Seedfolk: the farm or plot aggregate should own the rules that keep farm state coherent, not a random button handler.

That means this is suspicious:

if (selectedTool === "watering-can") {
plot.moisture = "watered";
crop.lastWateredAt = Date.now();
}

And this is better:

const result = farm.apply({
type: "WaterPlot",
plotId,
toolId: "watering-can",
at: now,
});

The second version gives the domain a chance to say no, emit the right event, and keep the state shape honest.

Events are things that happened and matter to the domain.

type FarmEvent =
| { type: "PlotPlaced"; plotId: PlotId; tile: TileCoord }
| { type: "PlotTilled"; plotId: PlotId }
| { type: "CropPlanted"; plotId: PlotId; cropId: CropId; seedId: ItemId }
| { type: "PlotWatered"; plotId: PlotId; wateredUntil: Instant }
| { type: "CropBecameRipe"; plotId: PlotId; cropId: CropId }
| { type: "CropHarvested"; plotId: PlotId; cropId: CropId; yield: ItemStack[] };

Events are especially useful for Seedfolk because so much of the game wants to react without owning the farming rules.

The scene can animate CropHarvested. The UI can show a toast. The quest system can count it. Analytics can record it. Persistence can save it. None of those layers need to decide what “harvested” means.

EventStorming is worth knowing here because it is a workshop format for exploring complex domains through events. You do not need a wall of sticky notes to benefit from the idea. Just asking “what happened?” instead of “what did this function call?” tends to uncover better game language.

Finally, the contract should include the shapes that cross process or package boundaries.

For Seedfolk, that probably includes:

  • save-game farm state
  • worker/API farm snapshot responses
  • commands accepted from the client
  • events published to other systems
  • authored map property names from Tiled
  • seed/crop definitions from content data

This is where OpenAPI, JSON Schema, Zod, TypeScript types, or Pact-style tests can help. The exact tool matters less than the promise: the boundary is explicit, checked, and versioned.

A lightweight example:

const cropDefinitionSchema = z.object({
id: z.string(),
displayName: z.string(),
growsInMs: z.number().int().positive(),
waterPolicy: z.enum(["optional", "required", "attention-heavy"]),
harvestYield: z.array(
z.object({
itemId: z.string(),
min: z.number().int().nonnegative(),
max: z.number().int().nonnegative(),
}),
),
});

The real value is not the schema by itself. The value is that content, code, and tests all agree on what a crop definition is allowed to be.

If I were starting this today, I would make docs/domain/farming-contract.md and keep it painfully direct.

# Farming Domain Contract
## Scope
This contract covers player-owned farm plots, crop lifecycle, watering,
fertilizer, harvest, and cleanup.
It does not cover world navigation, NPC schedules, market prices, cosmetics,
or account ownership.
## Vocabulary
Plot: A farmable unit attached to one map tile.
Crop: A plant lifecycle attached to a plot.
Farm day: The calendar boundary used for daily farm updates.
## Invariants
- A tile can hold at most one plot.
- A plot can hold at most one active crop.
- Only prepared plots can receive seeds.
- Harvesting a ripe annual crop leaves a spent plot.
- Failed commands do not mutate farm state.
## Commands
PlantCrop(plotId, seedId)
Requires: prepared empty plot, seed available.
Emits: CropPlanted.
Errors: PlotMissing, PlotNotPrepared, PlotOccupied, SeedUnavailable.
WaterPlot(plotId, toolId)
Requires: plot exists, tool can water.
Emits: PlotWatered.
Errors: PlotMissing, ToolCannotWater.
HarvestCrop(plotId)
Requires: plot has ripe crop.
Emits: CropHarvested.
Errors: PlotMissing, CropMissing, CropNotRipe.
## Boundary events
CropHarvested must include plotId, cropId, item yield, and occurredAt.

That is not a grand architecture document. Good. It is a small enough artifact that it might survive contact with actual development.

Here is how the contract pays rent.

Example one: the player tries to plant on rough soil.

const result = farm.apply({
type: "PlantCrop",
plotId: "plot_042",
seedId: "seed.carrot",
at: now,
});
expect(result).toEqual({
ok: false,
error: "PlotNotPrepared",
});

The UI can turn that into a disabled action, a hint, or a little “till first” nudge. The Phaser scene does not need to know the whole planting rule. It just needs the domain answer.

Example two: watering changes soil state.

const result = farm.apply({
type: "WaterPlot",
plotId: "plot_042",
toolId: "tool.watering-can",
at: now,
});
expect(result.events).toContainEqual({
type: "PlotWatered",
plotId: "plot_042",
wateredUntil: endOfFarmDay,
});

That keeps Seedfolk from accidentally growing three meanings of “watered”: an animation flag, a crop timestamp, and a server boolean. The contract says it is a plot moisture state, so the other layers follow.

Example three: offline ripening is a domain update, not a loading-screen trick.

const farm = loadFarm(savedState);
const result = farm.advanceTo({
farmDay: "spring-12",
at: new Date("2026-06-24T08:00:00Z"),
});
expect(result.events).toContainEqual({
type: "CropBecameRipe",
plotId: "plot_042",
cropId: "crop_9001",
});

That one matters later. If the scene is open, the crop ripens. If the player comes back tomorrow, the crop also ripens. Same rule, same event, different route through the product.

The contract should make everyday implementation less mushy.

When adding strawberries:

  • add a crop definition
  • validate it against the crop definition schema
  • add lifecycle tests if strawberries regrow differently
  • keep the existing PlantCrop, WaterPlot, and HarvestCrop command meanings intact

When adding offline progress:

  • define which timestamps are authoritative
  • decide whether crop transitions emit catch-up events
  • test that a closed scene and an open scene produce equivalent farm state

When adding a server worker:

  • validate incoming commands at the boundary
  • run commands through the same domain rules
  • return domain errors by name, not mystery strings
  • keep client prediction subordinate to server reconciliation

When changing terminology:

  • change the contract first
  • update map properties, content data, code names, and UI strings together
  • do not let aliases linger unless the contract says they are intentional

That last part matters. A domain contract is not only a technical safety net. It is a way to keep the project thinking in one language while the codebase grows more rooms.

The trap is making the contract too official too early.

If the document tries to describe the entire game, it will become stale. If it requires ceremony for every experiment, it will get bypassed. If it is only prose, it will drift away from the code. If it is only generated types, it will miss the rule language humans actually need.

The useful middle is:

  • short prose for meaning
  • typed commands and events for implementation
  • schemas for boundaries
  • tests for invariants
  • examples for weird cases

That gives Seedfolk something sturdy without freezing the design.

So, what the hell is a domain contract?

It is the farm rules written down in a form the team, the code, and the tests can all argue with.

For Seedfolk, it is the difference between “watering probably means this” and “watering changes plot moisture, emits PlotWatered, never creates a crop, and is valid only when the tool can water.”

That is the point. Not more architecture. Less guessing.