Keep Controllers Thin, Services Smart
Services — The Business Layer
Services hold business logic. Controllers translate HTTP; services do the work — and that separation is what makes Nest apps testable.
What you'll learn
- Move logic out of controllers into services
- Compose services that depend on other services
- Test business logic without spinning up HTTP
A controller’s job is to translate HTTP into method calls and back. A service’s job is to do the actual work. Mixing the two is the most common mistake new Nest devs make.
A Thin Controller
The controller should read params, call the service, and return whatever comes back. No business rules.
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
findAll() {
return this.users.findAll();
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.users.create(dto);
}
} A Smart Service
All the logic — validation beyond shape, calling the repo, sending mail, publishing events — lives in the service.
@Injectable()
export class UsersService {
constructor(
private readonly repo: UsersRepository,
private readonly mailer: MailService,
) {}
findAll() {
return this.repo.find();
}
async create(dto: CreateUserDto) {
if (await this.repo.findByEmail(dto.email)) {
throw new ConflictException('email taken');
}
const user = await this.repo.save(dto);
await this.mailer.sendWelcome(user.email);
return user;
}
} Services That Depend on Services
Services compose just like everything else — inject one into another. The container takes care of construction order.
@Injectable()
export class OrdersService {
constructor(
private readonly users: UsersService,
private readonly inventory: InventoryService,
) {}
async place(userId: string, items: Item[]) {
const user = await this.users.findById(userId);
await this.inventory.reserve(items);
return { user, items };
}
} Why This Pays Off
Pulling logic into services unlocks three big wins:
- Testable without HTTP. Instantiate the service directly with mocked collaborators. No supertest, no port binding.
- Reusable across transports. The same
UsersServicecan back a REST controller, a GraphQL resolver, a WebSocket gateway, or a CLI script. - Easier to read. Controllers become a one-page map of the API surface; services become the one-page map of the domain.
// Unit test — no Nest needed
const repo = { find: jest.fn().mockResolvedValue([]) } as any;
const mailer = {} as any;
const service = new UsersService(repo, mailer);
await expect(service.findAll()).resolves.toEqual([]);