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.
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.
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.
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
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.
Task — the thing that rides the beltCreate 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; }
}
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.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.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.
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);
}
}
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.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);
}
}
@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.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) { }
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.)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 a
TaskRequest for you.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:
Jackson turns the JSON body into a TaskRequest;
create() pulls out the title and calls the service. No rules applied.
Applies the rule "new tasks start not-done", builds a Task
with no id, calls repository.save().
Sees a null id → assigns 1, puts it in the Map, returns
the now-identified task.
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.
Goal. PATCH /tasks/{id}/done sets done = true and
returns the updated task, without the client sending a body.
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.
/tasks/stats → {"open":2,"done":1}.findAll(); count from that list.Map<String,Long>. Use a stream:
findAll().stream().filter(t -> !t.isDone()).count() for the open count, and the opposite for
done.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.
The four-layer build on one page
Task)@Repository) → findAll/findById/save/deleteById@Service) → the rules; injects the repository via constructor@RestController) → routes; injects the service; one call each@RequestMapping("/tasks") → shared prefix@GetMapping @PostMapping @PutMapping @DeleteMapping @PatchMapping → the verbs@PathVariable → {id} from the path · @RequestBody → JSON body → object@ResponseStatus(NOT_FOUND) maps it to 404 at the edgerecord makes it a one-linerChapter 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.
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.
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.Likely files: model/Task.java, repository/TaskRepository.java, service/TaskService.java, controller/TaskController.java. Suggested commit: feat: wire taskflow task layers.
Before opening the solution: trace one POST, identify the rule, and name the repository seam.
The next TaskFlow workshop keeps the controller and service shape, then swaps its storage seam for JPA.