Commerce package

The big idea

Two package layers:

  • packages/commercebusiness truth (framework-pure)
  • packages/featuresproduct experience (Next.js aware)

The dependency direction is strict:

apps/web
  → packages/features
    → packages/commerce
      → packages/db

Commerce does not depend on features. Features depend on commerce.

What each layer owns

packages/commerce

The marketplace engine. Framework-pure — no "use server", no updateTag, no after(), no redirect(), no cookies(), no headers().

Owns:

  • State machines (lifecycle structure)
  • Policies (contextual business rules)
  • Workflows (business operations with transactions)
  • Queries (focused DB operations for business logic)
  • Events (domain event appending)
  • Types (enum-derived, result types)

packages/features

The product/app layer. Next.js aware.

Owns:

  • Server actions ("use server" entrypoints)
  • Page/screen read queries
  • Components (feature-specific UI)
  • Config (status-to-presentation mapping)
  • Views (composed screens)
  • Cache invalidation (updateTag, revalidatePath)
  • Non-critical effects (after() — analytics, notifications, emails, socket)

The core rule

Commerce owns mutations, features own framework effects.

A feature action is thin:

"use server";

export async function cancelOrderAction(input) {
  const session = await getSession();
  validate(input);

  const result = await cancelOrderWorkflow({
    orderId, actorId: session.user.id
  });

  updateTag("orders");
  await executeEffects(result);
  return result;
}

A commerce workflow owns the transaction:

export async function cancelOrderWorkflow(input) {
  return await db.transaction(async (tx) => {
    const order = await getOrderById(tx, input.orderId);
    const transition = findTransition("buyer_cancel", order.status, "buyer");
    checkGuard(transition.guard, order);
    await updateOrderStatus(tx, order.id, "cancelled");
    await releaseItemReservation(tx, order.itemId);
    await appendOrderEvent(tx, { ... });
    return { ok: true, order, itemId: order.itemId };
  });
}

Commerce module structure

Each stateful feature follows this internal shape:

packages/commerce/<module>/
  machine.ts            — lifecycle structure (states, transitions)
  types.ts              — enum-derived types, result types
  diagram.ts            — mermaid visualization
  policies/             — contextual business rules (*.policy.ts)
  workflows/            — business operations (*.workflow.ts)
  queries/              — focused DB operations (*.query.ts)
  events/               — domain event inserters (*.event.ts)

Not every module needs every folder. Items has no policies/ (guards live in machine.ts). Offers and disputes don't have workflows/ yet.

Naming convention: folders AND suffixes. queries/reserve-item.query.ts, workflows/apply-item-transition.workflow.ts. The folder groups, the suffix identifies in grep/tabs.

Feature module structure

packages/features/<module>/
  actions/              — server action entrypoints (*.action.ts)
  queries/              — page/screen read models (*.query.ts)
  components/           — feature-specific UI
  config/               — status-to-presentation mapping (*.config.ts)
  views/                — composed screens (*.view.tsx)
  events/               — feature-level event facades (*.event.ts)
  schema/               — zod validation schemas
  types/                — typescript types (if needed)
  hooks/                — react hooks
  utils/                — small pure helpers

Current modules

Commerce (packages/commerce/)

ModuleMachineWorkflowQueriesEventsPolicies
Itemsmachine.tsapply-item-transition.workflow.ts5 queriesinsert-item-event.event.ts
Ordersmachine.tsapply-order-transition.workflow.ts4 queriesinsert-order-event.event.tstransition-guards.policy.ts
Offersmachine.ts
Disputesmachine.ts
Payoutsmachine.ts
Messagesmachine.ts
Onboardingshared.ts

Feature highlights

  • Items: Feature action wraps commerce workflow. Bridge queries (reserveItem, releaseItem, markItemSold) centralize all item status writes. logItemEvent facade delegates DB insert to commerce.
  • Orders: Feature action wraps commerce workflow. Effects (emails, socket, ME shipment, held-funds release, refund) stay in features.
  • Offers/Disputes: Domain moved to commerce. Transition actions still do inline DB writes (commerce workflows not yet extracted).

What stays outside commerce

These are command boundaries, not state machine transitions:

  • Listing creation (createDraftListingAction, publishListingAction) — complex commands with validation, NSFW checks, image handling
  • Listing deletion (deleteListingAction) — hard delete
  • Order creation (createOrder) — transaction with FOR UPDATE lock, stays in features but calls commerce reserveItem query

Result types, not throws

Commerce workflows return discriminated unions:

type ItemTransitionResult =
  | { ok: true; itemId: string; publicId: number; from: ItemStatus; to: ItemStatus; ... }
  | { ok: false; code: "not_found" | "invalid_transition" | "concurrent_modification"; message: string };

Expected business rejections (not found, invalid transition) return { ok: false }. Only unexpected failures (DB errors) throw. Feature actions map ok: false to { success: false, error } without hitting Sentry.

Optimistic concurrency

Status-checked updates prevent races:

const rows = await tx
  .update(items)
  .set({ status: "draft", updatedAt: new Date() })
  .where(and(eq(items.id, itemId), eq(items.status, "available")))
  .returning({ id: items.id });

return { changed: rows.length > 0 };

Not row locking (FOR UPDATE). Returns { changed: false } on conflict. Callers gate event logging on changed to prevent false events.