Create a business module
Below, steps describe how to create a new API route in our monorepo. This guide assumes you're creating a new route for the API application.
Overview
Creating a new route involves several steps:
- Define the route types in shared routes
- Create DTOs for request/response
- Create a service to handle business logic
- Create a controller to handle HTTP requests
- Create a module to wire everything together
- Register the module in the main HTTP module
- Add unit tests (service functions tested separately)
- Add E2E tests (endpoints tested from a black-box perspective)
Step-by-Step Guide
1. Define Routes in Shared Types
First, add your new route group to the Routes interface in libs/shared/src/routes.ts:
export interface Routes {
// ... existing routes ...
/**
* @title Your Feature Name
* @description Description of your feature
*/
'your-feature': {
create: {
input: Dto.CreateYourFeatureRequest;
output: Dto.CreateYourFeatureResponse;
};
'get-paginated': {
input: Dto.GetPaginatedYourFeaturesRequest;
output: Dto.GetPaginatedYourFeaturesResponse;
};
'find-by-id': {
input: Dto.FindYourFeatureByIdRequest;
output: Dto.FindYourFeatureByIdResponse;
};
update: {
input: Dto.UpdateYourFeatureRequest;
output: Dto.UpdateYourFeatureResponse;
};
'soft-delete': {
input: Dto.SoftDeleteYourFeatureRequest;
output: Dto.SoftDeleteYourFeatureResponse;
};
};
}
2. Create Models (if needed)
If you need new data models, create them in libs/shared/src/models/:
// libs/shared/src/models/your-feature/your-feature.ts
import { Common } from '..';
export interface YourFeature {
name: string;
description?: string;
// ... other fields
}
export type YourFeatureResource = Common.Resource<YourFeature>;
export type SoftDeletedYourFeatureResource = Common.SoftDeletedResource<YourFeatureResource>;
export interface YourFeatureRequest {
id: YourFeatureResource['_id'];
}
3. Create DTOs
Create DTOs in libs/shared/src/dto/your-feature/:
// libs/shared/src/dto/your-feature/create.dto.ts
import { YourFeature, YourFeatureResource } from '../../models';
export type CreateYourFeatureRequest = YourFeature;
export type CreateYourFeatureResponse = YourFeatureResource;
// libs/shared/src/dto/your-feature/find-by-id.dto.ts
import { YourFeatureRequest, YourFeatureResource } from '../../models';
export type FindYourFeatureByIdRequest = YourFeatureRequest;
export type FindYourFeatureByIdResponse = YourFeatureResource;
// libs/shared/src/dto/your-feature/get-paginated.dto.ts
import { Common, YourFeatureResource } from '../../models';
export interface GetPaginatedYourFeaturesRequest {
pagination: Common.PaginationRequest;
// Add filters specific to your feature
}
export interface GetPaginatedYourFeaturesResponse {
items: YourFeatureResource[];
pagination: Common.PaginationResponse;
}
// libs/shared/src/dto/your-feature/update.dto.ts
import { YourFeatureResource } from '../../models';
export interface UpdateYourFeatureRequest extends Partial<Pick<YourFeatureResource, 'name' | 'description' /* ... */>> {
yourFeatureId: YourFeatureResource['_id'];
}
export type UpdateYourFeatureResponse = YourFeatureResource;
// libs/shared/src/dto/your-feature/soft-delete.dto.ts
import { YourFeatureRequest, SoftDeletedYourFeatureResource } from '../../models';
export type SoftDeleteYourFeatureRequest = YourFeatureRequest;
export type SoftDeleteYourFeatureResponse = SoftDeletedYourFeatureResource;
Don't forget to export your DTOs in libs/shared/src/dto/index.ts:
// libs/shared/src/dto/index.ts
// ... existing exports ...
// Your Feature
export * from './your-feature/create.dto';
export * from './your-feature/find-by-id.dto';
export * from './your-feature/get-paginated.dto';
export * from './your-feature/update.dto';
export * from './your-feature/soft-delete.dto';
4. Create Database Model (if needed)
If you need a database model, create it in libs/database/src/models/:
// libs/database/src/models/your-feature/your-feature.model.ts
import { prop } from '@typegoose/typegoose';
import { CosmyTrackableDocument } from '../common';
import { YourFeatureResource, SoftDeletedYourFeatureResource } from '@cosmy/shared';
export class YourFeature extends CosmyTrackableDocument {
@prop({ required: true })
name!: string;
@prop()
description?: string;
// Add more properties as needed
getInfo(): YourFeatureResource {
return {
_id: this._id.toString(),
name: this.name,
description: this.description,
createdAt: this.createdAt.toISOString(),
updatedAt: this.updatedAt.toISOString(),
createdBy: this.createdBy?.toString(),
updatedBy: this.updatedBy?.toString(),
};
}
getSoftDeletedInfo(): SoftDeletedYourFeatureResource {
return {
...this.getInfo(),
deletedAt: this.deletedAt!.toISOString(),
deletedBy: this.deletedBy!.toString(),
};
}
static getProjection(): Record<keyof YourFeatureResource, 1> {
return {
_id: 1,
name: 1,
description: 1,
createdAt: 1,
updatedAt: 1,
createdBy: 1,
updatedBy: 1,
};
}
}
export type YourFeatureModelType = ReturnModelType<typeof YourFeature>;
export const YourFeatureModel = getModelForClass(YourFeature);
5. Create Service
Create the service in libs/http/src/your-feature/your-feature.service.ts:
import { Injectable } from '@nestjs/common';
import { YourFeatureModel } from '@cosmy/database';
import { CosmyLogger } from '@cosmy/logger';
import { Dto, CosmyError } from '@cosmy/shared';
@Injectable()
export class YourFeatureService {
constructor(private readonly logger: CosmyLogger) {
this.logger.setContext(YourFeatureService.name);
}
async create(createRequest: Dto.CreateYourFeatureRequest, callerId: string): Promise<Dto.CreateYourFeatureResponse> {
this.logger.debug('Creating your feature', { details: createRequest });
const newYourFeature = new YourFeatureModel({
...createRequest,
createdBy: callerId,
updatedBy: callerId,
});
return (await newYourFeature.save()).getInfo();
}
async findById({ id }: Dto.FindYourFeatureByIdRequest): Promise<Dto.FindYourFeatureByIdResponse> {
const yourFeature = await YourFeatureModel.findById(id);
if (!yourFeature) {
throw new CosmyError('YOUR_FEATURE/NOT_FOUND');
}
return yourFeature.getInfo();
}
async getPaginated(getPaginatedRequest: Dto.GetPaginatedYourFeaturesRequest): Promise<Dto.GetPaginatedYourFeaturesResponse> {
// Implement pagination logic
const { pagination } = getPaginatedRequest;
const [items, total] = await Promise.all([YourFeatureModel.find().limit(pagination.limit).skip(pagination.offset).exec(), YourFeatureModel.countDocuments()]);
return {
items: items.map((item) => item.getInfo()),
pagination: {
...pagination,
total,
},
};
}
async update({ yourFeatureId, ...updateData }: Dto.UpdateYourFeatureRequest, callerId: string): Promise<Dto.UpdateYourFeatureResponse> {
const updatedYourFeature = await YourFeatureModel.findByIdAndUpdate(yourFeatureId, { ...updateData, updatedBy: callerId }, { new: true });
if (!updatedYourFeature) {
throw new CosmyError('YOUR_FEATURE/NOT_FOUND');
}
return updatedYourFeature.getInfo();
}
async softDelete({ id }: Dto.SoftDeleteYourFeatureRequest, callerId: string): Promise<Dto.SoftDeleteYourFeatureResponse> {
const yourFeature = await YourFeatureModel.findById(id);
if (!yourFeature) {
throw new CosmyError('YOUR_FEATURE/NOT_FOUND');
}
yourFeature.softDelete(callerId);
await yourFeature.save();
return yourFeature.getSoftDeletedInfo();
}
}
6. Create Controller
Create the controller in libs/http/src/your-feature/your-feature.controller.ts:
import { Body } from '@nestjs/common';
import { AuthType, CallerId } from '@cosmy/auth';
import { createDecorators } from '@cosmy/core';
import { CosmyLogger } from '@cosmy/logger';
import type { Dto, TypedController } from '@cosmy/shared';
import { YourFeatureService } from './your-feature.service';
const { CosmyController, CosmyAction } = createDecorators('your-feature');
@CosmyController({
prefix: 'your-feature',
tags: ['your-feature'],
})
export class YourFeatureController implements TypedController<'your-feature'> {
constructor(
private readonly yourFeatureService: YourFeatureService,
private readonly logger: CosmyLogger,
) {
this.logger.setContext(YourFeatureController.name);
}
/**
* Create a new your feature
*/
@CosmyAction({
path: 'create',
returns: 'The created your feature',
errors: ['YOUR_FEATURE/DUPLICATE_NAME'], // Define relevant errors
})
async create(@Body() body: Dto.CreateYourFeatureRequest, @CallerId() callerId: string): Promise<Dto.CreateYourFeatureResponse> {
return this.yourFeatureService.create(body, callerId);
}
/**
* Get paginated your features
*/
@CosmyAction({
path: 'get-paginated',
returns: 'The paginated list of your features',
})
async getPaginated(@Body() body: Dto.GetPaginatedYourFeaturesRequest): Promise<Dto.GetPaginatedYourFeaturesResponse> {
return this.yourFeatureService.getPaginated(body);
}
/**
* Find your feature by ID
*/
@CosmyAction({
path: 'find-by-id',
returns: 'The your feature with the specified ID',
errors: ['YOUR_FEATURE/NOT_FOUND'],
})
async findById(@Body() body: Dto.FindYourFeatureByIdRequest): Promise<Dto.FindYourFeatureByIdResponse> {
return this.yourFeatureService.findById(body);
}
/**
* Update your feature
*/
@CosmyAction({
path: 'update',
returns: 'The updated your feature',
errors: ['YOUR_FEATURE/NOT_FOUND'],
})
async update(@Body() body: Dto.UpdateYourFeatureRequest, @CallerId() callerId: string): Promise<Dto.UpdateYourFeatureResponse> {
return this.yourFeatureService.update(body, callerId);
}
/**
* Soft delete your feature
*/
@CosmyAction({
path: 'soft-delete',
returns: 'The soft deleted your feature',
errors: ['YOUR_FEATURE/NOT_FOUND'],
})
async softDelete(@Body() body: Dto.SoftDeleteYourFeatureRequest, @CallerId() callerId: string): Promise<Dto.SoftDeleteYourFeatureResponse> {
return this.yourFeatureService.softDelete(body, callerId);
}
}
7. Create Module
Create the module in libs/http/src/your-feature/your-feature.module.ts:
import { Module } from '@nestjs/common';
import { LoggerModule } from '@cosmy/logger';
import { YourFeatureController } from './your-feature.controller';
import { YourFeatureService } from './your-feature.service';
@Module({
imports: [LoggerModule], // Add other required modules
controllers: [YourFeatureController],
providers: [YourFeatureService],
exports: [YourFeatureService], // Export if other modules need to use this service
})
export class YourFeatureModule {}
8. Register Module
- Add the module export to
libs/http/src/modules.ts:
export * from './your-feature/your-feature.module';
- Import and register the module in
libs/http/src/http.module.ts:
import {
// ... existing imports
YourFeatureModule,
} from './modules';
@Module({
imports: [
// ... existing imports
YourFeatureModule,
],
// ...
})
export class HttpModule implements NestModule {
// ...
}
- If you want the module to appear in Swagger documentation, add it to
apps/api/src/swagger.ts:
import {
// ... existing imports
YourFeatureModule,
} from '@cosmy/http';
// Add to the modules array in setupSwagger function
9. Add Tests
Create tests for your service and controller:
// libs/http/src/your-feature/your-feature.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CosmyLogger } from '@cosmy/logger';
import { YourFeatureService } from './your-feature.service';
jest.mock('@cosmy/logger');
describe('YourFeatureService', () => {
let service: YourFeatureService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [YourFeatureService, CosmyLogger],
}).compile();
service = module.get<YourFeatureService>(YourFeatureService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
// Add more specific tests
});
10. Error Codes
If you need specific error codes for your feature, add them to libs/shared/src/constants/error-codes.ts:
// libs/shared/src/constants/error-codes.ts
export const COSMY_ERROR_CODES = {
// ... existing error codes ...
// Your Feature
'YOUR_FEATURE/NOT_FOUND': {
message: 'The your feature was not found',
status: 404,
},
'YOUR_FEATURE/DUPLICATE_NAME': {
message: 'A your feature with this name already exists',
status: 409,
},
'YOUR_FEATURE/INVALID_DATA': {
message: 'The provided your feature data is invalid',
status: 400,
},
'YOUR_FEATURE/ACCESS_DENIED': {
message: 'You do not have permission to access this your feature',
status: 403,
},
// ... rest of existing error codes
} as const satisfies CosmyErrorCodes;
Error Code Structure:
- Code Format:
'FEATURE/ERROR_TYPE'(e.g.,'YOUR_FEATURE/NOT_FOUND') - Message: Human-readable error message
- Status: HTTP status code (optional, defaults to 400)
Common Error Types:
NOT_FOUND- Resource doesn't exist (404)DUPLICATE_*- Resource already exists (409)INVALID_*- Invalid data provided (400)ACCESS_DENIED- Permission denied (403)
Using Error Codes in Controller:
Add the error codes to the errors array in your @CosmyAction decorator:
@CosmyAction({
verb: 'post',
path: 'create',
description: 'The created your feature',
errors: ['YOUR_FEATURE/DUPLICATE_NAME', 'YOUR_FEATURE/INVALID_DATA'],
})
async create(
@Body() createRequest: Dto.CreateYourFeatureRequest,
@CallerId() callerId: string,
): Promise<Dto.CreateYourFeatureResponse> {
return this.yourFeatureService.create(createRequest, callerId);
}
Throwing Errors in Service:
Use CosmyError to throw business logic errors:
// In your service
import { CosmyError } from '@cosmy/shared';
if (!yourFeature) {
throw new CosmyError('YOUR_FEATURE/NOT_FOUND');
}
// With additional details
if (existingFeature) {
throw new CosmyError('YOUR_FEATURE/DUPLICATE_NAME', {
details: { existingId: existingFeature._id, providedName: createRequest.name },
});
}
The framework automatically:
- Generates Swagger documentation for the error responses
- Maps error codes to HTTP status codes
- Formats error responses consistently
- Groups errors by status code in the API documentation