Services — The Business Layer

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.

4 min read Level 2/5 #nestjs#services#architecture
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:

  1. Testable without HTTP. Instantiate the service directly with mocked collaborators. No supertest, no port binding.
  2. Reusable across transports. The same UsersService can back a REST controller, a GraphQL resolver, a WebSocket gateway, or a CLI script.
  3. 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([]);
Injection Scopes →