Enforcement layers
The four questions every stateful write has to answer, and where each one is answered
Every stateful feature enforces its rules in four places. Each answers a different question, and none of them can cover for another.
| Layer | Question | Lives in |
|---|---|---|
| Machine | What could ever happen? | <entity>.machine.ts |
| Guard | What may happen now? | The machine's guards, evaluated with runtime context |
| Repository | Can this write land safely? | <entity>.repository.ts |
| Service | Do all of it, atomically | <entity>.service.ts |
Machine — what could ever happen
The set of valid states and the transitions between them. Nothing else.
// packages/commerce/listings/listing.machine.ts
export const LISTING_TRANSITIONS = [
{ from: "draft", to: "available", allowedBy: ["seller"] },
{ from: "available", to: "reserved", allowedBy: ["system", "buyer"] },
{ from: "reserved", to: "sold", allowedBy: ["system"] },
{ from: "reserved", to: "available", allowedBy: ["system", "admin"] },
];It answers "is pending → paid a real transition?" and "is this actor's role allowed to fire it?". It knows nothing about the specific order in front of you.
Guard — what may happen now
A transition can be structurally valid and still be wrong at this moment. Guards carry that judgement, and they are declared on the machine, not in a separate policy layer.
// packages/commerce/orders/order.status-projection.ts
guards: {
methodTakesEvent: ({ context, event }) =>
methodTakes(context.fulfillmentMethod, event.type.toLowerCase()),
noActiveDispute: not(hasActiveDispute),
}The auto-accept timer is fenced by noActiveDispute; every event a fulfillment method owns by methodTakesEvent, which asks the method's traits. Role rules travel with the event — every dispute event carries the role that fired it, and byAdmin / byBuyer / bySeller read it.
There is no policies/ folder. If a rule needs runtime context, it is a guard.
Repository — can this write land safely
The DB primitives. Their job is to make a write safe no matter who called it, by putting the invariants into the WHERE clause.
// packages/commerce/listings/listing.repository.ts
const rows = await tx
.update(listings)
.set({ status: "available", reservedByOrderId: null })
.where(
and(
eq(listings.status, "reserved"), // status invariant
eq(listings.reservedByOrderId, orderId) // ownership invariant
)
)
.returning({ id: listings.id });Nothing happens if the row moved underneath you — the caller finds out from what came back, and decides. This is optimistic concurrency, not row locking: no FOR UPDATE, no lock held across the transaction.
Use .returning({ id }) and check rows.length, not rowCount — that is the form confirmed to behave the same across Drizzle runtimes.
Service — perform the mutation
One transaction, in order, returning a result the feature layer can act on.
// packages/commerce/listings/listing.service.ts
export async function transitionListing(input) {
return await db.transaction(async (tx) => {
const listing = await getListingForTransition(tx, input.listingId);
if (!listing) return { ok: false, code: "not_found" };
if (!canTransition(listing.status, input.to, input.triggeredBy))
return { ok: false, code: "invalid_transition" };
const { changed } = await updateListingStatus(tx, {/* … */});
if (!changed) return { ok: false, code: "concurrent_modification" };
await insertListingEvent(tx, {/* … */}); // atomic with the status write
return { ok: true /* … */ };
});
}The event append is inside the transaction, so the timeline can never disagree with the row.
Rejections are values, not exceptions
Services return discriminated unions:
type Result =
| { ok: true /* … */ }
| {
ok: false;
code: "not_found" | "invalid_transition" | "concurrent_modification";
};Expected business rejections come back as { ok: false }. Only genuine failures — a database error — throw. The feature action maps ok: false to a message for the user without paging anyone.
The whole path
Feature action ("use server")
├── auth + input validation
│
├── Commerce service (db.transaction)
│ ├── machine check → is this transition real?
│ ├── guard check → is it allowed right now?
│ ├── repository write → invariants in the WHERE clause
│ └── event append → same transaction
│
├── cache invalidation (updateTag)
└── effects (notifications, emails, sockets)Worked example: releasing a payout
- Machine:
blocked → eligibleandeligible → paidexist;paid → eligibledoes not. - Guards: no open dispute; delivery plus grace period has passed; the buyer's payment settled.
- Repository: update only if the status is still
eligible, so two concurrent releases cannot both win. - Service: load → machine → guards → write → append event → return.
The machine knows the shape. The guards know the situation. Neither is optional.