Skip to content

CRUD Router Factory

CruzJS provides two complementary approaches for building CRUD resources. Both produce typed tRPC procedures, share the same filter and serializer building blocks, and integrate with the DI container.

SignalApproach
Tags, categories, config entries, lookup tablescreateCrud() factory
Projects, invoices, orders — real domain objectsManual (BaseCrudService + @Router())
Need a JOIN or subquery on the list endpointManual
Need item-level ownership checksManual
Need custom queries beyond the 5 standard opsManual (or factory + actions)
Prototype or genuinely thin resourceFactory first, eject later

Default to manual. The factory is excellent for genuinely thin resources. For anything with business logic, explicit code is easier to read, debug, and extend.


One call generates a typed Service, Trpc router, RestRouter, and DI token.

features/tags/tags.crud.ts
import { createCrud } from '@cruzjs/core';
import { z } from 'zod';
import { tags } from '../../database/schema';
export const {
Service: TagsService,
Trpc: TagsTrpc,
RestRouter: TagsRestRouter,
ServiceToken: TagsServiceToken,
} = createCrud({
name: 'Tags',
table: tags,
scope: 'org',
createSchema: z.object({ name: z.string().min(1).max(50) }),
updateSchema: z.object({ name: z.string().min(1).max(50).optional() }),
});

Register in a module:

features/tags/tags.module.ts
import { Module } from '@cruzjs/core';
import { TagsService, TagsTrpc, TagsRestRouter } from './tags.crud';
@Module({
providers: [TagsService, TagsTrpc, TagsRestRouter],
trpcRouters: { tags: TagsTrpc },
apiRouters: [TagsRestRouter],
})
export class TagsModule {}

This gives you:

tRPCREST
tags.listGET /api/tags
tags.getGET /api/tags/:id
tags.createPOST /api/tags
tags.updatePATCH /api/tags/:id
tags.deleteDELETE /api/tags/:id
import { createCrud, defineFilters } from '@cruzjs/core';
import { z } from 'zod';
import { orgProcedure } from '@cruzjs/core';
export const { Service: TagsService, Trpc: TagsTrpc, RestRouter: TagsRestRouter } = createCrud({
name: 'Tags',
table: tags,
// --- Required ---
scope: 'org', // 'org' | 'user' | 'global'
createSchema: z.object({ name: z.string() }),
updateSchema: z.object({ name: z.string().optional() }),
// --- Optional ---
scopeColumn: 'orgId', // FK column — defaults: orgId, userId per scope
// Declarative field filters on the list endpoint
filters: defineFilters(tags, { name: 'search', active: 'boolean' }),
// Columns users can sort by
ordering: ['name', 'createdAt'],
// Output serializer (hides internal columns, adds computed fields)
resource: TagResource,
// Lifecycle hooks
hooks: {
beforeCreate: (data, ctx) => ({ ...data, createdBy: ctx.userId }),
afterCreate: async (item, ctx) => events.emit(new TagCreated(item)),
beforeDelete: async (id, ctx) => { /* throw to abort */ },
},
// Per-action permission guards — return false → FORBIDDEN
permissions: {
create: (ctx) => ctx.role !== 'VIEWER',
delete: (ctx) => ctx.role === 'OWNER' || ctx.role === 'ADMIN',
},
// Extra tRPC procedures added to the generated router
actions: {
popular: orgProcedure.query(async ({ ctx }) => { /* custom query */ }),
},
softDelete: true, // auto-detected from deletedAt column; override here
idColumn: 'id', // default: 'id'
});
interface CrudCtx {
userId: string | null; // authenticated user
orgId: string | null; // current org (org scope only)
role: string | null; // user's org role (org scope only)
request: Request; // raw HTTP request
}
  • You need a JOIN or subquery on list — the base list() is a simple SELECT *
  • You need item-level checks (e.g. “only the creator can edit their own record”)
  • You need the service in other services via DI with a custom interface
  • GeneratedService appearing in stack traces is frustrating to debug

In these cases, use the manual pattern below.


Approach 2: Manual — BaseCrudService + @Router()

Section titled “Approach 2: Manual — BaseCrudService + @Router()”

Explicit classes. Real names in stack traces. Override any method. Add custom queries naturally.

Extend BaseCrudService to inherit the 5 standard operations, then add your own:

features/projects/project.service.ts
import { Injectable, Inject } from '@cruzjs/core';
import { BaseCrudService, type CrudListOptions } from '@cruzjs/core';
import { DRIZZLE, type CruzDatabase } from '@cruzjs/core';
import { projects } from '../../database/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
@Injectable()
export class ProjectService extends BaseCrudService<typeof projects> {
constructor(@Inject(DRIZZLE) db: CruzDatabase) {
super(db, projects, { scope: 'org', softDelete: true });
}
// Custom queries — this is what the factory can't do
async getFeatured(orgId: string) {
return this.db.select().from(this.table)
.where(and(eq(projects.orgId, orgId), eq(projects.featured, true)))
.orderBy(desc(projects.createdAt));
}
// Override base method for custom behaviour (e.g. adding a JOIN)
async listWithStats(orgId: string, opts: CrudListOptions) {
// build on this.activeWhere() to keep soft-delete + scope filtering
const where = this.activeWhere(orgId);
return this.db
.select({ ...projects, taskCount: sql<number>`count(t.id)` })
.from(this.table)
.leftJoin(tasks, eq(tasks.projectId, projects.id))
.where(where)
.groupBy(projects.id)
.limit(opts.perPage)
.offset((opts.page - 1) * opts.perPage);
}
}
MethodSignatureNotes
list(scopeId, opts: CrudListOptions)Paginated, scope+soft-delete filtered, orderable
getById(id, scopeId)Returns null if not found or soft-deleted
create(data)Insert + returning
update(id, data)Partial, auto-sets updatedAt
delete(id)Soft (sets deletedAt) or hard delete

Protected helpers available to subclasses:

this.db // CruzDatabase
this.table // the Drizzle table
this.col(name) // column accessor
this.activeWhere(scopeId) // pre-built scope + soft-delete WHERE condition
this.hasSoftDelete() // boolean
interface CrudListOptions {
page: number;
perPage: number;
orderBy?: string; // column name
orderDir?: 'asc' | 'desc';
whereConditions?: SQL[]; // extra pre-computed conditions (from defineFilters)
}

Use the standard @Router() / @Route() / @Inject() pattern:

features/projects/project.trpc.ts
import { TrpcRouter, Router, Route, Inject } from '@cruzjs/core';
import { orgProcedure } from '@cruzjs/core';
import { defineFilters } from '@cruzjs/core';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { ProjectService } from './project.service';
import { projects } from '../../database/schema';
import { createProjectSchema, updateProjectSchema } from './project.validation';
const filters = defineFilters(projects, {
name: 'search',
status: 'exact',
createdAt: 'date-range',
});
@Router()
export class ProjectTrpc extends TrpcRouter {
@Inject(ProjectService) private svc!: ProjectService;
@Route() list = orgProcedure
.input(z.object({
page: z.number().default(1),
perPage: z.number().max(100).default(20),
orderBy: z.enum(['name', 'createdAt']).optional(),
orderDir: z.enum(['asc', 'desc']).optional(),
...filters.toSchema().shape,
}))
.query(async ({ ctx, input }) => {
const where = filters.toWhereConditions(projects, input);
return this.svc.list(ctx.org.orgId, {
page: input.page,
perPage: input.perPage,
orderBy: input.orderBy,
orderDir: input.orderDir,
whereConditions: where,
});
});
@Route() get = orgProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const item = await this.svc.getById(input.id, ctx.org.orgId);
if (!item) throw new TRPCError({ code: 'NOT_FOUND' });
return item;
});
@Route() create = orgProcedure
.input(createProjectSchema)
.mutation(async ({ ctx, input }) =>
this.svc.create({ ...input, orgId: ctx.org.orgId, createdBy: ctx.org.userId }));
@Route() update = orgProcedure
.input(z.object({ id: z.string(), data: updateProjectSchema }))
.mutation(async ({ ctx, input }) => {
const item = await this.svc.getById(input.id, ctx.org.orgId);
if (!item) throw new TRPCError({ code: 'NOT_FOUND' });
// Item-level ownership check — not possible with the factory
if (item.createdBy !== ctx.org.userId && ctx.org.role !== 'OWNER') {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return this.svc.update(input.id, input.data);
});
@Route() delete = orgProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const item = await this.svc.getById(input.id, ctx.org.orgId);
if (!item) throw new TRPCError({ code: 'NOT_FOUND' });
await this.svc.delete(input.id);
});
// Custom endpoints are just more @Route() methods
@Route() featured = orgProcedure
.query(({ ctx }) => this.svc.getFeatured(ctx.org.orgId));
}
features/projects/project.module.ts
import { Module } from '@cruzjs/core';
import { ProjectService } from './project.service';
import { ProjectTrpc } from './project.trpc';
@Module({
providers: [ProjectService, ProjectTrpc],
trpcRouters: { project: ProjectTrpc },
})
export class ProjectsModule {}

Building Blocks (use with either approach)

Section titled “Building Blocks (use with either approach)”

Generates a Zod input schema AND Drizzle WHERE conditions from the same config.

import { defineFilters } from '@cruzjs/core';
const filters = defineFilters(products, {
name: 'search', // LIKE %value%
status: 'exact', // = value
price: 'range', // priceMin + priceMax inputs
createdAt: 'date-range', // createdAtAfter + createdAtBefore inputs
category: 'in', // IN (...) — accepts array (tRPC) or "a,b,c" (REST)
active: 'boolean', // = true/false, coerces "true"/"1" from query params
});
// Add to tRPC input:
.input(z.object({ page: z.number(), ...filters.toSchema().shape }))
// Build WHERE:
const where = filters.toWhereConditions(products, input);
await svc.list(orgId, { page, perPage, whereConditions: where });
OperatorGenerated inputsDrizzle operation
exactfieldeq(col, value)
searchfieldlike(col, '%value%')
rangefieldMin, fieldMaxgte, lte
date-rangefieldAfter, fieldBeforegte, lte
infield (array or CSV)inArray(col, arr)
booleanfieldeq(col, bool)

Control exactly what each endpoint returns. Hides internal columns, adds computed fields.

import { Resource } from '@cruzjs/core';
class ProjectResource extends Resource<typeof projects.$inferSelect> {
transform() {
return {
id: this.model.id,
name: this.model.name,
isArchived: this.model.status === 'archived', // computed
// orgId, deletedAt, internalField are omitted
};
}
}
// Apply manually in a tRPC handler:
return new ProjectResource(item).transform();
// Apply to a list:
return { items: items.map(i => new ProjectResource(i).transform()), total };

Pass as resource: in createCrud() to apply automatically to all outputs.


scopeDefault scopeColumnProcedureContext field
'org'orgIdorgProcedurectx.org.orgId
'user'userIdprotectedProcedurectx.session.user.id
'global'protectedProceduren/a

Override: scopeColumn: 'workspaceId'


createCrud() Manual
─────────────────────────────────────────
Boilerplate low medium
Custom queries ✗ ✓
Override list/get ✗ ✓
Item-level permissions ✗ ✓
Real class names ✗ ✓
IDE go-to-definition ✗ ✓
defineFilters ✓ ✓
Resource serializer ✓ ✓
REST + tRPC auto ✓ tRPC yes, REST via @ApiRouter()
Eject path ✓ n/a