Skip to main content

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:

  1. Define the route types in shared routes
  2. Create DTOs for request/response
  3. Create a service to handle business logic
  4. Create a controller to handle HTTP requests
  5. Create a module to wire everything together
  6. Register the module in the main HTTP module
  7. Add unit tests (service functions tested separately)
  8. 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

  1. Add the module export to libs/http/src/modules.ts:
export * from './your-feature/your-feature.module';
  1. 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 {
// ...
}
  1. 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