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.
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.
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.
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.
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).
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).
package com.example.spendlog.model;
public enum Category { FOOD, TRAVEL, BILLS, OTHER }
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;
}
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.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.
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
}
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.AlertService interface and its implementationsHere'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:
package com.example.spendlog.alert;
public interface AlertService {
void sendOverLimitAlert(String message);
}
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);
}
}
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);
}
}
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:
| Fix | How | Use when |
|---|---|---|
| Only one is a bean | Put @Service on Email only (as above); SMS
isn't wired yet. | Simplest — the "later" impl isn't live. |
| @Primary | Both are beans; mark the default with @Primary. |
One is the usual choice, the other occasional. |
| @Qualifier / @ConditionalOnProperty | Name the wanted bean at the injection point, or switch by a config property. | Choose per-environment from
application.properties. |
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.
The service depends on the repository and the AlertService interface. The over-limit rule
lives here — that's the "decision" the spec described.
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);
}
}
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.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:
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.
@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());
}
}
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.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.
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].
ExpenseService is byte-for-byte unchanged.[SMS] ....@Service to SMS, startup fails (two beans). Either move
@Service from Email to SMS, or mark SMS @Primary and leave Email a bean too.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.
The design cycle, built
@Query@Primary /
@Qualifier / @ConditionalOnPropertyBigDecimal · enum persisted as @Enumerated(EnumType.STRING)@Value("${...:default}") from config, not a constantChapter 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.
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.
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.
Likely files: controller, service, repository, and one interface implementation. Suggested commit: feat: implement design through clean seams.
Before opening the solution: prove one slice, explain one seam, and find its test.
The next workshop brings the lessons back to TaskFlow’s HTTP boundary and ownership rules.