Package Contracts
A package contract is the provider-owned, typed public surface of a package: capabilities first, explicitly narrowed public entity refs second. Contracts make package boundaries explicit and enforce them at compile time and at boot.
This is the recommended path for any package whose public surface is consumed by another package. The lower-level Capabilities and entityRef primitives still exist — contracts compose them — but reach for them directly only when a contract is overkill or unavailable.
What a contract gives you
Section titled “What a contract gives you”- Provider-owned identity. A capability or public entity ref carries the contract name as metadata, so the framework can validate cross-package wiring at boot.
- Explicit publication. You can’t accidentally expose an entity to other packages — every public ref requires an exposure decision (
readonly,as, orunsafeFullAdapter). - Narrowed adapter surface.
readonly([...])produces a runtime-wrapped adapter that exposes only the named methods. Consumers can’t calldelete()if you only publishedgetByIdandlist. - Boot-time validation. Missing dependencies, mismatched contract ownership, and unimplemented capabilities throw with source-located error messages.
A complete example
Section titled “A complete example”// @skip-typecheckimport { defineEntity, definePackageContract, entity, field } from '@lastshotlabs/slingshot';
export const Matches = definePackageContract('matches');
const Match = defineEntity('Match', { namespace: 'matches', fields: { id: field.string({ primary: true, default: 'uuid' }), homeTeam: field.string(), awayTeam: field.string(), },});
const matchModule = entity({ config: Match });
export const MatchRefs = Matches.publicEntities({ Match: Matches.publicEntity(matchModule).readonly(['getById', 'list']),});
export interface MatchSeatService { approve(args: { matchId: string; userId: string }): Promise<{ seatId: string }>;}
export const MatchSeats = Matches.capability<MatchSeatService>('seats');// @skip-typecheckimport { provideCapability } from '@lastshotlabs/slingshot';import { MatchSeats, Matches } from './public';
export const matchesPackage = Matches.definePackage({ mountPath: '/api', entities: [matchModule], domains: [ /* ... */ ], capabilities: { provides: [ provideCapability(MatchSeats, () => ({ async approve({ matchId, userId }) { return { seatId: `seat-${matchId}-${userId}` }; }, })), ], },});// @skip-typecheckimport { definePackageContract, domain, route } from '@lastshotlabs/slingshot';import { MatchRefs, MatchSeats, Matches } from '../matches/public';
export const Gameplay = definePackageContract('gameplay');
export const gameplayPackage = Gameplay.definePackage({ dependencies: [Matches], capabilities: { requires: [MatchSeats] }, domains: [ domain({ name: 'gameplay', basePath: '/gameplay', routes: [ route.post({ path: '/approve-seat/:matchId', auth: 'userAuth', handler: async ({ params, entities, capabilities, respond }) => { const matches = entities.get(MatchRefs.Match); const match = await matches.getById(params.matchId); if (!match) return respond.notFound();
const seats = capabilities.require(MatchSeats); const seat = await seats.approve({ matchId: params.matchId, userId: 'me' }); return respond.json({ seat }); }, }), ], }), ],});A few things to notice:
- The contract is declared in
public.tsso consumers can import it without pulling the package’s implementation. MatchRefs.Matchis typed as a narrowed adapter —entities.get(MatchRefs.Match)returns an object exposing onlygetByIdandlist. Any other method is both a TypeScript error and a runtimeundefined.Matches.definePackage(...)injectsname: 'matches'and convertsdependencies: [Matches]to the resolved name list.- Boot validation refuses to start the app if
gameplayrequiresMatchSeatswithout declaringMatchesas a dependency.
publicEntity exposure modes
Section titled “publicEntity exposure modes”Every public entity ref must pick one mode. Raw entity modules are rejected at the type level by publicEntities(...) — this is intentional; publishing an entity is always a deliberate decision.
readonly([...]) — narrowed and enforced
Section titled “readonly([...]) — narrowed and enforced”The recommended default. The framework validates the method names against the underlying adapter at TypeScript compile time, and wraps the adapter at lookup time so other methods aren’t reachable.
This enforcement applies everywhere the framework resolves the ref: ctx.entities.get(...) in handlers, services.entities.get(...) in out-of-request hooks, and route-level permissionAdapter / parentAdapter lookups used by the permission middleware. The boundary is honest end-to-end. Permission and parent adapters today only call .getById(id), so any contract that publishes getById works as a permission adapter.
// @skip-typecheckconst PostRefs = Posts.publicEntities({ Post: Posts.publicEntity(postModule).readonly(['getById', 'list']),});// @skip-typecheckconst post = entities.get(PostRefs.Post);await post.getById('id-1'); // okawait post.list({}); // ok// @ts-expect-error — `delete` is not part of the public surfaceawait post.delete('id-1');Methods declared in readonly([...]) become non-optional in the resulting type — declaring a method publicly asserts it exists at runtime. If the underlying adapter doesn’t implement it, the framework throws when the ref is resolved with a source-located error.
as<T>() — custom typed shape
Section titled “as<T>() — custom typed shape”Use when you want to project the entity into a hand-rolled adapter type, e.g., to alias method names or map field shapes. The shape must be a structural subset of the underlying adapter — extra keys produce a never return.
// @skip-typecheckinterface PublicPostAdapter { fetch(id: string): Promise<{ id: string; title: string } | null>;}
const PostRefs = Posts.publicEntities({ Post: Posts.publicEntity(postModule).as<PublicPostAdapter>(),});as<T>() is type-only — the runtime returns the full underlying adapter. There is no method-name list for the framework to enforce.
unsafeFullAdapter() — full CRUD exposure
Section titled “unsafeFullAdapter() — full CRUD exposure”Opts out of narrowing entirely. The full adapter (every CRUD method, every operation) is exposed. Reach for this when the consumer genuinely needs the whole surface and as<T>() would be repetitive.
// @skip-typecheckconst PostRefs = Posts.publicEntities({ Post: Posts.publicEntity(postModule).unsafeFullAdapter(),});The verbose name is the friction. Full CRUD exposure should feel exceptional.
contract.capability vs defineCapability
Section titled “contract.capability vs defineCapability”Matches.capability<T>(name) produces a PackageCapabilityHandle that carries contract: 'matches' as metadata. The runtime equivalent of free-floating defineCapability<T>(name) still works for legacy code, but the contract-bound form gives you:
- Static rejection if the wrong package tries to provide the capability (
contract.definePackage(...)throws). - Boot validation that consumers declare the providing package as a dependency.
- Source-located error messages when wiring is wrong.
Free-floating capabilities still resolve normally for backward compatibility.
What contract.definePackage validates
Section titled “What contract.definePackage validates”When you call Matches.definePackage({...}):
- The
namefield is injected from the contract — passing it explicitly is not allowed. - Each entry in
dependenciesis a contract object (not a string); names are extracted automatically. - Every provided capability with a
contractfield must own that capability — otherwise the call throws with a source-located error. - Every entity that the contract has published via
publicEntities(...)must appear inentities. The contract collects publications across the file, so missing registrations are caught at authoring time, not at boot.
What boot validation adds
Section titled “What boot validation adds”The framework’s package compiler runs after every package’s definePackage and walks the resulting graph:
- Duplicate package names are rejected.
- Provided capabilities owned by a foreign contract are rejected (defense-in-depth check, also enforced at authoring).
- Contract-bound capabilities must be required by packages that declare the providing package as a dependency.
- Published capabilities must have an implementation in
provides. - Published entities must be registered in
entities.
Errors include the source location of the offending declaration when available.
When to use a contract
Section titled “When to use a contract”Use one whenever your package has a public surface that another package consumes. That covers most real packages.
If a package has no consumers — it just owns routes, entities, and middleware for its own use — definePackage(...) directly is still fine. Contracts are about boundaries between packages, not boilerplate for every package.
When NOT to use a contract
Section titled “When NOT to use a contract”- One package, one app, no cross-package consumers. A blog package with no other packages reading its data is just
definePackage. - Framework-level plugins. Auth, persistence, queues, and other plugins use the
SlingshotPlugininterface, not packages or contracts.
Next in your journey
Section titled “Next in your journey”Your packages now publish typed public surfaces. The next steps in composing the app are:
- Events and the Event Bus — when one package needs to react to another without coupling, fire and subscribe to typed events.
- Plugin Interface — when you need framework-level infrastructure (auth, persistence, queues, search) with lifecycle hooks.
Reference for later:
- definePackage — the package authoring surface contracts call into.
- Capabilities and entityRef — the lower-level mechanism contracts wrap.