Study Notes
Spring Boot by Buildingintegration

Design in action: blueprint to working code

Build SpendLog from blueprint to code, using an AlertService interface and multiple implementations to make interface-based design concrete.

45 min read·verified Spring Boot 4.x·reviewed 2026-08-28

After this you should be able to

  • Implement a design incrementally
  • Use interfaces to isolate variation
SpendLogInterfaceTransfer

Spring Boot by Building · Chapter 8

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
  • Implement a design incrementally
  • Use interfaces to isolate variation

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.

Learn next: Use implement a design incrementally as the base, then configure a security filter chain in Security: Spring Security & Basic Auth. Interview next: Use implement a design incrementally as the base, then integrate spring boot skills in one application in TaskFlow Integration Sprint.

Continue to Security: Spring Security & Basic Auth

// 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#

  • noun → entity/enum · verb → service method + route · aggregate verb → @Query
  • "multiple answers" verb → interface + impls in their own package

Interface + impls with DI#

  • Service depends on the interface; Spring injects the bean impl
  • Two impls → pick with one bean / @Primary / @Qualifier / @ConditionalOnProperty
  • Swap impl → callers unchanged (the payoff)

Design-worthy field choices#

  • Money → BigDecimal · enum persisted as @Enumerated(EnumType.STRING)
  • Tunable value → @Value("${...:default}") from config, not a constant

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.