Spring Boot by Building · Chapter 8

Design in action — blueprint to working code

We run the Chapter 7 cycle end-to-end and build SpendLog. The star of the chapter is the AlertService interface with two implementations, wired by dependency injection — so you finally see why interfaces exist, how variants bundle with the service and its tests, and how a blueprint becomes files one-to-one.

Chapter 8 · We do — fully guided · A second full app · Hands-on ~50 min


Choose your route

Study the same Design in action: blueprint to working code 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

Prove the design method works by shipping from it. You'll watch every box in the Fig-1 blueprint become a class, build the app one vertical slice at a time, and implement the "alert when over limit" rule using an interface + two impls chosen by config — the canonical reason abstraction earns its place. By the end you've built a second, structurally different app, so the shape is no longer "the TaskFlow shape" — it's just the shape.

// the blueprint we're building (from Chapter 7)

Expense entity (amount, category, date, note) + Category enum · ExpenseRepository (JpaRepository) · ExpenseService (rules incl. monthly total + over-limit alert) · AlertService interface → EmailAlertService / SmsAlertService · ExpenseController. Start a fresh project via the Chapter-7 starter ritual (artifact spendlog).

Slice 1 · ModelExpense + the Category enum

Step 1 of the cycle said: nouns → data. expense is the entity; category is a fixed set → an enum (a noun with a closed list of values, not its own table).

model/Category.java
package com.example.spendlog.model;

public enum Category { FOOD, TRAVEL, BILLS, OTHER }
model/Expense.java
package com.example.spendlog.model;

import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;

@Entity @Table(name = "expenses")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class Expense {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private BigDecimal amount;               // money → BigDecimal, never double

    @Enumerated(EnumType.STRING)             // store the enum as text, not a number
    @Column(nullable = false)
    private Category category;

    @Column(nullable = false)
    private LocalDate date;

    private String note;
}
two design-worthy field choices
  • BigDecimal amount — money is never double (rounding errors). This is a decision, recorded in the model.
  • @Enumerated(EnumType.STRING) — persist the enum as "FOOD", not its ordinal number. Ordinals break the moment you reorder the enum; strings are safe. A classic JPA gotcha, decided once.

Slice 2 · RepositoryQueries the verbs demanded

Verbs "filter by category" and "monthly total" told us which queries to declare. The filter is a derived query; the sum is a custom @Query because it aggregates.

repository/ExpenseRepository.java
package com.example.spendlog.repository;

import com.example.spendlog.model.Category;
import com.example.spendlog.model.Expense;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

public interface ExpenseRepository extends JpaRepository<Expense, Long> {

    List<Expense> findByCategory(Category category);          // derived query

    @Query("select coalesce(sum(e.amount), 0) from Expense e " + "where e.date between :start and :end")
    BigDecimal totalBetween(@Param("start") LocalDate start, @Param("end") LocalDate end);      // custom aggregate
}
when derived isn't enough
  • Derived queries (findByCategory) handle "find rows where…". For computing a sum you write a small @Query in JPQL (SQL over your entities). coalesce(..., 0) makes an empty month return 0, not null — a rule pushed to the query where it belongs.

Slice 3 · AbstractionThe AlertService interface and its implementations

Here's the chapter's centrepiece — the one place step 4 said "interface," because the spec has multiple answers to "how do we alert?" First the socket, then an appliance:

alert/AlertService.java — the interface (the "socket")
package com.example.spendlog.alert;

public interface AlertService {
    void sendOverLimitAlert(String message);
}
alert/EmailAlertService.java — impl #1
package com.example.spendlog.alert;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service                                   // this is the bean that satisfies AlertService
public class EmailAlertService implements AlertService {
    @Override
    public void sendOverLimitAlert(String message) {
        // a real one would call an email API; we log to prove it fired
        log.info("[EMAIL] {}", message);
    }
}
alert/SmsAlertService.java — impl #2 (the "maybe later")
package com.example.spendlog.alert;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
// @Service   <-- leave OFF for now; see the DI note below
public class SmsAlertService implements AlertService {
    @Override
    public void sendOverLimitAlert(String message) {
        log.info("[SMS] {}", message);
    }
}
// the DI question everyone hits: two impls, which one gets injected?

If both classes are @Service, Spring finds two beans for one AlertService type and fails at startup with "expected single matching bean but found 2." Three clean fixes — pick one:

FixHowUse when
Only one is a beanPut @Service on Email only (as above); SMS isn't wired yet.Simplest — the "later" impl isn't live.
@PrimaryBoth are beans; mark the default with @Primary. One is the usual choice, the other occasional.
@Qualifier / @ConditionalOnPropertyName the wanted bean at the injection point, or switch by a config property.Choose per-environment from application.properties.
// this IS the payoff of the interface

The day SMS goes live you add @Service (or a @Primary/property) to SmsAlertService — and nothing in ExpenseService changes, because it depends on AlertService, the socket, not on any appliance. That swap-without-touching-callers is the entire reason step 4 chose an interface here. You've now felt it, not just read it.


Slice 4 · ServiceThe rules, and where the interface gets used

The service depends on the repository and the AlertService interface. The over-limit rule lives here — that's the "decision" the spec described.

service/ExpenseService.java
package com.example.spendlog.service;

import com.example.spendlog.alert.AlertService;
import com.example.spendlog.model.*;
import com.example.spendlog.repository.ExpenseRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

@Service
public class ExpenseService {

    private final ExpenseRepository repository;
    private final AlertService alertService;             // the INTERFACE, not an impl
    private final BigDecimal limit;

    public ExpenseService(ExpenseRepository repository, AlertService alertService,     // Spring injects EmailAlertService
    @Value("${spendlog.alert-limit:1000}") BigDecimal limit) {
        this.repository = repository;
        this.alertService = alertService;
        this.limit = limit;                              // configurable, from properties
    }

    public Expense add(Expense expense) {
        Expense saved = repository.save(expense);
        if (saved.getAmount().compareTo(limit) > 0) {   // THE rule
            alertService.sendOverLimitAlert("Expense of " + saved.getAmount() + " exceeds limit " + limit);
        }
        return saved;
    }

    public List<Expense> byCategory(Category c) { return repository.findByCategory(c); }

    public BigDecimal monthlyTotal(int year, int month) {
        LocalDate start = LocalDate.of(year, month, 1);
        LocalDate end = start.withDayOfMonth(start.lengthOfMonth());
        return repository.totalBetween(start, end);
    }
}
read how the pieces bundle
  • The field type is AlertService (interface). Spring sees one bean implementing it (EmailAlertService) and injects that. Swap which impl is the bean → this class is untouched.
  • @Value("${spendlog.alert-limit:1000}") injects the limit from config (default 1000) — the "configurable limit" from the spec, wired without a constant in code.
  • The over-limit decision sits in the service, the alert mechanism sits behind the interface, the storage sits in the repository. Three concerns, three homes — the whole design method in one method body.

Slice 5 · ControllerThe routes, thin as ever

controller/ExpenseController.java
package com.example.spendlog.controller;

import com.example.spendlog.model.*;
import com.example.spendlog.service.ExpenseService;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;

@RestController
@RequestMapping("/expenses")
public class ExpenseController {

    private final ExpenseService service;
    public ExpenseController(ExpenseService service) { this.service = service; }

    @PostMapping
    public Expense add(@RequestBody Expense expense) { return service.add(expense); }

    @GetMapping                                          // GET /expenses?category=FOOD
    public List<Expense> byCategory(@RequestParam Category category) {
        return service.byCategory(category);
    }

    @GetMapping("/total")                                // GET /expenses/total?year=2026&month=8
    public BigDecimal monthlyTotal(@RequestParam int year, @RequestParam int month) {
        return service.monthlyTotal(year, month);
    }
}

Run it, add a big expense, and watch the interface fire through the log:

$ curl -X POST http://localhost:8080/expenses -H "Content-Type: application/json" \ -d '{"amount":1500,"category":"TRAVEL","date":"2026-08-23","note":"flight"}' {"id":1,"amount":1500,"category":"TRAVEL","date":"2026-08-23","note":"flight"} # in the app console — the EmailAlertService ran because 1500 > 1000: INFO c.e.s.alert.EmailAlertService - [EMAIL] Expense of 1500 exceeds limit 1000

Slice 6 · TestProve the rule — mock the interface

Here's how the interface pays off a second time: in the test you inject a mock AlertService and verify it's called only when over the limit. No email is ever sent; you're testing the decision, not the mechanism.

src/test/java/.../service/ExpenseServiceTest.java
@ExtendWith(MockitoExtension.class)
class ExpenseServiceTest {

    @Mock ExpenseRepository repository;
    @Mock AlertService alertService;          // mock the interface — no real email

    ExpenseService service;

    @BeforeEach
    void setUp() {
        // limit = 1000 for the test; construct the service by hand with the mocks
        service = new ExpenseService(repository, alertService, new BigDecimal("1000"));
        when(repository.save(any(Expense.class))).thenAnswer(i -> i.getArgument(0));
    }

    @Test
    void add_overLimit_firesAlert() {
        service.add(new Expense(null, new BigDecimal("1500"), Category.TRAVEL, LocalDate.now(), "x"));
        verify(alertService, times(1)).sendOverLimitAlert(anyString());
    }

    @Test
    void add_underLimit_doesNotAlert() {
        service.add(new Expense(null, new BigDecimal("50"), Category.FOOD, LocalDate.now(), "x"));
        verify(alertService, never()).sendOverLimitAlert(anyString());
    }
}
why this is only possible because of the design choices
  • Constructor injection lets the test build the service with fakes — no Spring needed.
  • The interface lets you mock the alerter cleanly (you're stubbing a contract, not a concrete email class).
  • verify(..., times(1)) vs never() encodes the rule "alert iff over limit" as two fast tests. Design, DI, interfaces, and Mockito all bundling together — exactly the "how it all fits" you wanted to see.

Section 7Look back: blueprint → files, one-to-one

com.example.spendlog ├─ model/ Expense.java // noun → entity │ Category.java // noun w/ fixed values → enum ├─ repository/ ExpenseRepository.java // "filter", "total" verbs → queries ├─ alert/ AlertService.java // "email now / SMS later" → interface │ EmailAlertService.java // impl #1 (the live bean) │ SmsAlertService.java // impl #2 (ready, not wired) ├─ service/ ExpenseService.java // rules: total + over-limit decision └─ controller/ ExpenseController.java // user-facing verbs → routes
// the whole point, landed

Every box in the Chapter 7 blueprint is a file, and every design decision (money=BigDecimal, category=enum, alert=interface, limit=config) is visible in the code. You didn't improvise the structure — you derived it from the spec with a repeatable cycle. That's the skill you were missing, and you just ran it start to finish.


Section 8Exercise — extend the design honestly

Exercise 8.1 you do

Make SMS the active alerter — without touching ExpenseService

Goal. Switch the live alert channel from email to SMS, changing no code in ExpenseService. Prove it by seeing [SMS] in the log instead of [EMAIL].

  • Only the alert package / a property changes.
  • ExpenseService is byte-for-byte unchanged.
  • An over-limit expense now logs [SMS] ....
Hint 1 — the two-bean problem
If you just add @Service to SMS, startup fails (two beans). Either move @Service from Email to SMS, or mark SMS @Primary and leave Email a bean too.
Show one clean solution

Simplest: move the @Service annotation from EmailAlertService to SmsAlertService. Now SMS is the only AlertService bean, Spring injects it, and ExpenseService — which asked for the interface — gets SMS with no edit.

Cleaner for real apps: keep both beans, add @ConditionalOnProperty(name="spendlog.channel", havingValue="sms") to SMS and havingValue="email" (with matchIfMissing=true) to Email, then flip spendlog.channel=sms in application.properties. Channel chosen by config, zero code change. That is the interface earning its keep.


Notebook Summary

The design cycle, built

Blueprint → files

Interface + impls with DI

Design-worthy field choices

Box these

  • ⭐The interface pays off twice: swap impls in prod, mock it in tests. Same seam.
  • ⚠Two beans of one interface type → startup error. Decide with @Primary/@Qualifier/one-bean.
  • 💡Decision in the service, mechanism behind the interface, storage in the repository — three concerns, three homes.
// next — production concerns

Chapter 9 — API boundaries and Chapter 10 — relationships make TaskFlow more realistic before security. Then Chapters 11 and 12 add the guard at the door: users, roles, Basic Auth, then stateless tokens. After that, Chapter 13 is guided practice and Chapter 14 hands you real specs and a blank editor.

TaskFlow checkpoint

Current state: a deliberate blueprint. Outcome: a second design implemented through clean seams. Prerequisite: boundary-first design. Java bridge: polymorphism lets one service depend on a stable contract. Estimate: 45 minutes.

Mission: implement the design one vertical slice at a time

Build the specified behaviour, prove it with tests and curl, and explain which shared Spring shape transfers from TaskFlow without copying TaskFlow into this app.

  • One vertical slice works end-to-end.
  • The variation is isolated behind a purposeful interface.
  • Tests and a manual request prove the observable result.

Likely files: controller, service, repository, and one interface implementation. Suggested commit: feat: implement design through clean seams.

Hint 1
Keep the interface at the service-facing boundary.
Hint 2
Implement a thin working slice before a complete class hierarchy.
Hint 3
Use the project’s behaviour, not TaskFlow names, in tests.

Before opening the solution: prove one slice, explain one seam, and find its test.

Reference patch — clean seam
The controller depends on a service contract; implementations stay replaceable where the requirement actually varies.

Recall before continuing

  1. What remains stable across two Spring apps?
  2. What makes an interface purposeful?
  3. How do you prove a vertical slice?

The next workshop brings the lessons back to TaskFlow’s HTTP boundary and ownership rules.