Skip to content

Domains and Routes

Not every route should be generated from an entity. When your package needs endpoints that don’t map to CRUD — webhooks, preview rendering, third-party callbacks, health checks, dashboards — use domain() and route.*().

src/packages/blog.ts
import { z } from 'zod';
import { definePackage, domain, route } from '@lastshotlabs/slingshot';
const previewBody = z.object({
markdown: z.string().min(1),
});
const previewResponse = z.object({
html: z.string(),
wordCount: z.number(),
});
export const blogPackage = definePackage({
name: 'blog',
domains: [
domain({
name: 'content',
basePath: '/content',
routes: [
route.post({
path: '/preview',
auth: 'userAuth',
summary: 'Preview rendered markdown',
request: { body: previewBody },
responses: {
200: { description: 'Rendered preview', schema: previewResponse },
},
handler: ({ body, respond }) => {
const words = body.markdown.trim().split(/\s+/).filter(Boolean).length;
return respond.json({
html: `<article>${body.markdown}</article>`,
wordCount: words,
});
},
}),
],
}),
],
});

The request and responses Zod schemas:

  • Validate incoming requests (400 on bad input)
  • Generate OpenAPI docs with full request/response schemas
  • Give you typed body, params, and query in the handler
route.get({ path, handler, ... })
route.post({ path, handler, ... })
route.put({ path, handler, ... })
route.patch({ path, handler, ... })
route.delete({ path, handler, ... })
OptionWhat it does
pathURL path (supports :param placeholders)
handlerThe route handler function
auth'userAuth', 'bearer', or 'none'
permissionPermission check (same options as entity route policy)
rateLimit{ windowMs, max } throttle config
middlewareNamed middleware from the package
idempotencyDuplicate request protection
eventPublish an event on success
summaryOne-line description for OpenAPI
descriptionDetailed description for OpenAPI
requestZod schemas for body, params, query
responsesZod schemas per status code for OpenAPI
handler: ({
params, // URL parameters (typed if request.params is set)
query, // Query string (typed if request.query is set)
body, // Request body (typed if request.body is set)
input, // Merged params + query + body
request, // The raw Hono Context (c)
actor, // Current authenticated actor
packageName, // Name of the owning package
capabilities, // Cross-package capability reader
entities, // Cross-package entity adapter reader
services, // Domain-local services
requestContext, // Request-scoped context
respond, // Typed response helpers
}) => { ... }
respond.json(data); // 200 JSON response
respond.json(data, 201); // JSON with custom status
respond.text('ok'); // Plain text
respond.html('<h1>Hello</h1>'); // HTML
respond.noContent(); // 204
respond.notFound(); // 404
respond.body(readableStream); // Raw body
const webhookBody = z.object({
event: z.string(),
data: z.record(z.unknown()),
signature: z.string(),
});
route.post({
path: '/stripe/webhook',
auth: 'none',
summary: 'Stripe webhook receiver',
request: { body: webhookBody },
event: { key: 'payments:webhook.received', payload: ['event'] },
handler: async ({ body, respond }) => {
// Verify signature, process event
console.log('Webhook event:', body.event);
return respond.noContent();
},
});
route.post({
path: '/upload',
auth: 'userAuth',
rateLimit: { windowMs: 60_000, max: 10 },
summary: 'Upload a file',
handler: async ({ request, actor, respond }) => {
const formData = await request.req.formData();
const file = formData.get('file');
if (!file || typeof file === 'string') return respond.json({ error: 'No file' }, 400);
// Process file...
return respond.json({ uploaded: true, userId: actor.id });
},
});
route.get({
path: '/users',
auth: 'userAuth',
permission: { requires: 'admin:users.read' },
rateLimit: { windowMs: 60_000, max: 30 },
summary: 'List all users (admin only)',
handler: async ({ entities, respond }) => {
// Access another package's entity
const users = entities.get(UserRef);
const all = await users.list({});
return respond.json({ users: all.items });
},
});
const inviteParams = z.object({ teamId: z.string() });
const inviteBody = z.object({ email: z.string().email() });
route.post({
path: '/teams/:teamId/invite',
auth: 'userAuth',
permission: { requires: 'team:invite', scope: { teamId: 'param:teamId' } },
request: { params: inviteParams, body: inviteBody },
event: { key: 'teams:invite.sent', payload: ['teamId', 'email'] },
handler: async ({ params, body, respond }) => {
// Send invite...
return respond.json({ invited: body.email, team: params.teamId }, 201);
},
});

A domain can publish helper functions that its routes receive as services:

const analyticsDomain = domain({
name: 'analytics',
basePath: '/analytics',
services: {
calculateRetention(signups: number, active: number) {
return active / signups;
},
formatPercentage(value: number) {
return `${(value * 100).toFixed(1)}%`;
},
},
routes: [
route.get({
path: '/retention',
auth: 'userAuth',
handler: ({ services, respond }) => {
const rate = services.calculateRetention(1000, 450);
return respond.json({ retention: services.formatPercentage(rate) });
},
}),
],
});

Services are scoped to the domain. They do not leak into other domains or packages.

Use multiple domains when a package has distinct route groups:

export const appPackage = definePackage({
name: 'app',
domains: [
domain({
name: 'health',
basePath: '/health',
routes: [
route.get({
path: '/',
auth: 'none',
handler: ({ respond }) => respond.json({ ok: true }),
}),
],
}),
domain({
name: 'admin',
basePath: '/admin',
routes: [
route.get({
path: '/stats',
auth: 'userAuth',
middleware: ['requireAdmin'],
handler: ({ respond }) => respond.json({ users: 42, posts: 128 }),
}),
],
}),
domain({
name: 'webhooks',
basePath: '/webhooks',
routes: [
route.post({
path: '/stripe',
auth: 'none',
handler: ({ body, respond }) => respond.noContent(),
}),
],
}),
],
});

When to use package routes vs entity routes

Section titled “When to use package routes vs entity routes”
The route…Use
Does CRUD on a data modelEntity
Looks up, transitions, or searches entity dataEntity operation
Receives webhooksPackage route
Renders previews, transforms dataPackage route
Handles file uploadsPackage route
Serves a dashboard or admin panelPackage route
Proxies to an external APIPackage route
Does something that is not data-shapedPackage route

Your package has routes. Most apps grow into multiple packages. The next step turns your package into a good neighbor:

  • Package Contracts — declare your package’s typed public surface so other packages can consume capabilities and narrowed entity refs through a provider-owned, boot-validated contract.

After that:

  • Events and the Event Bus — publish typed events from a route via the event: shorthand.
  • Entity System — when the route should be generated from a data model rather than handwritten.