Skip to main content
A workflow is a business operation that writes to more than one table and must succeed or fail as a unit — create a user and their credentials, accept an invite and mark the token used. In Ship you don’t reach for a session object or a separate orchestration layer. You wrap the writes in db.transaction, and the same typed DbService API works inside it.
Standardised patterns. Zero guesswork.

db.transaction

db.transaction(async (tx) => ...) runs a function against a transactional copy of the whole data layer. The tx you receive has the same shape as db — every table service, fully typed — but every call runs inside one Postgres transaction. Return a value and it resolves; throw and the entire transaction rolls back.
If the second insert throws, the first is undone. No partial user with no credentials, ever.
Use tx, not db, for every call inside the callback. A call on the outer db runs in its own connection and won’t be part of the transaction — so it won’t roll back with the rest.

Where workflows live

A workflow that’s used in one place can live right in the endpoint handler. Once it’s reused — or just large enough to deserve a name — move it into the resource’s methods/ folder, where shared business logic lives:
A method is a plain function that calls db (or db.transaction) — no base class, no registration:
The endpoint just calls it — the gate stack and the Zod contract stay in the endpoint, the multi-table write stays in the method:

Inside a transaction, the full API still works

tx is the same typed surface as db, so reads, filters, soft deletes and updates all behave identically — they’re just scoped to the transaction. A typical accept-invite workflow reads a token, creates a user, and marks the token used, all atomically:
Throwing inside the callback — here an ORPCError from @orpc/server — rolls the whole thing back. The user is only ever created together with their account and a consumed token.

Mutation events and transactions

Each write inside a transaction still emits its typed MutationEvent through the event bus. Handlers run after the surrounding write returns, so keep transaction callbacks focused on the writes themselves — push notifications, analytics and socket pushes belong in <resource>/handlers/*.ts, reacting to the committed change rather than running inside the transaction.
Return the value you need from the callback — db.transaction resolves to whatever the callback returns. That’s usually the primary entity (the new user), which the endpoint then maps to its .output() schema.