By day 40, you've likely encountered plenty of architectural patterns, but one sits at the heart of nearly every production application I've built: the service layer. It's the boundary between your HTTP request handlers and your domain logic—the place where use cases actually happen. Today, we're looking at why it matters and how to build it right.
What the service layer actually does
The service layer orchestrates. It sits between your controllers and your repositories, between external APIs and your domain objects. Its job isn't to contain business rules; it's to coordinate them.
On a ride-hailing platform I worked on, creating a ride involved several moving parts: validating the passenger exists, checking available drivers, creating a ride record, publishing an event for the matching service, and logging analytics. None of that logic belonged in the controller, and most of it didn't belong scattered across domain objects either. The service layer pulled it together.
Here's what I mean:
// services/CreateRideService.ts
export class CreateRideService {
constructor(
private rideRepository: RideRepository,
private passengerRepository: PassengerRepository,
private driverMatchingClient: DriverMatchingClient,
private eventBus: EventBus,
private logger: Logger
) {}
async execute(command: CreateRideCommand): Promise<Ride> {
// Orchestrate, don't implement business rules here
const passenger = await this.passengerRepository.findById(command.passengerId);
if (!passenger) {
throw new PassengerNotFoundError();
}
const ride = Ride.create({
passengerId: passenger.id,
pickupLocation: command.pickupLocation,
dropoffLocation: command.dropoffLocation,
});
await this.rideRepository.save(ride);
await this.driverMatchingClient.requestMatching(ride.id, ride.pickupLocation);
this.eventBus.publish(new RideCreatedEvent(ride.id, passenger.id));
this.logger.info(`Ride ${ride.id} created by passenger ${passenger.id}`);
return ride;
}
}Notice what's not here: there's no complex validation logic, no state transitions, no business rule enforcement. Those belong on the Ride domain object. The service orchestrates their execution.
Thin controllers, smart services
Your controllers should be thin—ideally, they just translate HTTP into a service call and translate the response back into HTTP.
I've seen controllers with 200 lines of business logic. They're hard to test, hard to reuse, and they conflate HTTP concerns with domain concerns. A service layer prevents that.
Here's the pattern I use:
// controllers/RideController.ts
@Controller('rides')
export class RideController {
constructor(private createRideService: CreateRideService) {}
@Post()
async create(@Body() dto: CreateRideDto, @Req() req: Request) {
const command = new CreateRideCommand(
req.user.id,
dto.pickupLat,
dto.pickupLng,
dto.dropoffLat,
dto.dropoffLng
);
const ride = await this.createRideService.execute(command);
return {
id: ride.id,
status: ride.status,
pickupLocation: ride.pickupLocation,
};
}
}The controller doesn't know about database queries, external API clients, or event publishing. It just calls the service and returns the result. This makes the controller testable with a simple mock, and it makes the service reusable from other channels—CLI, scheduled jobs, internal APIs.
Transaction boundaries: one use case, one transaction
A transaction should wrap exactly one use case. No more, no less.
Early in my career, I saw services that called other services, each with their own transactions. Debugging failures became a nightmare. Now, I ensure each service method—each use case—opens exactly one transaction at the boundary.
// services/CompleteRideService.ts
export class CompleteRideService {
constructor(
private rideRepository: RideRepository,
private paymentService: PaymentService,
private dataSource: DataSource
) {}
@Transactional() // Wraps the entire use case in one transaction
async execute(command: CompleteRideCommand): Promise<Ride> {
const ride = await this.rideRepository.findById(command.rideId);
if (!ride) {
throw new RideNotFoundError();
}
// Complete the ride (state transition on domain object)
ride.complete();
// Process payment within the same transaction
const payment = await this.paymentService.processRidePayment(
ride.id,
ride.fare
);
// Persist everything together
await this.rideRepository.save(ride);
return ride;
}
}If payment fails, the entire transaction rolls back. If saving the ride fails, the payment is never processed. One atomic unit of work.
The key: don't nest transactions. If CompleteRideService calls PaymentService, and both have transaction decorators, you'll end up with nested transactions—which databases handle inconsistently and which makes your code harder to reason about.
Avoiding anemic services
An anemic service is one that does all the thinking. It contains conditionals, data transformations, and business rules that should live on domain objects. It treats those objects as data bags.
// ❌ Anemic service—don't do this
export class UpdateRideService {
async execute(command: UpdateRideCommand): Promise<Ride> {
const ride = await this.rideRepository.findById(command.rideId);
// Business rule: can only cancel within 5 minutes
if (Date.now() - ride.createdAt.getTime() > 5 * 60 * 1000) {
throw new CancellationWindowClosedError();
}
// Recalculate fare (should be on Ride domain object)
const newFare = command.newDistance * this.pricePerKm;
ride.fare = newFare;
ride.status = 'cancelled';
ride.cancelledAt = new Date();
await this.rideRepository.save(ride);
return ride;
}
}
Compare that to a rich domain object:
// ✅ Service delegates to domain object
export class UpdateRideService {
async execute(command: UpdateRideCommand): Promise<Ride> {
const ride = await this.rideRepository.findById(command.rideId);
// Domain object enforces the rule
ride.cancel();
await this.rideRepository.save(ride);
return ride;
}
}
// domain/Ride.ts
export class Ride {
private createdAt: Date;
private status: RideStatus;
cancel(): void {
const elapsedMs = Date.now() - this.createdAt.getTime();
if (elapsedMs > 5 * 60 * 1000) {
throw new CancellationWindowClosedError();
}
this.status = RideStatus.CANCELLED;
}
}
The second version is easier to test (you test the domain object directly), easier to maintain (business rules are colocated), and safer (the domain object can't be misused).
The service layer's job is to orchestrate, not to think. Push thinking onto your domain objects.
Common mistakes
- Talking HTTP in services: Never accept or return HTTP types. No
Request, noResponse. Create command objects instead. This keeps services reusable and testable without spinning up an HTTP server. - Services calling services in a chain: If service A calls service B calls service C, you have a hidden transaction boundary problem and testing becomes a pyramid of mocks. Design for direct orchestration of repositories and domain objects instead.
- Skipping domain objects: The opposite problem from anemic services—putting everything on domain objects and having services that barely do anything. Services are the glue; they should coordinate, but business rules live on domain objects.
- Repositories inside services inside controllers: This is fine. Repositories inside services inside services is a smell. If you need that many layers, you probably need to rethink your domain boundaries.
- No error handling strategy: Services should throw domain-specific exceptions, not generic ones. Catch and wrap external service failures so callers don't depend on third-party API details.
Key takeaways
- The service layer orchestrates domain objects, repositories, and external services to implement use cases.
- Keep controllers thin—they should translate HTTP into service calls, nothing more.
- One use case equals one transaction. Open it at the service boundary, not deeper.
- Avoid anemic services by pushing business rules onto domain objects; services coordinate their execution.
- Services shouldn't know about HTTP, and they shouldn't nest calls to other services unless you have a very specific reason.
- Use command objects to pass data into services, keeping them independent of any transport layer.






Comments
Loading comments…