Foundation · Build

Controller → Service → Repository

Split one fat controller into three collaborating layers and see what each is allowed to know about the others.

Primary source: Spring Framework 6.2: Core Technologies

25 min

Lesson 1 of 2

1d streak

The Architectural Question

A controller that talks to the database directly cannot be unit tested without HTTP, cannot be reused by another entry point, and mixes transport formatting with business rules.

What this guide proves

  • Fat controller problems
  • Layer responsibilities
  • Dependency direction

Assign each decision to the layer with the right knowledge

LayerMay knowMust not own
ControllerHTTP routes, DTOs, status, headersDatabase queries and business branching
ServiceUse cases, policies, transaction boundariesServlet types and JSON formatting
RepositoryPersistence operations and query intentHTTP status and workflow orchestration
MapperExplicit boundary transformationsI/O or business decisions

The useful rule is not that every request must pass through exactly three classes. The rule is that transport, application decisions, and persistence have different reasons to change. Introduce a seam when it protects one of those boundaries.

Make dependency direction visible in code

task/TaskController.java
@RestController
@RequestMapping("/api/tasks")
class TaskController {
    private final TaskService tasks;

    TaskController(TaskService tasks) { this.tasks = tasks; }

    @PostMapping
    ResponseEntity<TaskResponse> create(@Valid @RequestBody CreateTaskRequest request) {
        var created = tasks.create(request);
        return ResponseEntity.created(URI.create("/api/tasks/" + created.id())).body(created);
    }
}
task/TaskService.java
@Service
class TaskService {
    private final TaskRepository repository;

    TaskService(TaskRepository repository) { this.repository = repository; }

    @Transactional
    TaskResponse create(CreateTaskRequest request) {
        if (repository.existsByTitleIgnoreCase(request.title())) {
            throw new DuplicateTaskException(request.title());
        }
        return TaskMapper.toResponse(repository.save(TaskMapper.toEntity(request)));
    }
}

interface TaskRepository extends JpaRepository<TaskEntity, Long> {
    boolean existsByTitleIgnoreCase(String title);
}

Transactions belong around the use case

The service knows which reads and writes form one business operation. A repository method knows only its own database interaction; a controller should not keep a transaction open across HTTP translation.

Use the layers as focused test seams

  1. 01Use a controller slice to prove DTO binding, status, headers, JSON, and advice while mocking TaskService.
  2. 02Use a plain unit test to prove duplicate-title policy in TaskService while faking TaskRepository.
  3. 03Use a repository slice to prove derived queries, mappings, and database constraints.
  4. 04Keep a small integration path to prove the selected implementations wire together.
  5. 05Add an architecture rule or review check that controllers never import repositories.

Active Recall Checkpoint

A scheduled job and an HTTP endpoint must enforce the same task-limit rule. Where should the rule live?

Hint: Both entry points should call the same use case.

Show answer & explanation

Put the rule in the application service and have both adapters invoke it. Controller validation may still reject malformed HTTP input, but the shared business decision cannot belong to either transport adapter.

Runtime Architecture & Flow

Dependency direction

Boundaries follow ownership

Each layer answers one kind of question: HTTP translation, business decisions, persistence—never all three in one class.

Fat controller problems

B1

Layer responsibilities

B2

Dependency direction

B3

Read these as design constraints. The arrows belong in the implementation only after the dependency rule is chosen.

Working Implementation

Code Walkthrough

Refactor a controller that validates, queries, and formats into TaskController + TaskService + TaskRepository and keep every test green.

LayeredArchitectureLab.java
package com.example.layers;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Demonstrates strict 3-tier layering:
 * Controller: Handles HTTP contracts, validation, status codes (Zero business logic).
 * Service: Enforces domain rules, invariants, and transactional boundaries.
 * Repository: Encapsulates persistence queries and data storage.
 */
public class LayeredArchitectureLab {

    public record CreateAccountRequest(String email, long initialDepositCents) {}
    public record AccountResponse(String accountId, String email, long balanceCents) {}

    @RestController
    @RequestMapping("/api/v1/accounts")
    public static class AccountController {
        private final AccountService accountService;

        public AccountController(AccountService accountService) {
            this.accountService = accountService;
        }

        @PostMapping
        public ResponseEntity<AccountResponse> createAccount(@RequestBody CreateAccountRequest req) {
            AccountResponse response = accountService.createAccount(req.email(), req.initialDepositCents());
            return ResponseEntity.status(HttpStatus.CREATED).body(response);
        }
    }

    @Service
    public static class AccountService {
        private final AccountRepository accountRepository;

        public AccountService(AccountRepository accountRepository) {
            this.accountRepository = accountRepository;
        }

        @Transactional
        public AccountResponse createAccount(String email, long deposit) {
            if (deposit < 0) throw new IllegalArgumentException("Deposit cannot be negative");
            String accountId = "ACC-" + Math.abs(email.hashCode());
            accountRepository.save(accountId, email, deposit);
            return new AccountResponse(accountId, email, deposit);
        }
    }

    @Repository
    public static class AccountRepository {
        private final Map<String, AccountRecord> store = new ConcurrentHashMap<>();
        public record AccountRecord(String id, String email, long balance) {}

        public void save(String id, String email, long balance) {
            store.put(id, new AccountRecord(id, email, balance));
        }
    }
}
Controllers must never invoke repositories directly; the Service layer owns business transactions and invariants.
Database entities must not leak directly into HTTP controller responses; use DTO records.
Transactions belong on the service layer where multiple repository interactions can be committed or rolled back atomically.

Synthesis & Production Verification

Deliberate Failure Condition

  1. Break: Call TaskRepository from TaskController and duplicate its validation in a second endpoint
  2. Observe: The same rule drifts between endpoints and one fix misses the other copy
  3. Repair: Rules live in services so every entry point enforces them once; repositories stay persistence-only.

Production Acceptance Criteria

  • Controllers contain no SQL, no rules, and no mapping beyond HTTP concerns
  • Services hold business decisions and transaction boundaries
  • Dependencies point controller -> service -> repository, never backward or sideways

Architectural & Technical Review

Core Interview Questions

Q01What determines whether a responsibility belongs in a controller or service?Show answer

Transport knowledge such as routes, DTO binding, status, and headers belongs in the controller. Rules shared by entry points and transactional orchestration belong in the service.

Q02Why is the service the usual transaction boundary?Show answer

It knows which repository reads and writes form one business operation; neither one repository method nor HTTP response formatting has that complete view.

Q03How should tests follow the three seams?Show answer

Use MVC slices for HTTP translation, plain service tests for business decisions, repository slices for mappings and queries, and a small integration set for wiring.

Production Pitfalls

Critical Anti-Patterns

  • Pitfall: Injecting repositories into controllers — It couples transport directly to persistence and invites duplicated rules across HTTP, jobs, and messaging adapters.
  • Pitfall: Adding pass-through services mechanically — A seam should protect decisions or a transaction. A class that only forwards every call adds indirection without ownership.

Primary Documentation & Specifications

Supplementary Module Resources