Commerce package
The marketplace kernel — what belongs in it, what must stay out, and the file shape inside
Features ask. Commerce decides. Workflows orchestrate. The database stores.
packages/commerce is the marketplace engine: the rules, the state machines, and the guarded writes for everything the marketplace has an opinion on — orders, listings, payments, fulfillments, payouts, disputes, offers, wallet, onboarding.
The dependency direction
apps/web → packages/features → packages/commerce → packages/dbStrictly one way. Commerce never imports a feature.
| Layer | Owns |
|---|---|
packages/features | Server actions, page reads, components, views, cache invalidation, effects (emails, notifications, sockets) |
packages/commerce | State machines, transition rules, money math, market config, the protected writes |
What must stay out of commerce
Anything that imports next/*, @repo/delivery, @repo/payments, a provider SDK, @repo/ui-web, @repo/internationalization, or an href. And no "use server" — server actions are features/<feature>/actions/.
The pure parts (machines, models, view-state, diagrams) import only xstate and @repo/db types, so a client component can import them to gate UI without dragging in the framework or a durable runtime. That is the property the boundary protects.
The structural rule
No bare db.update(<protected_table>) and no bare db.insert(<protected_table>) outside commerce/<entity>/, for orders, listings, payments, fulfillments, payouts, disputes, user balances, offers, refunds and balance transactions.
Status, balances and an order's money columns go through the entity's service; a row is created by its repository (insertOrder, insertListing, insertOffer…). The user_balance_transactions ledger is append-only — only wallet.events.insertBalanceEvent writes there.
@repo/oxlint-plugin (cute/no-protected-write, wired in oxlint.config.ts) enforces this. A commit that renames a protected column or adds a protected table updates that plugin's tables in the same commit; its tests in packages/oxlint-plugin/__tests__ are where the shape is pinned.
Layout
Sibling folders per domain, flat files inside — no domain/, application/ or services/ subfolders. Files are dotted: order.machine.ts, never order-machine.ts.
| File | Owns |
|---|---|
*.model.ts | What the thing is — status unions, row shape, transition and result types |
*.machine.ts | The pure machine. No DB, no provider, no Restate |
*.view-state.ts | Pure intent derivation for UI (getOrderPrimaryAction) |
*.service.ts | The transition workflow. Takes a tx, writes via the repository, logs the event. The authoritative protected write |
*.repository.ts | DB primitives the service uses — find, atomic update, optimistic lock |
*.events.ts | The append-only event-log writer |
*.diagrams.ts | Mermaid strings, consumed by these docs and the simulator |
Domains: orders, listings, payments, fulfillments, offers, payouts, refunds, wallet, onboarding, identity, invoices, sellers, reviews, boosts, plus the cross-cutting markets, config, pricing, money, transitions.
Two machine shapes
Orders + payments + fulfillment + disputes are one XState v5 parallel machine (orders/order.machine.ts) — four concurrent regions that advance together when a single event fires. A dispute is not its own machine; it is a region.
That file declares its side effects as types with throwing placeholders, so it imports only xstate. The durable runtime lives in @repo/workflows: order.restate.ts injects the real implementations via orderMachine.provide({ actors }) and hands the result to Restate as a virtual object keyed by orderId. Each effect commits through this package's atomic transitionOrder. The machine orchestrates; Postgres stays the truth.
Everything else — listings, offers, payouts, messages, wallet — stays on the lighter TransitionDef[] rule tables. Don't migrate them to XState without a real parallel-region or interrupt reason.
Scale
Cüte is Vinted-class: a C2C marketplace with a simple lifecycle (paid → shipped → delivered, plus disputes, held funds and payouts). It is not Shopify-class — no variants, no subscriptions, no multi-warehouse, no tax engine. Vendure / commercetools / Medusa patterns are sized for catalogue ecommerce. Don't reach for them.
Do not: reshape into domain/ + application/; add a second payments or delivery port (there is exactly one of each); adopt Inngest, Temporal or Trigger.dev (Restate owns durability); rename orders to transactions.
Related
packages/commerce/README.md— the fuller version, kept next to the code- Enforcement layers
- State machines