Fullstack CourseLearn by building
Back to week 1

Topic

Services

Definition

A Nest service is an @Injectable provider that contains application and data-access logic, kept separate from the HTTP-facing controller so that logic can be reused, tested, and injected independently of any specific route.

In simpler words

A service is where the real work happens — querying the database, applying rules, and shaping the response — while the controller just calls it.

TicketsService is a clear example: repository injection, a DTO in, a mapped response out, and NotFoundException for missing records.

After this you can

  • Explain why TicketsService, not TicketsController, injects Repository<Ticket>
  • Follow a repository-backed service method (list/get/create/update/remove) and describe each step
  • Explain toResponse() as a boundary between entity and API shape
  • Throw the correct Nest exception for a missing resource

What belongs in a service

Definition

A service holds business rules, data persistence operations, and any validation beyond what a DTO or pipe already checked, so that controllers can stay limited to request/response translation.

In simpler words

If it queries the database, enforces a domain rule, or would need to be reused by more than one route, put it in the service.

TicketsService owns the TypeORM repository, builds queries, maps entities to API responses, and throws domain-appropriate exceptions — none of that lives in TicketsController.

Keeping this logic out of the controller means it is testable in isolation (mock the repository) and reusable if a second controller or a background job ever needs the same operation.

TicketsService shape

@Injectable()
export class TicketsService {
  constructor(
    @InjectRepository(Ticket)
    private readonly ticketsRepo: Repository<Ticket>,
  ) {}

  async get(id: string) {
    const ticket = await this.ticketsRepo.findOne({ where: { id } });
    if (!ticket) throw new NotFoundException('Ticket not found');
    return this.toResponse(ticket);
  }
}

Throwing NotFoundException here means the controller never writes an if-not-found branch.

Shaping responses: entity in, contract shape out

Definition

A private mapping method that converts a persisted entity into the API’s response shape decouples the database column layout from the HTTP contract clients depend on.

In simpler words

toResponse() takes the raw database row and turns it into exactly the JSON fields the frontend expects — so a column rename does not have to become a breaking API change.

Ticket entity fields include DB-specific columns (assignee_id, created_at) while the mapped response uses camelCase (assigneeId, createdAt) for the JSON contract consumed by @repo/contracts and the web app.

list() applies page/limit/status/q filtering with a query builder, then returns { data, meta } — data is the mapped array, meta carries pagination totals the frontend needs for its table UI.

Paginated list contract

async list(query: ListTicketsQueryDto) {
  const qb = this.ticketsRepo.createQueryBuilder('t');
  if (query.status) qb.andWhere('t.status = :status', { status: query.status });
  qb.skip((query.page - 1) * query.limit).take(query.limit);
  const [rows, total] = await qb.getManyAndCount();
  return {
    data: rows.map((r) => this.toResponse(r)),
    meta: { page: query.page, limit: query.limit, total, totalPages: Math.ceil(total / query.limit) },
  };
}

getManyAndCount runs one query pair instead of a separate hand-written count query.

Mistake: leaking the raw entity

// Wrong — exposes DB-shaped fields, couples clients to schema
async get(id: string) {
  return this.ticketsRepo.findOne({ where: { id } }); // raw entity
}

// Right
async get(id: string) {
  const ticket = await this.ticketsRepo.findOne({ where: { id } });
  if (!ticket) throw new NotFoundException('Ticket not found');
  return this.toResponse(ticket);
}

Without mapping, a future column rename becomes an accidental breaking API change.

Keep in mind

  • Constructor-inject the repository via @InjectRepository; never query the database from a controller.
  • Throw NotFoundException / ConflictException / etc. from the service — let the global filter format the error JSON.
  • A service method should be understandable without knowing which controller calls it.

Test

Check your understanding

At least 10 questions — mix of concept, syntax, practical, and logic. Score ≥80% (enforced by the API) to save progress.

Checking your session…

15 questions · concept 5 · syntax 3 · practical 4 · logic 3

1. What is a Nest service?
Concept
2. Who typically injects Repository<Ticket>?
Syntax
3. Must a service appear in a controller decorator?
Practical
4. Why map entities with toResponse()-style helpers?
Logic
5. Where should NotFoundException for missing tickets be thrown?
Concept
6. Can services call other services?
Practical
7. What should services avoid returning casually?
Syntax
8. How do services get config?
Logic
9. Which layer applies ticket assignment rules?
Concept
10. Why unit-test services with mocked repositories?
Practical
11. What should replace the comment so a missing ticket returns 404?
Conceptintermediate
async findOne(id: string) {
  const ticket = await this.repo.findOneBy({ id });
  // ?
  return ticket;
}
12. This handler leaks sensitive data. What is the safe fix?
Syntaxadvanced
async getUser(id: string) {
  const user = await this.repo.findOneBy({ id });
  return user; // entity has a passwordHash column
}
13. Which layer owns the repository in this architecture?
Practicalintermediate
@Injectable()
export class TicketsService {
  constructor(@InjectRepository(Ticket) private repo: Repository<Ticket>) {}
}
14. What must be true for AuditService to inject here?
Logicadvanced
@Injectable()
export class TicketsService {
  constructor(private readonly audit: AuditService) {}
}
15. Where does the rule "cannot assign a closed ticket" belong?
Conceptintermediate
// enforce: cannot assign a closed ticket

Checking your session…