Spring Boot by Building · Chapter 3

Controller → Service → Repository, wired

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.

Chapter 3 · We do — fully guided · Hands-on ~45 min · Storage is in-memory (no DB yet)


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.

Learning goal

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.

// goal of this chapter

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.

// why we build bottom-up

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.)

NEW · model/Task.java
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; }
}
why it looks like this
  • 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.

NEW · repository/TaskRepository.java
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);
    }
}
read it against Chapter 1
  • @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.
// pitfall — in-memory means it forgets on restart

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.

NEW · service/TaskService.java
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 two things to really absorb
  • The constructor is the injection. TaskService declares "I need a TaskRepository"; because the repository is a bean, Spring passes it in at startup. No new 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:

NEW · service/TaskNotFoundException.java
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);
    }
}
a clean seam trick
  • 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:

NEW · dto/TaskRequest.java
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) { }
why a DTO, and why a record
  • A DTO ("data transfer object") is the shape of the request/response body, kept separate from the internal Task model so the outside world can't dictate your storage. Here it drops id — clients shouldn't set ids.
  • record is 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.)
NEW · controller/TaskController.java
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);
    }
}
the new HTTP annotations, once
  • @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 a TaskRequest for 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:

com.example.taskflow ├─ TaskflowApplication.java ├─ controller/ TaskController.java ← HTTP in/out ├─ service/ TaskService.java ← the rules │ TaskNotFoundException.java ├─ repository/ TaskRepository.java ← the Map "database" ├─ model/ Task.java ← the data └─ dto/ TaskRequest.java ← the request shape

Run it and create a task:

$ curl -X POST http://localhost:8080/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Write chapter 3","done":false}' {"id":1,"title":"Write chapter 3","done":false} $ curl http://localhost:8080/tasks [{"id":1,"title":"Write chapter 3","done":false}] $ curl http://localhost:8080/tasks/99 {"status":404,"error":"Not Found","message":"Task not found: 99", ...}

Here is that POST walking the line — the whole point of the chapter in one trace:

POST /tasks + JSON→ Controller.create()→ Service.create()→ Repository.save()→ Map.put
Map.put→ returns Task(id=1)→ Service returns it→ Controller returns it→ JSON back to client
  1. Controller

    Jackson turns the JSON body into a TaskRequest; create() pulls out the title and calls the service. No rules applied.

  2. Service

    Applies the rule "new tasks start not-done", builds a Task with no id, calls repository.save().

  3. Repository

    Sees a null id → assigns 1, puts it in the Map, returns the now-identified task.

  4. Back up

    The task travels up unchanged; the controller returns it; Jackson turns it into JSON. Same steps, reversed — exactly Fig 1.

// the payoff — feel the seams hold

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

Exercise 3.1 we do — full solution shown

Add "mark a task done" as its own endpoint

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.

add to TaskService.java
public Task markDone(Long id) {
    Task task = getById(id);     // reuses the 404 rule
    task.setDone(true);
    return repository.save(task);
}
add to TaskController.java
@PatchMapping("/{id}/done")
public Task markDone(@PathVariable Long id) {
    return service.markDone(id);
}
$ curl -X PATCH http://localhost:8080/tasks/1/done {"id":1,"title":"Write chapter 3","done":true}

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.

Exercise 3.2 you do

Add a "count of unfinished tasks" endpoint

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?
A new URL (controller route) + a new rule, i.e. counting (service method). The repository already gives you findAll(); count from that list.
Hint 2 — the service method shape
Return a 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
TaskService.java
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);
}
TaskController.java
@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)

HTTP annotations

Clean-seam moves

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.
// next

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 /tasks creates a task and GET /tasks returns 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
Let the repository own storage details.
Hint 2
Keep HTTP annotations out of the service.
Hint 3
Use the constructor as the dependency list.

Before opening the solution: trace one POST, identify the rule, and name the repository seam.

Reference patch — layered task flow
The controller translates the request, the service owns behaviour, and the repository owns the in-memory collection.

Recall before continuing

  1. Which layer may expose HTTP annotations?
  2. What does constructor injection reveal?
  3. What changes when persistence replaces the map?

The next TaskFlow workshop keeps the controller and service shape, then swaps its storage seam for JPA.