Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 16

    Structural Design Patterns

    Structural patterns arrange objects that already exist so a client sees one stable shape. They do not decide how a certificate is built. That was lesson 15. They decide what sits b

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Structural Design Patterns

    Advanced System Design & Software Architecture · Lesson 16 · Low-Level Design and Design Patterns

    Lesson Overview

    Structural patterns arrange objects that already exist so a client sees one stable shape. They do not decide how a certificate is built. That was lesson 15. They decide what sits between PlaceOrder and a payment rail, what may wrap that call without changing its contract, and how a learning path can contain courses and other paths without a special case at every total.

    TechLearningPro hit three structural mistakes in one release of checkout. A campus-card client and a transit-card client returned different JSON, and PlaceOrder grew an if on the rail that compared the strings "APPROVED" and "OK" itself. A metrics wrapper then caught timeouts and returned an approved authorization so the dashboard would stay green during a demo. The wrapper still implemented PaymentAuthorizer, so it looked like a decorator, and it changed the meaning of the contract. The same week the catalog modeled a learning path as a tree and allowed a path to contain itself. price() recursed until the request thread died. The class diagram had a star. It did not have a cycle rule.

    This lesson implements Adapter, Decorator, Facade, Proxy, and Composite on that ground, and it says where Bridge and Flyweight are the better name. Adapter translates one incompatible API into the port the use case already calls. Decorator adds a behavior, such as a timer, and must not rewrite success and failure. Facade hides a small subsystem (method lookup, the chosen adapter, an audit write) behind one call. Proxy controls access: Spring's transactional proxy around PlaceOrder, and a protection check in front of certificate export. Composite treats a course and a path uniformly for pricing, and refuses a cycle. Spring is used where it is the real mechanism: RestClient, Micrometer's Timer, and the @Transactional proxy. No vendor SDK is invented. The JSON fields below are the contract our tests lock for two internal rails.

    /learn/design-patterns/adapter, /learn/design-patterns/decorator, /learn/design-patterns/facade, /learn/design-patterns/proxy, and /learn/design-patterns/composite are the pattern-course primers. /learn/low-level-design/adapter is the shorter LLD introduction. Here the bar is a stack you can draw as a sequence, a composite you can price without blowing the stack, and a clear refusal when a wrapper is a proxy or a facade in costume only.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Tell adapter, decorator, facade, and proxy apart by the force each one answers, not by the fact that one class holds another.
    2. Translate two payment payloads into one Authorization with RestClient, and fail closed on an unrecognized status.
    3. Add a Micrometer timer as a decorator that records failures and still returns unknown on timeout.
    4. Hide method lookup, rail choice, and an audit write behind a facade the use case calls once.
    5. Explain Spring's @Transactional proxy, including class proxies and the self-invocation path that never starts a transaction.
    6. Price a learning-path composite of courses and nested paths, and reject a cycle when the path is edited.
    7. Choose a bridge when the report kind and the renderer vary independently, and a flyweight when intrinsic course facts are shared across many enrollments.
    8. Review a stack for swallowed timeouts, logged secrets, and a leaf that implements add.

    Prerequisites

    • Lesson 14's applicability test and the campus-card adapter sketch. This lesson adds a second rail and the objects around the adapter. It does not repeat the cargo-cult essay.
    • Lesson 13's rule that timeout is an alt guard and that @Transactional applies only to calls that enter through the proxy.
    • Java 21 sealed types and records. Money is the non-negative, single-currency record from lesson 11: plus rejects a currency mismatch.
    • A Spring Boot 3 application with RestClient (Spring Framework 6.1) and, for the timer, Micrometer on the classpath (spring-boot-starter-actuator brings it). You do not need a card-brand certification.

    Detailed Core Concepts

    The shape, not the construction

    A structural pattern leaves the product's constructor alone and changes the graph the client walks. The client programs to an interface it already understood. Something else occupies the slot: a translator, a wrapper, a simplified entry, a guard, or a tree. If the client must name CampusCardResponse or walk only leaves, the structure has leaked.

    The five patterns in focus answer different forces:

    PatternForceWhat the client seesWhat goes wrong if you fake it
    AdapterAn API you do not own does not match the portPaymentAuthorizerProvider strings leak into PlaceOrder
    DecoratorAn extra behavior on the same contractThe same authorizeThe wrapper redefines approved and declined
    FacadeA subsystem of several calls should look like oneOne methodThe "facade" forwards to a single object and adds a name
    ProxyAccess to the object must be controlledThe same operations, sometimes delayed or refusedA hand-rolled proxy duplicates @Transactional and skips it on this
    CompositePart and whole share an operationprice() on a course or a pathA cycle, or add on a leaf that throws

    Bridge and Flyweight are in the same GoF chapter and show up in interviews beside these five. They are shorter here because checkout's live forces are the five above. Bridge splits an abstraction from an implementor so both can vary. Flyweight shares immutable intrinsic state. Neither is "a class that wraps another class."

    Adapter

    An adapter converts the adaptee's interface into the target the client expects. In Java the practical form is an object adapter: a class that implements the target and holds the adaptee. A class adapter would extend the adaptee and implement the target, which needs multiple inheritance of classes. Java does not offer that. Do not contort a hierarchy to imitate it.

    The target is PaymentAuthorizer. The adaptees are HTTP resources with different bodies. The campus rail returns status and reference. The transit rail returns outcome and authId. Those names are the fields our adapter tests assert against the resources we call. They are not a claim about a public SDK. RestClient performs the POST. Each adapter is the only type allowed to read its rail's strings.

    Translation fails closed. APPROVED and transit OK become approved. A decline status becomes declined. Anything else, including APPROVED_TEST, a null body, and ResourceAccessException (the Spring exception for an I/O failure on RestClient, including a timeout once the client is configured with one), becomes unknown. Unknown is not a retry inside the adapter. Lesson 13's sequence retries with the same idempotency key at the use-case edge, or it stops. An adapter that loops hides that decision.

    The idempotency key is copied onto the outbound body and not replaced. A new key per attempt is how a structural "cleanup" becomes a double charge.

    Decorator

    A decorator implements the same interface as the component it wraps and adds behavior before or after the delegation. The client cannot tell, from the type, whether it holds the raw authorizer or a metered one. That transparency is the point and the danger. A decorator that returns approved when the delegate would return unknown is transparent in the type system and dishonest in the contract.

    Metrics are the textbook extra behavior that belongs here. Micrometer's Timer records the call. The timer must stop when the delegate throws and when it returns. It must not catch Authorization and rewrite it. Logging the payment key and the kind is acceptable. Logging a raw body is not, because a future field may carry a token or a primary account number.

    Decorators stack. Order is a design decision. A timer outside the facade measures the whole subsystem, including method lookup. A timer inside one adapter measures only that rail and misses the other. This lesson puts a single MeteredAuthorizer outside the facade so one series covers checkout. Rail-specific timers can be added inside adapters when operations asks for them. Stacking two timers by accident double-counts latency in the dashboard. The configuration class is the place that order is visible.

    A decorator is not a subclass that overrides one method. Subclassing CampusCardAuthorizer to add timing couples the metric to one rail and invites a copy for transit. Wrapping the interface keeps one meter.

    Facade

    A facade is a single entry to a subsystem that still has its own types behind it. PaymentSubsystem implements PaymentAuthorizer and, on each call, loads the saved payment method (rail plus a server-side token reference), delegates to the campus or transit adapter, and appends an audit row with the key, the rail, and the kind. PlaceOrder does not see those three steps. That is the simplification.

    A facade that only calls one adapter is a pass-through. Delete it and inject the adapter. A facade that also prices the cart, sends mail, and enrolls the learner is the god class from lesson 11 with a structural label. The subsystem here is payment execution, not checkout. Enrollment stays on the order transaction from lesson 13.

    The facade is allowed to know both adapters. The adapters do not know the facade or each other. Dependency direction is the review: concrete rails point at HTTP, the facade points at the port's implementations, the use case points at the port. If you inject the facade as the PaymentAuthorizer bean, the use case does not name PaymentSubsystem either. The configuration names it.

    The audit is written after the provider has answered. A failed insert is logged with the key and does not change the kind: inventing a failure after a real approval invites a second charge. The audit row is allowed to be missing. It is not pulled into the same database transaction as the remote call.

    Proxy

    A proxy stands in for another object and controls access to it. The control may be a permission check, a cache, a remote hop, or a transaction. The proxy's interface matches the subject so the client is unchanged. That overlap with the decorator is why interviews ask for the difference. Intent distinguishes them. A decorator adds a behavior the client wants as part of the job (timing, compression) and is meant to be stacked. A proxy decides whether, when, or how the subject is reached. A protection proxy can refuse. A virtual proxy can delay construction. Spring's transactional proxy starts and commits a transaction around the method. None of those are "add a timer."

    PlaceOrder remains a plain class annotated @Transactional on place, called from CheckoutController. Spring Boot sets spring.aop.proxy-target-class to true, so the container subclasses the concrete class with a CGLIB proxy when it advises the bean. JDK dynamic proxies are used when the advice is applied through an interface and class proxies are turned off. Either way, the object injected into the controller is the proxy. A call from place to another method on the same instance does not enter the proxy. final methods are not overridden by CGLIB, so @Transactional on a final method does not run. Those are structural facts about the proxy, not transaction trivia from a different course.

    Do not reimplement that proxy with Proxy.newProxyInstance. It only implements interfaces, and a correct InvocationHandler must still answer equals, hashCode, and toString. Spring already does. A protection proxy you do write is GuardedExporter: it checks ExportPermission and only then calls CertificateExporter. Use Spring Security's @PreAuthorize when the rule is a role on the Authentication. Use the explicit guard when the rule is a domain fact, such as ownership of this certificate. Do not stack both if they can disagree.

    Composite

    A composite lets clients treat individual objects and compositions uniformly. A CoursePrice is a leaf: a SKU and a Money. A PathPrice is a composite: a list of Priced children, each a course or another path. price() on a path sums the children. The catalog page calls price() and does not walk types.

    Two GoF variants exist. A transparent composite puts add on the shared interface, and leaves throw. A safe composite gives add only to the composite type. This lesson uses the safe form, and Java 21 sealed types make it honest:

    java
    public sealed interface Priced permits CoursePrice, PathPrice {
    Money price();
    }

    Only PathPrice has add. A course cannot be asked to accept a child. The interviewer who wants add on every node is asking for the transparent variant. Say which you chose and why leaves that throw are a runtime surprise.

    A path that contains itself makes price() recurse without a base case. add rejects a child that already contains the parent id, and price() still throws if a corrupt row has a cycle. Money.plus rejects a currency mismatch. Sharing an immutable CoursePrice across paths is legal aliasing. Mutating a shared child is not. Paths are mutable only in the editor.

    Bridge and flyweight, in one pass

    A bridge separates an abstraction from its implementor so the two hierarchies change independently. Transcript and Invoice are abstractions. PdfGlyphs and HtmlGlyphs are implementors. Each abstraction holds a Glyphs and draws through it. You do not create PdfTranscript, HtmlTranscript, PdfInvoice, and HtmlInvoice. The bridge is a design you adopt before the combinations explode. An adapter is what you write when a finished class already has the wrong shape. Lesson 15's DocumentRenderer injected into one certificate type is a single implementor slot, not yet a bridge. It becomes a bridge when a second document kind must reuse those renderers without subclassing each pair. Until that second kind exists, do not add the abstraction hierarchy.

    A flyweight shares immutable intrinsic state. Course title and duration are one CourseFacts per SKU. Progress and member id stay on Enrollment. A setter on the shared facts would rewrite every live enrollment. Transcripts that must survive a title change store a copy at issue time. Integer.valueOf caches -128 through 127, which is why == on small boxed integers surprises people. That cache is not a reason to String.intern learner names.

    Architecture and Design Diagrams

    The payment stack the configuration builds, from the use case inward:

    mermaid
    classDiagram
    class PlaceOrder
    class PaymentAuthorizer {
    <<interface>>
    +authorize(key, amount, methodId) Authorization
    }
    class MeteredAuthorizer {
    <<decorator>>
    -PaymentAuthorizer delegate
    }
    class PaymentSubsystem {
    <<facade>>
    }
    class CampusCardAuthorizer {
    <<adapter>>
    }
    class TransitCardAuthorizer {
    <<adapter>>
    }
    class RestClient
    PlaceOrder --> PaymentAuthorizer
    PaymentAuthorizer <|.. MeteredAuthorizer
    PaymentAuthorizer <|.. PaymentSubsystem
    MeteredAuthorizer --> PaymentAuthorizer : wraps
    PaymentSubsystem --> CampusCardAuthorizer
    PaymentSubsystem --> TransitCardAuthorizer
    CampusCardAuthorizer --> RestClient
    TransitCardAuthorizer --> RestClient

    PlaceOrder is itself reached through Spring's transactional proxy. That proxy is not drawn as a class you maintain. The sequence shows it because commit is on that lifeline, as in lesson 13.

    mermaid
    sequenceDiagram
    participant Proxy as PlaceOrder proxy
    participant Place as PlaceOrder
    participant Meter as MeteredAuthorizer
    participant Facade as PaymentSubsystem
    participant Campus as CampusCardAuthorizer
    participant Tx as PlatformTransactionManager
    Proxy->>Tx: getTransaction
    Proxy->>Place: place(...)
    Place->>Meter: authorize(key, amount, methodId)
    Meter->>Facade: authorize(...)
    Facade->>Campus: authorize(...)
    alt timeout or unrecognized status
    Campus-->>Facade: unknown
    Facade-->>Meter: unknown
    Meter-->>Place: unknown
    Place-->>Proxy: PaymentUnknown
    Proxy->>Tx: rollback
    else approved
    Campus-->>Facade: approved plus reference
    Facade-->>Meter: approved
    Meter-->>Place: approved
    Place->>Place: save order
    Place-->>Proxy: orderId
    Proxy->>Tx: commit
    end

    The meter records both operands. It does not delete the unknown return. The facade's audit write sits on the facade activation, after the adapter returns, and a failed audit does not change the kind. Transit is the other adapter behind the same facade; the sequence names campus to keep one diagram readable. A second copy of the alt with TransitCardAuthorizer adds nothing if the returned Authorization is identical in kind.

    The catalog composite is a separate picture. Diamonds are composition of the path's children. Courses are leaves. A course may be referenced from many paths; that is association of an immutable value, drawn as a line rather than a second owner of a mutable course.

    mermaid
    classDiagram
    class Priced {
    <<interface>>
    +price() Money
    }
    class CoursePrice {
    +price() Money
    }
    class PathPrice {
    +price() Money
    +add(Priced) void
    }
    Priced <|.. CoursePrice
    Priced <|.. PathPrice
    PathPrice o-- Priced : children

    The hollow diamond is deliberate and narrow: children can be shared immutable courses, so this is not exclusive composition of every child. The path still owns the membership list. Lesson 12's caution stands. The note next to the diagram says: deleting a path does not delete a course SKU; it deletes the membership rows. A cycle is rejected in add and is not representable as a legal object diagram.

    Technical Explanation

    Fail closed at the boundary

    Adapters return Authorization (approved, declined, or unknown) and never the HTTP body. An approval without a reference is unknown. Configure RestClient with a connect and read timeout; otherwise a stuck rail occupies a request thread and the timer runs until the platform gives up. Compare trimmed status tokens exactly. A prefix check such as startsWith("APP") approves APPROVED_TEST.

    What the decorator is allowed to catch

    Timer.record(Supplier) times the supplier and propagates exceptions. Use it. A hand-written try/catch around the delegate is how the demo bug returns. If you catch ResourceAccessException inside the decorator, you have stolen the adapter's job and you will disagree with the adapter about unknown. Let the adapter map I/O to unknown. Let the decorator time whatever comes back, including exceptions the facade still throws for a programming error (IllegalArgumentException on a blank key). Those exceptions should fail the request. They are not payment declines.

    Labels on the timer are low-cardinality: rail and kind. The order id is not a tag. High-cardinality tags explode the registry's memory. Put the order id in the log, not in the metric name.

    Facade lookups and tokens

    PaymentMethodStore.find(PaymentMethodId) returns a rail and a token reference that was created when the learner saved a payment method. The facade passes the token reference to the adapter. It does not accept a primary account number from PlaceOrder. The HTTP body field is tokenRef. If a future rail requires a network token exchange before authorize, that exchange is another adaptee behind the facade, still not a PAN in the use case.

    The method id is an argument to authorize because the use case learned it from the checkout request and must not look up rails itself. Passing the rail enum instead of the method id pushes rail knowledge back into PlaceOrder. The store is the specialist. The facade calls it.

    Proxy and the call stack

    authorize runs inside the use-case transaction only when the controller called the proxy. The connection stays checked out for the HTTP call. Splitting authorize outside that transaction is a sequence change with the same idempotency key, not a new decorator. This lesson keeps the call inside place so rollback still matches the diagram, and reopens the split when pool wait tracks payment latency. this.recordAudit() never starts a new transaction. The facade is a different object, which is why the audit lives there without REQUIRES_NEW.

    Composite cost

    TechLearningPro bills every inclusion, so a course reached through two child paths is summed twice. A memo that charges it once is a product change. Cycle detection is the set of path ids on the stack, not a price cache. Validate on write, and keep the read-time guard. A cached total on the path row is either rebuilt on write or it is not the source of truth.

    Practical Implementation or Design Walkthrough

    Problem

    Checkout must authorize either a campus card or a transit card without PlaceOrder reading provider JSON. One timer covers the call. An audit line records the kind. A learning path prices as a tree of courses and paths, and the editor cannot save a cycle. Certificate export stays behind a permission check. Do not log raw payment bodies.

    The port and the two adapters

    java
    package com.techlearningpro.pay;
    import java.util.Objects;
    import org.springframework.web.client.ResourceAccessException;
    import org.springframework.web.client.RestClient;
    public interface PaymentAuthorizer {
    Authorization authorize(PaymentKey key, Money amount, PaymentMethodId methodId);
    }
    public final class CampusCardAuthorizer {
    private final RestClient http;
    public CampusCardAuthorizer(RestClient http) {
    this.http = Objects.requireNonNull(http);
    }
    public Authorization authorize(PaymentKey key, Money amount, String tokenRef) {
    record Req(String idempotencyKey, String tokenRef, java.math.BigDecimal amount, String currency) {}
    record Res(String status, String reference) {}
    Res body;
    try {
    body = http.post().uri("/v1/authorizations").body(new Req(key.value(), tokenRef, amount.amount(), amount.currency()))
    .retrieve().body(Res.class);
    } catch (ResourceAccessException ex) {
    return Authorization.unknown("timeout");
    }
    if (body == null || body.status() == null) {
    return Authorization.unknown("empty");
    }
    return switch (body.status().trim()) {
    case "APPROVED" -> referenceOrUnknown(body.reference());
    case "DECLINED" -> Authorization.declined(body.reference());
    default -> Authorization.unknown(body.status());
    };
    }
    private static Authorization referenceOrUnknown(String reference) {
    if (reference == null || reference.isBlank()) {
    return Authorization.unknown("missing-reference");
    }
    return Authorization.approved(reference);
    }
    }

    CampusCardAuthorizer does not implement the port. It is the adaptee-facing translator. The facade implements the port and supplies the token. That split keeps tokenRef out of PlaceOrder. Transit is the same shape with different names:

    java
    public final class TransitCardAuthorizer {
    private final RestClient http;
    public TransitCardAuthorizer(RestClient http) {
    this.http = Objects.requireNonNull(http);
    }
    public Authorization authorize(PaymentKey key, Money amount, String tokenRef) {
    record Req(String idempotencyKey, String tokenRef, long minorUnits, String currency) {}
    record Res(String outcome, String authId) {}
    Res body;
    try {
    body = http.post().uri("/charges").body(new Req(
    key.value(), tokenRef, amount.amount().movePointRight(2).longValueExact(), amount.currency()))
    .retrieve().body(Res.class);
    } catch (ResourceAccessException ex) {
    return Authorization.unknown("timeout");
    }
    if (body == null || body.outcome() == null) {
    return Authorization.unknown("empty");
    }
    return switch (body.outcome().trim()) {
    case "OK" -> body.authId() == null || body.authId().isBlank()
    ? Authorization.unknown("missing-reference")
    : Authorization.approved(body.authId());
    case "NO" -> Authorization.declined(body.authId());
    default -> Authorization.unknown(body.outcome());
    };
    }
    }

    Minor units are amount.movePointRight(2).longValueExact() for a scale-2 Money. A currency with a different scale must not share this method. The campus rail sends a BigDecimal. The difference is why two adapters exist. One DTO with every field optional would let a transit OK be read as a campus status and fall through to unknown, which is safe but accidental. Separate types make the mapping obvious.

    Facade and decorator

    java
    public final class PaymentSubsystem implements PaymentAuthorizer {
    private final PaymentMethodStore methods;
    private final CampusCardAuthorizer campus;
    private final TransitCardAuthorizer transit;
    private final AuthAudit audit;
    public PaymentSubsystem(PaymentMethodStore methods, CampusCardAuthorizer campus,
    TransitCardAuthorizer transit, AuthAudit audit) {
    this.methods = methods;
    this.campus = campus;
    this.transit = transit;
    this.audit = audit;
    }
    @Override
    public Authorization authorize(PaymentKey key, Money amount, PaymentMethodId methodId) {
    SavedMethod method = methods.find(methodId);
    Authorization result = switch (method.rail()) {
    case CAMPUS -> campus.authorize(key, amount, method.tokenRef());
    case TRANSIT -> transit.authorize(key, amount, method.tokenRef());
    };
    try {
    audit.record(key, method.rail(), result.kind());
    } catch (RuntimeException ex) {
    // The provider already answered. A missing audit line is a finance alert, not a second charge.
    }
    return result;
    }
    }
    public final class MeteredAuthorizer implements PaymentAuthorizer {
    private final PaymentAuthorizer delegate;
    private final io.micrometer.core.instrument.MeterRegistry registry;
    public MeteredAuthorizer(PaymentAuthorizer delegate, io.micrometer.core.instrument.MeterRegistry registry) {
    this.delegate = delegate;
    this.registry = registry;
    }
    @Override
    public Authorization authorize(PaymentKey key, Money amount, PaymentMethodId methodId) {
    return registry.timer("payments.authorize").record(() -> delegate.authorize(key, amount, methodId));
    }
    }

    MeterRegistry.timer(String) registers a Timer if needed. record(Supplier) returns the supplier's value and records the elapsed time. An exception from the delegate propagates and is still timed. The empty audit catch is the stand-in for an SLF4J warning with the order key (LoggerFactory.getLogger(PaymentSubsystem.class)). Swallowing without a log is how finance discovers the gap in a spreadsheet. There is no catch that returns Authorization.approved.

    Wiring

    java
    @Configuration
    class PaymentBeans {
    @Bean
    PaymentAuthorizer authorizer(PaymentMethodStore methods,
    RestClient campusHttp,
    RestClient transitHttp,
    AuthAudit audit,
    io.micrometer.core.instrument.MeterRegistry registry) {
    PaymentSubsystem facade = new PaymentSubsystem(
    methods,
    new CampusCardAuthorizer(campusHttp),
    new TransitCardAuthorizer(transitHttp),
    audit);
    return new MeteredAuthorizer(facade, registry);
    }
    }

    The bean's type is the port. PlaceOrder takes PaymentAuthorizer. The decorator is outside the facade. Two RestClient beans need qualifiers (@Bean(name = "campusHttp") and @Qualifier) so the method parameters do not collide. The snippet omits the annotations for length; the review must not. A single RestClient with one base URL cannot honestly call both rails.

    Composite

    java
    package com.techlearningpro.catalog;
    import java.util.ArrayList;
    import java.util.HashSet;
    import java.util.List;
    import java.util.Set;
    public sealed interface Priced permits CoursePrice, PathPrice {
    Money price();
    }
    public record CoursePrice(Sku sku, Money price) implements Priced {}
    public final class PathPrice implements Priced {
    private final PathId id;
    private final List<Priced> children = new ArrayList<>();
    public PathPrice(PathId id) {
    this.id = id;
    }
    public void add(Priced child) {
    if (child instanceof PathPrice path && reaches(path, id, new HashSet<>())) {
    throw new IllegalArgumentException("cycle");
    }
    children.add(child);
    }
    @Override
    public Money price() {
    return price(new HashSet<>());
    }
    private Money price(Set<PathId> stack) {
    if (!stack.add(id)) {
    throw new IllegalStateException("cycle");
    }
    Money sum = null;
    for (Priced child : children) {
    Money part = child instanceof PathPrice path ? path.price(stack) : child.price();
    sum = sum == null ? part : sum.plus(part);
    }
    stack.remove(id);
    if (sum == null) {
    throw new IllegalStateException("empty path");
    }
    return sum;
    }
    private static boolean reaches(PathPrice from, PathId target, Set<PathId> seen) {
    if (!seen.add(from.id)) {
    return false;
    }
    if (from.id.equals(target)) {
    return true;
    }
    for (Priced child : from.children) {
    if (child instanceof PathPrice path && reaches(path, target, seen)) {
    return true;
    }
    }
    return false;
    }
    public PathId id() { return id; }
    }

    add refuses a child path that already contains this path's id, which covers "add myself" and "add an ancestor." price still throws if a cycle was assembled by another constructor or a bad deserializer. An empty path has no price. Callers do not get a zero that looks like a free program. Money.plus enforces one currency.

    Protection proxy, explicit and small

    java
    public final class GuardedExporter {
    private final CertificateExporter exporter;
    private final ExportPermission permissions;
    public GuardedExporter(CertificateExporter exporter, ExportPermission permissions) {
    this.exporter = exporter;
    this.permissions = permissions;
    }
    public byte[] export(UserId actor, Certificate certificate) {
    if (!permissions.mayExport(actor, certificate)) {
    throw new ExportForbidden(actor);
    }
    return exporter.export(certificate);
    }
    }

    ExportForbidden is a runtime exception the web tier maps to 403. The exporter bean from lesson 15 does not grow a permission parameter. The guard is the proxy: same result type, controlled access. It is not a decorator that always exports and also checks. A failed check does not produce bytes.

    Tests that lock the consequences

    RestClient mapping tests can stub the client or exercise the switch through a narrow package-private function. The claim under test is the status mapping, not the HTTP library. A decorator test asserts that a delegate unknown is unchanged. A composite test rejects a path added to itself and sums two course prices. A guard test with permission denied expects ExportForbidden and zero exporter calls.

    Enterprise Real-World Scenarios

    The timer that approved timeouts

    Checkout's first meter was a class named TimingDecorator. It caught ResourceAccessException, incremented payments.success, and returned Authorization.approved("demo"). The type implemented PaymentAuthorizer. Reviews called it a decorator and moved on. During a campus-rail brownout the provider timed out, the catch ran, and PlaceOrder saved orders with a reference of demo. The ledger could not reconcile them. Refunds were manual. The sequence from lesson 13 said timeout rolls back. The wrapper had deleted that operand inside a class that looked optional.

    The replacement is MeteredAuthorizer as written above: Timer.record and no catch. The adapter maps ResourceAccessException to unknown. The use case throws PaymentUnknown, and the transactional proxy rolls back. The dashboard's success counter is a meter of kind=APPROVED only, incremented in the facade after a real approval, not inside a catch. The brownout now shows up as unknown latency, which is what paging needs. The class name had been decorative. The consequence test, "delegate returns unknown implies the wrapper returns unknown," is what review requires on every new wrapper.

    The path that contained itself

    An administrator built a professional-certificate path and, to reuse the capstone list, added the path as its own child through an internal id picker that listed every path. The UI sent the id. add appended it. The next catalog render called price() and the thread stack-overflowed. The node left the pool. The incident looked like a traffic spike because the load balancer retried.

    The model change is the cycle check in add and the defensive check in price. The API returns 422 on the editor call instead of accepting the row. A one-off SQL check found two other loops created by imports. They were unbundled by hand. Shared courses remained legal: the Java fundamentals course sits on three paths, and each path's price includes it, because the product bills inclusions. A flyweight CourseFacts holds the title those three paths display, so a title correction is one row. The price does not live on that flyweight. Prices are extrinsic to the shared facts and sit on CoursePrice, which is allowed to differ by path when a bundle discounts a course. Putting the price on the shared facts would have forced one price everywhere, which the catalog does not promise.

    The same quarter @Transactional on a method called through this did nothing. The annotation was removed. AopContext.currentProxy() was rejected. A different bean is the proxy boundary.

    Best Practices

    • Give each structural object one force: translate, time, simplify a subsystem, control access, or aggregate a tree.
    • Keep provider vocabulary inside the adapter. Unknown status and I/O failure become unknown.
    • Pass the idempotency key through unchanged. Do not mint a new one in a wrapper.
    • Decorators implement the port and call the delegate. They do not catch the delegate's result and invent an approval.
    • Put the timer outside the facade when you want one series, and do not stack a second timer unless the dashboard's denominator is clear.
    • A facade earns its keep with two or more collaborators. A single forward is a pass-through.
    • Do not log adapter bodies. Log key, rail, and kind.
    • Let Spring build the transactional proxy. Do not call @Transactional methods through this.
    • Use a safe composite: add lives on the path only. Reject cycles at edit time and again on read.
    • Sum money with Money.plus so a mixed currency cannot become a total.
    • Prefer a bridge only when a second abstraction will reuse the implementors. Prefer a flyweight only for immutable intrinsic state.
    • Test the consequence: lookalike status, unchanged unknown through the decorator, cycle rejection, and a guard that does not call the exporter.

    Common Mistakes and Anti-Patterns

    Adapter logic in the use case. if (rail == CAMPUS) and string compares inside PlaceOrder. The second rail's OK is judged by the campus vocabulary. Move each payload into its adapter.

    Fail-open decorator. A wrapper catches timeouts and returns approved, often with a sentinel reference. The type still matches. The contract does not. Time the call and pass the kind through.

    Facade as a rename of the use case. One class prices, charges, writes the order, and sends mail. You have concentrated every actor. Keep the facade on the payment subsystem.

    Pass-through facade. PaymentSubsystem.authorize calls one method on one adapter and nothing else. Delete the type. Inject the adapter.

    Hand-rolled transaction proxy. Proxy.newProxyInstance that starts a transaction and forgets equals and hashCode, or that advises a final method. Use @Transactional on a Spring bean and call it from another bean.

    Transparent composite with throwing leaves. Course.add throws UnsupportedOperationException. Callers discover the tree shape at runtime. Put add on PathPrice only.

    Unbounded recursion. A path contains itself and price() has no visiting set. The catalog page becomes an outage. Reject the cycle on write.

    Flyweight with a setter. A shared CourseFacts whose title setter rewrites every live enrollment and every historical page that did not snapshot the name. Keep intrinsic state immutable, and copy facts into a transcript at issue time.

    Bridge after one document kind. Four interfaces for a single PDF certificate. Wait until invoice and transcript both need PDF and HTML. Until then, one renderer interface is enough.

    Secrets in the decorator log. The metric class logs the RestClient body "for debugging" and a token lands in the log pipeline. Log identifiers and kinds.

    Troubleshooting and Design Review

    Symptom: Orders exist for authorizations the provider does not recognize, and references look like placeholders.

    Likely cause: a wrapper returns approved on timeout or on an unrecognized status.

    Investigation: catch blocks in types that implement PaymentAuthorizer. Search for sentinel references.

    Corrective action: map failures only in the adapter, to unknown. Assert the decorator returns the delegate's kind unchanged.

    Symptom: Transit payments never approve; campus payments do.

    Likely cause: one parser reads status and the transit body has outcome.

    Investigation: the adapter class for the rail, not the use case. Compare the test JSON with a captured response.

    Corrective action: a dedicated transit adapter and a mapping test for OK and NO. Do not add transit fields to the campus record.

    Symptom: @Transactional on an audit method does not roll back the audit insert, or does not open a transaction at all.

    Likely cause: self-invocation, or a final method under a CGLIB proxy.

    Investigation: the call site. If it is this.audit, the proxy is absent. Check whether the method is final.

    Corrective action: call a different bean, or fold the write into the facade without a nested transactional annotation. Avoid AopContext.currentProxy() as the default fix.

    Symptom: The catalog request thread dies with StackOverflowError when a particular path is rendered.

    Likely cause: a cycle in PathPrice.

    Investigation: walk children from that path id with a set. Look for the path id repeated.

    Corrective action: the add guard, a read-time visiting set, and a cleanup of existing loops. Return 422 from the editor.

    Symptom: A title change rewrites issued transcripts.

    Likely cause: transcripts render through the live flyweight instead of a stored title.

    Investigation: whether the issued document stores the words or only the SKU.

    Corrective action: snapshot the title at issue. Keep CourseFacts for the live catalog only.

    Symptom: Payment latency metrics are about twice the provider's own latency.

    Likely cause: two timers stacked on the same call.

    Investigation: the bean graph from PlaceOrder to RestClient.

    Corrective action: one timer at the boundary you meant to publish. Delete the inner one or give it a different name and a written reason.

    Review questions: Which class is the only parser of each status field? Does every wrapper preserve unknown? How many collaborators does the facade have? Is the transactional call crossing a bean boundary? Can a path contain itself? Is shared state immutable? What will we not log?

    Interview Questions and Answers

    Q1. How do you tell an adapter from a facade from a decorator?

    An adapter makes one incompatible API match the port the client already has. A facade reduces several collaborators in one subsystem to one call, and those collaborators may already be comfortable to use individually. A decorator implements the same interface as the object it wraps and adds behavior without changing the results the contract defined. I sort a payment translation into the adapter, the combination of method lookup, rail choice, and audit into the facade, and a Micrometer Timer into the decorator. If the wrapper changes unknown into approved, it is none of the three. It is a defect.

    Q2. Why is the Java adapter an object adapter?

    The adapter implements the target and holds the adaptee. A class adapter would extend a concrete client and implement the target at once, which Java's single class inheritance blocks. I do not invent a deep hierarchy to simulate it. CampusCardAuthorizer holds a RestClient and does not extend it. The facade, not the use case, is what implements PaymentAuthorizer when several adapters must be chosen among.

    Q3. What is the difference between a proxy and a decorator if both wrap the subject?

    Intent and control. A decorator adds a behavior and still performs the operation: timing, buffering, an immutable view. A proxy controls access: it may refuse, delay construction, or wrap the call in a transaction. Spring's @Transactional object is a proxy. MeteredAuthorizer is a decorator because it always delegates and does not decide whether the charge is allowed. GuardedExporter is a proxy because it can throw ExportForbidden and never call the exporter. I mention stacking as a clue: decorators are designed to stack; a protection proxy is usually the outer guard, not one of many optional layers.

    Q4. What does Spring actually put in front of a @Transactional bean?

    A proxy the container injects elsewhere. With Boot's default spring.aop.proxy-target-class=true, that is a CGLIB subclass, so the concrete class is advised. Interface-based JDK proxies appear when class proxies are disabled and the client type is an interface. The proxy starts the transaction, calls the target, then commits or rolls back. A call through this never enters that proxy. A final method is not overridden by the subclass, so advice on it does not run. I do not reimplement this with Proxy.newProxyInstance.

    Q5. How do you test an adapter without pretending you have a vendor SDK?

    I lock the JSON our service sends and the fields we read: campus status and reference, transit outcome and authId. The test feeds APPROVED, DECLINED, APPROVED_TEST, an empty body, and a transport exception, and it expects approved, declined, unknown, unknown, and unknown. The idempotency key on the outbound body equals the order key. I can drive that through RestClient against a mock server or through the mapping function. I do not assert that a class is "named Adapter."

    Q6. Where do you put a facade, and when do you delete it?

    I put it on a subsystem boundary the use case should not orchestrate. Payment method lookup, the rail switch, and the audit write are three steps with one outcome, so PaymentSubsystem is a facade. I delete it when those steps collapse to a single call, or when it starts to enroll learners and send mail. Those belong to PlaceOrder and the after-commit listener. A facade is not a place to hide a transaction that spans SMTP.

    Q7. How do you implement a composite so a leaf is not a second-class citizen at runtime?

    I use a sealed Priced with price() on both CoursePrice and PathPrice. Only the path has add. The catalog calls price() and does not downcast except inside the path, where it must recurse. I reject a child that reaches the parent id, and price() throws if a cycle is already present. I do not put add on the interface and throw from courses. That encodes the tree in exceptions. An empty path has no total. Mixed currencies fail in Money.plus.

    Q8. When would you reach for a bridge instead of more adapters?

    When two abstractions and two implementors would otherwise form a class per pair. Transcript and invoice, each able to draw with PDF or HTML glyphs, are a bridge: the document holds a Glyphs implementor. An adapter is for a type that already exists and does not match, such as a transit JSON body. One certificate and one PDF renderer do not need a bridge. Lesson 15 stopped at a renderer interface for that reason. I add the second abstraction when invoice is a real document, not when the diagram looks incomplete.

    Q9. What state is safe to share as a flyweight?

    Immutable facts that are identical across many objects and not historical snapshots. Course title and duration in the live catalog can be one CourseFacts per SKU, with progress and member id stored on the enrollment. I do not put a mutable price or a per-learner field on the shared object. Issued transcripts copy the title at issue time so a later catalog edit does not rewrite a legal document. Integer.valueOf caching small ints is the JDK example I mention if someone asks for a library flyweight, along with the == surprise. I do not intern arbitrary strings.

    Q10. A decorator and a proxy both sit on PaymentAuthorizer. What order do you wire, and why?

    The protection decision, if it existed on this port, would be outermost so a forbidden call is not timed as a payment and not sent to a rail. In the checkout we actually run, the meter is outermost and the facade is inside it, with adapters inside the facade. The timer then includes lookup and both rails. I do not put a second meter on each adapter unless the name is different and someone operates on it. The transactional proxy stays on PlaceOrder, outside this port, because the transaction's subject is the use case, not the HTTP client.

    Q11. What failure modes do you volunteer before the interviewer asks?

    Unrecognized provider strings must not approve. Timeouts must not approve. The idempotency key must survive every wrapper. Audit failure must not invent a second authorize. Self-invocation must not be the plan for a nested transaction. A path must not contain itself. Shared course facts must not be editable in place if transcripts depend on them. I would rather ship one adapter and a concrete call than a stack that fails open.

    Hands-On Exercises

    Exercise: Two rails, one port, a path that can be priced

    Problem statement. PlaceOrder must authorize a campus card or a transit card. The payloads differ (status/reference versus outcome/authId). A timer records the call. The use case does not parse JSON. A learning path sums child prices and rejects cycles. Export of a certificate checks permission before rendering.

    Tasks.

    1. Draw the class stack: decorator, facade, two adapters, RestClient. Mark the transactional proxy on PlaceOrder in a sequence, including unknown and rollback.
    2. Implement both adapters so a lookalike status and a transport failure return unknown, and an approval without a reference returns unknown.
    3. Implement PaymentSubsystem so it loads a saved method, selects the rail, and audits kind without failing the call when the audit write throws.
    4. Implement MeteredAuthorizer with Micrometer Timer.record. Add a test that unknown is unchanged.
    5. Implement PathPrice with cycle detection and a sum of two course prices. Show a test that adding the path to itself throws.
    6. Implement GuardedExporter so a denied actor does not call CertificateExporter.
    7. State one fact you will not put on a shared CourseFacts flyweight, and why.
    8. Note the bean boundary that @Transactional requires, and one self-invocation you will not use.

    Expected deliverables. The diagrams, the Java for adapters, facade, decorator, composite, and guard, plus the mapping, timer, cycle, and permission tests.

    Evaluation rubric.

    Assessment areaWeightWhat "good" looks like
    Translation25%Each rail's vocabulary is isolated; unknown fails closed
    Wrappers20%The timer does not rewrite kind; the facade has more than one job
    Sequence15%unknown rolls back; the key is present on the outbound call
    Composite20%Uniform price(), cycle rejected, currency respected
    Proxy10%Guard can refuse; transaction proxy is Spring's, called across beans
    Data care10%No PAN and no raw body in logs; shared facts are immutable

    A single class that parses both payloads and returns approved on any exception misses the exercise, regardless of how many pattern names appear in comments.

    Lesson Summary

    Structural patterns keep a client's view stable while the graph behind it does real work. Adapters translate each payment rail into Authorization and fail closed. A decorator times that call without redefining it. A facade loads the saved method, picks the adapter, and writes an audit the use case does not orchestrate. A proxy controls access: Spring's transactional proxy around the use case, and a small guard in front of export. A composite prices courses and paths through one operation and refuses a cycle. Bridge and flyweight wait for a second document kind and for intrinsic state that is truly shared and immutable. The release that hurt was the one whose wrapper looked like a decorator and whose tree looked like a star, while timeouts became orders and a path contained itself. The fix was a contract-preserving stack and a cycle rule, not another wrapper. Lesson 17 takes the behavioral patterns, where the variation is the algorithm rather than the shape of the graph.

    Further Reading


    Ready to mark this lesson complete?Track your journey across the entire course.