Skip to content

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.

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.

For multi-step entity behavior, use the higher-order operation DSL first:

  • transaction
  • pipe
  • derive
  • batch
  • upsert

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.

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.

  • 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 } inside run(). An adapter resolved before run() 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 EntityTransactionConflictError with HTTP status 409.
  • A callback or binding failure rolls back. Commit failures report TransactionCommitError.outcome as rolled_back or unknown; 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.

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

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.

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 factories wiring to plug in a custom repo-backed adapter
  • use manual wiring 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

Use this decision rule:

  • one entity, standard behavior: entity + operations
  • one entity, custom route semantics that still belong to the entity shell: entity + extraRoutes or overrides
  • multiple entities or reporting: package domain(...)
  • storage-specific query logic: custom factories/manual adapter wiring
  • framework-level escape hatch: raw plugin