Skip to content

VOL 06 / CH 09 / LESSON 01

9.1 Boundary Adaptation: Adapter, Facade, Proxy, and Bridge ​

A vendor reports accepted, while the application distinguishes submission acceptance from an updated ranking. A query needs authorization, and notifications need email and SMS delivery. Each case may add an object wrapper, but the wrapper has a different job.

Structural patterns address how objects fit together and connect. Their code often holds an object and forwards calls. The difference lies in design intent: adapting boundaries, narrowing entry points, controlling access, or decoupling two independently changing dimensions.

Adapter: Make External Models Conform to Internal Contracts ​

These are Java 21 project fragments. The project supplies domain types and the vendor SDK; put each public type in its matching file. The examples show boundary responsibilities, not a ready-to-use client for a real vendor.

Business use cases should not directly depend on third-party SDK request objects, exceptions, or status codes. Instead, first define the capabilities you actually need:

java
public interface RankingPort {
    RankingSubmission submit(MatchResult result);
}

public record RankingSubmission(
        String externalId,
        SubmissionStatus status) {}

public enum SubmissionStatus { ACCEPTED, PENDING, UNKNOWN }

The adapter handles translation across external boundaries:

java
public final class VendorRankingAdapter implements RankingPort {
    private final VendorRankingClient client;

    public VendorRankingAdapter(VendorRankingClient client) {
        this.client = java.util.Objects.requireNonNull(client, "client");
    }

    private static SubmissionStatus mapStatus(String status) {
        return switch (status) {
            case "accepted" -> SubmissionStatus.ACCEPTED;
            case "pending" -> SubmissionStatus.PENDING;
            case null, default -> SubmissionStatus.UNKNOWN;
        };
    }

    @Override
    public RankingSubmission submit(MatchResult result) {
        VendorRequest request = new VendorRequest(
                result.matchId().value(),
                result.winnerId().value(),
                result.score().value());
        try {
            VendorResponse response = client.createRanking(request);
            return new RankingSubmission(
                    response.id(), mapStatus(response.status()));
        } catch (VendorRateLimitException ex) {
            throw new RankingTemporarilyUnavailable(ex.retryAfter(), ex);
        } catch (VendorValidationException ex) {
            throw new RankingRejected(ex.getMessage(), ex);
        }
    }
}

Assume the vendor returns a string status with two known values, accepted and pending. New or null values map to UNKNOWN; the application must investigate rather than award points as though submission succeeded. ACCEPTED only means receipt by the vendor, not that rankings are already updated. Only two exception categories are translated here; a real SDK integration must define the rest.

An adapter can transform protocols, data models, and error classifications as well as method names. But it must never autonomously decide domain rules like "add extra points to the user when a failure occurs." When external concepts don’t align perfectly with internal ones, explicit mappings of lossy conversions and strategies for handling unknown values must be documented.

Never use string concatenation to build XML or JSON. Use the vendor’s SDK or a properly configured serializer to handle escaping, encoding, schema validation, and security constraints.

Adapter Testing ​

At minimum, test the following:

  • Field and unit mapping from internal to external requests;
  • Complete mapping of external states to internal states, including handling of unknown values;
  • Classification of failures such as timeouts, rate limiting, authentication errors, and validation failures;
  • Whether retries and idempotency keys are properly propagated;
  • Contract tests for SDK or protocol upgrades.

Facade: A Stable Entry Point for a Use Case ​

The Facade pattern consolidates collaboration among multiple subsystems into a coarse-grained, caller-friendly interface:

java
public final class RegistrationFacade {
    private final EligibilityService eligibility;
    private final PaymentService payments;
    private final RegistrationRepository registrations;

    public RegistrationFacade(EligibilityService eligibility,
                              PaymentService payments,
                              RegistrationRepository registrations) {
        this.eligibility = java.util.Objects.requireNonNull(eligibility);
        this.payments = java.util.Objects.requireNonNull(payments);
        this.registrations = java.util.Objects.requireNonNull(registrations);
    }

    public RegistrationReceipt register(RegisterCommand command) {
        eligibility.requireEligible(command.playerId(), command.tournamentId());
        PaymentReceipt payment = payments.authorize(command.payment());
        Registration registration = Registration.confirmed(command, payment);
        registrations.save(registration);
        return RegistrationReceipt.from(registration);
    }
}

The caller needs to know fewer subsystems and ordering rules. This example assumes payment authorization is sufficient to confirm registration. Authorization reserves funds; it is not capture. If confirmation requires capture, the workflow and states must change.

A Facade does not ensure consistency. If authorization succeeds and saving fails, durable recovery information is needed to void the authorization or resume the workflow. A lost save response must not cause blind duplicate charging. Races between eligibility checks and capacity allocation still need transactions or conditional updates. Placing calls in a tidy column does not make the database and payment gateway march in step.

Whether the Facade allows bypassing depends on its boundary. If it serves as a convenient API for a library, the underlying interfaces can remain exposed. But if the Facade enforces module consistency or defines a permission boundary, allowing external direct access to internal services would violate invariants.

The Facade often overlaps with the Application Service pattern. The name itself is less important than whether the entry point provides a complete contract around a specific use case, rather than becoming a monolithic "universal facade" with dozens of unrelated methods.

Proxy: Maintain the Interface While Controlling Access ​

A proxy implements the same interface as the real object and applies access policies before and after method calls. Common use cases include authorization, remote invocation, lazy loading, rate limiting, and caching.

java
public final class AuthorizedTournamentQueries
        implements TournamentQueries {
    private final TournamentQueries target;
    private final Authorizer authorizer;
    private final CurrentPrincipal principal;

    public AuthorizedTournamentQueries(TournamentQueries target,
                                       Authorizer authorizer,
                                       CurrentPrincipal principal) {
        this.target = java.util.Objects.requireNonNull(target);
        this.authorizer = java.util.Objects.requireNonNull(authorizer);
        this.principal = java.util.Objects.requireNonNull(principal);
    }

    @Override
    public TournamentView find(TournamentId id) {
        authorizer.require(
                principal.get(), Permission.VIEW_TOURNAMENT, id);
        return target.find(id);
    }
}

Authorizer.require() must throw on denial and return only on permission. A Fake can check that the target is called zero times when denied and once when allowed, exposing a query-before-authorization bug. Authorization failures must also be permitted by the public TournamentQueries contract.

The proxy must preserve the original contract. A caching proxy that returns data older than its freshness guarantee, or a remote proxy that disguises network failures as "data not found," fails to be transparently substitutable and thus violates the proxy principle.

Control Logic Is Not Naturally Secure ​

  • Authorization must deny by default and enforce object-level permissions.
  • Rate limiting must specify its scope, algorithm, clock source, and coordination across multiple instances.
  • Caching must define key generation, TTL, expiration behavior, and permission isolation.
  • Retries should only be applied to operations that are safely idempotent and must respect overall timeouts.
  • Remote proxies must preserve observability, cancellation semantics, and error handling.

Java dynamic proxies route interface calls through an InvocationHandler. AOP and service meshes can also intercept calls. A mesh generally sees network requests; it cannot supply application-specific authorization such as tournament ownership. The interception chain still needs a defined order and independent tests.

Bridge: Decoupling Two Independent Extension Dimensions ​

When both "message type" and "transmission channel" vary independently, creating a subclass for each combination produces a Cartesian product:

text
UrgentEmail, UrgentSms, UrgentPush,
DigestEmail, DigestSms, DigestPush ...

Bridge lets the high-level abstraction hold the implementation of the other dimension:

java
public interface DeliveryChannel {
    DeliveryResult deliver(Message message, Recipient recipient);
}

public abstract class Notification {
    private final DeliveryChannel channel;

    protected Notification(DeliveryChannel channel) {
        this.channel = java.util.Objects.requireNonNull(channel, "channel");
    }

    public final DeliveryResult send(Recipient recipient) {
        return channel.deliver(compose(), recipient);
    }

    protected abstract Message compose();
}

public final class MatchCancelledNotification extends Notification {
    private final Match match;

    public MatchCancelledNotification(DeliveryChannel channel, Match match) {
        super(channel);
        this.match = java.util.Objects.requireNonNull(match, "match");
    }

    @Override
    protected Message compose() {
        return Message.text("Match " + match.id() + " was cancelled");
    }
}

This assumes all channels support the shared Message contract. Differences such as SMS length limits or attachment support need validation before composition or an explicit unsupported result; not every pair is automatically valid.

Adding new message content does not require modifying the channel; adding a new channel doesn't require duplicating all existing message types. Unlike Adapter, which typically connects existing incompatible interfaces, Bridge proactively separates two variation dimensions during design.

If only one dimension is expected to change, ordinary strategy composition is sufficient.

Four Patterns for Quick Identification ​

The actual problem you're solvingMost likely pattern
Inconsistency between external interfaces/models and internal contractsAdapter
Callers should not need to know how subsystems collaborateFacade
Maintain the same interface while controlling access to itProxy
Two dimensions require independent expansionBridge

The next section covers layered behavior, tree structures, and large-scale shared objects.

References ​

Built with VitePress | Software Systems Atlas