Capabilities and entityRef
Packages should not import each other’s internals. When one package needs something from another, use a capability (for services) or an entityRef (for entity data access).
The problem
Section titled “The problem”Your notification package needs to send email. Your blog package defines the Post entity. Without cross-package contracts, you end up with tight coupling:
// @skip-typecheck// BAD — notification package directly imports from mail package internals// BAD — search package directly imports blog package adapterimport { postAdapter } from '../blog/runtime';import { sendEmail } from '../mail/mailer';This breaks when packages are reordered, extracted into separate repos, or swapped for alternatives.
Capabilities: cross-package services
Section titled “Capabilities: cross-package services”A capability is a typed contract. One package provides it, another consumes it.
Step 1: Define and provide the capability
Section titled “Step 1: Define and provide the capability”import { defineCapability, definePackage, provideCapability } from '@lastshotlabs/slingshot';
// The contract — a typed interfaceexport const Mailer = defineCapability<{ send(to: string, subject: string, body: string): Promise<void>;}>('mailer');
export const mailPackage = definePackage({ name: 'mail', capabilities: { provides: [ provideCapability(Mailer, () => ({ send: async (to, subject, body) => { console.log(`Sending "${subject}" to ${to}`); // In production: call SendGrid, Postmark, etc. }, })), ], },});Step 2: Require and use the capability
Section titled “Step 2: Require and use the capability”// @skip-typecheckimport { definePackage, domain, route } from '@lastshotlabs/slingshot';import { Mailer } from './mail';
export const notificationPackage = definePackage({ name: 'notifications', dependencies: ['mail'], capabilities: { requires: [Mailer], }, domains: [ domain({ name: 'notifications', basePath: '/notifications', routes: [ route.post({ path: '/send', auth: 'userAuth', handler: async ({ body, capabilities, respond }) => { const mailer = capabilities.require(Mailer); await mailer.send('user@example.com', 'New notification', 'You have updates'); return respond.json({ sent: true }); }, }), ], }), ],});What happens at boot
Section titled “What happens at boot”- The framework boots packages in dependency order (
mailbeforenotifications) mailregisters itsMailercapabilitynotificationscan resolveMailerviacapabilities.require(Mailer)- If
mailis not loaded, the framework throws a clear error at boot time — not at request time
require vs maybe
Section titled “require vs maybe”// Throws if the capability is not provided — use when the dependency is mandatoryconst mailer = capabilities.require(Mailer);
// Returns undefined if not provided — use for optional integrationsconst analytics = capabilities.maybe(Analytics);if (analytics) { analytics.track('notification.sent');}entityRef: cross-package data access
Section titled “entityRef: cross-package data access”When one package needs to read or write another package’s entity data, use entityRef.
Scenario: search package reads blog posts
Section titled “Scenario: search package reads blog posts”Pass the entity module directly to entityRef — its type, including any registered
operations, flows through to entities.get(...) automatically:
// @skip-typecheckimport { definePackage, domain, entity, entityRef, route } from '@lastshotlabs/slingshot';import { Post } from '../entities/post';import { PostOps } from '../entities/post-operations';
const postModule = entity({ config: Post, operations: PostOps });const PostRef = entityRef(postModule, { plugin: 'blog' });
export const searchPackage = definePackage({ name: 'search', dependencies: ['blog'], domains: [ domain({ name: 'search', basePath: '/search', routes: [ route.get({ path: '/posts', auth: 'none', summary: 'Search posts by keyword', handler: async ({ query, entities, respond }) => { const posts = entities.get(PostRef); const all = await posts.list({}); const keyword = String(query?.q ?? '').toLowerCase(); const filtered = all.items.filter(p => p.title.toLowerCase().includes(keyword)); return respond.json({ results: filtered }); }, }), route.get({ path: '/posts/by-slug/:slug', auth: 'none', summary: 'Look up a post by slug', handler: async ({ params, entities, respond }) => { const posts = entities.get(PostRef); const post = await posts.bySlug({ slug: params.slug }); if (!post) return respond.notFound(); return respond.json({ post }); }, }), ], }), ],});What the entity ref gives you
Section titled “What the entity ref gives you”entities.get(PostRef) returns a fully typed adapter with:
- Standard methods:
get,list,create,update,delete - Operation methods:
byAuthor,bySlug,publish(fromPostOps) - Full TypeScript autocompletion — no manual
PackageEntityAdapterForannotations
entityRef without the entity module
Section titled “entityRef without the entity module”If you don’t have access to the entity module (e.g., the entity is in a third-party package whose source you don’t import), use the stringly-typed form. The adapter still works at runtime, but operation methods aren’t autocompleted:
const PostRef = entityRef({ plugin: 'blog', entity: 'Post' });Reach for this form only when you can’t import the entity module directly.
Real-world example: a SaaS app
Section titled “Real-world example: a SaaS app”// @skip-typecheckimport { defineCapability, definePackage, domain, entityRef, provideCapability, route,} from '@lastshotlabs/slingshot';import { getActorId, getRequestTenantId } from '@lastshotlabs/slingshot';
// Billing provides a payment serviceexport const PaymentService = defineCapability<{ charge(userId: string, amount: number, currency: string): Promise<{ transactionId: string }>; refund(transactionId: string): Promise<void>;}>('payment-service');
// Billing reads from the organizations packageconst OrgRef = entityRef({ plugin: 'organizations', entity: 'Organization' });
export const billingPackage = definePackage({ name: 'billing', mountPath: '/billing', dependencies: ['slingshot-auth', 'organizations'], capabilities: { provides: [ provideCapability(PaymentService, () => ({ charge: async (userId, amount, currency) => { // Stripe integration here return { transactionId: `tx_${Date.now()}` }; }, refund: async transactionId => { // Stripe refund here }, })), ], }, domains: [ domain({ name: 'billing', routes: [ route.post({ path: '/charge', auth: 'userAuth', handler: async ({ body, capabilities, entities, request, respond }) => { const org = entities.get(OrgRef); const tenantId = getRequestTenantId(request); const orgData = tenantId ? await org.get(tenantId) : null;
const payment = capabilities.require(PaymentService); const result = await payment.charge( getActorId(request), body.amount, orgData?.currency ?? 'usd', ); return respond.json(result, 201); }, }), ], }), ],});Other packages can now consume the payment service:
// @skip-typecheckimport { PaymentService } from './billing';
const orderPackage = definePackage({ name: 'orders', dependencies: ['billing'], capabilities: { requires: [PaymentService] }, domains: [ domain({ name: 'orders', basePath: '/orders', routes: [ route.post({ path: '/:id/pay', auth: 'userAuth', handler: async ({ params, capabilities, respond }) => { const payment = capabilities.require(PaymentService); const result = await payment.charge(params.id, 99_99, 'usd'); return respond.json({ paid: true, transaction: result.transactionId }); }, }), ], }), ],});When to use which
Section titled “When to use which”When a package contract exists, prefer the contract-bound forms (Matches.capability(...), Matches.publicEntities({...})) over free-floating defineCapability(...) and entityRef(...). The table below applies to the underlying choice itself; the recommended way to organize that choice is a Package Contract.
| Dependency is… | Use |
|---|---|
| A service (send email, charge card, log) | Capability |
| Data access (read posts, list users) | entityRef |
| Both | Both |
- Package Contracts — the recommended way to organize capabilities and entity refs once a package has a public surface
- Events and the Event Bus — the other way to decouple packages
- Lower-level Seams — overrides and manual wiring when capabilities and entityRef aren’t enough
- Entity System: Storage and Adapter Wiring — how entity adapters resolve across backends