Skip to content

Generated Routes, Overrides, and Extra Routes

The entity system generates routes by default. When the default behavior is not enough, you have two escape hatches: overrides (replace a generated route) and extra routes (add new ones).

An entity with route config generates up to five CRUD routes plus one route per operation:

POST /posts ← create
GET /posts ← list
GET /posts/:id ← get
PATCH /posts/:id ← update
DELETE /posts/:id ← delete
GET /posts/by-author/:authorId ← lookup operation
POST /posts/:id/publish ← transition operation

Every route gets request validation, response schemas, and OpenAPI documentation.

Use an override when you want to keep the route identity (same method, same path, same OpenAPI entry) but replace the handler logic.

Scenario: The default GET /posts/:id returns the raw record. You want to add a view count:

src/packages/blog.ts
// @skip-typecheck
import { definePackage, entity } from '@lastshotlabs/slingshot';
import { defineEntityExecutor } from '@lastshotlabs/slingshot-entity';
import { Post } from '../entities/post';
import { PostOps } from '../entities/post-operations';
export const blogPackage = definePackage({
name: 'blog',
entities: [
entity({
config: Post,
operations: PostOps,
overrides: {
get: defineEntityExecutor({
summary: 'Get a post and increment view count',
build:
({ entityAdapter }) =>
async ({ params, respond }) => {
const post = await entityAdapter.get(params.id);
if (!post) return respond.notFound();
// Custom logic: increment view count
await entityAdapter.update(params.id, {
viewCount: (post.viewCount ?? 0) + 1,
});
return respond.json({ ...post, viewCount: (post.viewCount ?? 0) + 1 });
},
}),
},
}),
],
});

The build function receives an EntityRouteExecutorBuilderContext:

PropertyWhat it is
entityThe resolved entity config
entityAdapterThe entity’s storage adapter (get, list, create, update, delete + operations)
routeKeyWhich route this is ('get', 'create', etc.)
getEntityAdapterAccess another entity’s adapter by plugin + entity name

The handler function receives the route execution context:

PropertyWhat it is
paramsURL parameters
requestThe Hono Context object
entityResolved entity config
entityAdapterThe entity’s storage adapter
respondTyped response helpers (json, notFound, noContent, etc.)
filterAny filters applied by middleware or route policy
dataScopeBindingsResolved data scope bindings

Override with custom request/response schemas

Section titled “Override with custom request/response schemas”

You can also override the OpenAPI schema for the route:

// @skip-typecheck
overrides: {
get: defineEntityExecutor({
summary: 'Get a post with related data',
request: {
query: z.object({
include: z.enum(['author', 'comments']).optional(),
}),
},
responses: {
200: {
description: 'Post with optional related data',
schema: z.object({
post: z.any(),
author: z.any().optional(),
comments: z.array(z.any()).optional(),
}),
},
},
build:
({ entityAdapter, getEntityAdapter }) =>
async ({ params, query, respond }) => {
const post = await entityAdapter.get(params.id);
if (!post) return respond.notFound();
const result: Record<string, unknown> = { post };
if (query?.include === 'author') {
const users = getEntityAdapter({ plugin: 'auth', entity: 'User' });
result.author = await users.get(post.authorId);
}
return respond.json(result);
},
}),
},

Use extra routes when you want a new endpoint that lives alongside the generated entity routes but is not one of the defaults.

Scenario: Add a POST /posts/:id/preview endpoint that returns a rendered preview:

src/packages/blog.ts
// @skip-typecheck
import { defineEntityRoute, entity } from '@lastshotlabs/slingshot-entity';
entity({
config: Post,
operations: PostOps,
extraRoutes: [
defineEntityRoute({
key: 'preview',
method: 'post',
path: '/:id/preview',
auth: 'userAuth',
summary: 'Preview a post with rendered markdown',
buildExecutor:
({ entityAdapter }) =>
async ({ params, respond }) => {
const post = await entityAdapter.get(params.id);
if (!post) return respond.notFound();
return respond.json({
title: post.title,
html: `<article><h1>${post.title}</h1>${post.body}</article>`,
});
},
}),
defineEntityRoute({
key: 'stats',
method: 'get',
path: '/stats',
auth: 'userAuth',
permission: { requires: 'blog:post.admin' },
summary: 'Get post statistics',
buildExecutor:
({ entityAdapter }) =>
async ({ respond }) => {
const all = await entityAdapter.list({});
const published = all.items.filter((p: any) => p.status === 'published').length;
return respond.json({
total: all.items.length,
published,
draft: all.items.length - published,
});
},
}),
],
});

Extra routes can use the same policy options as generated routes: auth, permission, rateLimit, middleware, event, and typed request/responses.

defineEntityRoute({
key: 'feature',
method: 'post',
path: '/:id/feature',
auth: 'userAuth',
permission: { requires: 'blog:post.moderate' },
event: {
key: 'blog:post.featured',
payload: ['id'],
exposure: ['client-safe'],
},
buildExecutor:
({ entityAdapter }) =>
async ({ params, respond }) => {
await entityAdapter.update(params.id, { featured: true });
return respond.json({ featured: true });
},
});

The framework does not allow ambiguous routes. If you create an extra route with the same method and path as a generated route, the runtime throws an error at boot time.

To replace a generated route, use an override, not an extra route. This makes the intent explicit and avoids silent shadowing.

If you do not want certain CRUD routes at all:

routes: {
disable: ['delete'], // no DELETE endpoint
},

Disabled routes are not registered — they do not appear in OpenAPI docs.

You want to…Use
Use the default generated behaviorNothing — it works
Change how a generated route worksoverrides
Add a new route inside the entity shellextraRoutes
Remove a generated routeroutes.disable
Add a route outside the entity shelldomain() + route.* in the package