Skip to content

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).

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 adapter
import { postAdapter } from '../blog/runtime';
import { sendEmail } from '../mail/mailer';

This breaks when packages are reordered, extracted into separate repos, or swapped for alternatives.

A capability is a typed contract. One package provides it, another consumes it.

src/packages/mail.ts
import { defineCapability, definePackage, provideCapability } from '@lastshotlabs/slingshot';
// The contract — a typed interface
export 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.
},
})),
],
},
});
src/packages/notifications.ts
// @skip-typecheck
import { 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 });
},
}),
],
}),
],
});
  1. The framework boots packages in dependency order (mail before notifications)
  2. mail registers its Mailer capability
  3. notifications can resolve Mailer via capabilities.require(Mailer)
  4. If mail is not loaded, the framework throws a clear error at boot time — not at request time
// Throws if the capability is not provided — use when the dependency is mandatory
const mailer = capabilities.require(Mailer);
// Returns undefined if not provided — use for optional integrations
const analytics = capabilities.maybe(Analytics);
if (analytics) {
analytics.track('notification.sent');
}

When one package needs to read or write another package’s entity data, use entityRef.

Pass the entity module directly to entityRef — its type, including any registered operations, flows through to entities.get(...) automatically:

src/packages/search.ts
// @skip-typecheck
import { 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 });
},
}),
],
}),
],
});

entities.get(PostRef) returns a fully typed adapter with:

  • Standard methods: get, list, create, update, delete
  • Operation methods: byAuthor, bySlug, publish (from PostOps)
  • Full TypeScript autocompletion — no manual PackageEntityAdapterFor annotations

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.

src/packages/billing.ts
// @skip-typecheck
import {
defineCapability,
definePackage,
domain,
entityRef,
provideCapability,
route,
} from '@lastshotlabs/slingshot';
import { getActorId, getRequestTenantId } from '@lastshotlabs/slingshot';
// Billing provides a payment service
export 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 package
const 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:

src/packages/orders.ts
// @skip-typecheck
import { 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 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
BothBoth