All articles
169 articles · updated weekly See our Tools
All articles
Tutorials

Controller, service and repository: responsibilities

What controller, service and repository actually do, the anti-patterns (fat controller, anemic service, repository leaking the ORM) and when the three layers are overkill.

Controller, service and repository: responsibilities
COVER · Tutorials

I opened an OrderController.php the other day and found 600 lines. Inside: inline SQL stitched together with string concatenation, tax calculation, a discount rule based on customer tier, payload validation, and somewhere in the middle a header('Location: ...'). The store() method alone was 180 lines. Nobody could test anything without booting the whole framework, and any change to the tax logic meant saying a prayer before deploy. That is the classic symptom of code that never separated controller, service, and repository.

This piece is about what each of those three layers does, what it must not do, and when it is worth having all three.

The controller owns the HTTP boundary, and only that

The controller is where the HTTP world touches your code. Its responsibility: read the request, validate the shape of the input (does the field exist? is it a number? does it match the schema?), call the right service, and format the response. That is all.

What the controller does not do: business logic. It does not decide whether the customer gets a discount, does not calculate tax, does not know whether an order can be cancelled. It translates HTTP into a method call and translates the result back into HTTP. If you swapped the application from REST to a message queue tomorrow, the controller would be thrown away and the logic would stay intact.

class OrderController {
  function store(request) {
    input = validate(request.body)        // shape, not rule
    order = orderService.place(input)     // delegate
    return json(order, 201)               // format response
  }
}

Three useful lines. A thin controller is the goal, not an accident. Validating shape here is fine (DTO, framework request validation). Validating a business rule ("is this SKU in stock?") is not shape, it is logic, and it goes to the service.

The service is where business logic lives

The service orchestrates. It is the home of business rules: it calculates tax, applies the tier discount, decides the order state transition, opens and closes the transaction. Ideally it is framework-agnostic: it does not know Request, does not know Response, does not know HTTP status codes. You can call it from a controller, a background job, a CLI command, or a test, without changing a line.

That is what makes it testable. A service test is a function test: a domain input goes in, a domain result comes out, with the repositories mocked. No HTTP boot, no real database.

class OrderService {
  function place(input) {
    customer = customerRepo.findById(input.customerId)
    if (!customer.canOrder()) throw DomainError(...)
    total = pricing.apply(input.items, customer.tier)
    order = Order.create(customer, input.items, total)
    return orderRepo.save(order)   // persist via repository
  }
}

Notice the service writes no SQL. It asks "save this order" and does not care how.

The repository abstracts data access

The repository is the boundary with the database. It hides the ORM, the SQL, the persistence detail, and returns domain objects, not raw rows. orderRepo.findById(id) returns an Order, not an associative array nor an ORM Entity leaking outward. The point of the layer is a seam: if tomorrow you swap Postgres for something else, or swap the ORM, only the repository changes. The service never finds out.

When defining that contract between layers, generating the types from a response JSON saves time and errors: paste the payload into a JSON to TypeScript converter and you have the domain interfaces ready to type what the repository returns.

Why separate at all

Three concrete reasons, not philosophy:

  • Testability. Service without a framework = fast, isolated test. The controller gets so dumb it barely needs a unit test.
  • Swappable database. The repository seam lets you mock persistence in tests and, at the limit, swap the storage technology without touching the rules.
  • Thin controllers. When the logic lives in the service, the controller becomes a three-line router. Small diff, easy review, fewer places for a bug.

The anti-patterns you will meet

Fat controller. The 600-line case from the top. Everything piled at the HTTP boundary. Impossible to test without booting the framework, impossible to reuse.

Anemic service that is just a passthrough. The other extreme: OrderService.save() that only calls orderRepo.save() and returns. That is not a layer, it is a typing tax. If the service holds no rule, it has no reason to sit in the middle.

Repository leaking the ORM. The most subtle and the worst. The repository returns an IQueryable, a query builder, an EntityManager, or a lazy-loaded entity that fires a query in the service when you access a property. At that moment the ORM has leaked upward and the seam is broken: the service now depends on the very detail the repository was supposed to hide. A repository returns finished data, a domain object, a materialized list. It does not return a promise of a query.

When this is overkill

Honestly: not every project needs the three layers. A tiny CRUD, an internal admin for one form, an endpoint that just runs SELECT * WHERE id and returns, gains nothing from three files and two interfaces in the middle. You only added ceremony. The rule I follow: the logic always lives in the service; the controller always stays dumb; but the repository only shows up when you actually need the seam. Do not add a repository just to wrap the ORM 1:1 — if it hides nothing, it abstracts nothing, and you only renamed data access. Add it when you need the seam: swapping the database, mocking in a test, having more than one data source.

If you want to go deeper into inverting dependencies and isolating the domain behind ports and adapters, the sibling of this piece is hexagonal architecture.

Frequently asked questions

Where does validation go, in the controller or the service?

It depends on the type. Shape validation (field exists, right type, matches the schema) goes in the controller, before calling the service. Business-rule validation ("can the customer buy?", "is there stock?") goes in the service, because it depends on domain state, not on the request shape.

Do I need an interface for every repository?

Not by default. The interface only pays its cost when you actually have two implementations or want to mock without a database. In a small team with a single database, a concrete repository class already delivers the separation. Add the interface when the seam becomes a real need.

Can a service call another service?

It can, and it is normal in larger orchestrations (a CheckoutService calling PaymentService and InventoryService). The care is to avoid circular dependencies and keep each service with a clear responsibility. If two services call each other in a loop, the boundary between them is in the wrong place.

Is a repository the same as a DAO?

They are relatives. In practice a DAO tends to be glued to the table (one per table, raw CRUD), while a repository thinks in terms of a domain aggregate and returns domain objects. The repository's intent is to hide persistence behind a domain language; a DAO just encapsulates data access.

Takeaway

The controller owns HTTP and stays dumb. The service holds the business logic and knows no framework. The repository hides the database and returns domain objects, not rows. Separate by these three responsibilities, not by dogma: logic always in the service, the controller always thin, and the repository only when you need the seam — never just to wrap the ORM one to one.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile