Config-Driven Domain
Describe the domain in config and let the framework wire the runtime around it. The result is a package that works the same way Slingshot’s built-in domain packages work: one source of truth for data shape, route policy, permissions, events, and generated adapters.
Source-backed example app: examples/config-driven-domain/
What this demonstrates
Section titled “What this demonstrates”- entity schema, indexes, route policy, and events in one definition
- operations as the domain transition layer beyond CRUD
- package-local plugin composition instead of app-local route sprawl
- how config-driven authoring turns into a real mounted HTTP surface
The authoring loop
Section titled “The authoring loop”- Define the entity shape and route contract.
- Add operations for transitions, lookups, search, or aggregates.
- Wrap the entity in a plugin so app composition stays clean.
- Compose the plugin beside platform packages such as auth, search, or notifications.
- Let Slingshot generate routes, adapters, policy checks, and event plumbing from that one source of truth.
Install
Section titled “Install”bun add @lastshotlabs/slingshot @lastshotlabs/slingshot-core @lastshotlabs/slingshot-entityDefine the entity
Section titled “Define the entity”One definition carries the data model, index strategy, route policy, permission scoping, and event configuration. Nothing drifts from anything else.
import { defineEntity, field, index } from '@lastshotlabs/slingshot';
export const Post = defineEntity('Post', { namespace: 'blog', fields: { id: field.string({ primary: true, default: 'uuid' }), authorId: field.string(), title: field.string(), body: field.string(), status: field.enum(['draft', 'published', 'archived'] as const, { default: 'draft' }), publishedAt: field.date({ optional: true }), createdAt: field.date({ default: 'now' }), updatedAt: field.date({ default: 'now', onUpdate: 'now' }), }, indexes: [ index(['status', 'createdAt'], { direction: 'desc' }), index(['authorId', 'createdAt'], { direction: 'desc' }), ], routes: { defaults: { auth: 'userAuth' }, get: { auth: 'none' }, list: { auth: 'none' }, create: { permission: { requires: 'blog:post.write', scope: { resourceType: 'blog:author', resourceId: 'param:authorId' }, }, event: { key: 'blog:post.created', payload: ['id', 'authorId', 'title'], scope: { resourceType: 'blog:post', resourceId: 'record:id' }, exposure: ['client-safe'], }, }, permissions: { resourceType: 'blog:author', scopeField: 'authorId', actions: ['read', 'write', 'publish'], roles: { owner: ['*'], editor: ['read', 'write'] }, }, },});Operations beyond CRUD
Section titled “Operations beyond CRUD”Operations are the domain transition layer: state changes, search, aggregates, and bulk actions that do not fit plain CRUD.
import { defineOperations, op } from '@lastshotlabs/slingshot';import { Post } from './post.js';
export const postOperations = defineOperations(Post, { publish: op.transition({ field: 'status', from: 'draft', to: 'published', match: { id: 'param:id' }, set: { publishedAt: 'now' }, returns: 'entity', }), archive: op.transition({ field: 'status', from: ['draft', 'published'], to: 'archived', match: { id: 'param:id' }, returns: 'entity', }), search: op.search({ fields: ['title', 'body'], filter: { status: 'published' }, paginate: true, }),});Build the package
Section titled “Build the package”The canonical authoring path uses definePackage(...) (via the createXxxPackage(...) factory
the package exposes); the framework’s package compiler wraps it in the createEntityPlugin(...)
plumbing internally. The example below shows the lower-level escape hatch — useful when you need
direct control over the entity-plugin surface, but for most domain packages you should reach for
definePackage(...) first.
import { createEntityFactories } from '@lastshotlabs/slingshot';import { type SlingshotPlugin, resolveRepo } from '@lastshotlabs/slingshot-core';import { createEntityPlugin } from '@lastshotlabs/slingshot-entity';import type { BareEntityAdapter } from '@lastshotlabs/slingshot-entity/routing';import { Post } from './entities/post.js';import { postOperations } from './entities/postOperations.js';
export interface BlogPluginConfig { mountPath?: string;}
export function createBlogPlugin(config: BlogPluginConfig = {}): SlingshotPlugin { return createEntityPlugin({ name: 'blog', dependencies: ['slingshot-auth'], mountPath: config.mountPath ?? '/blog', entities: [ { config: Post, operations: postOperations.operations, buildAdapter: (storeType, infra): BareEntityAdapter => resolveRepo( createEntityFactories(Post, postOperations.operations), storeType, infra, ) as unknown as BareEntityAdapter, }, ], });}The important point is ownership: the package owns its entity config, operations, and plugin assembly together. The app imports one factory instead of re-implementing route shape every time.
Compose at the app layer
Section titled “Compose at the app layer”import { defineApp } from '@lastshotlabs/slingshot';import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';import { createBlogPlugin } from './src/plugin.js';
export default defineApp({ port: 3000, security: { signing: { secret: process.env.JWT_SECRET! } }, plugins: [ createAuthPlugin({ auth: { roles: ['user', 'admin'], defaultRole: 'user' }, db: { auth: 'memory', sessions: 'memory', oauthState: 'memory' }, }), createBlogPlugin({ mountPath: '/posts' }), ],});What the framework generates
Section titled “What the framework generates”From those definitions, Slingshot mounts:
| Method | Path | Auth | Notes |
|---|---|---|---|
GET | /posts | none | List posts |
POST | /posts | user | Checks blog:post.write permission and fires blog:post.created |
GET | /posts/:id | none | Get a post |
PUT | /posts/:id | user | Update a post |
DELETE | /posts/:id | user | Delete a post |
POST | /posts/:id/publish | user | draft to published transition |
POST | /posts/:id/archive | user | Archive transition |
GET | /posts/search | none | Full-text search over published posts |
No route files. No adapter boilerplate. No event wiring by hand.
Why this matters
Section titled “Why this matters”The config-driven path is not just a convenience DSL. It is the mechanism that keeps these surfaces aligned:
- storage shape
- generated adapters
- route inputs and outputs
- permission checks
- emitted events
- generated docs and API reference
When teams bypass this pattern for entity-shaped domains, drift usually shows up first in routes, permissions, or docs.
Add custom middleware
Section titled “Add custom middleware”Declare named middleware in the plugin and reference it by name in entity route config:
import { getActor } from '@lastshotlabs/slingshot-core';
declare function getPost(id: string): Promise<{ authorId: string }>;
const middleware = { ownerOnly: async (c: import('hono').Context, next: import('hono').Next) => { const actor = getActor(c); const post = await getPost(c.req.param('id')!); if (post.authorId !== actor.id) return c.json({ error: 'Forbidden' }, 403); await next(); },};
void middleware;Then reference that middleware by name in the entity route config:
const routes = { update: { middleware: ['ownerOnly'] }, delete: { middleware: ['ownerOnly'] },};
void routes;When to use this pattern
Section titled “When to use this pattern”Use createEntityPlugin() when the domain has a clear entity model and you want routes,
permissions, and events to stay synchronized. Use hand-written routes for endpoints that genuinely
do not map to an entity: health checks, webhook receivers, or one-off transformations.
slingshot-community is the flagship implementation of this pattern. Read its source alongside this
example when you need a complex, production-grade reference.
Where to go next
Section titled “Where to go next”- Package-First Authoring for the canonical package-level mental model
- Entity System for the canonical entity authoring path
- Entity System: Operations for named behavior beyond CRUD
- slingshot-entity for the full API surface
- slingshot-community source for the flagship implementation