Layer Two: The Service Layer

Article · 11:00

The service layer is where your product actually lives: everything above it is transport and everything below it is storage. The rule that keeps it that way is that it must not import anything from express, and must not know what a status code is.

Layer Two: The Service Layer

The service layer is where your product actually lives. Everything above it is transport and everything below it is storage.

The rule

A service module must not import anything from express, and must not know what an HTTP status code is.

That one rule buys you a lot. It means you can call the same function from an HTTP route, a CLI script, a queue consumer or a test, and get the same behaviour. It also means the layer is testable without spinning up a server.

// services/progress-service/src/services/progress.service.js
async function completeLesson({ userId, lessonId }) {
  const enrolment = await enrolmentRepository.findByUserAndLesson(userId, lessonId);
  if (!enrolment) {
    throw new NotFoundError('You are not enrolled in this course');
  }

  await lessonProgressRepository.markComplete({ enrolmentId: enrolment.id, lessonId });
  await enrolmentRepository.refreshRollup(enrolment.id);

  return enrolmentRepository.findById(enrolment.id);
}

NotFoundError is a domain error, not an HTTP one. A single error-handling middleware maps it to 404 at the edge. If you ever call this from a queue consumer, the same error means "give up, do not retry" - and that mapping happens in the consumer, not in here.

What does not belong here

  • SQL. It goes in a repository. If you find yourself importing a database driver into a service file, stop.
  • Request parsing. By the time you reach this layer, the input is validated and shaped.
  • Response formatting. Return domain objects. The controller decides what the wire format looks like.

Orchestration across services

When a service needs data another service owns, it makes an HTTP call, and that call goes through a thin client in src/clients/. Keeping it there means the retry policy, the timeout and the request-id header are written once:

const course = await courseClient.getCourse(courseId, { requestId });

If courseClient throws, you decide here whether the whole operation fails or whether you can degrade. That decision is business logic, which is why it lives in the service layer and nowhere else.