Services and Repositories
Controllers, services and repositories each have one job. Follow a refund request across the three layers, see what belongs where, and make repositories return domain data instead of raw rows.
🎯 What you'll learn
- Three layers, three jobs
- Why the service layer exists
- Why the repository layer exists
- Sorting responsibilities and returning domain data
📚 Before you start
This topic assumes familiarity with backend/frameworks/05-controllers (thin controllers delegate outward) and backend/frameworks/09-dependency_injection (how the objects described here actually get wired together). It also connects to architecture/architecture/07-repository_pattern, which covers the repository abstraction as a general design pattern — this topic is about how that pattern plugs specifically into a framework's controller/service layering, not the pattern's design rationale in the abstract.
1Three Layers, Three Jobs
A typical framework-backed backend splits request handling into three layers, each with exactly one job:
Controller -> parse the request, call a service, shape the response (backend/frameworks/05-controllers)
Service -> business logic: rules, orchestration, decisions
Repository -> data access: talk to the database (or any other storage), nothing else@Controller("orders")
class OrderController {
constructor(private orderService: OrderService) {}
@Post()
async create(@Body() dto: CreateOrderDto) {
return this.orderService.placeOrder(dto); // controller does NOT touch the database
}
}
@Injectable()
class OrderService {
constructor(private orderRepo: OrderRepository, private inventoryRepo: InventoryRepository) {}
async placeOrder(dto: CreateOrderDto) {
const inStock = await this.inventoryRepo.checkStock(dto.productId, dto.quantity);
if (!inStock) throw new BadRequestException("insufficient stock"); // BUSINESS RULE lives here
return this.orderRepo.create(dto); // delegates the actual write
}
}
@Injectable()
class OrderRepository {
constructor(private db: Database) {}
create(dto: CreateOrderDto) {
return this.db.query("INSERT INTO orders ...", [dto]); // ONLY data access, no business rules
}
}Each arrow flows one direction: controller calls service, service calls repository — a repository never calls back up into a service, and a controller never talks to a repository directly.
2Why the Service Layer Exists at All
It's tempting to ask why OrderController can't just call OrderRepository directly and skip the middle layer — for a single trivial CRUD operation, it could. The service layer earns its place the moment there's a decision to make that isn't itself a database query: checking stock before allowing an order, applying a discount rule, coordinating a write across two repositories (orders and inventory) as one logical operation, sending a notification after a successful write. None of that is "get/set a row" — it's business logic, and business logic belongs in the service, not smeared across the controller (which should stay a thin request/response adapter) or the repository (which should stay ignorant of why a query runs, only how).
3Why the Repository Layer Exists at All
Symmetrically, the repository exists to isolate how data is stored from what the business logic needs to do with it. OrderService calls orderRepo.create(dto) without knowing or caring whether that's a raw SQL INSERT, an ORM call, a call to a third-party API, or (in a test) an in-memory array push:
class InMemoryOrderRepository implements OrderRepository {
private orders: Order[] = [];
create(dto: CreateOrderDto) {
const order = { id: this.orders.length + 1, ...dto };
this.orders.push(order);
return order;
}
}This is the same substitutability payoff covered in backend/frameworks/09-dependency_injection — because OrderService depends on the repository's interface, not a concrete database-backed class, a test can construct OrderService with InMemoryOrderRepository and exercise the real business logic (the stock check, the discount rule) with zero real I/O.
4What Belongs Where — A Concrete Sorting Exercise
| Concern | Layer |
|---|---|
Parsing req.body, setting the HTTP status code | Controller |
| "an order needs items and a valid customer" | Service (business rule) |
| "insert this row, return the generated id" | Repository (mechanics) |
| "apply a 10% discount for orders over $100" | Service (business rule) |
| "run this exact SQL join across three tables" | Repository (mechanics) |
| "reject the order if any line item is out of stock" | Service (orchestrates repo calls + decides) |
The dividing line is always the same question: is this a decision about what should happen, or a mechanical detail of how data gets read or written? The former is a service's job; the latter is a repository's.
5Repositories Return Domain Data, Not Raw Storage Shapes
A well-designed repository hides its storage mechanics behind method names that describe what is being asked for, not how it's fetched — findActiveCustomers(), not runQuery(sql). This is what actually makes swapping the underlying storage possible later (moving from a SQL database to a different store) without touching every service that calls the repository: the service-facing contract is a set of meaningful method names, never a leaked SQL string or an ORM-specific query builder object.
⚠️ Common Pitfalls
- Putting business logic in the repository. A repository method that decides "skip this row if the discount would make the total negative" has smuggled a business rule into the data-access layer, which means that rule can never be tested without a real (or in-memory) storage backend, defeating the whole reason a service layer exists.
- Putting data-access details in the service. A service that builds raw SQL strings or ORM query objects directly has skipped the repository entirely, coupling business logic to one specific storage technology.
- A fat controller that calls the repository directly, "just this once." Every exception erodes the "controllers never touch repositories" rule, and the business logic that belonged in a service ends up duplicated the next time a second controller needs the same operation.
- A repository interface shaped exactly like a SQL table. If every method maps 1:1 to a raw column, the abstraction isn't actually hiding storage details — it's just SQL with extra steps, and swapping storage mechanisms later means rewriting every consumer anyway.
📋 Quick Reference
Controller -> HTTP in/out only: parse request, call service, shape response
Service -> business logic: rules, decisions, orchestration across repositories
Repository -> data access only: how storage works, hidden behind meaningful method names
Flow -> Controller -> Service -> Repository (never backwards, never skipped)
Payoff -> swap a repository for an in-memory fake in tests without touching the service🧠 Key takeaways
- Controller: HTTP translation. Service: business rules, orchestration, transactions. Repository: persistence.
- Services are reusable from HTTP, queues, CLIs and tests.
- Repositories hide the ORM/SQL and return domain objects, not raw rows.
- When in doubt: does it mention HTTP? controller. A business rule? service. A query? repository.
🎤 Interview Q&A
Q1What's the role of the service layer?core▾
Implements application use cases: orchestrates domain logic, repositories and external services, manages transactions, and enforces business rules and authorization. Controllers call services; services don't know about HTTP.
- Service vs domain model? In rich models, entities hold rules and services orchestrate.
Q2Where should transactions be managed?core▾
At the service/use-case level (unit of work), so all repository operations for one business action commit or roll back together. Keep transactions short and avoid external calls inside them.
- What about sending an email after commit? Use the outbox pattern or after-commit hooks.
Q3Do you need repositories when using an ORM?core▾
The ORM context already is a repository/unit of work. Repositories still help to encapsulate complex queries, keep ORM details out of services, and simplify testing, but generic pass-through repositories add little.
- Prisma/EF users often... Use the client/context directly in small apps and add repositories as complexity grows.
Q4How do you avoid 'god services'?senior▾
Split by use case or aggregate (PlaceOrderService, RefundService), use command/query handlers (CQRS-lite, MediatR), and move domain rules into entities and value objects.
- What's a vertical slice handler? One class per request handling everything that use case needs.
Q5How do you test services?core▾
Unit tests with fakes for repositories and external gateways for business logic; integration tests for repositories with a real DB.
- Should you mock the ORM? No; test repository behaviour against a real database.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.