NestJS CRUD controller generator

createCrudController is a NestJS CRUD generator for Drizzle. One call builds list, get, create, update, delete, trash and bulk routes from your service.

createCrudController(Service, options) is the NestJS CRUD generator for Drizzle in this package: it returns a controller class whose routes call your service. You get offset and cursor lists, get, create, update, delete, soft delete, restore, a trash list and four opt-in bulk routes. The id pipe follows the primary key column, so you stop copying the same controller for every table.

Before you start

You need a service registered with forFeature. Every route goes through that service, so allowlists, scope, soft delete, optimistic locking and events apply exactly as they do when you call the service in code.

How do I generate a controller?

Extend the class the factory returns and put @Controller on your subclass:

projects.controller.ts
import { Controller } from '@nestjs/common';
import { createCrudController } from '@avytrix/nestjs-drizzle-crud';
import { CreateProjectDto } from './dto/create-project.dto';
import { UpdateProjectDto } from './dto/update-project.dto';
import { ProjectsService } from './projects.service';

@Controller('projects')
export class ProjectsController extends createCrudController(ProjectsService, {
  dto: { create: CreateProjectDto, update: UpdateProjectDto },
  deleteMode: 'soft',
  routes: { bulkCreate: true, bulkDelete: true },
}) {}

Import the DTOs as values, not with import type. The factory hands the classes to Nest, and a type-only import is erased at build time.

Which routes does it generate?

Eight routes are on by default. The four bulk routes stay off until you turn them on.

RouteMethod and pathCallsStatusDefault
listGET /findAll, or findByCursor when ?cursor= (or ?after=) is present200on
trashGET /trashfindAll or findByCursor with onlyDeleted200on
getGET /:idfind, 404 when the row is missing200on
createPOST /create201on
updatePATCH /:idupdate200on
deleteDELETE /:iddelete, or softDelete with deleteMode: 'soft'200on
softDeletePOST /:id/soft-deletesoftDelete200on
restorePOST /:id/restorerestore200on
bulkCreatePOST /bulkmassCreate, body is an array201off
bulkUpdatePATCH /bulkmassUpdate, body is { ids, data }200off
bulkDeletePOST /bulk-deletemassDelete, or massSoftDelete with deleteMode: 'soft', body is { ids }200off
bulkRestorePOST /bulk-restoremassRestore, body is { ids }200off

A few rules hold across the table:

  • Query strings. list and trash run the whole query string through parseQuery, so filters, sortBy, fields, with, counts, withCount and cursor all work. get, create, update, restore and the bulk writes read only ?with=, ?fields= and ?counts= and ignore the rest.
  • Trash. On an entity without soft delete, trash, softDelete, restore and bulkRestore answer 404 with This resource has no trash. The trash route turns allowTrash off, so ?withDeleted= cannot widen it.
  • Bulk bodies. ids must be a non-empty array and data must be an object. Anything else is a 400.
  • Route order. The static paths (/trash, /bulk, /bulk-delete, /bulk-restore) are registered before /:id, so they never match as an id.

How is the id parsed?

From the primary key column at request time, the way the matching Nest pipe would parse it:

Primary key columnParsingBad input
integer, serialParseIntPipe400 Validation failed (numeric string is expected)
uuidParseUUIDPipe400
bigint in bigint modedigits (with an optional leading -), then BigInt400
anything else, including numeric and decimalthe string as isnone

The service exposes the same logic as service.parseId(raw) and service.idKind(), which you can call from your own controllers. Set idType: 'int' | 'uuid' | 'string' to fix the pipe when the class is built instead. It then shows on the route as a normal @Param('id', ParseIntPipe).

Which options does createCrudController take?

Every option is optional. The source is crud-controller.factory.ts.

pathOptional

string

Applies @Controller(path) to the generated class. Leave it out and decorate your subclass instead.
idTypeOptional

"int" | "uuid" | "string"

Fixes the :id pipe when the class is built. Unset, the id follows the primary key column at request time.
dto.createOptional

Type

DTO class for POST /, and the ParseArrayPipe items type for POST /bulk. A global ValidationPipe validates it.
dto.updateOptional

Type

DTO class for PATCH /:id.
queryOptional

ParseCrudQueryOptions

Options for parseQuery on every route, such as allowTrash or ignore.

Defaults to {}

routesOptional

{ [route: CrudRouteName]: boolean | CrudRouteOptions }

Turns a route on or off. An object { enabled?, decorators? } can also give one route its own decorators.
decoratorsOptional

MethodDecorator[]

Applied to every route, before the per-route decorators.
classDecoratorsOptional

ClassDecorator[]

Applied to the generated class, for example ApiTags or UseGuards.
deleteModeOptional

"hard" | "soft"

"soft" makes DELETE /:id and POST /bulk-delete move rows to the trash instead of deleting them.

Defaults to "hard"

openapiOptional

true | ApiCrudSource

Documents list and trash with ApiCrudList. Pass the entity config with its table, or true for a service decorated with @CrudService. Needs @nestjs/swagger.

How do I validate request bodies?

Pass the DTO classes in dto. They become the parameter types of create and update, so a global ValidationPipe validates them the usual way. bulkCreate validates each array element with ParseArrayPipe({ items: dto.create }).

main.ts
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

Without dto, bodies reach the service unvalidated. writableColumns still applies, so a body cannot write a column you did not allow.

How do I turn routes on or off?

Set a route to true or false in routes. An object turns it on and carries decorators for that route alone:

tags.controller.ts
@Controller('tags')
export class TagsController extends createCrudController(TagsService, {
  routes: {
    delete: false,                              // no DELETE /:id
    bulkDelete: true,                           // POST /bulk-delete
    update: { decorators: [Roles('admin')] },   // PATCH /:id, admins only
  },
}) {}

Roles stands for your own role decorator. { enabled: false } works the same as false.

How do I add guards, interceptors or Swagger tags?

decorators applies method decorators to every route, before the per-route ones. classDecorators decorates the generated class. You can also decorate your subclass directly, which reads like any other Nest controller:

projects.controller.ts
@ApiTags('projects')
@UseGuards(AuthGuard)
@Controller('projects')
export class ProjectsController extends createCrudController(ProjectsService, {
  decorators: [UseInterceptors(AuditInterceptor)],
}) {}

How do I document the routes in Swagger?

Pass openapi. The factory applies ApiCrudList to list, and ApiCrudList(source, { trash: true }) to trash:

projects.controller.ts
@Controller('projects')
export class ProjectsController extends createCrudController(ProjectsService, {
  openapi: { table: projects, ...projectsCrud },
}) {}

Keep the entity config in one constant (projectsCrud) and spread it into both forFeature and openapi. The docs then list the same filterable and sortable columns the service accepts.

Can I add my own routes?

Yes. The factory returns a class, so extend it and add methods. The service is on this.service:

projects.controller.ts
@Controller('projects')
export class ProjectsController extends createCrudController(ProjectsService) {
  @Get('stats/by-name')
  stats() {
    return this.service.countBy('name');
  }
}

To replace a generated route, turn it off in routes and declare your own handler for the same path. Do not override a generated method by name: the route metadata sits on the generated handler, so Nest would not route your override.

Common questions

Next steps

Last updated View change history

On this page