NestJS-specific utilities for building robust backend applications following hexagonal architecture and domain-driven design principles.
npm install @jsfsi-core/ts-nestjsNestJS-specific utilities for building robust backend applications following hexagonal architecture and domain-driven design principles.
``bash`
npm install @jsfsi-core/ts-nestjs
Peer Dependencies:
- @nestjs/core@nestjs/common
- express
- body-parser
-
This package provides NestJS-specific implementations of hexagonal architecture patterns:
- Application Bootstrap: Configured NestJS application factory
- Configuration: Type-safe configuration service with Zod validation
- Exception Filters: Centralized error handling at application edges
- Validators: Type-safe request validation decorators
- Middlewares: Request logging and common middleware
`text`
src/
โโโ app/
โ โโโ app.ts # Application factory
โ โโโ bootstrap.ts # Bootstrap helper
โโโ configuration/
โ โโโ AppConfigurationService.ts # Configuration setup
โโโ filters/
โ โโโ AllExceptionsFilter.ts # Exception handler (edge)
โโโ middlewares/
โ โโโ RequestMiddleware.ts # Request logging
โโโ validators/
โโโ ZodValidator.ts # Request validators
Type-safe application creation with pre-configured settings:
main.ts:
`typescript
import 'reflect-metadata';
import * as path from 'path';
import { bootstrap } from '@jsfsi-core/ts-nestjs';
import { GCPLogger } from '@jsfsi-core/ts-nodejs';
import { AppModule } from './app/AppModule';
bootstrap({
appModule: AppModule,
configPath: path.resolve(__dirname, '../configuration'),
logger: new GCPLogger('my-app'),
});
`
The bootstrap function:
- Loads environment configuration from the specified configPathConfigService
- Creates and configures the NestJS application
- Retrieves configuration using with APP_CONFIG_TOKEN
- Automatically starts the application on the port specified in your configuration
- Handles CORS, exception filters, and logging setup
Important: Your AppModule must import appConfigModuleSetup() to register the configuration with the APP_CONFIG_TOKEN. The bootstrap function will throw an error if the configuration is not found.
Type-safe configuration with Zod schemas:
`typescript
import { z } from 'zod';
import { AppConfigSchema, appConfigModuleSetup, APP_CONFIG_TOKEN } from '@jsfsi-core/ts-nestjs';
import { ConfigService } from '@nestjs/config';
// Define configuration schema
export const AppConfigSchema = z.object({
APP_PORT: z
.string()
.transform((val) => parseInt(val, 10))
.refine((val) => !isNaN(val), { message: 'APP_PORT must be a valid number' }),
DATABASE_URL: z.string().url(),
CORS_ORIGIN: z.string().default('*'),
});
export type AppConfig = z.infer
// In your app module (AppModule.ts)
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { appConfigModuleSetup, RequestMiddleware } from '@jsfsi-core/ts-nestjs';
import { BrowserAdapter } from '../adapters/BrowserAdapter';
import { HealthController } from '../communication/controllers/health/HealthController';
import { RenderController } from '../communication/controllers/render/RenderController';
import { RenderService } from '../domain/RenderService';
const controllers = [HealthController, RenderController];
const services = [RenderService];
const adapters = [BrowserAdapter];
@Module({
imports: [appConfigModuleSetup()],
controllers: [...controllers],
providers: [...services, ...adapters],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestMiddleware).forRoutes('*');
}
}
// Use in service
@Injectable()
export class MyService {
constructor(private readonly configService: ConfigService) {}
someMethod() {
const config = this.configService.get
// config is fully typed
}
}
`
Centralized exception handling at the application edge:
The createApp() function automatically registers AllExceptionsFilter which:
- Catches all unhandled exceptions
- Maps HTTP exceptions to appropriate status codes
- Logs errors for monitoring
- Returns consistent error responses
Note: This is where exceptions are caught (edge of hexagonal architecture). The filter is automatically registered, no manual setup needed.
Type-safe request validation with Zod:
`typescript
import { Controller, Post } from '@nestjs/common';
import { SafeBody, SafeQuery, SafeParams } from '@jsfsi-core/ts-nestjs';
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().positive(),
});
@Controller('users')
export class UserController {
@Post()
async createUser(@SafeBody(CreateUserSchema) user: z.infer
// user is fully typed based on schema
// Validation happens automatically
// Returns 400 Bad Request if validation fails
}
@Get(':id')
async getUser(@SafeParams(z.object({ id: z.string().uuid() })) params: { id: string }) {
// params.id is validated as UUID
}
@Get()
async listUsers(
@SafeQuery(z.object({ page: z.string().transform(Number).optional() }))
query: {
page?: number;
},
) {
// query.page is validated and transformed
}
}
`
Use CustomLogger instead of the built-in NestJS Logger for consistent logging across your application:
`typescript
import { CustomLogger } from '@jsfsi-core/ts-nestjs';
import { Injectable } from '@nestjs/common';
@Injectable()
export class HealthService {
private readonly logger = new CustomLogger(HealthService.name);
async check(): Promise<{ status: string }> {
this.logger.log('Checking health');
return { status: 'OK' };
}
}
`
The CustomLogger extends NestJS's Logger and provides a consistent interface for logging throughout your services. By passing the class name to the constructor, logs will be prefixed with the service name, making it easier to trace logs in production.
Available methods:
- log(message: string) - General informationerror(message: string, trace?: string)
- - Error messages with optional stack tracewarn(message: string)
- - Warning messagesdebug(message: string)
- - Debug information (only shown in development)verbose(message: string)
- - Verbose logging
Example output:
``
[Nest] 12345 - 2025/01/01, 12:00:00 LOG [HealthService] Checking health
Automatic request logging:
`typescript
import { RequestMiddleware } from '@jsfsi-core/ts-nestjs';
// In your app module
@Module({
// ...
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestMiddleware).forRoutes('*');
}
}
`
Logs include:
- HTTP method and URL
- Status code
- Response time
- Request/response headers
- Severity level based on status code
- Controllers: PascalCase suffix with Controller (e.g., UserController, AuthController)getUser
- Endpoints: Use RESTful naming (e.g., , createUser, updateUser)
- Services: PascalCase suffix with Service (e.g., UserService, AuthService)
- Domain Services: Live in domain layer, not in NestJS services
- Modules: PascalCase suffix with Module (e.g., UserModule, AppModule)
`typescript
import { createTestingApp } from '@jsfsi-core/ts-nestjs';
import { Controller, Get, Module } from '@nestjs/common';
import request from 'supertest';
@Controller('test')
class TestController {
@Get()
getHello(): { message: string } {
return { message: 'Hello' };
}
}
@Module({
controllers: [TestController],
})
class TestModule {}
describe('TestController', () => {
it('returns hello message', async () => {
const app = await createTestingApp(TestModule);
const response = await request(app.getHttpServer()).get('/test');
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: 'Hello' });
});
});
`
`typescript
import { Test } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
describe('UserService', () => {
let service: UserService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UserService,
{
provide: ConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
`
When services return Result types, test accordingly:
`typescript
import { isFailure } from '@jsfsi-core/ts-crossplatform';
describe('AuthService', () => {
it('returns user on successful sign in', async () => {
const [user, failure] = await authService.signIn(email, password);
expect(user).toBeDefined();
expect(failure).toBeUndefined();
});
it('returns SignInFailure on authentication error', async () => {
const [user, failure] = await authService.signIn(email, password);
expect(user).toBeUndefined();
expect(isFailure(SignInFailure)(failure)).toBe(true);
});
});
`
Exceptions should only be thrown at the edge (in controllers/exception filters), not in domain logic:
`typescript
// โ
Good - In controller (edge)
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthenticationService) {}
@Post('signin')
async signIn(@SafeBody(SignInSchema) body: SignInDto) {
const [user, failure] = await this.authService.signIn(body.email, body.password);
if (isFailure(SignInFailure)(failure)) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
}
// โ
Good - Domain service returns Result
export class AuthenticationService {
async signIn(email: string, password: string): Promise
// No exceptions thrown here
return this.authAdapter.signIn(email, password);
}
}
// โ
Good - Exception filter catches all exceptions
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(error: unknown, host: ArgumentsHost) {
// All exceptions caught here (edge)
}
}
// โ Bad - Throwing in domain service
export class AuthenticationService {
async signIn(email: string, password: string): Promise
// Don't throw exceptions in domain layer
if (!isValid(email)) {
throw new Error('Invalid email');
}
}
}
`
Domain services should return Result types:
`typescript
// โ
Good
@Injectable()
export class UserService {
async getUser(id: string): Promise
const [user, failure] = await this.userRepository.findById(id);
if (isFailure(UserNotFoundFailure)(failure)) {
return Fail(failure);
}
return Ok(user);
}
}
// โ
Good - Mapping Result to HTTP in controller
@Controller('users')
export class UserController {
@Get(':id')
async getUser(@SafeParams(IdSchema) params: { id: string }) {
const [user, failure] = await this.userService.getUser(params.id);
if (isFailure(UserNotFoundFailure)(failure)) {
throw new NotFoundException('User not found');
}
return user;
}
}
`
Use SafeBody, SafeQuery, SafeParams for automatic validation:
`typescript
// โ
Good - Automatic validation
@Post('users')
async createUser(@SafeBody(CreateUserSchema) user: CreateUserDto) {
// user is already validated
return this.userService.create(user);
}
// โ Bad - Manual validation
@Post('users')
async createUser(@Body() user: any) {
// Manual validation needed
if (!user.email) {
throw new BadRequestException('Email required');
}
}
`
Domain logic should be framework-agnostic:
`text`
src/
โโโ domain/
โ โโโ models/
โ โ โโโ User.ts
โ โ โโโ SignInFailure.ts
โ โโโ services/
โ โโโ AuthenticationService.ts # Domain service (no NestJS dependencies)
โโโ adapters/
โ โโโ DatabaseAdapter.ts # Implements domain interfaces
โโโ controllers/ # NestJS-specific (edge)
โโโ AuthController.ts
Domain services contain business logic:
`typescript
// โ
Good - Domain service (no NestJS decorators)
export class AuthenticationService {
constructor(private readonly authAdapter: AuthenticationAdapter) {}
async signIn(email: string, password: string): Promise
// Business logic here
return this.authAdapter.signIn(email, password);
}
}
// โ
Good - Inject domain service in NestJS service
@Injectable()
export class AuthService {
constructor(private readonly authenticationService: AuthenticationService) {}
async signIn(email: string, password: string) {
return this.authenticationService.signIn(email, password);
}
}
`
Domain services return Result types, controllers map to HTTP:
`typescript
import { Result, isFailure } from '@jsfsi-core/ts-crossplatform';
@Controller('orders')
export class OrderController {
constructor(private readonly orderService: OrderService) {}
@Post()
async createOrder(@SafeBody(CreateOrderSchema) order: CreateOrderDto) {
const [orderId, failure] = await this.orderService.create(order);
if (isFailure(ValidationFailure)(failure)) {
throw new BadRequestException(failure.message);
}
if (isFailure(PaymentFailure)(failure)) {
throw new PaymentRequiredException('Payment failed');
}
return { id: orderId };
}
}
`
Map domain failures to HTTP exceptions:
`typescript
function mapFailureToHttpException(failure: Failure): HttpException {
if (isFailure(ValidationFailure)(failure)) {
return new BadRequestException(failure.message);
}
if (isFailure(NotFoundFailure)(failure)) {
return new NotFoundException(failure.message);
}
if (isFailure(UnauthorizedFailure)(failure)) {
return new UnauthorizedException(failure.message);
}
return new InternalServerErrorException('An error occurred');
}
`
Use constructor injection:
`typescript`
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly configService: ConfigService,
) {}
}
Group related functionality in modules:
`typescript`
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserController],
providers: [UserService, UserRepository],
exports: [UserService],
})
export class UserModule {}
Always use typed configuration:
`typescript
// โ
Good
const config = this.configService.get
// โ Bad
const port = process.env.PORT; // Not type-safe
`
Always validate requests with Zod schemas:
`typescript
// โ
Good
@Post()
async create(@SafeBody(CreateSchema) data: CreateDto) {
// data is validated and typed
}
// โ Bad
@Post()
async create(@Body() data: any) {
// No validation, no type safety
}
`
Use Result types in domain, exceptions only at edge:
`typescript
// Domain: Result types
async getUser(id: string): Promise
// ...
}
// Controller: Map to HTTP
async getUser(@Param('id') id: string) {
const [user, failure] = await this.service.getUser(id);
if (isFailure(UserNotFoundFailure)(failure)) {
throw new NotFoundException();
}
return user;
}
``
- NestJS Documentation
- NestJS Testing
- Hexagonal Architecture with NestJS
- Clean Architecture
- Zod Documentation
- NestJS Validation
ISC