@zola_do/crud
v0.2.9
Published
Generic CRUD controllers and services for NestJS
Maintainers
Readme
@zola_do/crud
Generic CRUD controllers, services, and repositories for NestJS entities with built-in authorization, hooks, and pagination.
Overview
@zola_do/crud provides reusable CRUD factories that generate type-safe controllers and services for any TypeORM entity. It includes:
- Controller Factories —
EntityCrudController,ExtraCrudController,RelationCrudController - Service Classes — Generic CRUD operations with hooks
- Repository Pattern — EntityCrudRepository for data access
- Authorization Guards — Permission-based access control
- Operation Hooks — before/after callbacks for each operation
- Endpoint Control — Hide or block specific operations
- Request Context — Automatic tenant/user context mapping
- Global Exception Filter — Safe JSON error responses
Installation
# Install individually
npm install @zola_do/crud
# Or via meta package
npm install @zola_do/nestjs-sharedDependencies
npm install @zola_do/collection-query @zola_do/authorizationOptional Dependencies
# For RPC exception filter
npm install @nestjs/microservicesQuick Start
1. Create an Entity
import { CommonEntity } from '@zola_do/nestjs-shared';
import {
Entity,
Column,
PrimaryGeneratedColumn,
DeleteDateColumn,
} from 'typeorm';
@Entity('products')
export class Product extends CommonEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
name: string;
@Column({ type: 'decimal', precision: 10, scale: 2 })
price: number;
@Column({ default: 'active' })
status: string;
@DeleteDateColumn()
deletedAt?: Date;
}2. Create Controller
import { Controller } from '@nestjs/common';
import { EntityCrudController } from '@zola_do/crud';
import { EntityCrudService } from '@zola_do/crud/service';
import { Product } from './product.entity';
@Controller('products')
export class ProductsController extends EntityCrudController<Product>({
createPermission: 'product:create',
viewPermission: 'product:view',
updatePermission: 'product:update',
deletePermission: 'product:delete',
}) {
constructor(service: EntityCrudService<Product>) {
super(service);
}
}3. Create Module
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EntityCrudService } from '@zola_do/crud/service';
import { Product } from './product.entity';
import { ProductsController } from './products.controller';
@Module({
imports: [TypeOrmModule.forFeature([Product])],
controllers: [ProductsController],
providers: [EntityCrudService],
})
export class ProductsModule {}Available Endpoints
| Method | Endpoint | Description |
| -------- | -------------- | -------------------------------------------- |
| POST | / | Create a new entity |
| GET | / | List all entities (with ?q= query support) |
| GET | /:id | Get single entity by ID |
| PUT | /:id | Update entire entity |
| PATCH | /:id | Partial update |
| DELETE | /:id | Soft delete |
| PATCH | /restore/:id | Restore soft-deleted entity |
| GET | /archived | List archived entities |
EntityCrudOptions
Full configuration options for the CRUD controller:
import { EntityCrudOptions } from '@zola_do/crud';
const options: EntityCrudOptions = {
// DTOs for validation
createDto: CreateProductDto,
updateDto: UpdateProductDto,
// Permissions for guards
createPermission: 'product:create',
viewPermission: 'product:view',
updatePermission: 'product:update',
deletePermission: 'product:delete',
restorePermission: 'product:restore',
viewArchivedPermission: 'product:view_archived',
// Operation hooks (create-time context mapping)
beforeCreate: async ({ req, itemData }) => {
itemData.organizationId = req?.user?.organizationId;
itemData.createdBy = req?.user?.id;
},
afterCreate: async ({ service, req, result }) => {
await service.sendNotification(result.id);
},
// Write field restrictions
allowedWriteFields: ['name', 'price'],
blockedWriteFields: ['id', 'createdAt'],
beforeFindAll: async ({ service, req, where }) => {
where.organizationId = req.user.organizationId;
},
afterFindAll: async ({ service, req, result }) => {
result.items = result.items.filter(i => i.isActive);
},
beforeFindOne: async ({ service, req, where, params }) => {
where.id = params.id;
},
beforeUpdate: async ({ service, req, itemData, params }) => {
itemData.updatedBy = req.user.id;
},
afterUpdate: async ({ service, req, result }) => {
await service.invalidateCache(result.id);
},
beforeDelete: async ({ service, req, params }) => {
await service.assertCanDelete(params.id);
},
beforeRestore: async ({ service, req, params }) => {
await service.assertCanRestore(params.id);
},
// Authorization callbacks
authorize: async ({ req, operation }) => {
return req.user.isAdmin;
},
canCreate: async ({ service, req }) => /* boolean */,
canFindAll: async ({ service, req }) => /* boolean */,
canFindOne: async ({ service, req, params }) => /* boolean */,
canUpdate: async ({ service, req, params }) => /* boolean */,
canDelete: async ({ service, req, params }) => /* boolean */,
canRestore: async ({ service, req, params }) => /* boolean */,
// Delete behavior
deleteMode: 'soft', // 'auto' | 'soft' | 'hard'
// Endpoint control
operations: {
create: 'hide', // Hide from routes (404)
findAll: { mode: 'block', reason: 'maintenance' }, // Block with 403
findOne: false, // Shorthand for 'hide'
update: 'block',
delete: 'hide',
restore: 'hide',
findAllArchived: 'hide',
},
};Operation Hooks
Hooks are callbacks that run before or after CRUD operations. They receive a context object and can modify the request data or perform side effects.
Hook Types
interface CrudHookContext {
req?: any; // Express request object
params?: any; // Route parameters
query?: any; // Query parameters
id?: string; // Entity ID (for single-entity operations)
itemData?: any; // Create/Update payload (mutable)
where?: any; // Query filter (mutable for reads)
entity?: any; // Existing entity (for updates)
result?: any; // Operation result
operation: string; // 'create' | 'findAll' | 'findOne' | 'update' | 'delete' | 'restore'
service?: any; // CRUD service instance
repository?: any; // TypeORM repository
setMeta?: (key: string, value: any) => void; // Cross-hook metadata
getMeta?: <T = any>(key: string) => T | undefined;
}Hook Examples
Before Create - Add Context Data
beforeCreate: async ({ service, req, itemData }) => {
const userId = req?.user?.sub ?? req?.user?.id;
const orgId = req?.user?.organizationId;
if (!userId || !orgId) {
throw new UnauthorizedException('User context required');
}
itemData.createdBy = userId;
itemData.organizationId = orgId;
itemData.status ??= 'pending';
},Before FindAll - Filter by Tenant
beforeFindAll: async ({ service, req, where }) => {
where.organizationId = req.user.organizationId;
where.deletedAt = undefined; // Exclude soft-deleted
},Before Update - Validate and Modify
beforeUpdate: async ({ service, req, itemData, params }) => {
const existing = await service.findOne(params.id);
if (existing.status === 'completed') {
throw new BadRequestException('Cannot update completed items');
}
itemData.updatedBy = req.user.id;
itemData.updatedAt = new Date();
},After Create - Side Effects
afterCreate: async ({ service, req, result }) => {
await service.sendWebhook('item.created', result);
await service.updateAnalytics(result);
},Using Metadata for Cross-Hook Communication
beforeFindAll: async ({ req, setMeta }) => {
setMeta('userId', req.user.id);
setMeta('orgId', req.user.organizationId);
},
afterFindAll: async ({ service, req, result, getMeta }) => {
const userId = getMeta('userId');
await service.trackSearch(userId, result.items.length);
},Authorization Callbacks
Operation-specific authorization allows fine-grained access control:
const options: EntityCrudOptions = {
// Global authorize (runs for all operations)
authorize: async ({ req, operation }) => {
if (!req.user) return false;
return true;
},
// Per-operation authorization
canCreate: async ({ service, req }) => {
return req.user.permissions.includes('product:create');
},
canFindAll: async ({ service, req }) => {
return true; // Public read
},
canUpdate: async ({ service, req, params }) => {
const entity = await service.findOne(params.id);
return entity.ownerId === req.user.id || req.user.isAdmin;
},
canDelete: async ({ service, req, params }) => {
const entity = await service.findOne(params.id);
return entity.ownerId === req.user.id;
},
canRestore: async ({ service, req, params }) => {
return req.user.isAdmin;
},
};Endpoint Disabling
Control which endpoints are available using the operations option:
operations: {
// 'hide' - Route not registered (404)
create: 'hide',
// 'block' - Route registered but denied (403)
findAll: { mode: 'block', reason: 'Under maintenance' },
// false - Same as 'hide'
findOne: false,
// 'block' with string shorthand
update: 'block',
// All available keys
delete: 'hide',
restore: 'hide',
findAllArchived: 'hide',
}Operation Keys
| Key | Endpoint | Description |
| ----------------- | -------------------- | ----------------- |
| create | POST / | Create new entity |
| findAll | GET / | List entities |
| findOne | GET /:id | Get single entity |
| update | PUT /:id | Full update |
| delete | DELETE /:id | Soft delete |
| restore | PATCH /restore/:id | Restore entity |
| findAllArchived | GET /archived | List deleted |
Request Transactions
Enable atomic mutating requests (create/update/delete hooks + DB writes commit or roll back together):
import { InjectDataSource } from '@nestjs/typeorm';
import {
bindCrudTransactionDataSource,
CrudTransactionModule,
} from '@zola_do/crud';
import { DataSource } from 'typeorm';
@Module({
imports: [TypeOrmModule.forRoot(dataSourceOptions), CrudTransactionModule],
})
export class AppModule {
constructor(@InjectDataSource() dataSource: DataSource) {
bindCrudTransactionDataSource(dataSource);
}
}EntityCrudController<Product>({
useTransaction: true,
beforeCreate: async ({ itemData }) => {
itemData.status = 'draft';
},
afterCreate: async ({ repository, result }) => {
// DB work here uses the same transaction; throws roll back the insert too.
await repository.insert({ ...relatedRow, productId: result.id });
},
});How it works:
useTransaction: trueattachesTransactionInterceptorto mutating routes (POST,PUT,DELETE, restore, bulk).- The interceptor starts a TypeORM transaction and stores the entity manager on
req. - CRUD services resolve repositories from that manager, so
beforeCreate, inserts/updates/deletes, andafterCreateDB calls share one transaction. - If any step throws, the interceptor rolls back and the client receives the error.
Notes:
- Non-DB side effects in hooks (HTTP, email, queues) are not rolled back automatically.
- Use
context.repository(or passcontext.reqinto other service methods) so related DB work stays in the same transaction. - Read endpoints (
GET) are unchanged and do not start a transaction unless you add the interceptor manually.
Postgres RLS / session context (consuming app)
Implement {@link CrudTransactionContextHook} in your app and bind it in AppModule
(the interceptor singleton is created before optional token injection resolves):
import {
bindCrudTransactionContextHook,
bindCrudTransactionDataSource,
CrudTransactionContextHook,
CrudTransactionModule,
} from '@zola_do/crud';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Injectable } from '@nestjs/common';
@Injectable()
class PostgresRlsTransactionContextHook implements CrudTransactionContextHook {
async apply({ req, queryRunner }) {
const user = req?.user;
await queryRunner.query(
`SELECT set_config('app.user_id', $1, true), set_config('app.tenant_id', $2, true)`,
[user?.id ?? '', user?.tenantId ?? ''],
);
}
}
@Module({
imports: [TypeOrmModule.forRoot(dataSourceOptions), CrudTransactionModule],
providers: [PostgresRlsTransactionContextHook],
})
export class AppModule {
constructor(
@InjectDataSource() dataSource: DataSource,
rlsHook: PostgresRlsTransactionContextHook,
) {
bindCrudTransactionDataSource(dataSource);
bindCrudTransactionContextHook(rlsHook);
}
}The hook runs after START TRANSACTION and before CRUD handlers. Use transaction-local
set_config(..., true) so pooled connections do not leak context. For read routes (GET),
add a similar global interceptor if RLS must apply there too.
Request Context Mapping
The CRUD package supports automatic context injection for create operations:
Method 1: Per-CRUD beforeCreate Hook
EntityCrudController<Product>({
beforeCreate: async ({ req, itemData }) => {
itemData.organizationId = req?.user?.organizationId;
itemData.organizationName = req?.user?.organizationName;
itemData.createdBy = req?.user?.id;
},
});When beforeCreate is configured, the legacy req.user.organization fallback does not overwrite client-supplied organization fields.
Method 2: Module-Level Resolver
import {
CRUD_REQUEST_CONTEXT_RESOLVER,
CrudRequestContextResolver,
} from '@zola_do/crud/request-context';
const resolver: CrudRequestContextResolver = {
resolveCreateContext: ({ req }) => ({
organizationId: req?.auth?.orgId,
organizationName: req?.auth?.orgName,
createdBy: req?.auth?.userId,
}),
};
@Module({
providers: [
{
provide: CRUD_REQUEST_CONTEXT_RESOLVER,
useValue: resolver,
},
],
})
export class ProductsModule {}Method 3: Legacy Fallback
If no resolver is configured, the system falls back to req.user.organization:
// Automatically uses req.user.organization.organizationId
// and req.user.organization.organizationNameRelation CRUD
For managing many-to-many relationships:
import { RelationCrudController, RelationCrudService } from '@zola_do/crud';
@Controller('products/:productId/categories')
export class ProductCategoriesController extends RelationCrudController(
{
firstEntityIdName: 'productId',
firstInclude: 'product',
secondEntityIdName: 'categoryId',
secondInclude: 'category',
viewPermission: 'product:view',
operations: {
bulkSaveFirst: 'hide',
findAllSecond: 'hide',
},
},
ProductCategory,
) {
constructor(service: RelationCrudService) {
super(service);
}
}Relation CRUD Endpoints
| Method | Endpoint | Description |
| ------ | ------------------- | ------------------------------- |
| POST | /bulk-save-first | Assign multiple first entities |
| POST | /bulk-save-second | Assign multiple second entities |
| GET | /first-entities | List all first entities |
| GET | /second-entities | List all second entities |
Extra CRUD
Extended CRUD with parent entity scoping:
import { ExtraCrudController, ExtraCrudService } from '@zola_do/crud';
@Controller('events/:eventId/attendees')
export class EventAttendeesController extends ExtraCrudController(
{
entityIdName: 'eventId',
createPermission: 'attendee:create',
viewPermission: 'attendee:view',
},
Attendee,
) {
constructor(service: ExtraCrudService<Attendee>) {
super(service);
}
}Global Exception Filter
The package includes a safe global exception filter:
import { GlobalExceptionFilter } from '@zola_do/crud';
// In your main.ts
app.useGlobalFilters(new GlobalExceptionFilter());Response Format
// Success response
{
"statusCode": 200,
"data": { ... }
}
// Error response (production)
{
"statusCode": 400,
"message": "Validation failed",
"error": "Bad Request",
"path": "/api/products",
"timestamp": "2024-01-01T00:00:00.000Z"
}Note: Raw exceptions and stack traces are never exposed to clients in production.
Delete Modes
| Mode | Behavior | Use Case |
| ------ | ------------------------------ | ------------------------------------------ |
| auto | Uses entity metadata (default) | Standard entities with @DeleteDateColumn |
| soft | Sets deletedAt timestamp | Audit trail required |
| hard | Physical delete | Data that can be safely removed |
// Force hard delete
EntityCrudController<Product>({
deleteMode: 'hard',
beforeDelete: async ({ service, params }) => {
await service.cleanupRelatedData(params.id);
},
});Query String Support
List endpoints support collection query parameters via ?q=:
GET /products?q=w=status$eq$active&t=20&o=createdAt$desc&i=categorySee @zola_do/collection-query for full query syntax.
API Reference
Controllers
EntityCrudController<T>(options: EntityCrudOptions)
Generic CRUD controller factory.
class EntityCrudController<T> {
constructor(
protected readonly service: EntityCrudService<T>,
) {}
// POST /
create(@Body() dto: any, @Request() req: any): Promise<T>
// GET /
findAll(
@Query('q') query: string,
@Request() req: any,
): Promise<CollectionResult<T>>
// GET /:id
findOne(@Param('id') id: string, @Request() req: any): Promise<T>
// PUT /:id
update(
@Param('id') id: string,
@Body() dto: any,
@Request() req: any,
): Promise<T>
// PATCH /:id
patch(
@Param('id') id: string,
@Body() dto: any,
@Request() req: any,
): Promise<T>
// DELETE /:id
delete(@Param('id') id: string, @Request() req: any): Promise<void>
// PATCH /restore/:id
restore(@Param('id') id: string, @Request() req: any): Promise<T>
// GET /archived
findAllArchived(@Request() req: any): Promise<T[]>
}ExtraCrudController<T>(options: ExtraCrudOptions, entity: new () => T)
Extended CRUD with parent entity scope.
RelationCrudController(options: RelationCrudOptions, entity: new () => any)
Many-to-many relationship management.
Services
EntityCrudService<T>
Generic CRUD service with query builder.
class EntityCrudService<T> {
constructor(protected readonly repository: Repository<T>) {}
findAll(query: CollectionQuery): Promise<CollectionResult<T>>;
findOne(id: string): Promise<T>;
create(data: any): Promise<T>;
update(id: string, data: any): Promise<T>;
patch(id: string, data: any): Promise<T>;
delete(id: string): Promise<void>;
restore(id: string): Promise<T>;
}Repositories
EntityCrudRepository<T>
Repository with built-in query construction.
Recommended Imports
Subpath imports are recommended for tree-shaking:
import { EntityCrudController } from '@zola_do/crud/controller';
import { EntityCrudService } from '@zola_do/crud/service';
import { EntityCrudRepository } from '@zola_do/crud/repository';
import {
CRUD_REQUEST_CONTEXT_RESOLVER,
CrudRequestContextResolver,
} from '@zola_do/crud/request-context';
import { EntityCrudOptions, GlobalExceptionFilter } from '@zola_do/crud';Root import is supported for backward compatibility:
import {
EntityCrudController,
EntityCrudService,
EntityCrudOptions,
} from '@zola_do/crud';Optional Features
RPC Exception Filter
For NestJS microservices:
import { ExceptionFilter } from '@zola_do/crud/optional/rpc';
@Module({
providers: [
{
provide: APP_FILTER,
useClass: ExceptionFilter,
},
],
})
export class OrdersModule {}Requires @nestjs/microservices peer dependency.
Troubleshooting
Q: How do I add custom methods to the CRUD service?
Extend the generic service:
@Injectable()
export class ProductService extends EntityCrudService<Product> {
constructor(
@InjectRepository(Product)
repository: Repository<Product>,
) {
super(repository);
}
async findActive(): Promise<Product[]> {
return this.repository.find({ where: { status: 'active' } });
}
}Q: How do I validate the DTO?
Use class-validator decorators:
import { IsString, IsNumber, IsEnum, IsOptional } from 'class-validator';
export class CreateProductDto {
@IsString()
name: string;
@IsNumber()
price: number;
@IsEnum(['draft', 'active', 'archived'])
@IsOptional()
status?: string;
}Q: How do I handle file uploads?
Combine with @nestjs/platform-express:
// Add to your DTO
import { UploadedFile } from '@nestjs/common';
async create(
@Body() dto: CreateProductDto,
@UploadedFile() file: Express.Multer.File,
) {
if (file) {
dto.imageUrl = await this.uploadService.upload(file);
}
return this.productService.create(dto);
}Related Packages
- @zola_do/collection-query — Query decoding for list endpoints
- @zola_do/authorization — Guards for protected CRUD
- @zola_do/typeorm — Entity configuration
License
ISC
