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.

LayerQuestionLives in
MachineWhat could ever happen?<entity>.machine.ts
GuardWhat may happen now?The machine's guards, evaluated with runtime context
RepositoryCan this write land safely?<entity>.repository.ts
ServiceDo 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 → eligible and eligible → paid exist; paid → eligible does 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.