Senior-level pattern judgement — factories with Supplier and lambdas, Builder and immutability, Prototype vs Builder, Decorator and Strategy for the Open/Closed Principle, how Facades create coupling, Proxy + Strategy caching, Comparator as Strategy, Command vs Strategy, LSP violations in Template Method, combining State and Strategy, asynchronous Commands, macro commands, and the design issues of mixing Strategy with Template Method.
Published September 25, 2026
At 8+ years, pattern questions aren't definitions (the 5–8 years tier covers those). They're about trade-offs, combinations, and failure modes. Use modern Java idioms (lambdas, records, sealed types), and tie every answer to a principle: OCP, LSP, or coupling and cohesion.
Suppliers or lambdas?Short answer: Replace a switch-based factory, or one factory class per product, with a registry of Suppliers or constructor references. That's open for extension (register new types), and there's no class explosion.
public final class ExporterFactory {
private final Map<Format, Supplier<Exporter>> registry = new EnumMap<>(Format.class);
public ExporterFactory() {
registry.put(Format.CSV, CsvExporter::new);
registry.put(Format.JSON, JsonExporter::new);
registry.put(Format.PDF, () -> new PdfExporter(PageSize.A4)); // lambdas can pass configuration
}
public Exporter create(Format f) {
Supplier<Exporter> s = registry.get(f);
if (s == null) throw new IllegalArgumentException("Unsupported format " + f);
return s.get();
}
}
// Parameterised creation: Function<Config, Exporter>, or a BiFunction for two arguments
Key points to cover:
Map<String, Exporter> of beans, or ObjectProvider<T> for prototype-scoped products.Learn it in depth → Factory & Abstract Factory
Short answer:
build() validates it, and passes a complete snapshot to a private constructor that assigns final fields (defensively copying collections). The product never exists in a half-initialised or mutable state, so it's safe to share across threads..timeout(Duration.ofSeconds(5)).retries(3)) replace positional arguments (new Client(5, 3, true, null)). Optional parameters get defaults. Invariants across fields are checked in one place.With records, a nested builder, or "wither" methods (withTimeout(...) returning a copy), gives the same immutability for simple cases.
Learn it in depth → Builder Pattern
Short answer: When objects are expensive to configure from scratch, but many near-identical copies are needed, Prototype wins. You clone a pre-configured template, then tweak only what differs:
Builder is better when every object is assembled differently from parameters. Prototype is better when you copy an existing, richly configured instance, especially when the concrete class is only known at runtime (a registry of prototypes).
Short answer: Both let you add behaviour without modifying existing, tested code:
if/else or switch that would otherwise need editing (the typical OCP violation).The difference: decorators stack around one implementation, while strategies replace each other.
Learn it in depth → Single Responsibility & Open/Closed
Short answer:
Mitigations: several role-specific facades, DTOs at the boundary, keeping domain logic in the domain services, and letting advanced clients bypass it.
Short answer: A caching proxy implements the service interface, and intercepts calls. Which caching policy it uses is a pluggable Strategy (the eviction policy, the TTL rules, the key generation, or local vs distributed storage):
interface CachePolicy<K, V> { Optional<V> get(K key); void put(K key, V value); } // strategy: LRU, TTL, Redis…
final class CachingPriceService implements PriceService { // proxy
private final PriceService target;
private final CachePolicy<String, Price> cache;
CachingPriceService(PriceService target, CachePolicy<String, Price> cache) { this.target = target; this.cache = cache; }
@Override public Price price(String sku) {
return cache.get(sku).orElseGet(() -> { Price p = target.price(sku); cache.put(sku, p); return p; });
}
}
PriceService service = new CachingPriceService(new RemotePriceService(), new TtlCachePolicy<>(Duration.ofMinutes(5)));
Key points to cover:
@Cacheable is exactly this: an AOP proxy, plus a pluggable CacheManager (Caffeine, Redis) as the strategy.Comparator uses the Strategy pattern.Short answer: Collections.sort(list, comparator), List.sort, TreeMap(comparator) and stream.sorted(comparator) are contexts whose sorting algorithm stays fixed (TimSort, a red-black tree), while the comparison strategy is injected. You swap orderings (by name, by price descending, locale-aware) without changing the sorting code. Comparators compose (thenComparing, reversed, nullsFirst), which is strategy composition.
Comparator<Product> byPriceThenName = Comparator.comparing(Product::price).thenComparing(Product::name);
products.sort(byPriceThenName); // the same sort algorithm, a different strategy
products.sort(byPriceThenName.reversed());
Short answer: When you need to treat requests as objects with a lifecycle: queue them, log them, undo them, retry them, or schedule them. For example:
MoveShape, ChangeColor) is a command with execute() and undo(), stored on undo and redo stacks.SendInvoiceCommand), executed later by workers, retried with idempotency keys, and audited.PlaceOrderCommand objects validated, authorised and handled uniformly.Strategy answers "how do I compute this" (an interchangeable algorithm, called synchronously). Command answers "what should be done, by whom, and when" (a request object with its data, receiver and history).
Short answer: The template method's base class assumes certain contracts from its hook methods (pre- and post-conditions, no side effects, no exceptions of certain kinds, call-order expectations). A subclass that violates them breaks the algorithm, even though it compiles. For example:
validate() hook overridden to throw UnsupportedOperationException, or to skip validation: the base workflow then persists invalid data;parse() rejects inputs the base class guaranteed would be accepted;final, reordering the steps.Clients depending on AbstractImporter then can't substitute that subclass safely: an LSP violation. The mitigations:
final;Short answer: When an object's behaviour depends on its lifecycle state (State), and some step within a state has interchangeable algorithms (Strategy). For example, in an order:
Created, Paid, Shipped, Cancelled) decide which transitions are allowed, and what cancel() means in each state;Paid state, the refund strategy varies by payment method (card void vs refund, UPI refund API, wallet credit), and it's injected as a Strategy;Checkout state.The key distinction: states replace themselves (transitions are internal), while strategies are chosen by the client or configuration, and don't know about each other. Combining them keeps each concern focused.
Learn it in depth → State Pattern
Short answer: Separate invocation from execution, using a queue:
BlockingQueue with an executor, or a durable broker (Kafka, RabbitMQ, SQS) for reliability.CompletableFuture in-process, or a job ID that the client polls (202 Accepted + /jobs/{id}), or a webhook or event on completion.record SendInvoice(UUID idempotencyKey, UUID orderId) implements Command {}
class AsyncCommandBus {
private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor();
private final Map<Class<?>, CommandHandler<?>> handlers;
AsyncCommandBus(Map<Class<?>, CommandHandler<?>> handlers) { this.handlers = handlers; }
@SuppressWarnings("unchecked")
<C extends Command> CompletableFuture<Void> dispatch(C cmd) {
var handler = (CommandHandler<C>) handlers.get(cmd.getClass());
return CompletableFuture.runAsync(() -> handler.handle(cmd), workers);
}
}
Short answer: A macro command is a Composite of commands: it holds a list of commands, execute() runs them in order, and undo() undoes them in reverse order. A macro is itself a Command, so it can be queued, logged, undone as one unit, or nested in other macros. To make it atomic, track the executed sub-commands. If one fails midway, compensate the ones already done (the same idea as a saga).
final class MacroCommand implements Command {
private final List<Command> steps; private final Deque<Command> done = new ArrayDeque<>();
MacroCommand(List<Command> steps) { this.steps = List.copyOf(steps); }
public void execute() {
try { for (Command c : steps) { c.execute(); done.push(c); } }
catch (RuntimeException e) { undo(); throw e; } // all-or-nothing
}
public void undo() { while (!done.isEmpty()) done.pop().undo(); }
}
It's used for "Apply formatting" in editors, batch admin operations, and recorded user workflows.
Short answer: They can coexist (a template method whose steps delegate to injected strategies), but there are pitfalls:
The guidance: pick one variation mechanism per dimension:
JdbcTemplate callbacks).Document which points are meant to vary.
Short answer: The context is closed: it depends only on the Strategy interface, and never needs editing. The system is open: a new behaviour is a new implementation, registered by configuration or dependency injection. Removing switch/if-else on type codes means new cases don't touch old code or old tests, which reduces regression risk. With sealed interfaces, you can deliberately choose the opposite trade-off: closed types, with exhaustive switches, when the set of variants should not be extensible.
Q: Decorator vs Proxy vs Adapter, in one line each? A: A decorator adds behaviour through the same interface. A proxy controls access through the same interface (lazy, remote, security, caching). An adapter converts one interface into another.
Q: Is dependency injection a pattern that replaces Factory?
A: Partly. The DI container is a giant, configurable factory, and assembles object graphs. You still write explicit factories for runtime-parameterised creation (objects created per request, from user input), often exposed as beans or ObjectProviders.
Q: What's the risk of overusing patterns? A: Over-engineering: indirection without variability, and more classes for readers to navigate (a YAGNI violation). Introduce a pattern when you have at least two real variants, or a concrete extension need, and refactor into it when the need appears.
Q: How do sealed interfaces change the Visitor pattern?
A: With sealed hierarchies and pattern-matching switch, you can write exhaustive, type-safe operations over a closed set of types without Visitor's double-dispatch boilerplate. The compiler flags any missing case when a type is added.