Commerce package
The big idea
Two package layers:
packages/commerce— business truth (framework-pure)packages/features— product experience (Next.js aware)
The dependency direction is strict:
apps/web
→ packages/features
→ packages/commerce
→ packages/dbCommerce 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 helpersCurrent modules
Commerce (packages/commerce/)
| Module | Machine | Workflow | Queries | Events | Policies |
|---|---|---|---|---|---|
| Items | machine.ts | apply-item-transition.workflow.ts | 5 queries | insert-item-event.event.ts | — |
| Orders | machine.ts | apply-order-transition.workflow.ts | 4 queries | insert-order-event.event.ts | transition-guards.policy.ts |
| Offers | machine.ts | — | — | — | — |
| Disputes | machine.ts | — | — | — | — |
| Payouts | machine.ts | — | — | — | — |
| Messages | machine.ts | — | — | — | — |
| Onboarding | shared.ts | — | — | — | — |
Feature highlights
- Items: Feature action wraps commerce workflow. Bridge queries (
reserveItem,releaseItem,markItemSold) centralize all item status writes.logItemEventfacade 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 withFOR UPDATElock, stays in features but calls commercereserveItemquery
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.