Controller -> Service -> Repository, wired
Grow TaskFlow into controller, service, repository, and model layers, with constructor injection wiring the app without manual new calls.
Assumes you know
After this you should be able to
- Wire controller service and repository
- Apply constructor injection
Spring Boot by Building · Chapter 3
We grow TaskFlow into the real four-layer shape from the map: a Task model, an
in-memory repository, a service holding the rules, and a controller exposing full CRUD. You'll build each layer
in order, bottom-up, and watch dependency injection thread them together with no new in sight.
Choose your route#
Study the same Controller -> Service -> Repository, wired explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.
- Wire controller service and repository
- Apply constructor injection
Shared core#
The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.
Learn deeply: build evidence#
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Prepare for interviews: explain the trade-off#
Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.
A working Task API: create, list, fetch-by-id, update, and delete tasks over HTTP — split
across the four kinds of class, each doing only its one job. By the end you'll be able to point at any line and
say which layer it belongs to and why. We keep data in a plain in-memory Map so the
shape is the whole lesson; Chapter 4 swaps that Map for a real database without touching the
service or controller — proof that the seams held.
We add layers in the order data is stored, not the order a request travels: Model → Repository → Service → Controller. Each layer only depends on the one below, so building upward means every class you write can compile and be reasoned about the moment you write it — you're never referring to something that doesn't exist yet. This ordering is itself a de-tangling habit.
Layer 1 · the ModelThe Task — the thing that rides the belt#
Create a model package and a plain class. No Spring annotations at all yet — a model is just data.
(In Chapter 4 we add @Entity to make it a table; today it lives only in memory.)
package com.example.taskflow.model;
public class Task {
private Long id;
private String title;
private boolean done;
public Task() { } // no-arg constructor (Jackson needs it)
public Task(Long id, String title, boolean done) {
this.id = id;
this.title = title;
this.done = done;
}
// getters & setters — plain accessors
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public boolean isDone() { return done; }
public void setDone(boolean done) { this.done = done; }
}- Three fields = the columns a task will eventually have. That's the entire "schema" for now.
- The no-arg constructor + getters/setters let Jackson turn JSON into a
Task(deserialize) and back (serialize). This boilerplate is exactly what Lombok will delete for us in Chapter 4 — for now, seeing it in full makes clear what Lombok is hiding.
Layer 2 · the RepositoryThe only class that "stores" things#
The repository's job is load and save — nothing else. Today its "database" is a Map in memory. Tag
it @Repository so Spring makes it a bean the service can receive.
package com.example.taskflow.repository;
import com.example.taskflow.model.Task;
import org.springframework.stereotype.Repository;
import java.util.*;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class TaskRepository {
private final Map<Long, Task> store = new HashMap<>();
private final AtomicLong nextId = new AtomicLong(1); // hands out 1, 2, 3, ...
public List<Task> findAll() {
return new ArrayList<>(store.values());
}
public Optional<Task> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public Task save(Task task) {
if (task.getId() == null) { // new task → assign an id
task.setId(nextId.getAndIncrement());
}
store.put(task.getId(), task); // insert or overwrite
return task;
}
public void deleteById(Long id) {
store.remove(id);
}
}@Repository= "Spring, make one of these and keep it." It's the same bean mechanism as@Service/@RestController; the different word is just documentation of its role.- The method names —
findAll,findById,save,deleteById— are chosen deliberately: they're the exact names Spring Data JPA gives you for free in Chapter 4. Learn them here on a Map; reuse them there on MySQL. Optional<Task>for "might not exist" — the caller must consciously handle "not found" rather than trip over a null.
Everything in this Map vanishes when you stop the app. That's fine — it's a learning stand-in. If your tasks "disappear," you didn't lose them; you restarted. Chapter 4's real database is what makes them survive.
Layer 3 · the ServiceWhere the rules live — and the first injection#
The service holds decisions: what "create a task" means, what happens when an id isn't found. It talks to the repository and knows nothing about HTTP. This is also where you meet constructor injection for real.
package com.example.taskflow.service;
import com.example.taskflow.model.Task;
import com.example.taskflow.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository repository;
// Spring injects the TaskRepository bean here — this is DI.
public TaskService(TaskRepository repository) {
this.repository = repository;
}
public List<Task> getAll() {
return repository.findAll();
}
public Task getById(Long id) {
return repository.findById(id).orElseThrow(() -> new TaskNotFoundException(id)); // a rule: missing = error
}
public Task create(String title) {
Task task = new Task(null, title, false); // new tasks start not-done — a rule
return repository.save(task);
}
public Task update(Long id, String title, boolean done) {
Task task = getById(id); // reuse the "must exist" rule
task.setTitle(title);
task.setDone(done);
return repository.save(task);
}
public void delete(Long id) {
getById(id); // 404 if it doesn't exist, then delete
repository.deleteById(id);
}
}- The constructor is the injection.
TaskServicedeclares "I need aTaskRepository"; because the repository is a bean, Spring passes it in at startup. Nonew TaskRepository()anywhere — you couldn't fake it in a test if there were. - Rules live here, only here. "New tasks start not-done", "updating something missing is a 404" — business decisions. The controller won't repeat them and the repository won't know them. One home per rule = no tangle.
That TaskNotFoundException is a tiny class — create it so "not found" becomes a real, catchable
thing:
package com.example.taskflow.service;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND) // Spring turns this exception into an HTTP 404
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(Long id) {
super("Task not found: " + id);
}
}- The service throws a meaningful exception;
@ResponseStatus(NOT_FOUND)tells Spring to translate it into a 404 at the HTTP edge. The service stays HTTP-ignorant (it just throws), yet the client gets a correct status code. The two layers cooperate without either reaching into the other.
Layer 4 · the ControllerThe HTTP face — five routes, zero rules#
Now the top layer. It receives HTTP, calls the service, returns the result. Notice it contains no logic — every method is one call down. That emptiness is correct: a fat controller is a classic tangle.
First, a tiny DTO for the request body, so the incoming JSON has a defined shape:
package com.example.taskflow.dto;
// The shape a client sends when creating/updating. No id — the server owns ids.
public record TaskRequest(String title, boolean done) { }- A DTO ("data transfer object") is the shape of the request/response body, kept separate
from the internal
Taskmodel so the outside world can't dictate your storage. Here it dropsid— clients shouldn't set ids. recordis Java's one-liner for an immutable data holder — it auto-generates the constructor and accessors. Perfect for DTOs. (Different tool from Lombok, same spirit: less boilerplate.)
package com.example.taskflow.controller;
import com.example.taskflow.dto.TaskRequest;
import com.example.taskflow.model.Task;
import com.example.taskflow.service.TaskService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/tasks") // every route here starts with /tasks
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) { // inject the service (DI again)
this.service = service;
}
@GetMapping // GET /tasks
public List<Task> all() {
return service.getAll();
}
@GetMapping("/{id}") // GET /tasks/5
public Task one(@PathVariable Long id) {
return service.getById(id);
}
@PostMapping // POST /tasks (body = TaskRequest)
public Task create(@RequestBody TaskRequest req) {
return service.create(req.title());
}
@PutMapping("/{id}") // PUT /tasks/5
public Task update(@PathVariable Long id, @RequestBody TaskRequest req) {
return service.update(id, req.title(), req.done());
}
@DeleteMapping("/{id}") // DELETE /tasks/5
public void delete(@PathVariable Long id) {
service.delete(id);
}
}@RequestMapping("/tasks")on the class → a shared prefix, so methods only name what's after it.@PostMapping/@PutMapping/@DeleteMapping→ the create / replace / remove verbs, mirroring@GetMapping.@PathVariable Long id→ captures the{id}from the URL path.@RequestBody TaskRequest req→ Jackson turns the incoming JSON body into aTaskRequestfor you.- Every method body is one line that calls the service. No rules, no storage. That's a healthy controller.
Section 5Run it, and trace one request through all four layers#
Your project now matches the map exactly:
Run it and create a task:
Here is that POST walking the line — the whole point of the chapter in one trace:
- Controller
Jackson turns the JSON body into a
TaskRequest;create()pulls out the title and calls the service. No rules applied. - Service
Applies the rule "new tasks start not-done", builds a
Taskwith no id, callsrepository.save(). - Repository
Sees a null id → assigns
1, puts it in the Map, returns the now-identified task. - Back up
The task travels up unchanged; the controller returns it; Jackson turns it into JSON. Same steps, reversed — exactly Fig 1.
You wired four classes and never wrote new for any of them. Each depended only on the one below,
received via its constructor. That is the whole Spring model, and you just built it end to end. If you can
re-derive these four files from the map, you can write a Spring backend from scratch — which is exactly what
Chapters 6 and 7 ask you to do.
Section 6Exercises — one worked, one you try#
Goal. PATCH /tasks/{id}/done sets done = true and
returns the updated task, without the client sending a body.
- New task created with done=false, then PATCHed, comes back done=true.
- PATCHing a missing id returns 404.
Which layers change? A new rule ("mark done") → the Service gets a method. A new URL → the Controller gets a route. The repository and model are untouched — they already do enough. That triage is the habit to copy.
public Task markDone(Long id) {
Task task = getById(id); // reuses the 404 rule
task.setDone(true);
return repository.save(task);
}@PatchMapping("/{id}/done")
public Task markDone(@PathVariable Long id) {
return service.markDone(id);
}Self-check: the rule went to the service, the route to the controller, nothing else
moved. If you found yourself putting setDone(true) in the controller, that's the tangle pulling —
rules belong one layer down.
Goal. GET /tasks/stats returns
{"open": 2, "done": 1} — how many tasks are not done vs done.
- Create 3 tasks, mark 1 done, hit
/tasks/stats→{"open":2,"done":1}. - The counting logic lives in the service, not the controller.
Hint 1 — which layers?
findAll(); count from that list.Hint 2 — the service method shape
Map<String,Long>. Use a stream:
findAll().stream().filter(t -> !t.isDone()).count() for the open count, and the opposite for
done.Show full solution
public Map<String, Long> stats() {
List<Task> all = repository.findAll();
long done = all.stream().filter(Task::isDone).count();
return Map.of("open", all.size() - done, "done", done);
}@GetMapping("/stats")
public Map<String, Long> stats() {
return service.stats();
}Add the imports for Map and List if your IDE doesn't.
Put the /stats route above or separate from /{id} — here it's fine
because "stats" can't be read as a numeric id, but keep it in mind: a literal path and a
{id} path can collide.
Notebook Summary#
The four-layer build on one page
Build order (bottom-up)#
- Model → plain class, the data (
Task) - Repository (
@Repository) → findAll/findById/save/deleteById - Service (
@Service) → the rules; injects the repository via constructor - Controller (
@RestController) → routes; injects the service; one call each
HTTP annotations#
@RequestMapping("/tasks")→ shared prefix@GetMapping @PostMapping @PutMapping @DeleteMapping @PatchMapping→ the verbs@PathVariable→{id}from the path ·@RequestBody→ JSON body → object
Clean-seam moves#
- Rules live only in the service; controllers stay one-line thin
- Service throws a meaningful exception;
@ResponseStatus(NOT_FOUND)maps it to 404 at the edge - DTO = request/response shape, separate from the entity; a
recordmakes it a one-liner
Box these#
- ⭐Triage every feature: new URL → controller, new rule → service, new field → model/repo. Decide the layer before you type.
- ⭐Repository method names (findAll/findById/save/deleteById) are the same ones JPA gives free next chapter — you're pre-learning the seam.
- ⚠In-memory data resets on restart. Not a bug.
- 💡A fat controller (rules or storage inside it) is the #1 structural smell. Push it down a layer.
Chapter 4 — Real data with JPA + H2 (and Lombok). We delete the in-memory Map and make tasks
survive restarts, by turning TaskRepository into a JpaRepository and
Task into an @Entity — while the service and controller don't change at all.
TaskFlow checkpoint#
Current state: a runnable ping. Outcome: an in-memory task API with clear seams. Prerequisite: constructor parameters and interfaces. Java bridge: dependencies are ordinary constructor arguments. Estimate: 40 minutes.
Mission: wire one task through every layer#
Create and fetch a task through controller, service, and repository. The artifact is a curl-visible task response backed by an in-memory repository.
POST /taskscreates a task andGET /tasksreturns it.- The controller calls only the service.
- The service receives its repository through a constructor.
Likely files: model/Task.java, repository/TaskRepository.java, service/TaskService.java, controller/TaskController.java. Suggested commit: feat: wire taskflow task layers.
Hint 1
Hint 2
Hint 3
Before opening the solution: trace one POST, identify the rule, and name the repository seam.
Reference patch — layered task flow
Recall before continuing#
- Which layer may expose HTTP annotations?
- What does constructor injection reveal?
- What changes when persistence replaces the map?
The next TaskFlow workshop keeps the controller and service shape, then swaps its storage seam for JPA.