Every engineer knows Builder and Singleton. They show up in every "design patterns" interview question, every framework, every Lombok annotation. They're also, by a wide margin, the two patterns most likely to be reached for when they aren't actually the right tool — a Builder on a three-field object, a Singleton doing the job a Spring bean already does for free.
The other nineteen patterns in the Gang of Four book get a fraction of that attention, and some of them solve real problems better than anything more fashionable does. This page went looking for them in my own codebases rather than inventing tidy examples — eleven patterns, checked for real, in production Java across several projects. Six turned up clean. One turned up, but weakly. Four didn't turn up at all, and that absence is reported here too, because "I looked and it isn't there" is more honest than a manufactured example, and because why it isn't there turned out to be worth a paragraph on its own.
Structural
Adapter
Wraps one interface to present the interface the rest of the code actually expects. The textbook version is a legacy-system wrapper; the version that shows up constantly in real code is a third-party SDK or API wrapped in a shape your own domain code can call without knowing which vendor is on the other end.
AnthropicClient, from a food-research agent, wraps Anthropic's raw JSON-over-HTTP Messages API behind a domain-shaped interface — the caller never touches a request or response body directly:
public JsonNode send(String systemPrompt, ArrayNode messages, ArrayNode tools) {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("max_tokens", maxTokens);
body.put("system", systemPrompt);
body.set("messages", messages);
if (tools != null && !tools.isEmpty()) body.set("tools", tools);
return sendWithRetry(body);
}This is Adapter earning its keep, not just naming something obvious: the class doc calls it "a thin wrapper," and that's the point. The caller is testable against a stub with the same shape, and if the provider changes, the blast radius is this one file — a promise this site makes explicitly on AI Stance: provider-portable, so a vendor change is a config change, not a rewrite.
Facade
One simple entry point standing in front of several subsystems. People build these constantly without naming them — any "service" method that just orchestrates three other services into one call is a Facade. The pattern earns its name when the subsystems behind it are genuinely plural and genuinely allowed to vary independently.
RetirementService, from the ViroTrade renewable-energy verification platform, is the only thing the controller ever calls. Behind it sit a dozen tracking-system-specific and document-type-specific services:
public ResponseRetirementUpload processUpload(String trackingSystem, String docType, String customer,
String year, MultipartFile file, boolean autoPromote, boolean testUpload) {
return switch (trackingSystem.toUpperCase()) {
case "ERCOT" -> processErcot(docType, customer, year, file, autoPromote, testUpload);
case "MRETS" -> processMrets(docType, customer, year, file, autoPromote, testUpload);
case "NAR" -> processNar(docType, customer, year, file, autoPromote, testUpload);
case "WREGIS" -> processWregis(docType, customer, year, file, autoPromote, testUpload);
// ...
};
}
private ResponseRetirementUpload processErcot(String docType, String customer, String year,
MultipartFile file, boolean autoPromote, boolean testUpload) {
return switch (docType) {
case "TransDetail" -> ercotTransDetailService.processUpload(customer, year, file, autoPromote, testUpload);
case "Screenshot" -> ercotScreenshotService.processUpload(customer, year, file, autoPromote, testUpload);
case "Email" -> ercotEmailService.processUpload(customer, year, file, autoPromote, testUpload);
// ...
};
}Each renewable-energy registry — ERCOT, M-RETS, NAR, WREGIS — emits proof of retirement in its own format, and each format needs its own parsing service. The controller doesn't know any of that exists. That's the actual value of Facade: not fewer lines of code, but a seam that lets a thirteenth registry get added without anyone touching the controller.
Flyweight
Share one instance of common state instead of constructing a fresh copy every time it's needed. Flyweight has a reputation for being a memory-optimization pattern for things like game engines or text renderers, which is true and also why most backend engineers never reach for it — but the same shape shows up anywhere a batch job would otherwise re-fetch or re-build the same shared object thousands of times.
NormalizationCache, from a clinical-trial data-import pipeline, caches shared Condition, Sponsor, and TrialSource domain objects for the duration of one import run, so the same condition name isn't re-queried on every row:
private final Map<String, Condition> conditionsByName = new HashMap<>();
Condition condition(String name, Function<String, Condition> loader) {
return cached(conditionsByName, name, () -> loader.apply(name));
}
private <K, V> V cached(Map<K, V> map, K key, Supplier<V> loader) {
if (map.containsKey(key)) { hits++; return map.get(key); }
misses++;
V value = loader.get();
map.put(key, value); // negative results cached too
return value;
}The class doc quantifies the actual motivation: roughly 3.9 redundant lookups per trial row before this existed, on an import that runs over thousands of trials. Worth noting the honest scope of it: this is a per-run cache, not a long-lived process-wide registry — a lighter-weight variant of the pattern, and still a legitimate one. Flyweight doesn't require the sharing to be forever, only that it's real sharing instead of silent duplication.
Behavioral
Template Method
An abstract base class fixes the shape of an algorithm; subclasses fill in the steps that actually vary. This is the pattern hiding inside every well-factored batch importer, and the pattern missing from every badly-factored one — the giveaway for the latter is a dozen near-identical CSV-parsing loops, each with its own slightly different bug.
AbstractCsvImporter, from ViroTrade's file-loading pipeline, fixes the algorithm — open file, parse headers, validate, loop rows, map, apply fields, save, collect errors, summarize — and leaves the parts that vary as hooks:
public ImportResult<T> importFromCsvWithResult(Path csvFilePath, Map<String, String> context, int rowLimit) throws IOException {
// ...
validateHeaders(headerMap);
for (CSVRecord record : csvParser) {
T entity = mapRecord(record, context); // varies per importer
applyGeneratedFields(entity, rowCount); // fixed step
applyCustomFields(entity, context); // overridable, defaults to no-op
T saved = dbService.create(entity);
imported.add(saved);
}
// ...
}A concrete subclass supplies only what's specific to its own CSV shape:
public class CrsPendingCsvImporter extends AbstractCsvImporter<CrsPending> {
protected String getTableName() { return "CrsPending"; }
protected CrsPending mapRecord(CSVRecord record, String version) {
return CrsPending.builder()
.uniqueId(buildUniqueId(record.get("Tracking System ID Number")))
.facility(record.get("Name of Generation Facility"))
// ...
.build();
}
protected Set<String> expectedHeaders() { return Set.of(/* ... */); }
}This is the cleanest match in the whole search, and it's not a coincidence that it's also the busiest corner of the codebase — a dozen concrete importers share this base, and a sibling AbstractHtmlTableImporter does the identical thing for HTML-sourced data. The REST API Architecture page describes a nine-layer template generated by hand for every new entity; Template Method is the same instinct pushed one level further, where the shared shape is enforced by the compiler instead of by discipline.
Chain of Responsibility
A sequence of handlers, each free to either resolve a request or pass it along. People write this shape constantly as a stack of filters or interceptors and rarely as business logic with the same rigor — which is a missed opportunity, because "first matching rule wins, with an explicit fallback" is a genuinely good way to express a set of business rules that would otherwise become one large, order-sensitive if-chain.
FacilityCrsApprovedRulesEngine, also from ViroTrade, evaluates eight rules in a fixed order. Each rule either returns a decision or declines by returning null, handing the request to the next rule:
public AutoAcceptDecision evaluateChanges(CrsApproved crsData, FacilityOutput existingFacility, Set<String> changedFields) {
AutoAcceptDecision decision;
decision = evaluateRule1_NewApplication(crsData, existingFacility, changedFields);
if (decision != null) return decision;
decision = evaluateRule2_EffectiveDateError(crsData, existingFacility, changedFields);
if (decision != null) return decision;
decision = evaluateRule3_OnlyCrsStatus(crsData, existingFacility, changedFields);
if (decision != null) return decision;
// rules 4 through 8, same shape
return new AutoAcceptDecision(PENDING_REVIEW, NO_RULE_MATCHED, /* ... */, "requires human review");
}
AutoAcceptDecision evaluateRule3_OnlyCrsStatus(CrsApproved crsData, FacilityOutput existingFacility, Set<String> changedFields) {
if (changedFields.size() != 1 || !changedFields.contains(FIELD_CRS_TRACKING_ATTESTATION_STATUS)) {
return null; // decline — pass to next rule
}
return new AutoAcceptDecision(AUTO_ACCEPT, ReconRuleCode.RULE_3.getCode(), /* ... */, "Only crsStatus changed, auto-accepting");
}No rule knows any other rule exists. Adding a ninth rule means adding one more handler and one more line in the chain — not touching the eight that already work. That property matters here specifically because these rules decide whether a facility-status change gets auto-accepted or routed to a human for review, and a system built this way is easier to audit than one where all eight conditions live inside a single method with nested branches.
Mediator
A coordinator sits between several peer services so they never reference each other directly. This is easy to confuse with Facade, and the difference is worth being precise about: a Facade hides subsystems that don't otherwise interact, presenting one simple call. A Mediator actively sequences interaction between peers that would otherwise need to know about each other.
CheckinAmPrepService, from a companion app for older adults, is the purer example of the two. It sequences a six-step workflow across four services that never reference one another:
public class CheckinAmPrepService {
private final CheckinAmService checkinAmService;
private final ProfileSeniorDbService profileSeniorDbService;
private final CheckinAmEmotionQuestionService checkinAmEmotionQuestionService;
private final CheckinAmPromptDbService checkinAmPromptDbService;
public CheckinAm prepareCheckinAm(String seniorExtid) throws DatabaseFailureException {
ProfileSenior senior = profileSeniorDbService.findByExtid(seniorExtid);
CheckinAm created = checkinAmService.create(CheckinAm.builder()/* ... */.build());
checkinAmService.update(created.getExtid(), CheckinAm.builder()/* ... */.build());
CheckinAmQuestion question = checkinAmEmotionQuestionService.findAndRecordBestPrompt(seniorExtid);
CheckinAmPrompt prompt = checkinAmPromptDbService.findById(question.getCheckinAmPromptId().longValue());
return checkinAmService.update(created.getExtid(), CheckinAm.builder()/* ... */.build());
}
}CheckinAmService, ProfileSeniorDbService, CheckinAmEmotionQuestionService, and CheckinAmPromptDbService are all injected here and nowhere near each other — none of them holds a reference to any of the others. Every interaction that would otherwise tangle four services into a small mesh of cross-dependencies instead passes through this one coordinator, in one readable, linear method.
Observer — a weaker case, and worth saying so
Subscribers get notified automatically when something happens, without the publisher knowing who's listening. This is the one pattern on this list I can't call genuinely underused in my own code — but the way it shows up is worth a caveat instead of a headline claim.
Every use found is Spring's built-in @EventListener(ApplicationReadyEvent.class), repeated close to a dozen times across startup hooks:
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
// seed or load data once the app is up
}That's real Observer — Spring's ApplicationEventPublisher is a legitimate pub/sub implementation — but it's the same generic "run this once at boot" idiom every time, never a custom domain event or a hand-rolled listener interface for something that actually matters to the business (an audit event, a status change worth notifying other parts of the system about). The honest read: Observer isn't ignored so much as underused deliberately — reached for as ready-made framework plumbing, never as a design choice for a problem that's actually shaped like "several things need to react to one thing happening." That's a gap worth noticing, not a gap worth pretending is filled.
What wasn't there, and why that's worth reporting
Four of the eleven patterns searched for — Prototype, Decorator, Composite, and State — turned up nowhere across several production Java codebases, after a real search rather than a token one. Reporting an absence honestly is more useful than forcing a weak match into the page, so here's what was actually true in each case.
Prototype — no copy constructors, no .toBuilder()-from-existing-instance, no clone-and-mutate anywhere. Every builder in every project constructs fresh, from a DTO or an external record, never from another instance of its own type. Not present.
Decorator — no hand-written class that wraps another implementation of the same interface to add behavior transparently. Absent as a hand-written pattern in these projects — Spring AOP (@Around advice) covers the same ground at the framework level, and it seems that's where that need has consistently gone instead.
Composite — no self-referential tree type anywhere in the domain models — no List<Self> of children, no nested-category or nested-rule structure treated uniformly with its leaves. Every list relationship found runs one class to a different class, never a class to a list of itself.
State — the one worth a real story. One of the ViroTrade codebases has two variant snapshots literally named viro-server_EnumBetter and viro-server_EnumFirst, which looked, from the names alone, like exactly the before-and-after this section wanted: a status enum that branches everywhere it's used, versus one that holds its own behavior per constant — the real Java idiom for State, where an enum implements an interface and each constant overrides the method body.
It isn't that. Every status enum in both variants — facility status, CRS status, AI provider, document pipeline stage — is a plain data-holding enum: a constructor, a display label, a sort order, a fromString lookup. None of them override a method per constant. The EnumBetter/EnumFirst naming turned out to describe a debate about enum shape — constant-name-as-database-value versus a separate display field — not a debate about where behavior should live. Wherever that behavior is needed, it's still an if or a switch on the enum value, at the call site, same as everywhere else.
That's the more honest anecdote: even in the one place in this codebase that visibly experimented with two takes on the same enum, neither take reached for State. The pattern that replaces branching-on-status with an object that knows how to behave as that status just isn't a reflex here — and probably isn't one in most codebases, which is exactly why it belongs on a list titled "ignored."
The actual point
Six of these are genuinely underused relative to how well they'd solve the problem they're built for — Adapter, Facade, Flyweight, Template Method, Chain of Responsibility, and Mediator all showed up doing real work the moment I looked. One, Observer, is used constantly but only ever as framework wiring, never as a deliberate design choice. Four weren't there at all, and the most interesting of those absences — State — is really a story about a codebase that got close to the idea and still didn't reach for it.
None of that is an argument for using more patterns. It's an argument for knowing the other nineteen exist before defaulting to Builder and Singleton out of familiarity, and for being honest — in code review, in a design doc, on a page like this one — about which patterns are actually earning their place and which ones are missing because nobody's needed them yet.