diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e2043d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Build, lint and test + run: mvn -B verify diff --git a/.gitignore b/.gitignore index 524f096..c9b1788 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,14 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +# Maven +target/ + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project diff --git a/README.md b/README.md index 3341257..644d5df 100644 --- a/README.md +++ b/README.md @@ -1 +1,85 @@ -# java_patterns \ No newline at end of file +# Java Design Patterns — Bookshop Examples + +📖 **Read this as a website:** https://williajm.github.io/java_patterns/ + +Mainstream design patterns you'll actually meet in real Java codebases, each demonstrated with a +small, self-contained **bookshop** example. No academic filler: only patterns that working Java +developers both *encounter constantly* (in the JDK, Spring, and friends) and *hand-write* in +ordinary application code. + +Every pattern has: + +- a focused implementation under `src/main/java/com/bookshop///` +- a `README.md` in the same package explaining the problem, the benefits, and where the pattern + appears in the wild +- a JUnit test written as **executable documentation** — read the tests to see the pattern's + payoff demonstrated, not just described + +## The patterns + +| Category | Pattern | Bookshop example | Key benefit | +|---|---|---|---| +| Creational | [Builder](src/main/java/com/bookshop/creational/builder/README.md) | Assembling an `Order` with optional gift message, promo code, express delivery | Readable construction of immutable objects; no telescoping constructors | +| Creational | [Factory Method](src/main/java/com/bookshop/creational/factorymethod/README.md) | Creating card/PayPal/gift-card processors from a `PaymentMethod` | Callers never couple to concrete classes; new methods touch one place | +| Creational | [Singleton](src/main/java/com/bookshop/creational/singleton/README.md) | Shop-wide config and feature flags via the enum idiom | Guaranteed single shared instance — with an honest "prefer DI" caveat | +| Structural | [Adapter](src/main/java/com/bookshop/structural/adapter/README.md) | Wrapping a legacy pence-and-status-codes payment gateway behind a clean interface | Incompatible APIs cooperate; the ugliness is quarantined in one class | +| Structural | [Decorator](src/main/java/com/bookshop/structural/decorator/README.md) | Stacking gift wrap, express handling and greeting card onto an order | Combine extras at runtime without 2ⁿ subclasses | +| Structural | [Facade](src/main/java/com/bookshop/structural/facade/README.md) | One `checkout()` call orchestrating inventory, payment and shipping | The correct sequence is written once; callers can't get it wrong | +| Structural | [Proxy](src/main/java/com/bookshop/structural/proxy/README.md) | A caching stand-in for the distributor's slow remote catalog | Caching/laziness/access control added without touching callers | +| Behavioral | [Strategy](src/main/java/com/bookshop/behavioral/strategy/README.md) | Swappable discount rules: loyalty, bulk, seasonal sale | Pricing rules are configuration, not `if/else` ladders; each testable alone | +| Behavioral | [Observer](src/main/java/com/bookshop/behavioral/observer/README.md) | Email/SMS/dashboard reactions to restocks | Event source is decoupled from its ever-growing list of reactions | +| Behavioral | [Template Method](src/main/java/com/bookshop/behavioral/templatemethod/README.md) | One sales-report skeleton, CSV and HTML variants | The algorithm exists once; formats can't drift apart | +| Behavioral | [Chain of Responsibility](src/main/java/com/bookshop/behavioral/chainofresponsibility/README.md) | Order validation pipeline: stock → address → fraud | Checks are independent components; the pipeline is just a list | + +## Principles: SOLID + DRY + +The same bookshop, applied to design principles rather than patterns. Each package shows the +*good* design in code and the violation as a snippet in its README, with tests demonstrating the +payoff. + +| Principle | Bookshop example | Key benefit | +|---|---|---| +| [Single Responsibility](src/main/java/com/bookshop/solid/srp/README.md) | Receipt totals, printing and storage as three classes | A receipt redesign can't break the maths | +| [Open/Closed](src/main/java/com/bookshop/solid/ocp/README.md) | New shipping rates plug in; the quote calculator is never edited | New behaviour without re-risking tested code | +| [Liskov Substitution](src/main/java/com/bookshop/solid/lsp/README.md) | A free sample chapter is *not* a `Purchasable`, so it can't reach the till | Contract violations become compile errors, not incidents | +| [Interface Segregation](src/main/java/com/bookshop/solid/isp/README.md) | `Bookseller`/`StockManager`/`Accountant` roles instead of one fat staff interface | No stubbed methods; till code can't call accounting | +| [Dependency Inversion](src/main/java/com/bookshop/solid/dip/README.md) | `OrderService` depends on gateway/repository interfaces, details injected | Business logic tested with fakes; infrastructure swappable | +| [DRY](src/main/java/com/bookshop/dry/README.md) | One authoritative `Vat` class; receipt and invoice cannot disagree | A rule change is one edit; no silently-drifting copies | + +## Anti-patterns, and using all this with judgement + +Two docs that keep the rest of the repo honest: + +- **[Anti-patterns](docs/anti-patterns.md)** — the five failure modes you'll actually meet (God + Class, magic numbers, copy-paste, global mutable state, Golden Hammer), each with its + counter-example in this repo. +- **[Use with judgement](docs/use-with-judgement.md)** — none of these are rigid rules. Every + pattern and principle here has a cost as well as a benefit, overuse is its own anti-pattern, and + the page gives concrete signals for both over- and under-engineering. + +## Running + +Requires JDK 17+ and Maven. + +```bash +mvn verify # lints (Checkstyle) and runs every pattern's tests +``` + +## What's deliberately left out — and why + +Half the Gang of Four catalogue rarely appears in modern application code. Excluded on purpose: + +- **Command** — its niche (tasks as objects) is covered by `Runnable` and lambdas; hand-rolled + undo stacks are rare. +- **State** — real projects usually reach for an enum + switch or a state-machine library rather + than the class-per-state shape. +- **Composite** — common *inside* UI/tree libraries, rarely hand-written in application code. +- **Iterator** — absorbed into the language: `Iterable` and for-each. +- **Abstract Factory, Prototype, Bridge, Flyweight, Visitor, Mediator, Memento, Interpreter** — + superseded by dependency injection, enums and lambdas, or simply too rare to earn a place in a + "patterns you'll actually use" list. + +If you're deciding whether to use a pattern at all: the tests here show the *problem* each pattern +earns its keep on. No matching problem, no pattern — the simplest code that works wins. The full +argument, including the signals of over- and under-engineering, is in +[Use with judgement](docs/use-with-judgement.md). diff --git a/_config.yml b/_config.yml new file mode 100644 index 0000000..35c8860 --- /dev/null +++ b/_config.yml @@ -0,0 +1,5 @@ +title: Java Design Patterns — Bookshop Examples +description: >- + Mainstream Java design patterns and SOLID principles, each demonstrated with a + small bookshop example and tests written as executable documentation. +theme: jekyll-theme-cayman diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..3cc7f3d --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/anti-patterns.md b/docs/anti-patterns.md new file mode 100644 index 0000000..3961d01 --- /dev/null +++ b/docs/anti-patterns.md @@ -0,0 +1,87 @@ +# Anti-patterns + +The failure modes you'll actually meet. These are shown as snippets only — deliberately bad code +doesn't belong in `src/` where it might get copied. Each entry links to the counter-example +elsewhere in this repo. + +None of these start as sabotage. Each one is a *reasonable shortcut taken one time too many* — +which is why they're worth naming: you recognise the slide while it's still cheap to stop. + +## 1. God Class (the Blob) + +```java +class BookshopManager { + // 4,000 lines: pricing, stock, customer emails, the sales report, + // gift card balances, and the CSV import "for now". + void doEverything(...) { ... } +} +``` + +**How it happens:** every new feature is "just one more method" on the class that already has the +data. **The damage:** every change risks everything else; nobody can test (or safely delete) +anything; merge conflicts concentrate in one file. **The counter:** one reason to change per class +— [SRP](../src/main/java/com/bookshop/solid/srp/README.md) — and a +[Facade](../src/main/java/com/bookshop/structural/facade/README.md) when callers genuinely want +one entry point over many small parts. + +## 2. Magic numbers and strings + +```java +if (order.total().compareTo(new BigDecimal("25.00")) >= 0) { ... } // 25.00 of what? why? +if (status == 3) { refund(order); } // 3 means... shipped? lost? +``` + +**How it happens:** the value was obvious to whoever typed it, that afternoon. **The damage:** the +business rule is unfindable and unsearchable; when "free shipping over £25" becomes £30, some of +the 25.00s get updated (see [copy-paste programming](#3-copy-paste-programming)); `status == 3` +invites off-by-one bugs no compiler can catch. **The counter:** named constants +(`FREE_SHIPPING_THRESHOLD`) and enums — +[`PaymentMethod`](../src/main/java/com/bookshop/creational/factorymethod/PaymentMethod.java) makes +invalid payment types unrepresentable, and the compiler checks every `switch` over it. + +## 3. Copy-paste programming + +```java +class CustomerReceipt { ... net.multiply(new BigDecimal("1.20")) ... } +class SupplierInvoice { ... net.multiply(new BigDecimal("1.20")) ... } +class RefundNote { ... net.multiply(new BigDecimal("1.175")) ... } // missed in the update +``` + +**How it happens:** copying working code *feels* safe — it worked over there. **The damage:** one +piece of knowledge now lives in several places and they drift; every copy must be found for every +change, and the one you miss is a silent bug. **The counter:** +[DRY](../src/main/java/com/bookshop/dry/README.md) — one authoritative home per piece of +knowledge. (And note DRY's own overuse warning there: deduplicate knowledge, not coincidence.) + +## 4. Global mutable state (Singleton abuse) + +```java +class Globals { + static ShopConfig config; + static Map cache = new HashMap<>(); + static Customer currentCustomer; // "temporarily", since 2019 +} +``` + +**How it happens:** the first global saves an hour of parameter-plumbing; each next one follows +the precedent. **The damage:** method signatures lie (nothing says `checkout()` reads +`currentCustomer`); tests pass or fail depending on what ran before them; concurrency bugs appear +under load only. **The counter:** the +[Singleton package](../src/main/java/com/bookshop/creational/singleton/README.md) shows the +disciplined version *and* its honest caveat, and +[DIP](../src/main/java/com/bookshop/solid/dip/README.md) shows the alternative: pass dependencies +in through constructors, where they're visible and swappable. + +## 5. Golden Hammer (pattern-itis) + +```java +public class AbstractBookFactoryStrategyProviderImpl + implements BookFactoryStrategyProvider { // it returns... a Book. That's all it does. +``` + +**How it happens:** patterns get learned, then get *looked for* — every problem starts resembling +the new hammer. This is the anti-pattern of this very repo's subject matter. **The damage:** five +files and three indirections where a constructor call belonged; readers must unwind ceremony to +find two lines of logic; the codebase optimises for imagined futures over the present. **The +counter:** every pattern here opens with *the problem it solves*. No matching problem, no pattern +— the full argument is on [Use with judgement](use-with-judgement.md). diff --git a/docs/use-with-judgement.md b/docs/use-with-judgement.md new file mode 100644 index 0000000..8cbd882 --- /dev/null +++ b/docs/use-with-judgement.md @@ -0,0 +1,53 @@ +# Use with judgement + +Everything in this repo — the patterns, SOLID, DRY — is a **tool for a specific problem**, not a +rule to comply with. That framing matters, because every one of these tools has a cost, and paying +the cost without having the problem makes code *worse*. Design maturity isn't knowing the +patterns; it's knowing when not to use them. + +## Every tool has a price + +The benefit column in this repo's tables is real, but so is the invoice: + +| Tool | Solves | Costs when the problem isn't there | +|---|---|---| +| Builder | Many optional fields | Fifty lines of ceremony for a two-field class — a constructor was clearer | +| Factory Method | Callers coupled to concrete types | An indirection layer over a single `new` that never varies | +| Strategy | Multiple interchangeable algorithms | An interface, a context, and one lonely implementation — an `if` was clearer | +| Interfaces everywhere | Multiple implementations / substitution in tests | `BookService` + `BookServiceImpl` pairs that only ever have one member | +| SRP | Unrelated reasons-to-change tangled together | Logic shattered into confetti — ten two-line classes nobody can follow | +| DRY | One piece of knowledge in several places | Coincidentally-similar code welded together, coupling rules that change separately | +| Observer | Many independent reactions to an event | Control flow nobody can trace — "what happens when I restock?" has no findable answer | + +## Signals you're overdoing it + +- More structure than logic: files, interfaces and indirections outnumber the lines that actually + do something. +- Abstractions with exactly one caller or one implementation, kept "because we might need it". + You aren't gonna need it (YAGNI) — and when you are, *that's* the day to add it. +- You can't answer "what problem does this layer solve?" with something that has already happened + at least once. +- Reading a two-line change requires opening five files. + +## Signals you're underdoing it + +- Shotgun surgery: one business change means edits scattered across many files (missing DRY/SRP). +- An `if/else` ladder that grows every sprint (Strategy/OCP's problem has arrived). +- Business logic you can't test without credentials, networks, or a database (DIP's problem). +- A class everyone edits and nobody understands (the God Class — see + [anti-patterns](anti-patterns.md)). + +## How to calibrate + +1. **Start with the simplest thing that works.** A constructor, a plain method, one class. +2. **Let the problem show up before the solution.** The *second or third* time a change hurts, the + real shape of the abstraction is visible — extract it then (the "rule of three"). Patterns + applied speculatively usually guess the wrong axis of change. +3. **Refactor *toward* patterns, don't start *from* them.** Their biggest everyday value is + vocabulary: "this wants to be a Strategy" is a one-sentence design review. +4. **Match the effort to the mattering.** The order-money path deserves DIP and careful seams; a + one-off report script does not. + +The tests in this repo model this deliberately: each one demonstrates a pattern *earning its keep* +against a concrete problem — telescoping constructors, drifting VAT copies, an untestable payment +call. If your code doesn't have the problem, it shouldn't have the pattern. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..5aa042a --- /dev/null +++ b/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + com.bookshop + java-patterns + 1.0.0-SNAPSHOT + jar + + Java Design Patterns — Bookshop Examples + Mainstream Java design patterns demonstrated with bookshop examples. + + + 17 + UTF-8 + 5.10.2 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.1 + + + com.puppycrawl.tools + checkstyle + 10.17.0 + + + + checkstyle.xml + true + true + true + + + + checkstyle-check + validate + + check + + + + + + + diff --git a/src/main/java/com/bookshop/behavioral/chainofresponsibility/AddressCheck.java b/src/main/java/com/bookshop/behavioral/chainofresponsibility/AddressCheck.java new file mode 100644 index 0000000..a28f666 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/chainofresponsibility/AddressCheck.java @@ -0,0 +1,14 @@ +package com.bookshop.behavioral.chainofresponsibility; + +/** Rejects orders without a usable shipping address. */ +public class AddressCheck extends OrderCheck { + + @Override + protected ValidationResult check(OrderRequest request) { + String address = request.shippingAddress(); + if (address == null || address.isBlank()) { + return ValidationResult.rejected("address", "shipping address is missing"); + } + return ValidationResult.ok(); + } +} diff --git a/src/main/java/com/bookshop/behavioral/chainofresponsibility/FraudCheck.java b/src/main/java/com/bookshop/behavioral/chainofresponsibility/FraudCheck.java new file mode 100644 index 0000000..29fcc12 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/chainofresponsibility/FraudCheck.java @@ -0,0 +1,21 @@ +package com.bookshop.behavioral.chainofresponsibility; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; + +/** Flags suspiciously large first orders from brand-new customers. */ +public class FraudCheck extends OrderCheck { + + private static final BigDecimal NEW_CUSTOMER_LIMIT = new BigDecimal("500.00"); + + @Override + protected ValidationResult check(OrderRequest request) { + BigDecimal total = request.books().stream() + .map(Book::price) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (request.customer().loyaltyYears() == 0 && total.compareTo(NEW_CUSTOMER_LIMIT) > 0) { + return ValidationResult.rejected("fraud", "large first order needs manual review"); + } + return ValidationResult.ok(); + } +} diff --git a/src/main/java/com/bookshop/behavioral/chainofresponsibility/OrderCheck.java b/src/main/java/com/bookshop/behavioral/chainofresponsibility/OrderCheck.java new file mode 100644 index 0000000..5ccf7d8 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/chainofresponsibility/OrderCheck.java @@ -0,0 +1,33 @@ +package com.bookshop.behavioral.chainofresponsibility; + +import java.util.List; + +/** + * A handler in the chain: performs its own check and, if satisfied, passes the + * request to the next handler. The chain itself is assembled with {@link #chainOf}. + */ +public abstract class OrderCheck { + + private OrderCheck next; + + /** Links the given checks in order and returns the head of the chain. */ + public static OrderCheck chainOf(List checks) { + if (checks.isEmpty()) { + throw new IllegalArgumentException("chain needs at least one check"); + } + for (int i = 0; i < checks.size() - 1; i++) { + checks.get(i).next = checks.get(i + 1); + } + return checks.get(0); + } + + public final ValidationResult validate(OrderRequest request) { + ValidationResult result = check(request); + if (!result.valid() || next == null) { + return result; + } + return next.validate(request); + } + + protected abstract ValidationResult check(OrderRequest request); +} diff --git a/src/main/java/com/bookshop/behavioral/chainofresponsibility/OrderRequest.java b/src/main/java/com/bookshop/behavioral/chainofresponsibility/OrderRequest.java new file mode 100644 index 0000000..434dbf8 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/chainofresponsibility/OrderRequest.java @@ -0,0 +1,9 @@ +package com.bookshop.behavioral.chainofresponsibility; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.util.List; + +/** An order as submitted, before the shop agrees to fulfil it. */ +public record OrderRequest(Customer customer, List books, String shippingAddress) { +} diff --git a/src/main/java/com/bookshop/behavioral/chainofresponsibility/README.md b/src/main/java/com/bookshop/behavioral/chainofresponsibility/README.md new file mode 100644 index 0000000..391695b --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/chainofresponsibility/README.md @@ -0,0 +1,40 @@ +# Chain of Responsibility + +## The problem + +Before the shop accepts an order it must pass a series of checks: is everything in stock? is there +a shipping address? does it look fraudulent? A single `validateOrder` method holding all the rules +becomes a wall of `if`s where the rules can't be reordered, reused, or tested independently — and +every new rule means editing (and re-risking) the same method. + +## The pattern + +Each check is a self-contained handler; handlers are linked into a chain and each either rejects +the request or passes it along: + +```java +OrderCheck validation = OrderCheck.chainOf(List.of( + new StockCheck(inStock), new AddressCheck(), new FraudCheck())); +ValidationResult result = validation.validate(request); +``` + +This is the servlet-filter / middleware shape: the pipeline is *configuration*, the steps are +*components*. + +## Benefits + +- **Steps are independent** — each check is a small class with one job, tested on its own. +- **The pipeline is data** — add, remove, or reorder checks by editing the list, not the logic. +- **Short-circuiting for free** — a rejection stops the chain; later (possibly expensive) checks + never run. +- **Separation of *what checks exist* from *which run*** — different channels (web, phone, trade) + can assemble different chains from the same parts. + +## Seen in the wild + +Servlet `Filter` chains, Spring Security's filter chain, Spring MVC interceptors, Netty's +`ChannelPipeline`, logging frameworks passing events up the logger hierarchy. + +## Example test + +[`OrderValidationChainTest`](../../../../../../test/java/com/bookshop/behavioral/chainofresponsibility/OrderValidationChainTest.java) diff --git a/src/main/java/com/bookshop/behavioral/chainofresponsibility/StockCheck.java b/src/main/java/com/bookshop/behavioral/chainofresponsibility/StockCheck.java new file mode 100644 index 0000000..c47185b --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/chainofresponsibility/StockCheck.java @@ -0,0 +1,24 @@ +package com.bookshop.behavioral.chainofresponsibility; + +import com.bookshop.domain.Book; +import java.util.Set; + +/** Rejects orders containing books the shop doesn't have. */ +public class StockCheck extends OrderCheck { + + private final Set inStockIsbns; + + public StockCheck(Set inStockIsbns) { + this.inStockIsbns = Set.copyOf(inStockIsbns); + } + + @Override + protected ValidationResult check(OrderRequest request) { + for (Book book : request.books()) { + if (!inStockIsbns.contains(book.isbn())) { + return ValidationResult.rejected("stock", "out of stock: " + book.title()); + } + } + return ValidationResult.ok(); + } +} diff --git a/src/main/java/com/bookshop/behavioral/chainofresponsibility/ValidationResult.java b/src/main/java/com/bookshop/behavioral/chainofresponsibility/ValidationResult.java new file mode 100644 index 0000000..7ea83ce --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/chainofresponsibility/ValidationResult.java @@ -0,0 +1,13 @@ +package com.bookshop.behavioral.chainofresponsibility; + +/** Outcome of running an order through the validation chain. */ +public record ValidationResult(boolean valid, String rejectedBy, String reason) { + + public static ValidationResult ok() { + return new ValidationResult(true, null, null); + } + + public static ValidationResult rejected(String checkName, String reason) { + return new ValidationResult(false, checkName, reason); + } +} diff --git a/src/main/java/com/bookshop/behavioral/observer/EmailAlerts.java b/src/main/java/com/bookshop/behavioral/observer/EmailAlerts.java new file mode 100644 index 0000000..0f72462 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/observer/EmailAlerts.java @@ -0,0 +1,20 @@ +package com.bookshop.behavioral.observer; + +import com.bookshop.domain.Book; +import java.util.ArrayList; +import java.util.List; + +/** One concrete observer: queues back-in-stock emails. */ +public class EmailAlerts implements StockListener { + + private final List outbox = new ArrayList<>(); + + @Override + public void onBackInStock(Book book) { + outbox.add("To waitlist: \"" + book.title() + "\" is back in stock!"); + } + + public List outbox() { + return List.copyOf(outbox); + } +} diff --git a/src/main/java/com/bookshop/behavioral/observer/Inventory.java b/src/main/java/com/bookshop/behavioral/observer/Inventory.java new file mode 100644 index 0000000..a452fe8 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/observer/Inventory.java @@ -0,0 +1,28 @@ +package com.bookshop.behavioral.observer; + +import com.bookshop.domain.Book; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * The subject: knows only that listeners exist, never what they do. Email, SMS, + * dashboards — all the same to the inventory. + */ +public class Inventory { + + private final List listeners = new CopyOnWriteArrayList<>(); + + public void subscribe(StockListener listener) { + listeners.add(listener); + } + + public void unsubscribe(StockListener listener) { + listeners.remove(listener); + } + + public void restock(Book book) { + for (StockListener listener : listeners) { + listener.onBackInStock(book); + } + } +} diff --git a/src/main/java/com/bookshop/behavioral/observer/README.md b/src/main/java/com/bookshop/behavioral/observer/README.md new file mode 100644 index 0000000..67688bd --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/observer/README.md @@ -0,0 +1,41 @@ +# Observer + +## The problem + +When a sold-out book is restocked, several things should happen: waitlisted customers get an email, +the storefront's "notify me" subscribers get a text, the sales dashboard updates. If `Inventory` +calls the email service, the SMS service, and the dashboard directly, the stock system depends on +every notification channel — and grows a new dependency each time marketing adds one. + +## The pattern + +Interested parties *subscribe* to the inventory; the inventory just announces the event to whoever +is currently listening: + +```java +inventory.subscribe(emailAlerts); +inventory.subscribe(book -> dashboard.increment(book.isbn())); // lambdas work too +inventory.restock(book); // every subscriber is notified +``` + +## Benefits + +- **Loose coupling** — the subject knows the listener *interface* only; notification channels come + and go without touching inventory code. +- **Open-ended reactions** — new behaviour (push notifications, analytics) is a new subscriber, not + a change to the event source. +- **Dynamic at runtime** — subscribe and unsubscribe as customers opt in and out. + +## A note on modern practice + +In-process, this is often spelled `ApplicationEventPublisher` (Spring) or an event bus; between +services it becomes messaging (Kafka, SQS). Same pattern, bigger arena. + +## Seen in the wild + +Swing/JavaFX listeners, `PropertyChangeListener`, Spring's `ApplicationEvent` + +`@EventListener`, `java.util.concurrent.Flow` (reactive streams). + +## Example test + +[`InventoryObserverTest`](../../../../../../test/java/com/bookshop/behavioral/observer/InventoryObserverTest.java) diff --git a/src/main/java/com/bookshop/behavioral/observer/StockListener.java b/src/main/java/com/bookshop/behavioral/observer/StockListener.java new file mode 100644 index 0000000..1f82dd6 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/observer/StockListener.java @@ -0,0 +1,10 @@ +package com.bookshop.behavioral.observer; + +import com.bookshop.domain.Book; + +/** The observer: implement this to be told when a book is back in stock. */ +@FunctionalInterface +public interface StockListener { + + void onBackInStock(Book book); +} diff --git a/src/main/java/com/bookshop/behavioral/strategy/DiscountStrategy.java b/src/main/java/com/bookshop/behavioral/strategy/DiscountStrategy.java new file mode 100644 index 0000000..77cf3f2 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/strategy/DiscountStrategy.java @@ -0,0 +1,15 @@ +package com.bookshop.behavioral.strategy; + +import com.bookshop.domain.Customer; +import java.math.BigDecimal; + +/** + * The strategy: one interchangeable pricing rule. A functional interface, so + * one-off strategies can be lambdas — the modern face of this pattern. + */ +@FunctionalInterface +public interface DiscountStrategy { + + /** Returns the discount (not the final price) for this subtotal and customer. */ + BigDecimal discountFor(BigDecimal subtotal, Customer customer); +} diff --git a/src/main/java/com/bookshop/behavioral/strategy/Discounts.java b/src/main/java/com/bookshop/behavioral/strategy/Discounts.java new file mode 100644 index 0000000..7b58dcd --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/strategy/Discounts.java @@ -0,0 +1,40 @@ +package com.bookshop.behavioral.strategy; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** The shop's standard discount strategies. */ +public final class Discounts { + + private Discounts() { + } + + public static DiscountStrategy none() { + return (subtotal, customer) -> BigDecimal.ZERO; + } + + /** 2% per full year of loyalty, capped at 10%. */ + public static DiscountStrategy loyalty() { + return (subtotal, customer) -> { + int percent = Math.min(customer.loyaltyYears() * 2, 10); + return percentOf(subtotal, percent); + }; + } + + /** 15% off orders of 50.00 or more. */ + public static DiscountStrategy bulkOrder() { + return (subtotal, customer) -> subtotal.compareTo(new BigDecimal("50.00")) >= 0 + ? percentOf(subtotal, 15) + : BigDecimal.ZERO; + } + + /** A flat percentage off everything, e.g. a summer sale. */ + public static DiscountStrategy seasonalSale(int percent) { + return (subtotal, customer) -> percentOf(subtotal, percent); + } + + private static BigDecimal percentOf(BigDecimal amount, int percent) { + return amount.multiply(BigDecimal.valueOf(percent)) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/bookshop/behavioral/strategy/PriceCalculator.java b/src/main/java/com/bookshop/behavioral/strategy/PriceCalculator.java new file mode 100644 index 0000000..7be31be --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/strategy/PriceCalculator.java @@ -0,0 +1,24 @@ +package com.bookshop.behavioral.strategy; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; + +/** + * The context: computes totals without knowing which discount rule is in force. + * Swapping the strategy changes pricing behaviour with zero changes here. + */ +public class PriceCalculator { + + private final DiscountStrategy discount; + + public PriceCalculator(DiscountStrategy discount) { + this.discount = discount; + } + + public BigDecimal totalFor(List books, Customer customer) { + BigDecimal subtotal = books.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + return subtotal.subtract(discount.discountFor(subtotal, customer)); + } +} diff --git a/src/main/java/com/bookshop/behavioral/strategy/README.md b/src/main/java/com/bookshop/behavioral/strategy/README.md new file mode 100644 index 0000000..75ed118 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/strategy/README.md @@ -0,0 +1,38 @@ +# Strategy + +## The problem + +The shop runs different pricing rules at different times: loyalty discounts, bulk-order discounts, +seasonal sales, sometimes none. Hard-coding them into the price calculator produces a growing +`if/else` ladder that must be re-tested in full every time marketing invents a new promotion. + +## The pattern + +Each rule is its own object behind one `DiscountStrategy` interface; the `PriceCalculator` context +is *configured* with a rule rather than *containing* the rules: + +```java +PriceCalculator januarySale = new PriceCalculator(Discounts.seasonalSale(20)); +PriceCalculator everyday = new PriceCalculator(Discounts.loyalty()); +``` + +`DiscountStrategy` is a `@FunctionalInterface`, so a one-off promotion is just a lambda — which is +how the pattern usually appears in modern Java (`Comparator` being the canonical example). + +## Benefits + +- **Swap algorithms at runtime** — the active promotion is data/configuration, not code structure. +- **Each rule tested in isolation** — a strategy is a tiny pure function; no combinatorial + calculator tests. +- **Open/closed** — a new promotion is a new strategy; the calculator and existing strategies are + untouched. +- **No conditional ladders** — the dispatch is polymorphism, not `if (promoType == ...)`. + +## Seen in the wild + +`Comparator` (sorting strategy), `ThreadFactory`, Spring Security's `PasswordEncoder`, Jackson's +`PropertyNamingStrategy`. + +## Example test + +[`PriceCalculatorTest`](../../../../../../test/java/com/bookshop/behavioral/strategy/PriceCalculatorTest.java) diff --git a/src/main/java/com/bookshop/behavioral/templatemethod/CsvSalesReport.java b/src/main/java/com/bookshop/behavioral/templatemethod/CsvSalesReport.java new file mode 100644 index 0000000..2eb2af6 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/templatemethod/CsvSalesReport.java @@ -0,0 +1,23 @@ +package com.bookshop.behavioral.templatemethod; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; + +/** CSV variant: supplies only the formatting steps, inherits the algorithm. */ +public class CsvSalesReport extends SalesReport { + + @Override + protected String header() { + return "title,author,price\n"; + } + + @Override + protected String line(Book sale) { + return sale.title() + "," + sale.author() + "," + sale.price() + "\n"; + } + + @Override + protected String footer(BigDecimal total) { + return "total,," + total + "\n"; + } +} diff --git a/src/main/java/com/bookshop/behavioral/templatemethod/HtmlSalesReport.java b/src/main/java/com/bookshop/behavioral/templatemethod/HtmlSalesReport.java new file mode 100644 index 0000000..8128447 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/templatemethod/HtmlSalesReport.java @@ -0,0 +1,23 @@ +package com.bookshop.behavioral.templatemethod; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; + +/** HTML variant: different formatting, identical structure and totalling logic. */ +public class HtmlSalesReport extends SalesReport { + + @Override + protected String header() { + return ""; + } + + @Override + protected String line(Book sale) { + return ""; + } + + @Override + protected String footer(BigDecimal total) { + return "
TitlePrice
" + sale.title() + "" + sale.price() + "
Total" + total + "
"; + } +} diff --git a/src/main/java/com/bookshop/behavioral/templatemethod/README.md b/src/main/java/com/bookshop/behavioral/templatemethod/README.md new file mode 100644 index 0000000..4a46929 --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/templatemethod/README.md @@ -0,0 +1,42 @@ +# Template Method + +## The problem + +The shop exports its daily sales report as CSV for accounting and HTML for the manager's dashboard. +The *structure* is identical — header, one line per sale, footer with the grand total — only the +formatting differs. Two independent report classes would duplicate the iteration and totalling +logic, and the copies would inevitably drift (one gets a bug fix, the other doesn't). + +## The pattern + +The invariant algorithm lives once in an abstract base class as a `final` method; the variable +steps are `protected abstract` hooks that each format fills in: + +```java +String csv = new CsvSalesReport().generate(sales); // same skeleton, +String html = new HtmlSalesReport().generate(sales); // different steps +``` + +## Benefits + +- **The algorithm is written once** — iteration order and total calculation cannot drift between + formats. +- **The sequence is protected** — `generate` is `final`; a subclass can change *how a line looks*, + never *whether the footer comes last*. +- **Adding a format is trivial** — implement three small methods; the hard part is inherited. + +## A note on modern practice + +When the base class would have only one hook, prefer passing a lambda (Strategy) instead of +subclassing. Template Method earns its keep when several steps vary together and the skeleton must +be enforced — which is why frameworks are full of it. + +## Seen in the wild + +`AbstractList`/`AbstractMap` (implement a few methods, inherit the rest), servlet `HttpServlet` +(`doGet`/`doPost` hooks inside a fixed `service` flow), JUnit's lifecycle around your `@Test` +methods, Spring's `AbstractApplicationContext.refresh()`. + +## Example test + +[`SalesReportTest`](../../../../../../test/java/com/bookshop/behavioral/templatemethod/SalesReportTest.java) diff --git a/src/main/java/com/bookshop/behavioral/templatemethod/SalesReport.java b/src/main/java/com/bookshop/behavioral/templatemethod/SalesReport.java new file mode 100644 index 0000000..70325ac --- /dev/null +++ b/src/main/java/com/bookshop/behavioral/templatemethod/SalesReport.java @@ -0,0 +1,30 @@ +package com.bookshop.behavioral.templatemethod; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.List; + +/** + * The template: {@link #generate} fixes the report's skeleton — header, one line + * per sale, footer with the total — and is {@code final} so no subclass can break + * the sequence. Subclasses fill in only the format-specific steps. + */ +public abstract class SalesReport { + + public final String generate(List sales) { + StringBuilder out = new StringBuilder(); + out.append(header()); + for (Book sale : sales) { + out.append(line(sale)); + } + BigDecimal total = sales.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + out.append(footer(total)); + return out.toString(); + } + + protected abstract String header(); + + protected abstract String line(Book sale); + + protected abstract String footer(BigDecimal total); +} diff --git a/src/main/java/com/bookshop/creational/builder/Order.java b/src/main/java/com/bookshop/creational/builder/Order.java new file mode 100644 index 0000000..2d01404 --- /dev/null +++ b/src/main/java/com/bookshop/creational/builder/Order.java @@ -0,0 +1,112 @@ +package com.bookshop.creational.builder; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * A customer order with two required fields and several optional ones — the classic + * situation where telescoping constructors become unreadable and a builder shines. + */ +public final class Order { + + private final Customer customer; + private final List books; + private final String giftMessage; + private final String deliveryInstructions; + private final String promoCode; + private final boolean expressDelivery; + + private Order(Builder builder) { + this.customer = builder.customer; + this.books = List.copyOf(builder.books); + this.giftMessage = builder.giftMessage; + this.deliveryInstructions = builder.deliveryInstructions; + this.promoCode = builder.promoCode; + this.expressDelivery = builder.expressDelivery; + } + + /** Entry point: {@code Order.forCustomer(alice).add(book).express().build()}. */ + public static Builder forCustomer(Customer customer) { + return new Builder(customer); + } + + public Customer customer() { + return customer; + } + + public List books() { + return books; + } + + public Optional giftMessage() { + return Optional.ofNullable(giftMessage); + } + + public Optional deliveryInstructions() { + return Optional.ofNullable(deliveryInstructions); + } + + public Optional promoCode() { + return Optional.ofNullable(promoCode); + } + + public boolean expressDelivery() { + return expressDelivery; + } + + public BigDecimal subtotal() { + return books.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + } + + public static final class Builder { + + private final Customer customer; + private final List books = new ArrayList<>(); + private String giftMessage; + private String deliveryInstructions; + private String promoCode; + private boolean expressDelivery; + + private Builder(Customer customer) { + this.customer = Objects.requireNonNull(customer, "customer"); + } + + public Builder add(Book book) { + books.add(Objects.requireNonNull(book, "book")); + return this; + } + + public Builder giftMessage(String message) { + this.giftMessage = message; + return this; + } + + public Builder deliveryInstructions(String instructions) { + this.deliveryInstructions = instructions; + return this; + } + + public Builder promoCode(String code) { + this.promoCode = code; + return this; + } + + public Builder express() { + this.expressDelivery = true; + return this; + } + + /** Validation lives in one place, and the resulting {@link Order} is immutable. */ + public Order build() { + if (books.isEmpty()) { + throw new IllegalStateException("an order must contain at least one book"); + } + return new Order(this); + } + } +} diff --git a/src/main/java/com/bookshop/creational/builder/README.md b/src/main/java/com/bookshop/creational/builder/README.md new file mode 100644 index 0000000..d46a484 --- /dev/null +++ b/src/main/java/com/bookshop/creational/builder/README.md @@ -0,0 +1,43 @@ +# Builder + +## The problem + +An `Order` needs a customer and at least one book, plus a handful of *optional* extras: a gift +message, delivery instructions, a promo code, express delivery. Modelling that with constructors +forces either one giant constructor full of `null`s at every call site, or a "telescoping" pile of +overloads that grows combinatorially. Setters would fix readability but make the order mutable and +allow half-initialised objects. + +## The pattern + +A `Builder` collects the fields step by step with a fluent, self-describing API, validates once in +`build()`, and produces an immutable `Order`: + +```java +Order order = Order.forCustomer(alice) + .add(refactoring) + .add(effectiveJava) + .giftMessage("Happy birthday!") + .express() + .build(); +``` + +## Benefits + +- **Readable call sites** — every value is labelled by the method that sets it; no guessing what + the fourth `null` means. +- **Immutability** — the built `Order` has only final fields and defensive copies; it is safe to + share across threads. +- **Single validation point** — `build()` rejects invalid combinations (an empty order) before an + object ever exists in a bad state. +- **Evolvability** — adding a new optional field is one builder method; existing call sites don't + change. + +## Seen in the wild + +`StringBuilder`, `java.net.http.HttpRequest.newBuilder()`, `Stream.builder()`, Lombok's +`@Builder`, protobuf message builders. + +## Example test + +[`OrderBuilderTest`](../../../../../../test/java/com/bookshop/creational/builder/OrderBuilderTest.java) diff --git a/src/main/java/com/bookshop/creational/factorymethod/PaymentMethod.java b/src/main/java/com/bookshop/creational/factorymethod/PaymentMethod.java new file mode 100644 index 0000000..cee54bd --- /dev/null +++ b/src/main/java/com/bookshop/creational/factorymethod/PaymentMethod.java @@ -0,0 +1,8 @@ +package com.bookshop.creational.factorymethod; + +/** The payment options the shop offers at checkout. */ +public enum PaymentMethod { + CARD, + PAYPAL, + GIFT_CARD +} diff --git a/src/main/java/com/bookshop/creational/factorymethod/PaymentProcessor.java b/src/main/java/com/bookshop/creational/factorymethod/PaymentProcessor.java new file mode 100644 index 0000000..973d00c --- /dev/null +++ b/src/main/java/com/bookshop/creational/factorymethod/PaymentProcessor.java @@ -0,0 +1,9 @@ +package com.bookshop.creational.factorymethod; + +import java.math.BigDecimal; + +/** What the checkout code depends on — never a concrete processor class. */ +public interface PaymentProcessor { + + PaymentReceipt process(BigDecimal amount); +} diff --git a/src/main/java/com/bookshop/creational/factorymethod/PaymentProcessors.java b/src/main/java/com/bookshop/creational/factorymethod/PaymentProcessors.java new file mode 100644 index 0000000..a5d90d8 --- /dev/null +++ b/src/main/java/com/bookshop/creational/factorymethod/PaymentProcessors.java @@ -0,0 +1,42 @@ +package com.bookshop.creational.factorymethod; + +import java.math.BigDecimal; + +/** + * The factory method: the only place in the codebase that knows which concrete + * processor class backs each {@link PaymentMethod}. + */ +public final class PaymentProcessors { + + private PaymentProcessors() { + } + + public static PaymentProcessor forMethod(PaymentMethod method) { + return switch (method) { + case CARD -> new CardProcessor(); + case PAYPAL -> new PayPalProcessor(); + case GIFT_CARD -> new GiftCardProcessor(); + }; + } + + private static final class CardProcessor implements PaymentProcessor { + @Override + public PaymentReceipt process(BigDecimal amount) { + return new PaymentReceipt("card", amount); + } + } + + private static final class PayPalProcessor implements PaymentProcessor { + @Override + public PaymentReceipt process(BigDecimal amount) { + return new PaymentReceipt("paypal", amount); + } + } + + private static final class GiftCardProcessor implements PaymentProcessor { + @Override + public PaymentReceipt process(BigDecimal amount) { + return new PaymentReceipt("gift-card", amount); + } + } +} diff --git a/src/main/java/com/bookshop/creational/factorymethod/PaymentReceipt.java b/src/main/java/com/bookshop/creational/factorymethod/PaymentReceipt.java new file mode 100644 index 0000000..fb52762 --- /dev/null +++ b/src/main/java/com/bookshop/creational/factorymethod/PaymentReceipt.java @@ -0,0 +1,7 @@ +package com.bookshop.creational.factorymethod; + +import java.math.BigDecimal; + +/** Proof of a completed payment, tagged with the provider that handled it. */ +public record PaymentReceipt(String provider, BigDecimal amount) { +} diff --git a/src/main/java/com/bookshop/creational/factorymethod/README.md b/src/main/java/com/bookshop/creational/factorymethod/README.md new file mode 100644 index 0000000..1765557 --- /dev/null +++ b/src/main/java/com/bookshop/creational/factorymethod/README.md @@ -0,0 +1,39 @@ +# Factory Method + +## The problem + +Checkout has to charge customers via card, PayPal, or gift card. If checkout code does +`new CardProcessor()` / `new PayPalProcessor()` directly, every place that takes a payment knows +about every concrete class — and adding a payment method means hunting down all of them. + +## The pattern + +Creation is centralised behind one factory method. Callers name *what* they want +(a `PaymentMethod`), not *how* it's built: + +```java +PaymentProcessor processor = PaymentProcessors.forMethod(PaymentMethod.PAYPAL); +PaymentReceipt receipt = processor.process(total); +``` + +The concrete classes are `private` nested types — clients *cannot* couple to them even if they try. +This is the shape the pattern almost always takes in modern Java: a static factory method returning +an interface. (The GoF "subclass overrides the creator" variant survives mostly inside frameworks.) + +## Benefits + +- **Decoupling** — checkout depends only on the `PaymentProcessor` interface; concrete classes can + be renamed, replaced, or rewritten freely. +- **One place to change** — supporting a new payment method touches the factory and nothing else; + the exhaustive `switch` over the enum makes the compiler flag any case you forget. +- **Hidden implementation choice** — the factory may return a new instance, a cached one, or a + subtype; callers can't tell and don't care. + +## Seen in the wild + +`List.of()`, `Optional.of()`, `Files.newBufferedReader()`, JDBC's `DriverManager.getConnection()`, +`Executors.newFixedThreadPool()`, Spring's `FactoryBean`. + +## Example test + +[`PaymentProcessorsTest`](../../../../../../test/java/com/bookshop/creational/factorymethod/PaymentProcessorsTest.java) diff --git a/src/main/java/com/bookshop/creational/singleton/README.md b/src/main/java/com/bookshop/creational/singleton/README.md new file mode 100644 index 0000000..40b8c29 --- /dev/null +++ b/src/main/java/com/bookshop/creational/singleton/README.md @@ -0,0 +1,43 @@ +# Singleton + +## The problem + +Every part of the shop — checkout, search, shipping — needs the same configuration: currency, +feature flags, the free-shipping threshold. Creating a config object in each place risks the copies +drifting apart; passing one instance through every constructor by hand is exactly what you *should* +do in a DI application, but plenty of code (CLIs, small services, libraries) has no container. + +## The pattern + +One instance, globally reachable, created exactly once. The **enum idiom** is the recommended +implementation in Java: the JVM guarantees single instantiation, thread safety, and safety against +reflection and serialization attacks — for free. + +```java +if (ShopConfig.INSTANCE.isEnabled("gift-wrap")) { ... } +``` + +## Benefits + +- **Guaranteed single instance** — no double-checked-locking subtleties; the class loader does the + hard work. +- **Shared state stays consistent** — everyone reads the same flags and thresholds. +- **Lazy enough** — the instance is created on first use of the enum class. + +## The honest caveat + +Singletons are shared *mutable global state*: they hide dependencies (nothing in a method signature +says it reads config) and they leak state between tests — note the `reset()` hook the test needs. +In an application with a DI container, prefer a normal class registered as a singleton-scoped +*bean*: same one-instance benefit, but injected, visible in constructors, and swappable in tests. +The pattern is still worth knowing because you constantly *meet* it: `Runtime.getRuntime()`, +loggers, driver registries. + +## Seen in the wild + +`Runtime.getRuntime()`, `Desktop.getDesktop()`, SLF4J `LoggerFactory`'s internal state, Spring's +default bean scope (conceptually). + +## Example test + +[`ShopConfigTest`](../../../../../../test/java/com/bookshop/creational/singleton/ShopConfigTest.java) diff --git a/src/main/java/com/bookshop/creational/singleton/ShopConfig.java b/src/main/java/com/bookshop/creational/singleton/ShopConfig.java new file mode 100644 index 0000000..ac0cc94 --- /dev/null +++ b/src/main/java/com/bookshop/creational/singleton/ShopConfig.java @@ -0,0 +1,43 @@ +package com.bookshop.creational.singleton; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Shop-wide configuration as an enum singleton — the simplest thread-safe, + * serialization-proof way to guarantee exactly one instance (Effective Java, Item 3). + */ +public enum ShopConfig { + + INSTANCE; + + private final Map featureFlags = new ConcurrentHashMap<>(); + private volatile BigDecimal freeShippingThreshold = new BigDecimal("25.00"); + + public String currency() { + return "GBP"; + } + + public BigDecimal freeShippingThreshold() { + return freeShippingThreshold; + } + + public void freeShippingThreshold(BigDecimal threshold) { + this.freeShippingThreshold = threshold; + } + + public boolean isEnabled(String feature) { + return featureFlags.getOrDefault(feature, false); + } + + public void enable(String feature) { + featureFlags.put(feature, true); + } + + /** Test hook: singletons hold global state, so tests must be able to reset it. */ + public void reset() { + featureFlags.clear(); + freeShippingThreshold = new BigDecimal("25.00"); + } +} diff --git a/src/main/java/com/bookshop/domain/Book.java b/src/main/java/com/bookshop/domain/Book.java new file mode 100644 index 0000000..77f7839 --- /dev/null +++ b/src/main/java/com/bookshop/domain/Book.java @@ -0,0 +1,20 @@ +package com.bookshop.domain; + +import java.math.BigDecimal; +import java.util.Objects; + +/** + * A book in the shop's catalogue. Immutable — shared safely across all pattern examples. + */ +public record Book(String isbn, String title, String author, BigDecimal price) { + + public Book { + Objects.requireNonNull(isbn, "isbn"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(author, "author"); + Objects.requireNonNull(price, "price"); + if (price.signum() < 0) { + throw new IllegalArgumentException("price must not be negative: " + price); + } + } +} diff --git a/src/main/java/com/bookshop/domain/Customer.java b/src/main/java/com/bookshop/domain/Customer.java new file mode 100644 index 0000000..7b7fc22 --- /dev/null +++ b/src/main/java/com/bookshop/domain/Customer.java @@ -0,0 +1,18 @@ +package com.bookshop.domain; + +import java.util.Objects; + +/** + * A shop customer. {@code loyaltyYears} is how long they have held a loyalty card (0 = none). + */ +public record Customer(String id, String name, String email, int loyaltyYears) { + + public Customer { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(email, "email"); + if (loyaltyYears < 0) { + throw new IllegalArgumentException("loyaltyYears must not be negative: " + loyaltyYears); + } + } +} diff --git a/src/main/java/com/bookshop/dry/CustomerReceipt.java b/src/main/java/com/bookshop/dry/CustomerReceipt.java new file mode 100644 index 0000000..4a8007f --- /dev/null +++ b/src/main/java/com/bookshop/dry/CustomerReceipt.java @@ -0,0 +1,14 @@ +package com.bookshop.dry; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.List; + +/** Consumes the VAT knowledge; contains none of it. */ +public class CustomerReceipt { + + public BigDecimal totalWithVat(List books) { + BigDecimal net = books.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + return Vat.withVat(net); + } +} diff --git a/src/main/java/com/bookshop/dry/README.md b/src/main/java/com/bookshop/dry/README.md new file mode 100644 index 0000000..b6d5d0f --- /dev/null +++ b/src/main/java/com/bookshop/dry/README.md @@ -0,0 +1,50 @@ +# DRY — Don't Repeat Yourself + +*Every piece of knowledge should have a single, authoritative representation.* + +## The violation + +The VAT rule copy-pasted into every document that mentions money: + +```java +class CustomerReceipt { + BigDecimal total(...) { return net.multiply(new BigDecimal("1.20")); } +} +class SupplierInvoice { + BigDecimal total(...) { return net.multiply(new BigDecimal("1.20")); } +} +class RefundNote { + BigDecimal total(...) { return net.multiply(new BigDecimal("1.175")); } // ← the old rate. +} // Nobody noticed. +``` + +When the rate changed, two copies were updated and one wasn't. Now refunds disagree with receipts, +and the bug is invisible until an auditor finds it. That's the real cost of duplication: not the +extra lines, but the fact that a single piece of *knowledge* can now be wrong in some places and +right in others. + +## The fix + +[`Vat`](Vat.java) is the one authoritative home of the VAT rule. +[`CustomerReceipt`](CustomerReceipt.java) and [`SupplierInvoice`](SupplierInvoice.java) consume it +and contain no tax knowledge of their own — they *cannot* drift apart. + +## Benefits + +- **One change, everywhere correct** — a rate change is one edit, and every document agrees. +- **No silent divergence** — the copy-that-didn't-get-updated bug class is structurally impossible. +- **The knowledge is findable** — "how do we apply VAT?" has exactly one answer, with one test. + +## The overuse warning + +DRY is about **knowledge**, not textual similarity. Two code fragments that *look* the same but +represent *different decisions* — say, the customer discount cap and the staff discount cap both +being 10% this quarter — should stay separate: merging them couples rules that will change for +different reasons, and someone editing "the shared constant" changes both without knowing. +A useful heuristic is the *rule of three*: tolerate a second occurrence, extract on the third, +when the shape of the real abstraction is visible. See +[Use with judgement](../../../../../../docs/use-with-judgement.md). + +## Example test + +[`VatDryTest`](../../../../../test/java/com/bookshop/dry/VatDryTest.java) diff --git a/src/main/java/com/bookshop/dry/SupplierInvoice.java b/src/main/java/com/bookshop/dry/SupplierInvoice.java new file mode 100644 index 0000000..47ea036 --- /dev/null +++ b/src/main/java/com/bookshop/dry/SupplierInvoice.java @@ -0,0 +1,15 @@ +package com.bookshop.dry; + +import java.math.BigDecimal; + +/** A different document, same single source of VAT knowledge — they cannot disagree. */ +public class SupplierInvoice { + + public BigDecimal vatLine(BigDecimal netAmount) { + return Vat.vatOn(netAmount); + } + + public BigDecimal grandTotal(BigDecimal netAmount) { + return Vat.withVat(netAmount); + } +} diff --git a/src/main/java/com/bookshop/dry/Vat.java b/src/main/java/com/bookshop/dry/Vat.java new file mode 100644 index 0000000..cfbbe8d --- /dev/null +++ b/src/main/java/com/bookshop/dry/Vat.java @@ -0,0 +1,24 @@ +package com.bookshop.dry; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * The single source of one piece of business knowledge: how VAT is applied. + * When the rate or rounding rule changes, this is the only file that changes. + */ +public final class Vat { + + public static final BigDecimal STANDARD_RATE = new BigDecimal("0.20"); + + private Vat() { + } + + public static BigDecimal withVat(BigDecimal netAmount) { + return netAmount.add(vatOn(netAmount)); + } + + public static BigDecimal vatOn(BigDecimal netAmount) { + return netAmount.multiply(STANDARD_RATE).setScale(2, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/bookshop/solid/dip/InMemoryOrderRepository.java b/src/main/java/com/bookshop/solid/dip/InMemoryOrderRepository.java new file mode 100644 index 0000000..199babf --- /dev/null +++ b/src/main/java/com/bookshop/solid/dip/InMemoryOrderRepository.java @@ -0,0 +1,20 @@ +package com.bookshop.solid.dip; + +import java.util.ArrayList; +import java.util.List; + +/** A low-level detail. The service neither knows nor cares that this is the implementation. */ +public class InMemoryOrderRepository implements OrderRepository { + + private final List orders = new ArrayList<>(); + + @Override + public void save(PlacedOrder order) { + orders.add(order); + } + + @Override + public List forCustomer(String customerId) { + return orders.stream().filter(o -> o.customerId().equals(customerId)).toList(); + } +} diff --git a/src/main/java/com/bookshop/solid/dip/OrderRepository.java b/src/main/java/com/bookshop/solid/dip/OrderRepository.java new file mode 100644 index 0000000..ad14368 --- /dev/null +++ b/src/main/java/com/bookshop/solid/dip/OrderRepository.java @@ -0,0 +1,11 @@ +package com.bookshop.solid.dip; + +import java.util.List; + +/** Abstraction for order storage — in-memory today, PostgreSQL tomorrow, same service. */ +public interface OrderRepository { + + void save(PlacedOrder order); + + List forCustomer(String customerId); +} diff --git a/src/main/java/com/bookshop/solid/dip/OrderService.java b/src/main/java/com/bookshop/solid/dip/OrderService.java new file mode 100644 index 0000000..64fa016 --- /dev/null +++ b/src/main/java/com/bookshop/solid/dip/OrderService.java @@ -0,0 +1,30 @@ +package com.bookshop.solid.dip; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; + +/** + * High-level policy: what it means to place an order. Depends only on the two + * abstractions — it cannot name, and so cannot be coupled to, any concrete + * gateway or database. + */ +public class OrderService { + + private final PaymentGateway payments; + private final OrderRepository orders; + + public OrderService(PaymentGateway payments, OrderRepository orders) { + this.payments = payments; + this.orders = orders; + } + + public PlacedOrder placeOrder(Customer customer, List books) { + BigDecimal total = books.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + String paymentRef = payments.charge(customer, total); + PlacedOrder order = new PlacedOrder(customer.id(), total, paymentRef); + orders.save(order); + return order; + } +} diff --git a/src/main/java/com/bookshop/solid/dip/PaymentGateway.java b/src/main/java/com/bookshop/solid/dip/PaymentGateway.java new file mode 100644 index 0000000..5bb4d3a --- /dev/null +++ b/src/main/java/com/bookshop/solid/dip/PaymentGateway.java @@ -0,0 +1,10 @@ +package com.bookshop.solid.dip; + +import com.bookshop.domain.Customer; +import java.math.BigDecimal; + +/** Abstraction owned by the business logic — providers plug into it, not the other way round. */ +public interface PaymentGateway { + + String charge(Customer customer, BigDecimal amount); +} diff --git a/src/main/java/com/bookshop/solid/dip/PlacedOrder.java b/src/main/java/com/bookshop/solid/dip/PlacedOrder.java new file mode 100644 index 0000000..1fe3eb5 --- /dev/null +++ b/src/main/java/com/bookshop/solid/dip/PlacedOrder.java @@ -0,0 +1,7 @@ +package com.bookshop.solid.dip; + +import java.math.BigDecimal; + +/** A confirmed order: who, how much, and the payment reference proving it. */ +public record PlacedOrder(String customerId, BigDecimal total, String paymentRef) { +} diff --git a/src/main/java/com/bookshop/solid/dip/README.md b/src/main/java/com/bookshop/solid/dip/README.md new file mode 100644 index 0000000..fa382f6 --- /dev/null +++ b/src/main/java/com/bookshop/solid/dip/README.md @@ -0,0 +1,42 @@ +# Dependency Inversion Principle + +*High-level policy should not depend on low-level detail; both depend on abstractions.* + +## The violation + +The business logic constructing its own infrastructure: + +```java +class OrderService { + private final StripeGateway stripe = new StripeGateway("sk_live_..."); + private final PostgresOrderStore db = new PostgresOrderStore("jdbc:..."); + // Testing placeOrder() now needs Stripe credentials and a database. + // Switching payment provider means editing the business logic. +} +``` + +The *policy* (what placing an order means) is welded to *mechanisms* (Stripe, Postgres). The +dependency arrows point the wrong way: the most important code in the shop depends on the most +replaceable. + +## The fix + +[`OrderService`](OrderService.java) depends on two abstractions it owns — +[`PaymentGateway`](PaymentGateway.java) and [`OrderRepository`](OrderRepository.java) — and +receives implementations through its constructor. Concrete details like +[`InMemoryOrderRepository`](InMemoryOrderRepository.java) implement the interfaces at the edge. +This inversion is exactly what a DI container (Spring) automates — but the principle is just +constructors and interfaces, no framework required. + +## Benefits + +- **Testable business logic** — the test injects a fake gateway; no credentials, no database, no + mocking framework even. +- **Swappable infrastructure** — changing payment provider or database is a new adapter class, + with zero edits to the service. +- **Dependencies point at stability** — infrastructure churns; the order-placing policy and its + interfaces stay put. + +## Example test + +[`OrderServiceDipTest`](../../../../../../test/java/com/bookshop/solid/dip/OrderServiceDipTest.java) diff --git a/src/main/java/com/bookshop/solid/isp/Accountant.java b/src/main/java/com/bookshop/solid/isp/Accountant.java new file mode 100644 index 0000000..6ddb2f2 --- /dev/null +++ b/src/main/java/com/bookshop/solid/isp/Accountant.java @@ -0,0 +1,9 @@ +package com.bookshop.solid.isp; + +import java.math.BigDecimal; + +/** One role, one interface: the money side. */ +public interface Accountant { + + BigDecimal dailyTakings(); +} diff --git a/src/main/java/com/bookshop/solid/isp/Bookseller.java b/src/main/java/com/bookshop/solid/isp/Bookseller.java new file mode 100644 index 0000000..6a9000b --- /dev/null +++ b/src/main/java/com/bookshop/solid/isp/Bookseller.java @@ -0,0 +1,10 @@ +package com.bookshop.solid.isp; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; + +/** One role, one interface: serving customers at the till. */ +public interface Bookseller { + + String sell(Book book, Customer customer); +} diff --git a/src/main/java/com/bookshop/solid/isp/README.md b/src/main/java/com/bookshop/solid/isp/README.md new file mode 100644 index 0000000..d36b3db --- /dev/null +++ b/src/main/java/com/bookshop/solid/isp/README.md @@ -0,0 +1,43 @@ +# Interface Segregation Principle + +*No client should be forced to depend on methods it does not use.* + +## The violation + +One fat interface for everyone who works in the shop: + +```java +interface BookshopStaff { + String sell(Book book, Customer customer); + void restock(Book book, int copies); + BigDecimal dailyTakings(); +} + +class WeekendTemp implements BookshopStaff { + public String sell(...) { ... } // the actual job + public void restock(...) { /* not allowed to */ } // forced stub + public BigDecimal dailyTakings() { throw ...; } // forced landmine +} +``` + +Every implementer stubs or throws the methods that aren't theirs, every client that only needs +selling still sees (and can call!) accounting methods, and a change to `restock`'s signature +recompiles the till code. + +## The fix + +Role-sized interfaces: [`Bookseller`](Bookseller.java), [`StockManager`](StockManager.java), +[`Accountant`](Accountant.java). [`ShopManager`](ShopManager.java) genuinely does all three and +implements all three; [`WeekendTemp`](WeekendTemp.java) implements exactly the one that's true. + +## Benefits + +- **No forced stubs or landmine methods** — implementing an interface means honestly providing + all of it. +- **Least privilege** — till code that takes a `Bookseller` *cannot* call `dailyTakings()` on it. +- **Smaller blast radius** — changing the stock interface touches stock code, not the till. +- **Honest capabilities** — what a class implements documents what it can actually do. + +## Example test + +[`StaffRolesIspTest`](../../../../../../test/java/com/bookshop/solid/isp/StaffRolesIspTest.java) diff --git a/src/main/java/com/bookshop/solid/isp/ShopManager.java b/src/main/java/com/bookshop/solid/isp/ShopManager.java new file mode 100644 index 0000000..9bba82c --- /dev/null +++ b/src/main/java/com/bookshop/solid/isp/ShopManager.java @@ -0,0 +1,30 @@ +package com.bookshop.solid.isp; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +/** The full-timer genuinely does all three jobs, so implements all three roles. */ +public class ShopManager implements Bookseller, StockManager, Accountant { + + private final Map shelf = new HashMap<>(); + private BigDecimal takings = BigDecimal.ZERO; + + @Override + public String sell(Book book, Customer customer) { + takings = takings.add(book.price()); + return "sold " + book.title() + " to " + customer.name(); + } + + @Override + public void restock(Book book, int copies) { + shelf.merge(book.isbn(), copies, Integer::sum); + } + + @Override + public BigDecimal dailyTakings() { + return takings; + } +} diff --git a/src/main/java/com/bookshop/solid/isp/StockManager.java b/src/main/java/com/bookshop/solid/isp/StockManager.java new file mode 100644 index 0000000..089a68a --- /dev/null +++ b/src/main/java/com/bookshop/solid/isp/StockManager.java @@ -0,0 +1,9 @@ +package com.bookshop.solid.isp; + +import com.bookshop.domain.Book; + +/** One role, one interface: keeping the shelves full. */ +public interface StockManager { + + void restock(Book book, int copies); +} diff --git a/src/main/java/com/bookshop/solid/isp/WeekendTemp.java b/src/main/java/com/bookshop/solid/isp/WeekendTemp.java new file mode 100644 index 0000000..3e20a6d --- /dev/null +++ b/src/main/java/com/bookshop/solid/isp/WeekendTemp.java @@ -0,0 +1,16 @@ +package com.bookshop.solid.isp; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; + +/** + * The Saturday student only works the till — and with role-sized interfaces, + * that is the only method they are asked to implement. No stubs, no throws. + */ +public class WeekendTemp implements Bookseller { + + @Override + public String sell(Book book, Customer customer) { + return "sold " + book.title() + " to " + customer.name(); + } +} diff --git a/src/main/java/com/bookshop/solid/lsp/Ebook.java b/src/main/java/com/bookshop/solid/lsp/Ebook.java new file mode 100644 index 0000000..1f40166 --- /dev/null +++ b/src/main/java/com/bookshop/solid/lsp/Ebook.java @@ -0,0 +1,7 @@ +package com.bookshop.solid.lsp; + +import java.math.BigDecimal; + +/** A download — purchasable, honours the contract fully. */ +public record Ebook(String title, BigDecimal price, int sizeMb) implements Purchasable { +} diff --git a/src/main/java/com/bookshop/solid/lsp/FreeSampleChapter.java b/src/main/java/com/bookshop/solid/lsp/FreeSampleChapter.java new file mode 100644 index 0000000..8be16ec --- /dev/null +++ b/src/main/java/com/bookshop/solid/lsp/FreeSampleChapter.java @@ -0,0 +1,9 @@ +package com.bookshop.solid.lsp; + +/** + * Deliberately NOT {@link Purchasable}: a free sample has no price and cannot be + * bought, so it doesn't claim the contract it can't keep. The type system now + * prevents the LSP violation instead of a runtime exception punishing it. + */ +public record FreeSampleChapter(String title, String downloadUrl) { +} diff --git a/src/main/java/com/bookshop/solid/lsp/PrintedBook.java b/src/main/java/com/bookshop/solid/lsp/PrintedBook.java new file mode 100644 index 0000000..44e98d2 --- /dev/null +++ b/src/main/java/com/bookshop/solid/lsp/PrintedBook.java @@ -0,0 +1,7 @@ +package com.bookshop.solid.lsp; + +import java.math.BigDecimal; + +/** A physical book — purchasable, honours the contract fully. */ +public record PrintedBook(String title, BigDecimal price, int weightGrams) implements Purchasable { +} diff --git a/src/main/java/com/bookshop/solid/lsp/Purchasable.java b/src/main/java/com/bookshop/solid/lsp/Purchasable.java new file mode 100644 index 0000000..bafa36b --- /dev/null +++ b/src/main/java/com/bookshop/solid/lsp/Purchasable.java @@ -0,0 +1,14 @@ +package com.bookshop.solid.lsp; + +import java.math.BigDecimal; + +/** + * The contract: anything purchasable has a real price and can always be bought. + * Implementations must honour that everywhere — no "this subtype throws". + */ +public interface Purchasable { + + String title(); + + BigDecimal price(); +} diff --git a/src/main/java/com/bookshop/solid/lsp/README.md b/src/main/java/com/bookshop/solid/lsp/README.md new file mode 100644 index 0000000..00589d5 --- /dev/null +++ b/src/main/java/com/bookshop/solid/lsp/README.md @@ -0,0 +1,40 @@ +# Liskov Substitution Principle + +*Anywhere a supertype works, every subtype must work too.* + +## The violation + +Making the free sample "a kind of book" because it shares a few fields: + +```java +class FreeSampleChapter extends Book { + @Override + BigDecimal price() { + throw new UnsupportedOperationException("samples can't be bought!"); + } +} +``` + +Now every piece of code handling books — totals, receipts, recommendations — can blow up at +runtime on a subtype it was promised would behave like a `Book`. Callers start adding +`instanceof FreeSampleChapter` guards, which is the design admitting the inheritance was a lie. + +## The fix + +Subtyping follows *behaviour*, not field overlap. [`PrintedBook`](PrintedBook.java) and +[`Ebook`](Ebook.java) genuinely keep the [`Purchasable`](Purchasable.java) contract, so +[`Till.totalOf`](Till.java) works on any mix of them with no guards. +[`FreeSampleChapter`](FreeSampleChapter.java) simply *isn't* `Purchasable` — the compiler stops it +reaching the till, instead of an exception stopping the sale. + +## Benefits + +- **Trustworthy polymorphism** — code written against the interface works for every implementation, + present and future. +- **Errors move from runtime to compile time** — an unbuyable item in a basket is a type error, + not a production incident. +- **No `instanceof` litter** — callers never need to know which subtype they hold. + +## Example test + +[`TillLspTest`](../../../../../../test/java/com/bookshop/solid/lsp/TillLspTest.java) diff --git a/src/main/java/com/bookshop/solid/lsp/Till.java b/src/main/java/com/bookshop/solid/lsp/Till.java new file mode 100644 index 0000000..6c8ded4 --- /dev/null +++ b/src/main/java/com/bookshop/solid/lsp/Till.java @@ -0,0 +1,18 @@ +package com.bookshop.solid.lsp; + +import java.math.BigDecimal; +import java.util.List; + +/** + * Relies on the {@link Purchasable} contract and nothing else — no + * {@code instanceof} checks, no defensive try/catch around "weird" subtypes. + */ +public final class Till { + + private Till() { + } + + public static BigDecimal totalOf(List basket) { + return basket.stream().map(Purchasable::price).reduce(BigDecimal.ZERO, BigDecimal::add); + } +} diff --git a/src/main/java/com/bookshop/solid/ocp/ExpressShipping.java b/src/main/java/com/bookshop/solid/ocp/ExpressShipping.java new file mode 100644 index 0000000..12308c5 --- /dev/null +++ b/src/main/java/com/bookshop/solid/ocp/ExpressShipping.java @@ -0,0 +1,17 @@ +package com.bookshop.solid.ocp; + +import java.math.BigDecimal; + +/** Next-day courier: flat 7.50. */ +public class ExpressShipping implements ShippingRate { + + @Override + public String name() { + return "express"; + } + + @Override + public BigDecimal costFor(BigDecimal orderTotal) { + return new BigDecimal("7.50"); + } +} diff --git a/src/main/java/com/bookshop/solid/ocp/README.md b/src/main/java/com/bookshop/solid/ocp/README.md new file mode 100644 index 0000000..ccc505e --- /dev/null +++ b/src/main/java/com/bookshop/solid/ocp/README.md @@ -0,0 +1,46 @@ +# Open/Closed Principle + +*Open for extension, closed for modification.* + +## The violation + +A quote calculator with a `switch` that must be reopened for every new shipping option: + +```java +BigDecimal costFor(String type, BigDecimal total) { + return switch (type) { + case "standard" -> ...; + case "express" -> ...; + // international launch? edit this method, re-test everything it quotes + default -> throw new IllegalArgumentException(type); + }; +} +``` + +Every new option modifies (and re-risks) tested code, and the calculator accumulates knowledge of +every shipping deal the shop has ever offered. + +## The fix + +[`ShippingQuotes`](ShippingQuotes.java) is *closed*: it quotes whatever [`ShippingRate`](ShippingRate.java) +implementations it's given and is never edited again. The system is *open*: launching international +delivery means adding one new class and registering it. + +## Benefits + +- **New behaviour without touching tested code** — the risk of a new shipping option is confined + to the new class. +- **Deployment-time flexibility** — which rates are on offer is decided where the list is + assembled, not hard-coded in logic. +- **Scales with the business** — ten more shipping deals is ten small classes, not a 200-line + switch. + +## Relationship to Strategy + +The [Strategy pattern](../../behavioral/strategy/README.md) is the *mechanism* that +makes this possible; OCP is the *principle* it serves. Most OCP-compliant designs are built from +Strategy-shaped extension points. + +## Example test + +[`ShippingQuotesOcpTest`](../../../../../../test/java/com/bookshop/solid/ocp/ShippingQuotesOcpTest.java) diff --git a/src/main/java/com/bookshop/solid/ocp/ShippingQuotes.java b/src/main/java/com/bookshop/solid/ocp/ShippingQuotes.java new file mode 100644 index 0000000..2b9058c --- /dev/null +++ b/src/main/java/com/bookshop/solid/ocp/ShippingQuotes.java @@ -0,0 +1,27 @@ +package com.bookshop.solid.ocp; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Closed for modification: this class quotes whatever rates it is given and has + * no idea which shipping options exist. New options never require editing it. + */ +public class ShippingQuotes { + + private final List rates; + + public ShippingQuotes(List rates) { + this.rates = List.copyOf(rates); + } + + public Map quoteAll(BigDecimal orderTotal) { + Map quotes = new LinkedHashMap<>(); + for (ShippingRate rate : rates) { + quotes.put(rate.name(), rate.costFor(orderTotal)); + } + return quotes; + } +} diff --git a/src/main/java/com/bookshop/solid/ocp/ShippingRate.java b/src/main/java/com/bookshop/solid/ocp/ShippingRate.java new file mode 100644 index 0000000..0d09789 --- /dev/null +++ b/src/main/java/com/bookshop/solid/ocp/ShippingRate.java @@ -0,0 +1,11 @@ +package com.bookshop.solid.ocp; + +import java.math.BigDecimal; + +/** The extension point: a new shipping option implements this and nothing else changes. */ +public interface ShippingRate { + + String name(); + + BigDecimal costFor(BigDecimal orderTotal); +} diff --git a/src/main/java/com/bookshop/solid/ocp/StandardShipping.java b/src/main/java/com/bookshop/solid/ocp/StandardShipping.java new file mode 100644 index 0000000..5449ed8 --- /dev/null +++ b/src/main/java/com/bookshop/solid/ocp/StandardShipping.java @@ -0,0 +1,19 @@ +package com.bookshop.solid.ocp; + +import java.math.BigDecimal; + +/** Standard post: 3.00, free for orders of 25.00 or more. */ +public class StandardShipping implements ShippingRate { + + @Override + public String name() { + return "standard"; + } + + @Override + public BigDecimal costFor(BigDecimal orderTotal) { + return orderTotal.compareTo(new BigDecimal("25.00")) >= 0 + ? BigDecimal.ZERO + : new BigDecimal("3.00"); + } +} diff --git a/src/main/java/com/bookshop/solid/srp/README.md b/src/main/java/com/bookshop/solid/srp/README.md new file mode 100644 index 0000000..9fb640b --- /dev/null +++ b/src/main/java/com/bookshop/solid/srp/README.md @@ -0,0 +1,38 @@ +# Single Responsibility Principle + +*A class should have one reason to change.* + +## The violation + +One `ReceiptManager` doing everything: + +```java +class ReceiptManager { // changes when the maths changes, + BigDecimal total() {...} // AND when the layout changes, + String printText() {...} // AND when storage moves to a database. + void saveToFile() {...} +} +``` + +Three unrelated stakeholders (pricing rules, receipt design, infrastructure) now edit the same +class. Every change risks the other two jobs, tests must set up all three concerns at once, and +the class grows without limit. + +## The fix + +Three classes, each with exactly one reason to change: + +- [`Receipt`](Receipt.java) — the purchase and its total (changes with pricing rules) +- [`ReceiptPrinter`](ReceiptPrinter.java) — formatting (changes with receipt design) +- [`ReceiptRepository`](ReceiptRepository.java) — storage (changes with infrastructure) + +## Benefits + +- **Isolated change** — a receipt redesign cannot break the totals; a storage migration cannot + break either. +- **Small, focused tests** — the maths is tested without touching formatting or storage. +- **Composable** — the printer works on any receipt regardless of where it's stored. + +## Example test + +[`ReceiptSrpTest`](../../../../../../test/java/com/bookshop/solid/srp/ReceiptSrpTest.java) diff --git a/src/main/java/com/bookshop/solid/srp/Receipt.java b/src/main/java/com/bookshop/solid/srp/Receipt.java new file mode 100644 index 0000000..20796eb --- /dev/null +++ b/src/main/java/com/bookshop/solid/srp/Receipt.java @@ -0,0 +1,14 @@ +package com.bookshop.solid.srp; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; + +/** Responsibility 1: what was bought and what it adds up to. Nothing else. */ +public record Receipt(Customer customer, List books) { + + public BigDecimal total() { + return books.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + } +} diff --git a/src/main/java/com/bookshop/solid/srp/ReceiptPrinter.java b/src/main/java/com/bookshop/solid/srp/ReceiptPrinter.java new file mode 100644 index 0000000..064b15d --- /dev/null +++ b/src/main/java/com/bookshop/solid/srp/ReceiptPrinter.java @@ -0,0 +1,16 @@ +package com.bookshop.solid.srp; + +import com.bookshop.domain.Book; + +/** Responsibility 2: presentation. Reformatting receipts never risks the maths. */ +public class ReceiptPrinter { + + public String print(Receipt receipt) { + StringBuilder out = new StringBuilder("The Bookshop\n"); + for (Book book : receipt.books()) { + out.append(book.title()).append(" ").append(book.price()).append('\n'); + } + out.append("TOTAL ").append(receipt.total()).append('\n'); + return out.toString(); + } +} diff --git a/src/main/java/com/bookshop/solid/srp/ReceiptRepository.java b/src/main/java/com/bookshop/solid/srp/ReceiptRepository.java new file mode 100644 index 0000000..177bd73 --- /dev/null +++ b/src/main/java/com/bookshop/solid/srp/ReceiptRepository.java @@ -0,0 +1,18 @@ +package com.bookshop.solid.srp; + +import java.util.ArrayList; +import java.util.List; + +/** Responsibility 3: storage. Swapping this for a database touches no other class. */ +public class ReceiptRepository { + + private final List saved = new ArrayList<>(); + + public void save(Receipt receipt) { + saved.add(receipt); + } + + public List findByCustomer(String customerId) { + return saved.stream().filter(r -> r.customer().id().equals(customerId)).toList(); + } +} diff --git a/src/main/java/com/bookshop/structural/adapter/CardPayments.java b/src/main/java/com/bookshop/structural/adapter/CardPayments.java new file mode 100644 index 0000000..23ce7ef --- /dev/null +++ b/src/main/java/com/bookshop/structural/adapter/CardPayments.java @@ -0,0 +1,10 @@ +package com.bookshop.structural.adapter; + +import com.bookshop.domain.Customer; +import java.math.BigDecimal; + +/** The interface the shop's checkout is written against. */ +public interface CardPayments { + + PaymentResult charge(Customer customer, BigDecimal amount); +} diff --git a/src/main/java/com/bookshop/structural/adapter/LegacyGatewayAdapter.java b/src/main/java/com/bookshop/structural/adapter/LegacyGatewayAdapter.java new file mode 100644 index 0000000..fa53863 --- /dev/null +++ b/src/main/java/com/bookshop/structural/adapter/LegacyGatewayAdapter.java @@ -0,0 +1,30 @@ +package com.bookshop.structural.adapter; + +import com.bookshop.domain.Customer; +import java.math.BigDecimal; + +/** + * Translates between the shop's {@link CardPayments} interface and the legacy gateway's + * incompatible API. All the ugly conversion lives here — and nowhere else. + */ +public class LegacyGatewayAdapter implements CardPayments { + + private final LegacyPaymentGateway gateway; + + public LegacyGatewayAdapter(LegacyPaymentGateway gateway) { + this.gateway = gateway; + } + + @Override + public PaymentResult charge(Customer customer, BigDecimal amount) { + String reference = "CUST/" + customer.id(); + long pence = amount.movePointRight(2).longValueExact(); + + int status = gateway.makePayment(reference, pence); + return switch (status) { + case LegacyPaymentGateway.STATUS_OK -> PaymentResult.success(); + case LegacyPaymentGateway.STATUS_INSUFFICIENT_FUNDS -> PaymentResult.failure("insufficient funds"); + default -> PaymentResult.failure("gateway error (status " + status + ")"); + }; + } +} diff --git a/src/main/java/com/bookshop/structural/adapter/LegacyPaymentGateway.java b/src/main/java/com/bookshop/structural/adapter/LegacyPaymentGateway.java new file mode 100644 index 0000000..26e88fa --- /dev/null +++ b/src/main/java/com/bookshop/structural/adapter/LegacyPaymentGateway.java @@ -0,0 +1,28 @@ +package com.bookshop.structural.adapter; + +/** + * A third-party gateway the shop is contractually stuck with. Its API is incompatible + * with checkout in every way: amounts in pence as a long, customers as a formatted + * reference string, results as magic status codes. We cannot change this class. + */ +public class LegacyPaymentGateway { + + public static final int STATUS_OK = 0; + public static final int STATUS_INSUFFICIENT_FUNDS = 12; + public static final int STATUS_INVALID_REFERENCE = 99; + + /** + * @param customerReference must look like {@code "CUST/"} + * @param amountInPence whole pence, e.g. 1999 for £19.99 + * @return one of the {@code STATUS_*} codes + */ + public int makePayment(String customerReference, long amountInPence) { + if (customerReference == null || !customerReference.startsWith("CUST/")) { + return STATUS_INVALID_REFERENCE; + } + if (amountInPence > 100_000) { + return STATUS_INSUFFICIENT_FUNDS; + } + return STATUS_OK; + } +} diff --git a/src/main/java/com/bookshop/structural/adapter/PaymentResult.java b/src/main/java/com/bookshop/structural/adapter/PaymentResult.java new file mode 100644 index 0000000..0023375 --- /dev/null +++ b/src/main/java/com/bookshop/structural/adapter/PaymentResult.java @@ -0,0 +1,13 @@ +package com.bookshop.structural.adapter; + +/** Outcome of a charge attempt in the shop's own vocabulary. */ +public record PaymentResult(boolean successful, String message) { + + public static PaymentResult success() { + return new PaymentResult(true, "ok"); + } + + public static PaymentResult failure(String message) { + return new PaymentResult(false, message); + } +} diff --git a/src/main/java/com/bookshop/structural/adapter/README.md b/src/main/java/com/bookshop/structural/adapter/README.md new file mode 100644 index 0000000..ca916a6 --- /dev/null +++ b/src/main/java/com/bookshop/structural/adapter/README.md @@ -0,0 +1,37 @@ +# Adapter + +## The problem + +The shop's checkout is written against a clean `CardPayments` interface: `charge(customer, amount)` +with `BigDecimal` pounds and a meaningful result. The payment provider it must actually use is a +legacy gateway taking `"CUST/"` reference strings and amounts in pence, returning magic integer +status codes. Neither side can change: the gateway is third-party, and rewriting checkout around +its quirks would spread `STATUS_*` constants through the whole codebase. + +## The pattern + +An adapter implements the interface the client wants and translates every call onto the interface +the legacy class provides: + +```java +CardPayments payments = new LegacyGatewayAdapter(new LegacyPaymentGateway()); +PaymentResult result = payments.charge(alice, new BigDecimal("19.99")); // pounds in, meaning out +``` + +## Benefits + +- **Incompatible APIs cooperate without modifying either side** — essential when one side is + third-party or frozen. +- **The mess is quarantined** — pence conversion and status-code decoding exist in exactly one + class; the rest of the codebase speaks the domain language. +- **Swappable integrations** — when the shop migrates to a modern provider, only a new adapter is + written; checkout code doesn't change. + +## Seen in the wild + +`InputStreamReader` (adapts `InputStream` → `Reader`), `Arrays.asList()` (array → `List`), +`Collections.enumeration()`, Spring MVC's `HandlerAdapter`. + +## Example test + +[`LegacyGatewayAdapterTest`](../../../../../../test/java/com/bookshop/structural/adapter/LegacyGatewayAdapterTest.java) diff --git a/src/main/java/com/bookshop/structural/decorator/BasicOrder.java b/src/main/java/com/bookshop/structural/decorator/BasicOrder.java new file mode 100644 index 0000000..4a42fdd --- /dev/null +++ b/src/main/java/com/bookshop/structural/decorator/BasicOrder.java @@ -0,0 +1,25 @@ +package com.bookshop.structural.decorator; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.List; + +/** The undecorated component: just books at their cover prices. */ +public class BasicOrder implements PricedOrder { + + private final List books; + + public BasicOrder(List books) { + this.books = List.copyOf(books); + } + + @Override + public BigDecimal price() { + return books.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + } + + @Override + public String description() { + return books.size() + " book(s)"; + } +} diff --git a/src/main/java/com/bookshop/structural/decorator/ExpressHandling.java b/src/main/java/com/bookshop/structural/decorator/ExpressHandling.java new file mode 100644 index 0000000..285cb32 --- /dev/null +++ b/src/main/java/com/bookshop/structural/decorator/ExpressHandling.java @@ -0,0 +1,11 @@ +package com.bookshop.structural.decorator; + +import java.math.BigDecimal; + +/** Adds express handling to any order — decorated or not. */ +public class ExpressHandling extends OrderExtra { + + public ExpressHandling(PricedOrder wrapped) { + super(wrapped, "express handling", new BigDecimal("4.99")); + } +} diff --git a/src/main/java/com/bookshop/structural/decorator/GiftWrap.java b/src/main/java/com/bookshop/structural/decorator/GiftWrap.java new file mode 100644 index 0000000..3013489 --- /dev/null +++ b/src/main/java/com/bookshop/structural/decorator/GiftWrap.java @@ -0,0 +1,11 @@ +package com.bookshop.structural.decorator; + +import java.math.BigDecimal; + +/** Adds gift wrapping to any order — decorated or not. */ +public class GiftWrap extends OrderExtra { + + public GiftWrap(PricedOrder wrapped) { + super(wrapped, "gift wrap", new BigDecimal("2.50")); + } +} diff --git a/src/main/java/com/bookshop/structural/decorator/GreetingCard.java b/src/main/java/com/bookshop/structural/decorator/GreetingCard.java new file mode 100644 index 0000000..cc22f59 --- /dev/null +++ b/src/main/java/com/bookshop/structural/decorator/GreetingCard.java @@ -0,0 +1,11 @@ +package com.bookshop.structural.decorator; + +import java.math.BigDecimal; + +/** Adds a greeting card to any order — decorated or not. */ +public class GreetingCard extends OrderExtra { + + public GreetingCard(PricedOrder wrapped) { + super(wrapped, "greeting card", new BigDecimal("1.25")); + } +} diff --git a/src/main/java/com/bookshop/structural/decorator/OrderExtra.java b/src/main/java/com/bookshop/structural/decorator/OrderExtra.java new file mode 100644 index 0000000..4fad0b3 --- /dev/null +++ b/src/main/java/com/bookshop/structural/decorator/OrderExtra.java @@ -0,0 +1,30 @@ +package com.bookshop.structural.decorator; + +import java.math.BigDecimal; + +/** + * Base decorator: wraps another {@link PricedOrder} and adds a surcharge and a + * receipt line on top of whatever the wrapped order already costs. + */ +public abstract class OrderExtra implements PricedOrder { + + private final PricedOrder wrapped; + private final String label; + private final BigDecimal surcharge; + + protected OrderExtra(PricedOrder wrapped, String label, BigDecimal surcharge) { + this.wrapped = wrapped; + this.label = label; + this.surcharge = surcharge; + } + + @Override + public BigDecimal price() { + return wrapped.price().add(surcharge); + } + + @Override + public String description() { + return wrapped.description() + " + " + label; + } +} diff --git a/src/main/java/com/bookshop/structural/decorator/PricedOrder.java b/src/main/java/com/bookshop/structural/decorator/PricedOrder.java new file mode 100644 index 0000000..85e7c16 --- /dev/null +++ b/src/main/java/com/bookshop/structural/decorator/PricedOrder.java @@ -0,0 +1,11 @@ +package com.bookshop.structural.decorator; + +import java.math.BigDecimal; + +/** Anything with a price and a receipt line — a bare order or a wrapped one. */ +public interface PricedOrder { + + BigDecimal price(); + + String description(); +} diff --git a/src/main/java/com/bookshop/structural/decorator/README.md b/src/main/java/com/bookshop/structural/decorator/README.md new file mode 100644 index 0000000..0f7a985 --- /dev/null +++ b/src/main/java/com/bookshop/structural/decorator/README.md @@ -0,0 +1,41 @@ +# Decorator + +## The problem + +Orders can have optional paid extras: gift wrap, express handling, a greeting card — in any +combination. Modelling combinations as subclasses explodes +(`GiftWrappedExpressOrderWithCard`… 2ⁿ classes for n extras), and baking flags into the order class +means it accretes a field and an `if` for every extra, forever. + +## The pattern + +Each extra is a thin wrapper implementing the same `PricedOrder` interface and delegating to +whatever it wraps, adding its own surcharge and receipt line. Extras stack at runtime, in any +combination and order: + +```java +PricedOrder order = new GiftWrap(new ExpressHandling(new BasicOrder(books))); +order.price(); // books + 4.99 + 2.50 +order.description(); // "2 book(s) + express handling + gift wrap" +``` + +This is exactly how `java.io` works: +`new BufferedInputStream(new GZIPInputStream(new FileInputStream(f)))`. + +## Benefits + +- **No subclass explosion** — n extras need n small classes, not 2ⁿ. +- **Compose at runtime** — the combination is decided when the order is placed, not when the class + hierarchy is designed. +- **Open/closed** — a new extra ("signed bookplate") is one new class; nothing existing changes. +- **Transparent to clients** — checkout code sees a `PricedOrder` and neither knows nor cares how + many layers are underneath. + +## Seen in the wild + +`java.io` streams (`BufferedReader`, `GZIPInputStream`…), `Collections.unmodifiableList()`, +`Executors.privilegedCallable()`, Spring's `BeanPostProcessor`-driven wrappers. + +## Example test + +[`OrderExtrasTest`](../../../../../../test/java/com/bookshop/structural/decorator/OrderExtrasTest.java) diff --git a/src/main/java/com/bookshop/structural/facade/CheckoutFacade.java b/src/main/java/com/bookshop/structural/facade/CheckoutFacade.java new file mode 100644 index 0000000..2b096f9 --- /dev/null +++ b/src/main/java/com/bookshop/structural/facade/CheckoutFacade.java @@ -0,0 +1,36 @@ +package com.bookshop.structural.facade; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; + +/** + * The facade: one call that runs the whole checkout — stock check, reservation, + * payment, delivery — in the right order. Callers never touch the subsystems. + */ +public class CheckoutFacade { + + private final InventoryService inventory; + private final PaymentService payments; + private final ShippingService shipping; + + public CheckoutFacade(InventoryService inventory, PaymentService payments, ShippingService shipping) { + this.inventory = inventory; + this.payments = payments; + this.shipping = shipping; + } + + public CheckoutSummary checkout(Customer customer, List books, String address) { + if (!inventory.isInStock(books)) { + throw new IllegalStateException("one or more books are out of stock"); + } + inventory.reserve(books); + + BigDecimal total = books.stream().map(Book::price).reduce(BigDecimal.ZERO, BigDecimal::add); + String transactionId = payments.charge(customer, total); + String shipmentId = shipping.arrangeDelivery(customer, address); + + return new CheckoutSummary(transactionId, shipmentId, total); + } +} diff --git a/src/main/java/com/bookshop/structural/facade/CheckoutSummary.java b/src/main/java/com/bookshop/structural/facade/CheckoutSummary.java new file mode 100644 index 0000000..0f3b3e8 --- /dev/null +++ b/src/main/java/com/bookshop/structural/facade/CheckoutSummary.java @@ -0,0 +1,7 @@ +package com.bookshop.structural.facade; + +import java.math.BigDecimal; + +/** Everything the caller needs to know after a successful checkout. */ +public record CheckoutSummary(String transactionId, String shipmentId, BigDecimal totalCharged) { +} diff --git a/src/main/java/com/bookshop/structural/facade/InventoryService.java b/src/main/java/com/bookshop/structural/facade/InventoryService.java new file mode 100644 index 0000000..dd7ff3f --- /dev/null +++ b/src/main/java/com/bookshop/structural/facade/InventoryService.java @@ -0,0 +1,30 @@ +package com.bookshop.structural.facade; + +import com.bookshop.domain.Book; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Subsystem 1: stock levels and reservations. */ +public class InventoryService { + + private final Map stockByIsbn = new HashMap<>(); + + public void stock(Book book, int copies) { + stockByIsbn.merge(book.isbn(), copies, Integer::sum); + } + + public boolean isInStock(List books) { + return books.stream().allMatch(b -> stockByIsbn.getOrDefault(b.isbn(), 0) > 0); + } + + public void reserve(List books) { + for (Book book : books) { + stockByIsbn.merge(book.isbn(), -1, Integer::sum); + } + } + + public int stockLevel(Book book) { + return stockByIsbn.getOrDefault(book.isbn(), 0); + } +} diff --git a/src/main/java/com/bookshop/structural/facade/PaymentService.java b/src/main/java/com/bookshop/structural/facade/PaymentService.java new file mode 100644 index 0000000..ebf7b7a --- /dev/null +++ b/src/main/java/com/bookshop/structural/facade/PaymentService.java @@ -0,0 +1,15 @@ +package com.bookshop.structural.facade; + +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.concurrent.atomic.AtomicLong; + +/** Subsystem 2: taking the money. */ +public class PaymentService { + + private final AtomicLong nextTransaction = new AtomicLong(1000); + + public String charge(Customer customer, BigDecimal amount) { + return "txn-" + nextTransaction.incrementAndGet() + "-" + customer.id(); + } +} diff --git a/src/main/java/com/bookshop/structural/facade/README.md b/src/main/java/com/bookshop/structural/facade/README.md new file mode 100644 index 0000000..6562401 --- /dev/null +++ b/src/main/java/com/bookshop/structural/facade/README.md @@ -0,0 +1,36 @@ +# Facade + +## The problem + +Checking out involves three subsystems in a strict sequence: verify stock, reserve it, charge the +customer, book the courier. If every caller (web checkout, phone orders, the till in the physical +shop) orchestrates this by hand, the sequence is duplicated everywhere — and one caller forgetting +the stock check before charging is a refund waiting to happen. + +## The pattern + +A facade offers one intention-sized method and keeps the subsystem choreography inside: + +```java +CheckoutSummary summary = checkout.checkout(alice, books, "1 High Street"); +``` + +The subsystems still exist, are individually testable, and remain available to callers with +genuinely special needs — the facade adds a simple front door, it doesn't lock the side doors. + +## Benefits + +- **One correct sequence** — the stock-check-before-charge ordering is written once; callers can't + get it wrong. +- **Reduced coupling** — callers depend on one class, not three; subsystems can be refactored + behind the facade freely. +- **Readable client code** — `checkout(...)` says what happens; the how is a detail. + +## Seen in the wild + +`java.nio.file.Files` (facade over channels, charsets, attribute views), Spring's `JdbcTemplate` +(facade over connection/statement/result-set handling), SLF4J's `LoggerFactory`. + +## Example test + +[`CheckoutFacadeTest`](../../../../../../test/java/com/bookshop/structural/facade/CheckoutFacadeTest.java) diff --git a/src/main/java/com/bookshop/structural/facade/ShippingService.java b/src/main/java/com/bookshop/structural/facade/ShippingService.java new file mode 100644 index 0000000..1fda0ad --- /dev/null +++ b/src/main/java/com/bookshop/structural/facade/ShippingService.java @@ -0,0 +1,14 @@ +package com.bookshop.structural.facade; + +import com.bookshop.domain.Customer; +import java.util.concurrent.atomic.AtomicLong; + +/** Subsystem 3: booking the courier. */ +public class ShippingService { + + private final AtomicLong nextConsignment = new AtomicLong(500); + + public String arrangeDelivery(Customer customer, String address) { + return "ship-" + nextConsignment.incrementAndGet(); + } +} diff --git a/src/main/java/com/bookshop/structural/proxy/BookCatalog.java b/src/main/java/com/bookshop/structural/proxy/BookCatalog.java new file mode 100644 index 0000000..ab30fb7 --- /dev/null +++ b/src/main/java/com/bookshop/structural/proxy/BookCatalog.java @@ -0,0 +1,10 @@ +package com.bookshop.structural.proxy; + +import com.bookshop.domain.Book; +import java.util.Optional; + +/** Both the real catalog and its proxy implement this — clients can't tell them apart. */ +public interface BookCatalog { + + Optional findByIsbn(String isbn); +} diff --git a/src/main/java/com/bookshop/structural/proxy/CachingCatalogProxy.java b/src/main/java/com/bookshop/structural/proxy/CachingCatalogProxy.java new file mode 100644 index 0000000..bd169fc --- /dev/null +++ b/src/main/java/com/bookshop/structural/proxy/CachingCatalogProxy.java @@ -0,0 +1,25 @@ +package com.bookshop.structural.proxy; + +import com.bookshop.domain.Book; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * The proxy: same interface as the real catalog, but answers repeat lookups from a + * local cache. Clients gain caching without changing a single line. + */ +public class CachingCatalogProxy implements BookCatalog { + + private final BookCatalog remote; + private final Map> cache = new HashMap<>(); + + public CachingCatalogProxy(BookCatalog remote) { + this.remote = remote; + } + + @Override + public Optional findByIsbn(String isbn) { + return cache.computeIfAbsent(isbn, remote::findByIsbn); + } +} diff --git a/src/main/java/com/bookshop/structural/proxy/README.md b/src/main/java/com/bookshop/structural/proxy/README.md new file mode 100644 index 0000000..46a400e --- /dev/null +++ b/src/main/java/com/bookshop/structural/proxy/README.md @@ -0,0 +1,47 @@ +# Proxy + +## The problem + +Book details live in the distributor's remote catalog, and every lookup is a network round-trip. +Product pages, search results, and the recommendations widget all ask for the same handful of +bestsellers thousands of times a day. Sprinkling caching logic through every caller would tangle +domain code with infrastructure concerns. + +## The pattern + +A proxy implements the *same* `BookCatalog` interface as the real service and controls access to +it — here by answering repeat lookups from a cache: + +```java +BookCatalog catalog = new CachingCatalogProxy(remoteCatalog); +catalog.findByIsbn(isbn); // hits the remote service +catalog.findByIsbn(isbn); // served from cache — no round-trip +``` + +Because proxy and subject share an interface, callers are oblivious. The same shape carries other +access-control jobs: lazy initialisation (don't build the expensive thing until first use), +security checks, rate limiting, remote-call stubs. + +## Proxy vs Decorator + +Structurally identical (same interface, wraps the subject); the intent differs. A decorator *adds +behaviour the client asked for* (gift wrap changes the price). A proxy *controls access* — the +client wants the real subject's behaviour, just cheaper, later, or guarded. + +## Benefits + +- **Cross-cutting concerns without touching callers or the real subject** — caching lives in one + class. +- **Drop-in by construction** — wiring the proxy in (or out) is a one-line change at composition + time. +- **Foundation of the frameworks you use** — Spring AOP transactions (`@Transactional`) and + Hibernate lazy loading are dynamically generated proxies. + +## Seen in the wild + +`java.lang.reflect.Proxy`, Spring AOP (`@Transactional`, `@Cacheable`), Hibernate lazy-loaded +entities, gRPC/RMI client stubs. + +## Example test + +[`CachingCatalogProxyTest`](../../../../../../test/java/com/bookshop/structural/proxy/CachingCatalogProxyTest.java) diff --git a/src/main/java/com/bookshop/structural/proxy/RemoteBookCatalog.java b/src/main/java/com/bookshop/structural/proxy/RemoteBookCatalog.java new file mode 100644 index 0000000..c0354d8 --- /dev/null +++ b/src/main/java/com/bookshop/structural/proxy/RemoteBookCatalog.java @@ -0,0 +1,37 @@ +package com.bookshop.structural.proxy; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * The real subject: stands in for the distributor's catalog service, where every + * lookup is a network round-trip. {@code lookupCount} makes the cost observable. + */ +public class RemoteBookCatalog implements BookCatalog { + + private final Map books = new HashMap<>(); + private int lookupCount; + + public RemoteBookCatalog() { + add(new Book("978-0201633610", "Design Patterns", "Gamma, Helm, Johnson, Vlissides", + new BigDecimal("42.00"))); + add(new Book("978-0134685991", "Effective Java", "Joshua Bloch", new BigDecimal("39.99"))); + } + + private void add(Book book) { + books.put(book.isbn(), book); + } + + @Override + public Optional findByIsbn(String isbn) { + lookupCount++; + return Optional.ofNullable(books.get(isbn)); + } + + public int lookupCount() { + return lookupCount; + } +} diff --git a/src/test/java/com/bookshop/behavioral/chainofresponsibility/OrderValidationChainTest.java b/src/test/java/com/bookshop/behavioral/chainofresponsibility/OrderValidationChainTest.java new file mode 100644 index 0000000..9aa086e --- /dev/null +++ b/src/test/java/com/bookshop/behavioral/chainofresponsibility/OrderValidationChainTest.java @@ -0,0 +1,67 @@ +package com.bookshop.behavioral.chainofresponsibility; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Chain of Responsibility — order checks as a configurable pipeline") +class OrderValidationChainTest { + + private final Book inStockBook = new Book("978-0132350884", "Clean Code", "Robert C. Martin", + new BigDecimal("32.50")); + private final Book rareBook = new Book("978-0000000001", "Out of Print Rarity", "Unknown", + new BigDecimal("950.00")); + private final Customer loyalAlice = new Customer("c-1", "Alice", "alice@example.com", 4); + private final Customer newBob = new Customer("c-2", "Bob", "bob@example.com", 0); + + private final OrderCheck chain = OrderCheck.chainOf(List.of( + new StockCheck(Set.of("978-0132350884")), + new AddressCheck(), + new FraudCheck())); + + @Test + @DisplayName("a clean order passes every check in the chain") + void validOrderPasses() { + OrderRequest request = new OrderRequest(loyalAlice, List.of(inStockBook), "1 High Street"); + + assertTrue(chain.validate(request).valid()); + } + + @Test + @DisplayName("a failing check stops the chain and names itself — later checks never run") + void rejectionShortCircuits() { + OrderRequest request = new OrderRequest(newBob, List.of(rareBook), ""); + + ValidationResult result = chain.validate(request); + + assertFalse(result.valid()); + assertEquals("stock", result.rejectedBy()); + } + + @Test + @DisplayName("the pipeline is configuration — reordering the list reorders the checks") + void chainOrderIsConfigurable() { + OrderCheck addressFirst = OrderCheck.chainOf(List.of(new AddressCheck(), new FraudCheck())); + OrderRequest request = new OrderRequest(newBob, List.of(rareBook), ""); + + assertEquals("address", addressFirst.validate(request).rejectedBy()); + } + + @Test + @DisplayName("each check is a small unit, testable entirely on its own") + void checksAreIndependentlyTestable() { + ValidationResult result = new FraudCheck() + .validate(new OrderRequest(newBob, List.of(rareBook), "1 High Street")); + + assertEquals("fraud", result.rejectedBy()); + assertEquals("large first order needs manual review", result.reason()); + } +} diff --git a/src/test/java/com/bookshop/behavioral/observer/InventoryObserverTest.java b/src/test/java/com/bookshop/behavioral/observer/InventoryObserverTest.java new file mode 100644 index 0000000..81039f6 --- /dev/null +++ b/src/test/java/com/bookshop/behavioral/observer/InventoryObserverTest.java @@ -0,0 +1,56 @@ +package com.bookshop.behavioral.observer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Observer — inventory announces restocks without knowing who is listening") +class InventoryObserverTest { + + private final Inventory inventory = new Inventory(); + private final Book book = new Book("978-0201633610", "Design Patterns", + "Gamma, Helm, Johnson, Vlissides", new BigDecimal("42.00")); + + @Test + @DisplayName("every subscriber hears about a restock — email and lambda alike") + void allSubscribersAreNotified() { + EmailAlerts email = new EmailAlerts(); + List smsLog = new ArrayList<>(); + inventory.subscribe(email); + inventory.subscribe(b -> smsLog.add("SMS: " + b.title() + " available")); + + inventory.restock(book); + + assertEquals(List.of("To waitlist: \"Design Patterns\" is back in stock!"), email.outbox()); + assertEquals(List.of("SMS: Design Patterns available"), smsLog); + } + + @Test + @DisplayName("unsubscribed listeners stop receiving events") + void unsubscribeStopsNotifications() { + EmailAlerts email = new EmailAlerts(); + inventory.subscribe(email); + + inventory.unsubscribe(email); + inventory.restock(book); + + assertTrue(email.outbox().isEmpty()); + } + + @Test + @DisplayName("the subject needs no changes to support a brand-new reaction") + void newReactionsAreJustNewSubscribers() { + List dashboardEvents = new ArrayList<>(); + inventory.subscribe(b -> dashboardEvents.add(b.isbn())); + + inventory.restock(book); + + assertEquals(List.of("978-0201633610"), dashboardEvents); + } +} diff --git a/src/test/java/com/bookshop/behavioral/strategy/PriceCalculatorTest.java b/src/test/java/com/bookshop/behavioral/strategy/PriceCalculatorTest.java new file mode 100644 index 0000000..173c870 --- /dev/null +++ b/src/test/java/com/bookshop/behavioral/strategy/PriceCalculatorTest.java @@ -0,0 +1,52 @@ +package com.bookshop.behavioral.strategy; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Strategy — swapping pricing rules without touching the calculator") +class PriceCalculatorTest { + + private final List basket = List.of( + new Book("978-0134757599", "Refactoring", "Martin Fowler", new BigDecimal("47.99")), + new Book("978-0134685991", "Effective Java", "Joshua Bloch", new BigDecimal("39.99"))); + private final Customer loyalAlice = new Customer("c-1", "Alice", "alice@example.com", 4); + private final Customer newBob = new Customer("c-2", "Bob", "bob@example.com", 0); + + @Test + @DisplayName("the same calculator prices the same basket differently per strategy") + void strategiesAreInterchangeable() { + BigDecimal subtotal = new BigDecimal("87.98"); + + assertEquals(subtotal, + new PriceCalculator(Discounts.none()).totalFor(basket, newBob)); + assertEquals(new BigDecimal("80.94"), + new PriceCalculator(Discounts.loyalty()).totalFor(basket, loyalAlice)); + assertEquals(new BigDecimal("74.78"), + new PriceCalculator(Discounts.bulkOrder()).totalFor(basket, newBob)); + } + + @Test + @DisplayName("each strategy is a tiny unit, testable on its own") + void strategiesTestableInIsolation() { + assertEquals(new BigDecimal("8.00"), + Discounts.loyalty().discountFor(new BigDecimal("100.00"), loyalAlice)); + assertEquals(BigDecimal.ZERO, + Discounts.bulkOrder().discountFor(new BigDecimal("49.99"), newBob)); + } + + @Test + @DisplayName("a one-off promotion is just a lambda — no new class ceremony") + void oneOffStrategyAsLambda() { + DiscountStrategy flatFiver = (subtotal, customer) -> new BigDecimal("5.00"); + + BigDecimal total = new PriceCalculator(flatFiver).totalFor(basket, newBob); + + assertEquals(new BigDecimal("82.98"), total); + } +} diff --git a/src/test/java/com/bookshop/behavioral/templatemethod/SalesReportTest.java b/src/test/java/com/bookshop/behavioral/templatemethod/SalesReportTest.java new file mode 100644 index 0000000..ceee451 --- /dev/null +++ b/src/test/java/com/bookshop/behavioral/templatemethod/SalesReportTest.java @@ -0,0 +1,48 @@ +package com.bookshop.behavioral.templatemethod; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Template Method — one report skeleton, many output formats") +class SalesReportTest { + + private final List sales = List.of( + new Book("978-0132350884", "Clean Code", "Robert C. Martin", new BigDecimal("32.50")), + new Book("978-0134685991", "Effective Java", "Joshua Bloch", new BigDecimal("39.99"))); + + @Test + @DisplayName("the CSV report fills the skeleton with CSV formatting") + void csvReport() { + String report = new CsvSalesReport().generate(sales); + + assertEquals(""" + title,author,price + Clean Code,Robert C. Martin,32.50 + Effective Java,Joshua Bloch,39.99 + total,,72.49 + """, report); + } + + @Test + @DisplayName("the HTML report reuses the identical skeleton and totalling logic") + void htmlReport() { + String report = new HtmlSalesReport().generate(sales); + + assertTrue(report.startsWith("")); + assertTrue(report.contains("")); + assertTrue(report.endsWith("
TitleClean Code
Total72.49
")); + } + + @Test + @DisplayName("both formats agree on the total because the calculation exists exactly once") + void totalsCannotDrift() { + assertTrue(new CsvSalesReport().generate(sales).contains("72.49")); + assertTrue(new HtmlSalesReport().generate(sales).contains("72.49")); + } +} diff --git a/src/test/java/com/bookshop/creational/builder/OrderBuilderTest.java b/src/test/java/com/bookshop/creational/builder/OrderBuilderTest.java new file mode 100644 index 0000000..5d78c83 --- /dev/null +++ b/src/test/java/com/bookshop/creational/builder/OrderBuilderTest.java @@ -0,0 +1,65 @@ +package com.bookshop.creational.builder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Builder — assembling an Order readably, with validation in one place") +class OrderBuilderTest { + + private final Customer alice = new Customer("c-1", "Alice", "alice@example.com", 3); + private final Book refactoring = new Book("978-0134757599", "Refactoring", "Martin Fowler", + new BigDecimal("47.99")); + private final Book effectiveJava = new Book("978-0134685991", "Effective Java", "Joshua Bloch", + new BigDecimal("39.99")); + + @Test + @DisplayName("call sites read like a sentence instead of a null-riddled constructor") + void buildsFullOrderFluently() { + Order order = Order.forCustomer(alice) + .add(refactoring) + .add(effectiveJava) + .giftMessage("Happy birthday!") + .express() + .build(); + + assertEquals(alice, order.customer()); + assertEquals(2, order.books().size()); + assertEquals("Happy birthday!", order.giftMessage().orElseThrow()); + assertTrue(order.expressDelivery()); + assertTrue(order.promoCode().isEmpty()); + assertEquals(new BigDecimal("87.98"), order.subtotal()); + } + + @Test + @DisplayName("optional fields can simply be omitted — no telescoping overloads needed") + void omittedOptionalsGetSafeDefaults() { + Order order = Order.forCustomer(alice).add(refactoring).build(); + + assertTrue(order.giftMessage().isEmpty()); + assertTrue(order.deliveryInstructions().isEmpty()); + assertEquals(false, order.expressDelivery()); + } + + @Test + @DisplayName("build() is the single validation point — invalid orders never exist") + void rejectsEmptyOrder() { + Order.Builder builder = Order.forCustomer(alice); + + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + @DisplayName("the built order is immutable — its book list cannot be modified") + void builtOrderIsImmutable() { + Order order = Order.forCustomer(alice).add(refactoring).build(); + + assertThrows(UnsupportedOperationException.class, () -> order.books().add(effectiveJava)); + } +} diff --git a/src/test/java/com/bookshop/creational/factorymethod/PaymentProcessorsTest.java b/src/test/java/com/bookshop/creational/factorymethod/PaymentProcessorsTest.java new file mode 100644 index 0000000..9c4ddda --- /dev/null +++ b/src/test/java/com/bookshop/creational/factorymethod/PaymentProcessorsTest.java @@ -0,0 +1,28 @@ +package com.bookshop.creational.factorymethod; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +@DisplayName("Factory Method — checkout never touches a concrete processor class") +class PaymentProcessorsTest { + + @ParameterizedTest(name = "{0} → handled by the {1} provider") + @CsvSource({ + "CARD, card", + "PAYPAL, paypal", + "GIFT_CARD, gift-card" + }) + @DisplayName("the factory maps each payment method to the right implementation") + void factoryChoosesImplementation(PaymentMethod method, String expectedProvider) { + PaymentProcessor processor = PaymentProcessors.forMethod(method); + + PaymentReceipt receipt = processor.process(new BigDecimal("19.99")); + + assertEquals(expectedProvider, receipt.provider()); + assertEquals(new BigDecimal("19.99"), receipt.amount()); + } +} diff --git a/src/test/java/com/bookshop/creational/singleton/ShopConfigTest.java b/src/test/java/com/bookshop/creational/singleton/ShopConfigTest.java new file mode 100644 index 0000000..6ddafa0 --- /dev/null +++ b/src/test/java/com/bookshop/creational/singleton/ShopConfigTest.java @@ -0,0 +1,47 @@ +package com.bookshop.creational.singleton; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Singleton — one shared ShopConfig via the enum idiom") +class ShopConfigTest { + + @AfterEach + void resetGlobalState() { + ShopConfig.INSTANCE.reset(); + } + + @Test + @DisplayName("every access point sees the exact same instance") + void singleInstance() { + ShopConfig fromCheckout = ShopConfig.INSTANCE; + ShopConfig fromShipping = ShopConfig.INSTANCE; + + assertSame(fromCheckout, fromShipping); + } + + @Test + @DisplayName("state set in one part of the shop is visible everywhere else") + void sharedStateIsConsistent() { + assertFalse(ShopConfig.INSTANCE.isEnabled("gift-wrap")); + + ShopConfig.INSTANCE.enable("gift-wrap"); + + assertTrue(ShopConfig.INSTANCE.isEnabled("gift-wrap")); + } + + @Test + @DisplayName("the caveat in action: global state needs an explicit reset between tests") + void globalStateMustBeReset() { + ShopConfig.INSTANCE.enable("dark-mode"); + + ShopConfig.INSTANCE.reset(); + + assertFalse(ShopConfig.INSTANCE.isEnabled("dark-mode")); + } +} diff --git a/src/test/java/com/bookshop/dry/VatDryTest.java b/src/test/java/com/bookshop/dry/VatDryTest.java new file mode 100644 index 0000000..fa53a73 --- /dev/null +++ b/src/test/java/com/bookshop/dry/VatDryTest.java @@ -0,0 +1,33 @@ +package com.bookshop.dry; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("DRY — one authoritative home for the VAT rule, so documents cannot disagree") +class VatDryTest { + + @Test + @DisplayName("the knowledge itself has exactly one place and one test") + void vatRuleHasOneHome() { + assertEquals(new BigDecimal("2.00"), Vat.vatOn(new BigDecimal("10.00"))); + assertEquals(new BigDecimal("12.00"), Vat.withVat(new BigDecimal("10.00"))); + } + + @Test + @DisplayName("receipt and invoice agree by construction, not by careful copy-paste") + void documentsCannotDrift() { + List basket = List.of( + new Book("978-0132350884", "Clean Code", "Robert C. Martin", new BigDecimal("32.50"))); + + BigDecimal onReceipt = new CustomerReceipt().totalWithVat(basket); + BigDecimal onInvoice = new SupplierInvoice().grandTotal(new BigDecimal("32.50")); + + assertEquals(onReceipt, onInvoice); + assertEquals(new BigDecimal("39.00"), onReceipt); + } +} diff --git a/src/test/java/com/bookshop/solid/dip/OrderServiceDipTest.java b/src/test/java/com/bookshop/solid/dip/OrderServiceDipTest.java new file mode 100644 index 0000000..6b6c3e5 --- /dev/null +++ b/src/test/java/com/bookshop/solid/dip/OrderServiceDipTest.java @@ -0,0 +1,47 @@ +package com.bookshop.solid.dip; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("DIP — business logic tested with fakes because it depends on abstractions") +class OrderServiceDipTest { + + private final Customer alice = new Customer("c-1", "Alice", "alice@example.com", 2); + private final List basket = List.of( + new Book("978-0132350884", "Clean Code", "Robert C. Martin", new BigDecimal("32.50"))); + + @Test + @DisplayName("the service runs against a fake gateway — no credentials, no network, no mocks") + void serviceIsTestableWithFakes() { + List chargeLog = new ArrayList<>(); + PaymentGateway fakeGateway = (customer, amount) -> { + chargeLog.add(customer.id() + ":" + amount); + return "fake-ref-1"; + }; + OrderService service = new OrderService(fakeGateway, new InMemoryOrderRepository()); + + PlacedOrder order = service.placeOrder(alice, basket); + + assertEquals("fake-ref-1", order.paymentRef()); + assertEquals(List.of("c-1:32.50"), chargeLog); + } + + @Test + @DisplayName("infrastructure is swappable — the same service works with any repository") + void infrastructureIsSwappable() { + OrderRepository repository = new InMemoryOrderRepository(); + OrderService service = new OrderService((customer, amount) -> "ref", repository); + + service.placeOrder(alice, basket); + + assertEquals(1, repository.forCustomer("c-1").size()); + assertEquals(new BigDecimal("32.50"), repository.forCustomer("c-1").get(0).total()); + } +} diff --git a/src/test/java/com/bookshop/solid/isp/StaffRolesIspTest.java b/src/test/java/com/bookshop/solid/isp/StaffRolesIspTest.java new file mode 100644 index 0000000..21b7102 --- /dev/null +++ b/src/test/java/com/bookshop/solid/isp/StaffRolesIspTest.java @@ -0,0 +1,38 @@ +package com.bookshop.solid.isp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("ISP — role-sized interfaces mean nobody stubs methods they don't have") +class StaffRolesIspTest { + + private final Book book = new Book("978-0132350884", "Clean Code", "Robert C. Martin", + new BigDecimal("32.50")); + private final Customer alice = new Customer("c-1", "Alice", "alice@example.com", 2); + + @Test + @DisplayName("the till depends on Bookseller only — any seller works, temp or manager") + void tillNeedsOnlyTheBooksellerRole() { + Bookseller saturdayTill = new WeekendTemp(); + Bookseller weekdayTill = new ShopManager(); + + assertEquals("sold Clean Code to Alice", saturdayTill.sell(book, alice)); + assertEquals("sold Clean Code to Alice", weekdayTill.sell(book, alice)); + } + + @Test + @DisplayName("a class implementing several roles provides all of them honestly") + void multiRoleClassImplementsAllItsRoles() { + ShopManager manager = new ShopManager(); + + manager.sell(book, alice); + manager.restock(book, 5); + + assertEquals(new BigDecimal("32.50"), manager.dailyTakings()); + } +} diff --git a/src/test/java/com/bookshop/solid/lsp/TillLspTest.java b/src/test/java/com/bookshop/solid/lsp/TillLspTest.java new file mode 100644 index 0000000..0ec4a18 --- /dev/null +++ b/src/test/java/com/bookshop/solid/lsp/TillLspTest.java @@ -0,0 +1,32 @@ +package com.bookshop.solid.lsp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("LSP — every Purchasable behaves like one, so the till needs no guards") +class TillLspTest { + + @Test + @DisplayName("any mix of subtypes totals correctly with no instanceof checks") + void subtypesAreFullySubstitutable() { + List basket = List.of( + new PrintedBook("Refactoring", new BigDecimal("47.99"), 850), + new Ebook("Effective Java", new BigDecimal("29.99"), 12)); + + assertEquals(new BigDecimal("77.98"), Till.totalOf(basket)); + } + + @Test + @DisplayName("the unbuyable item is kept out by the type system, not a runtime exception") + void freeSamplesCannotReachTheTill() { + FreeSampleChapter sample = new FreeSampleChapter("Refactoring — Chapter 1", "https://example.com/ch1"); + + // List.of(sample) would not compile as a List — the violation + // is impossible to write, which is the whole point. + assertEquals("Refactoring — Chapter 1", sample.title()); + } +} diff --git a/src/test/java/com/bookshop/solid/ocp/ShippingQuotesOcpTest.java b/src/test/java/com/bookshop/solid/ocp/ShippingQuotesOcpTest.java new file mode 100644 index 0000000..3c117d1 --- /dev/null +++ b/src/test/java/com/bookshop/solid/ocp/ShippingQuotesOcpTest.java @@ -0,0 +1,46 @@ +package com.bookshop.solid.ocp; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("OCP — new shipping options are added, never edited in") +class ShippingQuotesOcpTest { + + @Test + @DisplayName("the calculator quotes every registered rate without knowing any of them") + void quotesAllRegisteredRates() { + ShippingQuotes quotes = new ShippingQuotes(List.of(new StandardShipping(), new ExpressShipping())); + + Map forSmallOrder = quotes.quoteAll(new BigDecimal("10.00")); + + assertEquals(new BigDecimal("3.00"), forSmallOrder.get("standard")); + assertEquals(new BigDecimal("7.50"), forSmallOrder.get("express")); + } + + @Test + @DisplayName("extension in action: a brand-new rate joins with zero changes to ShippingQuotes") + void newRateRequiresNoModification() { + ShippingRate droneDelivery = new ShippingRate() { + @Override + public String name() { + return "drone"; + } + + @Override + public BigDecimal costFor(BigDecimal orderTotal) { + return new BigDecimal("15.00"); + } + }; + ShippingQuotes quotes = new ShippingQuotes(List.of(new StandardShipping(), droneDelivery)); + + Map result = quotes.quoteAll(new BigDecimal("30.00")); + + assertEquals(BigDecimal.ZERO, result.get("standard")); + assertEquals(new BigDecimal("15.00"), result.get("drone")); + } +} diff --git a/src/test/java/com/bookshop/solid/srp/ReceiptSrpTest.java b/src/test/java/com/bookshop/solid/srp/ReceiptSrpTest.java new file mode 100644 index 0000000..ce725f0 --- /dev/null +++ b/src/test/java/com/bookshop/solid/srp/ReceiptSrpTest.java @@ -0,0 +1,44 @@ +package com.bookshop.solid.srp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("SRP — totals, formatting and storage each live in their own class") +class ReceiptSrpTest { + + private final Customer alice = new Customer("c-1", "Alice", "alice@example.com", 2); + private final Receipt receipt = new Receipt(alice, List.of( + new Book("978-0132350884", "Clean Code", "Robert C. Martin", new BigDecimal("32.50")))); + + @Test + @DisplayName("the maths is testable without any formatting or storage in sight") + void totalsTestedInIsolation() { + assertEquals(new BigDecimal("32.50"), receipt.total()); + } + + @Test + @DisplayName("formatting is a separate concern — it consumes a receipt, never computes one") + void printingIsSeparate() { + String printed = new ReceiptPrinter().print(receipt); + + assertTrue(printed.contains("Clean Code 32.50")); + assertTrue(printed.endsWith("TOTAL 32.50\n")); + } + + @Test + @DisplayName("storage is a separate concern — swappable without touching maths or layout") + void storageIsSeparate() { + ReceiptRepository repository = new ReceiptRepository(); + + repository.save(receipt); + + assertEquals(List.of(receipt), repository.findByCustomer("c-1")); + } +} diff --git a/src/test/java/com/bookshop/structural/adapter/LegacyGatewayAdapterTest.java b/src/test/java/com/bookshop/structural/adapter/LegacyGatewayAdapterTest.java new file mode 100644 index 0000000..748fe90 --- /dev/null +++ b/src/test/java/com/bookshop/structural/adapter/LegacyGatewayAdapterTest.java @@ -0,0 +1,34 @@ +package com.bookshop.structural.adapter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Adapter — checkout talks pounds and domain objects, the legacy gateway never leaks") +class LegacyGatewayAdapterTest { + + private final CardPayments payments = new LegacyGatewayAdapter(new LegacyPaymentGateway()); + private final Customer alice = new Customer("42", "Alice", "alice@example.com", 0); + + @Test + @DisplayName("a clean domain call is translated into the gateway's reference-and-pence API") + void successfulChargeIsTranslated() { + PaymentResult result = payments.charge(alice, new BigDecimal("19.99")); + + assertTrue(result.successful()); + } + + @Test + @DisplayName("magic status codes come back as meaningful domain results") + void statusCodesBecomeMeaningfulResults() { + PaymentResult result = payments.charge(alice, new BigDecimal("2000.00")); + + assertFalse(result.successful()); + assertEquals("insufficient funds", result.message()); + } +} diff --git a/src/test/java/com/bookshop/structural/decorator/OrderExtrasTest.java b/src/test/java/com/bookshop/structural/decorator/OrderExtrasTest.java new file mode 100644 index 0000000..c7779bc --- /dev/null +++ b/src/test/java/com/bookshop/structural/decorator/OrderExtrasTest.java @@ -0,0 +1,43 @@ +package com.bookshop.structural.decorator; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.bookshop.domain.Book; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Decorator — stacking paid extras onto an order without subclass explosion") +class OrderExtrasTest { + + private final List books = List.of( + new Book("978-0134757599", "Refactoring", "Martin Fowler", new BigDecimal("47.99")), + new Book("978-0134685991", "Effective Java", "Joshua Bloch", new BigDecimal("39.99"))); + + @Test + @DisplayName("an undecorated order is just the cover prices") + void basicOrder() { + PricedOrder order = new BasicOrder(books); + + assertEquals(new BigDecimal("87.98"), order.price()); + assertEquals("2 book(s)", order.description()); + } + + @Test + @DisplayName("extras stack in any combination, each adding its surcharge and receipt line") + void extrasStack() { + PricedOrder order = new GiftWrap(new ExpressHandling(new BasicOrder(books))); + + assertEquals(new BigDecimal("95.47"), order.price()); + assertEquals("2 book(s) + express handling + gift wrap", order.description()); + } + + @Test + @DisplayName("three layers deep works the same way — clients never know how many layers exist") + void deepStacking() { + PricedOrder order = new GreetingCard(new GiftWrap(new ExpressHandling(new BasicOrder(books)))); + + assertEquals(new BigDecimal("96.72"), order.price()); + } +} diff --git a/src/test/java/com/bookshop/structural/facade/CheckoutFacadeTest.java b/src/test/java/com/bookshop/structural/facade/CheckoutFacadeTest.java new file mode 100644 index 0000000..c9d7ea5 --- /dev/null +++ b/src/test/java/com/bookshop/structural/facade/CheckoutFacadeTest.java @@ -0,0 +1,51 @@ +package com.bookshop.structural.facade; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.bookshop.domain.Book; +import com.bookshop.domain.Customer; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Facade — one checkout() call orchestrates inventory, payment and shipping") +class CheckoutFacadeTest { + + private final Book book = new Book("978-0132350884", "Clean Code", "Robert C. Martin", + new BigDecimal("32.50")); + private final Customer alice = new Customer("c-1", "Alice", "alice@example.com", 1); + + private InventoryService inventory; + private CheckoutFacade checkout; + + @BeforeEach + void setUp() { + inventory = new InventoryService(); + checkout = new CheckoutFacade(inventory, new PaymentService(), new ShippingService()); + } + + @Test + @DisplayName("a single call performs the whole flow and returns everything the caller needs") + void oneCallDoesEverything() { + inventory.stock(book, 3); + + CheckoutSummary summary = checkout.checkout(alice, List.of(book), "1 High Street"); + + assertNotNull(summary.transactionId()); + assertNotNull(summary.shipmentId()); + assertEquals(new BigDecimal("32.50"), summary.totalCharged()); + assertEquals(2, inventory.stockLevel(book)); + } + + @Test + @DisplayName("the facade enforces the right order: no stock means no charge, ever") + void outOfStockStopsTheFlowBeforePayment() { + assertThrows(IllegalStateException.class, + () -> checkout.checkout(alice, List.of(book), "1 High Street")); + assertEquals(0, inventory.stockLevel(book)); + } +} diff --git a/src/test/java/com/bookshop/structural/proxy/CachingCatalogProxyTest.java b/src/test/java/com/bookshop/structural/proxy/CachingCatalogProxyTest.java new file mode 100644 index 0000000..f22bd5c --- /dev/null +++ b/src/test/java/com/bookshop/structural/proxy/CachingCatalogProxyTest.java @@ -0,0 +1,45 @@ +package com.bookshop.structural.proxy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.bookshop.domain.Book; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Proxy — a caching stand-in for the slow remote catalog") +class CachingCatalogProxyTest { + + private final RemoteBookCatalog remote = new RemoteBookCatalog(); + private final BookCatalog catalog = new CachingCatalogProxy(remote); + + @Test + @DisplayName("repeat lookups are served from cache — one remote round-trip, not three") + void cachesRepeatLookups() { + catalog.findByIsbn("978-0134685991"); + catalog.findByIsbn("978-0134685991"); + Optional book = catalog.findByIsbn("978-0134685991"); + + assertEquals("Effective Java", book.orElseThrow().title()); + assertEquals(1, remote.lookupCount()); + } + + @Test + @DisplayName("misses are cached too — an unknown ISBN is only asked about once") + void cachesMisses() { + assertTrue(catalog.findByIsbn("no-such-isbn").isEmpty()); + assertTrue(catalog.findByIsbn("no-such-isbn").isEmpty()); + + assertEquals(1, remote.lookupCount()); + } + + @Test + @DisplayName("proxy and real catalog are interchangeable — clients hold the same interface") + void proxyIsTransparent() { + BookCatalog direct = remote; + BookCatalog proxied = catalog; + + assertEquals(direct.findByIsbn("978-0201633610"), proxied.findByIsbn("978-0201633610")); + } +}