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.*().
A route with typed input and output
Section titled “A route with typed input and output”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, andqueryin the handler
Route methods
Section titled “Route methods”route.get({ path, handler, ... })route.post({ path, handler, ... })route.put({ path, handler, ... })route.patch({ path, handler, ... })route.delete({ path, handler, ... })Route options
Section titled “Route options”| Option | What it does |
|---|---|
path | URL path (supports :param placeholders) |
handler | The route handler function |
auth | 'userAuth', 'bearer', or 'none' |
permission | Permission check (same options as entity route policy) |
rateLimit | { windowMs, max } throttle config |
middleware | Named middleware from the package |
idempotency | Duplicate request protection |
event | Publish an event on success |
summary | One-line description for OpenAPI |
description | Detailed description for OpenAPI |
request | Zod schemas for body, params, query |
responses | Zod schemas per status code for OpenAPI |
What the handler receives
Section titled “What the handler receives”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}) => { ... }Response helpers
Section titled “Response helpers”respond.json(data); // 200 JSON responserespond.json(data, 201); // JSON with custom statusrespond.text('ok'); // Plain textrespond.html('<h1>Hello</h1>'); // HTMLrespond.noContent(); // 204respond.notFound(); // 404respond.body(readableStream); // Raw bodyReal scenarios
Section titled “Real scenarios”Webhook receiver
Section titled “Webhook receiver”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(); },});File upload endpoint
Section titled “File upload endpoint”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 }); },});Protected admin endpoint
Section titled “Protected admin endpoint”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 }); },});Route with URL params
Section titled “Route with URL params”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); },});Domain-local services
Section titled “Domain-local services”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.
Domains group related routes
Section titled “Domains group related routes”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 model | Entity |
| Looks up, transitions, or searches entity data | Entity operation |
| Receives webhooks | Package route |
| Renders previews, transforms data | Package route |
| Handles file uploads | Package route |
| Serves a dashboard or admin panel | Package route |
| Proxies to an external API | Package route |
| Does something that is not data-shaped | Package route |
Next in your journey
Section titled “Next in your journey”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.