Cross-Entity and Transactions
Most real applications do more than basic CRUD. They need:
- cross-entity lookups
- aggregates and reporting
- multi-step flows
- transaction-style writes
Slingshot supports those cases, but not all of them belong in the same layer.
Cross-entity reads
Section titled “Cross-entity reads”For read-heavy projections and reporting, use:
- operation configs like
lookup,aggregate, and computed variants - package domain routes when the response spans multiple entities
- capabilities when one package depends on another package’s service contract
Inside package domain routes, the canonical entity access pattern is typed entity modules:
const resumes = ctx.entities.get(ResumeEntity);const workExperiences = ctx.entities.get(WorkExperienceEntity);If the entity lives in another package, make that explicit:
const notes = ctx.entities.get(entityRef(NoteEntity, { plugin: 'notes' }));Avoid this as the default authoring style:
ctx.entities.get({ entity: 'Resume' })ctx.entities.get<BareEntityAdapter>(...)as any
If the endpoint is “analytics across notes and comments”, that is usually a package domain route, not an entity-local CRUD handler.
Transactions and orchestration
Section titled “Transactions and orchestration”For multi-step entity behavior, use the higher-order operation DSL first:
transactionpipederivebatchupsert
Those operation kinds keep the workflow declarative and compatible with generated adapters and routes.
See Operations Reference for the exact shape of each operation. Atomic batch, computed aggregate, and rollback support are store-specific; the generated entity backend support matrix is the source of truth.
Imperative package-service transactions
Section titled “Imperative package-service transactions”Use the application-owned manager when a package domain route or hook must make several typed
entity changes atomically. Resolve every adapter inside run() and pass the returned scope:
route.post({ path: '/transfer', auth: 'required', async handler(ctx) { return ctx.transactions.run('postgres', async scope => { const accounts = ctx.entities.get(AccountEntity, { scope }); const ledger = ctx.entities.get(LedgerEntryEntity, { scope });
await accounts.debit({ id: ctx.body.from, amount: ctx.body.amount }); await accounts.credit({ id: ctx.body.to, amount: ctx.body.amount }); await ledger.create({ id: crypto.randomUUID(), from: ctx.body.from, to: ctx.body.to, amount: ctx.body.amount, });
return ctx.respond.json({ transferred: true }); }); },});The same pattern is available outside a request through HookServices:
await services.transactions.run('sqlite', async scope => { const account = services.entities.get(AccountEntity, { scope }); const audit = services.entities.get(AuditEntity, { scope }); await account.update(accountId, { status: 'verified' }); await audit.create({ id: crypto.randomUUID(), accountId, action: 'verified' });});SQLite and PostgreSQL provide real rollback scopes. Memory, MongoDB, and Redis reject before
backend access. SQLite uses a per-app FIFO coordinator and BEGIN IMMEDIATE; unrelated standard
entity operations wait outside the open transaction instead of joining its shared connection.
Scope rules and failure behavior
Section titled “Scope rules and failure behavior”- Same-store nested
run()calls reuse the exact scope and do not open a second transaction. - Cross-store nesting rejects before opening another connection.
- A scope belongs to one app and one callback. Forged, foreign-app, mismatched, or closed scopes reject.
- Resolve adapters with
{ scope }insiderun(). An adapter resolved beforerun()remains unscoped; it does not become transactional later. - Await every scoped operation. Returning while a scoped promise is unsettled rolls back and
raises
UnsettledTransactionWorkError. - Do not retain a scope or scoped adapter. Calls after the callback settles raise
TransactionScopeClosedError; detached timers are outside the transaction lifetime. - Guarded or required declarative mutations that do not apply raise
EntityTransactionConflictErrorwith HTTP status 409. - A callback or binding failure rolls back. Commit failures report
TransactionCommitError.outcomeasrolled_backorunknown; never assume an unknown outcome committed or rolled back.
Database rollback does not make arbitrary HTTP requests, email, queue publication, object-storage writes, or other external effects atomic. For a governed event that must commit with SQL state, publish explicitly through the transactional outbox:
await transactions.run('postgres', async scope => { const orders = entities.get(Order, { scope }); const order = await orders.create(input); events.publish( 'orders:order.created', { orderId: order.id }, { delivery: 'outbox', transaction: scope, requestTenantId: order.tenantId ?? null, }, );});The broker may redeliver. Wrap SQL consumers with events.consume(..., { durable: true, name, inbox: { store } }); external effects still need provider
idempotency keyed by the envelope event ID.
When to move out of the entity DSL
Section titled “When to move out of the entity DSL”Move to a package domain route or raw plugin when:
- the workflow spans multiple entities with custom branching
- you need non-standard external I/O in the middle of the flow
- the response shape is custom and not a natural entity result
- the operation is better described as package orchestration than entity behavior
Cross-package calls
Section titled “Cross-package calls”Use capabilities for cross-package contracts:
const commentsApi = defineCapability<{ countForNote(noteId: string): Promise<number>;}>('comments.api');Then consume them from package domain routes or package-local services instead of reaching into random plugin state.
Can you write raw SQL?
Section titled “Can you write raw SQL?”Yes, but not inside the entity DSL itself.
The entity DSL is for declarative persistence and generated routing. If you need raw SQL or database-specific queries:
- use
factorieswiring to plug in a custom repo-backed adapter - use
manualwiring to build the adapter yourself - use a package domain route or raw plugin when the flow is not an entity adapter concern
That keeps the public authoring model clear:
- entity DSL for declarative domain behavior
- custom adapter/repo code for storage-specific logic
- package domain routes for orchestration and custom endpoints
Recommended split
Section titled “Recommended split”Use this decision rule:
- one entity, standard behavior: entity + operations
- one entity, custom route semantics that still belong to the entity shell: entity +
extraRoutesoroverrides - multiple entities or reporting: package
domain(...) - storage-specific query logic: custom factories/manual adapter wiring
- framework-level escape hatch: raw plugin