Skip to main content

NestJS Service Standards

Services contain all business logic, data access, and domain operations. They're independent of HTTP (no HTTP exceptions), return domain objects (not entities), and communicate errors through custom error classes.

Core Principles

Services contain business logic All validation, transformation, database access, and domain rules live in services. Controllers just orchestrate.

Return domain interfaces, not entities Convert database entities to plain objects before returning. Services should not leak ORM details.

Throw custom errors Don't throw HTTP exceptions. Throw custom domain errors that controllers convert to HTTP responses.

Use query builders for filtering Extend BaseReadManyInput for list queries and use applyBaseReadManyFilters() plus custom andWhere() calls for filters.

Basic Structure

Services define domain interfaces, accept clean input objects, and return domain objects.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

import {
BaseReadManyInput,
applyBaseReadManyFilters,
} from '../../common/service/input';

import { UserEntity } from './entities/user.entity';
import { UserNotFoundError } from './user.error';

// Domain interface (what the service returns)
export interface User {
id: string;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}

// Input/Output interfaces
export interface UserReadManyInput extends BaseReadManyInput {
email?: string;
name?: string;
}

export interface UserReadManyOutput {
total: number;
results: User[];
}

export interface UserReadOneInput {
id: string;
}

@Injectable()
export class UserService {
constructor(
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}

// Convert entity to domain object
private toUser(entity: UserEntity): User {
return {
id: entity.id,
name: entity.name,
email: entity.email,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}

async readMany(values: UserReadManyInput): Promise<UserReadManyOutput> {
const queryBuilder = this.userRepository.createQueryBuilder('user');

// Apply base filters (inIds, ninIds, date ranges)
applyBaseReadManyFilters(queryBuilder, values);

// Apply resource-specific filters
if (values.email) {
queryBuilder.andWhere('user.email = :email', { email: values.email });
}

if (values.name) {
queryBuilder.andWhere('user.name LIKE :name', { name: `%${values.name}%` });
}

const total = await queryBuilder.getCount();

// Apply sorting
if (values.sortKey && values.sortValue) {
const order = values.sortValue === 'asc' ? 'ASC' : 'DESC';
queryBuilder.orderBy(`user.${values.sortKey}`, order);
} else {
queryBuilder.orderBy('user.created_at', 'DESC');
}

// Apply pagination
if (values.skip) {
queryBuilder.skip(values.skip);
}
if (values.limit) {
queryBuilder.take(values.limit);
}

const entities = await queryBuilder.getMany();
const results = entities.map(entity => this.toUser(entity));

return { total, results };
}

async readOne(values: UserReadOneInput): Promise<User> {
const entity = await this.userRepository.findOne({
where: { id: values.id },
});

if (!entity) {
throw new UserNotFoundError(values.id);
}

return this.toUser(entity);
}
}

Entity to Domain Conversion

Always convert entities to plain objects. Services should not expose ORM details.

// Entity from database
class UserEntity {
id: string;
name: string;
email: string;
password: string; // Sensitive field
createdAt: Date;
updatedAt: Date;
}

// Domain object (what service returns)
interface User {
id: string;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
// Note: password excluded
}

// Conversion method
private toUser(entity: UserEntity): User {
return {
id: entity.id,
name: entity.name,
email: entity.email,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}

Error Handling

Throw custom errors that describe domain problems. Controllers convert these to HTTP exceptions.

// Define custom errors in module-name.error.ts
export class UserNotFoundError extends Error {
constructor(public userId: string) {
super(`User not found: ${userId}`);
this.name = 'user.notFound';
}
}

export class DuplicateEmailError extends Error {
constructor(public email: string) {
super(`Email already exists: ${email}`);
this.name = 'user.duplicateEmail';
}
}

// Throw in service
async createUser(values: UserCreateInput): Promise<User> {
const existing = await this.repository.findOne({ where: { email: values.email } });

if (existing) {
throw new DuplicateEmailError(values.email);
}

const user = await this.repository.save(values);
return this.toUser(user);
}

// Controller catches and converts
try {
const result = await this.userService.createUser(body);
return new UserResponseDTO({ result });
} catch (error) {
if (error instanceof DuplicateEmailError) {
throw new BadRequestException(error.message);
}
throw error;
}

Anti-Patterns

❌ Don't throw HTTP exceptions Services should be independent of HTTP. Throw domain errors.

// Bad - HTTP exception in service
async readOne(id: string) {
const user = await this.repository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException('User not found'); // WRONG!
}
return user;
}

// Good - domain error
async readOne(id: string) {
const user = await this.repository.findOne({ where: { id } });
if (!user) {
throw new UserNotFoundError(id); // Controller handles HTTP
}
return this.toUser(user);
}

❌ Don't return entities Convert to domain objects to hide ORM details.

// Bad - returns entity
async readOne(id: string): Promise<UserEntity> {
return await this.repository.findOne({ where: { id } });
}

// Good - returns domain object
async readOne(id: string): Promise<User> {
const entity = await this.repository.findOne({ where: { id } });
return this.toUser(entity);
}

❌ Don't put business logic in controllers Services own business logic. Controllers just orchestrate.

// Bad - logic in controller
@Post()
async createUser(@Body() body: CreateUserDTO) {
const exists = await this.userService.findByEmail(body.email);
if (exists) {
throw new BadRequestException('Email exists');
}

const user = await this.userService.create({
...body,
email: body.email.toLowerCase(),
});

return new UserResponseDTO({ result: user });
}

// Good - logic in service
@Post()
async createUser(@Body() body: CreateUserDTO) {
try {
const result = await this.userService.createUser(body);
return new UserResponseDTO({ result });
} catch (error) {
if (error instanceof DuplicateEmailError) {
throw new BadRequestException(error.message);
}
throw error;
}
}

❌ Don't mix repository and service responsibilities Services orchestrate, repositories handle database access.

// Bad - SQL in service
async findUsers(email: string) {
const query = 'SELECT * FROM users WHERE email = $1';
const result = await this.database.query(query, [email]);
return result.rows;
}

// Good - delegate to repository
async findUsers(email: string) {
return await this.userRepository.find({ where: { email } });
}

Other common mistakes:

  • ❌ Not defining domain interfaces at top of service file
  • ❌ Not converting entities to domain objects
  • ❌ Not using private methods for entity conversion
  • ❌ Not extending BaseReadManyInput for list query inputs
  • ❌ Not using applyBaseReadManyFilters() in readMany methods