Engineering Notes
Home/Backend/Frameworks/Services and Repositories
Backend · Frameworks

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.

⚙️ Backend● Intermediate⏱ 5 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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:

text
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
ts
@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.

Controller, service, repository and database layers with dependencies pointing one way and the repository returning domain objects rather than raw rows
Controller, service, repository and database layers with dependencies pointing one way and the repository returning domain objects rather than raw rows

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:

ts
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

ConcernLayer
Parsing req.body, setting the HTTP status codeController
"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

  1. 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.
  2. 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.
  3. 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.
  4. 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

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

1. 'Refunds allowed only within 30 days' belongs in…
Business rule.
2. Should a repository return Prisma model objects with all columns?
Decouple storage.
3. Where should the DB transaction for 'refund + update order' be started?
Use-case boundary.
4. Benefit of the service layer for a queue consumer?
Reuse.