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.
Assumes you know
After this you should be able to
- Implement a design incrementally
- Use interfaces to isolate variation
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.
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.
- 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.
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).
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).
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 neverdouble(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.
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
}- Derived queries (
findByCategory) handle "find rows where…". For computing a sum you write a small@Queryin 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:
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.
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.
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);
}
}- 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#
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:
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.
@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());
}
}- 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))vsnever()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#
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#
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.
ExpenseServiceis byte-for-byte unchanged.- An over-limit expense now logs
[SMS] ....
Hint 1 — the two-bean problem
@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.
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
Hint 2
Hint 3
Before opening the solution: prove one slice, explain one seam, and find its test.
Reference patch — clean seam
Recall before continuing#
- What remains stable across two Spring apps?
- What makes an interface purposeful?
- How do you prove a vertical slice?
The next workshop brings the lessons back to TaskFlow’s HTTP boundary and ownership rules.