Spring Boot by Building · Chapter 13
You've watched every layer get built, tested, logged, designed, and secured. Now you build, on the TaskFlow project you already have. The exercises begin with clear steps and get deliberately vaguer — because "I can't start without exact instructions" is the exact gap this chapter closes. For the vaguer ones, sketch a mini blueprint (Chapter 7) first. Hints are a ladder; solutions are a click away, for after you've tried.
Study the same TaskFlow Integration Sprint 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.
For each exercise: (1) read the goal and the acceptance checks; (2) before touching the keyboard, say out loud which layers change — that triage is the skill; (3) attempt it; (4) if stuck for more than ~10 minutes, open Hint 1, not the solution. The solutions exist so you never rage-quit — but every hint you resist is a rep. Do them in order; each reuses the one before.
When you don't know where to start, run this: (a) What layer owns this? (URL→controller, rule→service, field→model+repo, storage→config). (b) Have I done something with this shape before? (almost always yes — find it in Chapters 2–5). (c) Write the smallest version that could work, run it, then grow it. Blank-page paralysis is nearly always a missing step (a); name the layer and the page stops being blank.
Goal. POST /tasks with an empty or missing title returns HTTP 400
(Bad Request), not a saved task with a blank title.
{"title":"","done":false} → 400.{"title":"Real task"} → 200 and it saves.Steps (clear):
Add spring-boot-starter-validation to
pom.xml and reload.
Put @NotBlank on the request's
title.
Add @Valid before
@RequestBody.
record, annotate the component: public record TaskRequest(@NotBlank
String title, boolean done) {}. Import jakarta.validation.constraints.NotBlank.import jakarta.validation.constraints.NotBlank;
public record TaskRequest(@NotBlank String title, boolean done) { }import jakarta.validation.Valid;
@PostMapping
public Task create(@Valid @RequestBody TaskRequest req) {
return service.create(req.title());
}Spring now returns 400 automatically when @Valid fails, before your
method runs. The rule lives declaratively on the DTO — no if (title.isBlank()) scattered
around.
priority field, end to endGoal. A task has a priority (an int, default 3). You can set it on
create/update, and it comes back in every response.
"priority": 1 saves and returns it.TASKS table has a priority column.Which layers? (named for you this time — the fade begins) Model (new field), DTO (accept it), Service (pass it through, apply the default), Controller (no change if the DTO carries it). The repository is untouched — JPA adds the column automatically.
create, if the
incoming priority is 0/absent, use 3. Or give the DTO a sensible value. Pick one home for the rule — don't
put it in two places.ddl-auto=update, adding the field to the entity makes Hibernate add the
column on next startup. Check the startup SQL log to confirm.private int priority; // add the field (+ Lombok generates the accessor)public record TaskRequest(@NotBlank String title, boolean done, Integer priority) { }public Task create(String title, Integer priority) {
Task t = new Task();
t.setTitle(title);
t.setDone(false);
t.setPriority(priority == null ? 3 : priority); // the default rule, one home
return repository.save(t);
}Update the controller's create to pass
req.priority(). Do the same for update. The point: a new field is a predictable
walk down the same layers, every time.
From here you get the goal, the acceptance checks, and hints — but no numbered steps. Do the triage yourself.
Goal. GET /tasks/open returns tasks where done=false,
sorted by priority ascending (1 = most urgent, first).
findByDone; you can extend the grammar with OrderBy…Asc.findByDoneFalseOrderByPriorityAsc(). Read it as a sentence: find where done is
false, order by priority ascending. No body, no SQL.List<Task> findByDoneFalseOrderByPriorityAsc();// service
public List<Task> openByPriority() { return repository.findByDoneFalseOrderByPriorityAsc(); }
// controller
@GetMapping("/open")
public List<Task> open() { return service.openByPriority(); }Goal. GET /tasks?page=0&size=5 returns at most 5 tasks and
metadata about total pages.
page=0&size=5 returns 5; page=2 returns 2.JpaRepository already has findAll(Pageable) returning a
Page<Task>. You barely write anything — mostly plumb Pageable down from the
controller.Pageable parameter directly; Spring builds it from
?page=&size=&sort=. Return the Page<Task> — Jackson serialises its
metadata for you.// service
public Page<Task> page(Pageable pageable) { return repository.findAll(pageable); }
// controller (import org.springframework.data.domain.*)
@GetMapping("/paged")
public Page<Task> paged(Pageable pageable) { return service.page(pageable); }Notice how little you wrote: pagination is a built-in seam. Recognising "the framework probably already does this" is a real skill — check before you build.
These are the "not so clear instructions" you asked for. A goal, acceptance checks, and one nudge. You decide the endpoints, the layers, the method names. This is what a real ticket looks like.
Goal. A user can find tasks whose title contains a piece of text, case-insensitively.
findByTitleContainingIgnoreCase(String q). You design
the endpoint (e.g. GET /tasks/search?q=milk) and wire the two layers above it.// repository
List<Task> findByTitleContainingIgnoreCase(String q);
// service
public List<Task> search(String q) { return repository.findByTitleContainingIgnoreCase(q); }
// controller
@GetMapping("/search")
public List<Task> search(@RequestParam String q) { return service.search(q); }
Yours may differ in naming — that's fine. If the checks pass and each concern sits in the right layer, it's correct. There's rarely one right answer, only right shapes.
Goal. A task can have a dueDate. There's a way to list all tasks
that are past due and still not done.
dueDate < today and done=false.java.time.LocalDate for the field — JPA maps it to a DATE column with no
fuss. For the query, a derived name like findByDoneFalseAndDueDateBefore(LocalDate date) works;
pass LocalDate.now() from the service.// model: private LocalDate dueDate; (nullable — no annotation needed)
// repository
List<Task> findByDoneFalseAndDueDateBefore(LocalDate date);
// service
public List<Task> overdue() { return repository.findByDoneFalseAndDueDateBefore(LocalDate.now()); }
// controller
@GetMapping("/overdue")
public List<Task> overdue() { return service.overdue(); }
Null due dates are excluded automatically — DueDateBefore can't
match a null, so the third check passes for free. Add the field to the DTO/create path too so you can set
it.
Write a unit test (Chapter 5 style) for each new service method here. Add @Valid constraints where
sensible (a priority should be 1–5: @Min(1) @Max(5)). Every one is the same walk down the same
layers — the repetition is the point; it's how the shape becomes automatic.
The practice habits
@NotBlank/@Min on the DTO + @Valid at the controllerfindByDoneFalseOrderByPriorityAsc, …ContainingIgnoreCase,
…DueDateBeforePageable/Page before hand-rollingChapter 14 — Build from scratch. No TaskFlow to lean on. You get a product spec and a task
breakdown for a brand-new app, an empty start.spring.io, and a checklist — and you'll blueprint each
one first with the Chapter 7 method. This is where "I can't write it myself" turns into "I just did."
Current state: TaskFlow has layered persistence, boundary validation, tests, logging, and authorization. Outcome: one fresh requirement change spanning them. Prerequisite: you may revisit prior chapters; this is synthesis, not a lock. Java bridge: composition lets each concern remain testable. Estimate: 60 minutes.
Add a validated due date and reminder preference to a task, expose it through DTOs, persist it, enforce an owner rule, log the decision, and test both the allowed and rejected cases.
Likely files: controller/TaskController.java, dto/TaskRequest.java, service/TaskService.java, repository/TaskRepository.java, model/Task.java, relevant tests and logging configuration. Suggested commit: feat: add taskflow reminder policy.
Before opening the solution: create a requirement checklist for controller, service, repository, validation, tests, logging, and authorization; run every proof yourself.
The independent capstone follows only after this integrated proof, so you can carry the method rather than copy TaskFlow.