Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive)
Every creational pattern earns its keep by moving a decision (which class to instantiate, how to assemble it, whether more than one may exist, how to copy it) out of ad-hoc call sites and into one designed seam — and every one of them has a standard objection a senior reviewer raises at exactly that seam. This page is that review conversation: the objection, the mechanism-level answer, and the concurrency/edge-case gotchas that the introductory pattern pages don't cover.
Factory Method — “why not just inject the product?”
Factory Method puts a virtual createProduct() hook inside a Creator class; a subclass overrides the hook to choose the concrete product, while the Creator's own algorithm (a template method that calls createProduct() internally, then does real work with the result) stays shared and unchanged. The standard objection: “why subclass the Creator at all — why not just construct the product elsewhere and inject it via the constructor (dependency injection)?”
The answer is mechanism-level, not taste: Factory Method earns its keep when the Creator has real logic wrapped around the creation step and the product choice is an implementation detail of which Creator subclass you are — e.g. ReportDocument and ResumeDocument both share Document.render() (open template, paginate, apply header/footer) but each overrides createPage() to return a different Page implementation. The decision "which page type" is entangled with "which document subclass," so keeping it as a virtual method co-located with the class that owns the surrounding algorithm is the honest design — a new document type is added purely by subclassing, without touching the shared algorithm.
Dependency injection is the better call when creation is pure external wiring: there is no template-method logic in the Creator, it's just “give me a Product,” and the choice of which implementation to use is a composition-root decision (config, environment, test double) that has nothing to do with any surrounding class hierarchy. Building a subclass hierarchy purely to satisfy a “which implementation” choice is ceremony DI removes for free — you inject the finished product (or a factory function) once, and every consumer is trivially testable by substituting a mock.
Drill-down: one Creator, several product types
The follow-up question a senior interviewer asks next: “What if a single Creator must produce several different, runtime-selected product types — not one polymorphic product fixed at subclass-selection time?” Example: one NotificationService needs to create an Email, Sms, or Push notifier depending on a channel value that arrives at request time — there's no natural "NotificationService subclass per channel," because the channel varies per call, not per deployment.
The fix is a parameterized factory method: one method that takes a type key and internally maps it to a concrete product (a switch/registry lookup), e.g. createNotifier(Channel channel). This trades “one virtual method per Creator subclass” for “one method, runtime-selected key” — the right call when the set of product types is enumerable, centrally known, and doesn't need per-subclass specialization of surrounding logic, but you still want all construction funneled through one auditable place rather than scattered new calls.
Abstract Factory — the product-axis rigidity, and three real mitigations
Abstract Factory groups several related creator methods (createDrink(), createPastry()) behind one factory interface so a single factory choice locks every product it returns into the same family. The trade-off the intro page names but doesn't resolve: adding a new family (a JuiceBarFactory) is cheap — implement the existing interface once more — but adding a new product kind (a createMug()) is a breaking change to the interface, forcing every existing concrete factory to add the method or the code stops compiling. Three mitigations a senior engineer actually reaches for:
- Extension via a generic
create(kind). Replace one method per product kind with a singlecreate(ProductKind kind)that internally dispatches (a registry/map keyed by kind) to the right concrete product, returning a shared supertype. A new product kind is now an enum value plus a registry entry inside each factory — the interface signature never changes. Cost: you trade compile-time completeness (the old shape guaranteed every factory implements every product) for a runtime lookup that can fail if a factory's registry is missing an entry. - Defaulting / optional methods. Add the new product method to the interface but give it a default implementation (Java 8+
defaultmethod) that throwsUnsupportedOperationExceptionor returns a null-object. Existing concrete factories keep compiling untouched; only factories that genuinely support the new product override it. Cost: the “every factory implements every product” invariant becomes a runtime contract instead of a compiler-checked one — a caller can hit the unsupported path in production if a factory was never updated. - Accept the rigidity for a genuinely stable product set. If the product axis (what kinds of things get created) is settled and only the family axis (which variant) is expected to grow, the rigidity isn't a cost you'll ever pay — it's the compile-time guarantee you actually want. Document that assumption explicitly in the interface javadoc/comment, because if the product axis does grow later, migrating to (1) or (2) is itself a breaking interface change that ripples through every concrete factory.
Builder — concurrency: a fluent builder is mutable, not thread-safe
A fluent builder accumulates state field-by-field across chained .with...() calls before a final build() materializes the object — that accumulation is ordinary mutable state with no synchronization. If one builder instance is shared across threads (a cached “template” builder, a static field, a builder passed into a thread pool task and reused) and two threads call setters concurrently, there is no atomicity and no visibility guarantee: the two threads' partial writes interleave, so build() can hand back an object that is a splice of thread A's and thread B's fields, or a write from one thread can simply never become visible to the other (a classic missing-happens-before bug, the same family as an unsynchronized counter++). The contract that avoids this entirely: exactly one builder instance per construction, confined to the thread that uses it — never publish a builder across threads, never reuse one after build(). If many builds share common defaults, create a fresh builder per use (copying the shared defaults in), rather than mutating one shared instance.
The step/staged builder: enforcing required fields and order at compile time
A plain fluent builder still lets you call build() before every required field is set — that surfaces as a runtime exception (or worse, a silently invalid object), never a compile error, because every with...() method returns the same builder type. The staged (step) builder idiom fixes this structurally: define one interface per required step, where each step's method returns the next step's interface — not this — so only the methods legal at that point in construction are visible to the compiler. Only the final step's interface exposes build().
Traced: building an HttpRequest with a staged builder
Required fields: url then method, in that order; an optional repeatable header. The static entry point returns the narrowest interface, UrlStep, so no other method is reachable until the sequence is followed.
| Step | Call | Compile-time (static) type returned | Methods now callable | What fails to compile if skipped |
|---|---|---|---|---|
| 1 | HttpRequestBuilder.newBuilder() | UrlStep | .url(String) | — |
| 2 | .url("https://api.example.com") | MethodStep | .method(String) | .build() — not declared on MethodStep |
| 3 | .method("GET") | BuildStep | .header(k,v), .build() | .method(...) again — not declared on BuildStep |
| 4 | .header("Accept","application/json") | BuildStep (self-loop) | .header(k,v), .build() | — |
| 5 | .build() | HttpRequest | — | — |
So newBuilder().method("GET") fails to compile — UrlStep declares no method() — and newBuilder().url(u).build() fails to compile — MethodStep declares no build(). The required-field-and-order invariant that a plain fluent builder can only check at runtime (via a null check inside build()) is, here, a property the compiler proves for every call site before the program ever runs.
Java
interface UrlStep { MethodStep url(String url); }
interface MethodStep { BuildStep method(String method); }
interface BuildStep { BuildStep header(String k, String v); HttpRequest build(); }
final class HttpRequestBuilder implements UrlStep, MethodStep, BuildStep {
private String url, method;
private final Map<String,String> headers = new LinkedHashMap<>();
private HttpRequestBuilder() {}
static UrlStep newBuilder() { return new HttpRequestBuilder(); } // narrowest type on purpose
public MethodStep url(String url) { this.url = url; return this; }
public BuildStep method(String method) { this.method = method; return this; }
public BuildStep header(String k, String v) { headers.put(k, v); return this; }
public HttpRequest build() { return new HttpRequest(url, method, headers); }
}
public class Solution {
public static void main(String[] args) {
HttpRequest req = HttpRequestBuilder.newBuilder()
.url("https://api.example.com") // -> MethodStep
.method("GET") // -> BuildStep
.header("Accept", "application/json")
.build(); // -> HttpRequest
System.out.println(req);
// HttpRequestBuilder.newBuilder().method("GET"); // COMPILE ERROR: no method() on UrlStep
// HttpRequestBuilder.newBuilder().url("x").build(); // COMPILE ERROR: no build() on MethodStep
}
}
Go
Go has no method-covariant return narrowing via inheritance, but the same staging shape works with distinct wrapper types around one shared, unexported builder struct — each wrapper type exposes only the methods legal at its stage.
package main
import "fmt"
type httpRequest struct {
url, method string
headers map[string]string
}
type core struct{ req httpRequest }
type UrlStep struct{ c *core }
type MethodStep struct{ c *core }
type BuildStep struct{ c *core }
func NewBuilder() UrlStep {
return UrlStep{c: &core{req: httpRequest{headers: map[string]string{}}}}
}
func (s UrlStep) Url(u string) MethodStep {
s.c.req.url = u
return MethodStep{c: s.c} // narrows to the next stage only
}
func (s MethodStep) Method(m string) BuildStep {
s.c.req.method = m
return BuildStep{c: s.c}
}
func (s BuildStep) Header(k, v string) BuildStep {
s.c.req.headers[k] = v
return s // self-loop: optional, repeatable
}
func (s BuildStep) Build() httpRequest { return s.c.req }
func main() {
req := NewBuilder().
Url("https://api.example.com"). // -> MethodStep
Method("GET"). // -> BuildStep
Header("Accept", "application/json").
Build()
fmt.Printf("%+v\n", req)
// NewBuilder().Method("GET") // COMPILE ERROR: Method undefined on UrlStep
// NewBuilder().Url("x").Build() // COMPILE ERROR: Build undefined on MethodStep
}
Singleton — the classloader caveat: one per classloader, not one per JVM
Static state in the JVM is scoped to the pair (Class object, defining classloader), not to the class name alone. A “Singleton” is therefore guaranteed to be one instance per classloader that loads it — not one instance JVM-wide. In any topology with more than one classloader — an application server hosting two web apps each with their own classloader, an OSGi/plugin architecture, a JAR loaded both by the application classloader and a plugin classloader — each classloader that loads the Singleton's .class file gets its own copy of every static field, including the instance-holding field. The result: two (or more) “the one instance” objects exist simultaneously, silently, with no exception thrown anywhere.
This typically surfaces as a baffling symptom, not a stack trace: “a config change made in one place doesn't show up somewhere else,” or “my in-memory cache seems to randomly reset.” The diagnostic is to compare instanceA.getClass().getClassLoader() against instanceB.getClass().getClassLoader() at the two call sites — if they differ, that's the whole bug, and no amount of fixing the thread-safety mechanics helps, because this is an orthogonal dimension: it's a deployment topology problem, not a concurrency problem. The existing Singleton thread-safety page (double-checked locking / holder idiom / enum singleton, in this guide's Creational topic) assumes a single classloader throughout — correct DCL inside one classloader still yields two distinct singletons across two classloaders, because DCL only prevents a race within one classloader's copy of the static field.
Mitigation, when a true JVM-wide single instance is required across classloaders: move the class to a shared/parent classloader that all child classloaders delegate to (the standard parent-first delegation model in app servers means a class loaded by a shared/common lib loader is loaded once and reused by every child), or externalize the single point of truth to something outside per-classloader static state entirely — a JNDI-registered resource, an external cache/registry, or a system-scoped resource guaranteed to be loaded by the boot/system classloader. This is a JVM-specific gotcha: Go has no analogous pluggable-classloader runtime, so a Go package-level sync.Once-guarded singleton genuinely is one instance per process, with no equivalent caveat.
Prototype — deep copy via serialization, and its price
Hand-writing a recursive deepClone() for every nested field is tedious and easy to get wrong when the object graph changes shape. The pragmatic alternative: serialize the object graph to a byte buffer, then deserialize it back into a brand-new graph (Java: Serializable + ObjectOutputStream/ObjectInputStream; Go: an encoding/gob or JSON round-trip). Serialization walks every reachable reference and reconstructs each one fresh on the way back in, which gives a genuine deep copy — no reference in the copy is shared with the original — without writing a single recursive clone method by hand.
The trade-offs are real, not cosmetic:
- Slow. Full-graph serialization plus reconstruction is orders of magnitude slower than a shallow field copy or even a hand-rolled deep clone, and it allocates a large amount of short-lived garbage — unsuitable on a hot path.
- Requires every reachable class to be serializable (Java
Serializable) — a class that isn't throwsNotSerializableExceptionat copy time, which can force marking classes serializable purely to satisfy this technique, a concern that has nothing to do with the domain model. - Misses
transientfields. Fields markedtransientare explicitly excluded from Java serialization and come back as their zero-value/null in the copy — correct when the field holds runtime-only state (a lock, a live connection, a cache handle), a silent data-loss bug when it accidentally holds meaningful domain data.
Copy constructor vs clone()/deserialization: who actually re-runs validation
A copy constructor — public Foo(Foo other) { this.x = other.x; ... } — is an ordinary constructor. It runs exactly like any other call to new Foo(...): every validation check and defensive copy already written in the constructor body executes on every copy, with no special-cased path around it.
clone() and deserialization take a different path entirely, and it's the crux of the gotcha: neither one calls a constructor at all. Object.clone() performs a raw, native field-for-field copy of the object's memory layout — it never invokes new Foo() or any constructor, so any invariant-checking or defensive-copying logic living in your constructors is silently skipped. Java deserialization is documented to behave the same way for a plain Serializable class: the no-arg constructor is not invoked; fields are written directly from the byte stream into a freshly allocated instance.
Concretely: if Foo's constructor enforces if (x < 0) throw new IllegalArgumentException(), a copy-constructor call re-checks and re-enforces that invariant on every copy (redundant when the source was already valid, but safe). A clone() of a Foo whose invariant was corrupted after construction (e.g. via reflection, or a buggy setter) happily produces a clone carrying the same bad x — and, more seriously, a maliciously crafted serialized byte stream can hand back a deserialized Foo with x = -999 even though no code path that goes through the constructor would ever permit that value. This is a documented Java deserialization hardening concern (see Effective Java's guidance on defensive readObject/readResolve), not an academic curiosity.
Practical rule: where invariant enforcement matters — security-sensitive classes, immutable value objects with real constraints — prefer a copy constructor (or a static factory that revalidates) over clone()/deserialization for copying. Where deserialization of such a class is unavoidable, implement readObject() yourself and re-run the validation there explicitly — never rely on the default deserialization path to preserve invariants it never checks.
Pitfalls
- Factory Method as ceremony. Building a Creator subclass hierarchy when there is no template-method logic around the creation step is pure overhead — if it's really just “give me a Product,” inject the product and skip the hierarchy.
- Abstract Factory: adding a product kind silently breaks every concrete factory that hasn't opted into a mitigation (generic
create(kind)or defaulted methods) — know which mitigation you chose before the second product kind shows up, not after. - Sharing one Builder instance across threads or across builds. A fluent builder is ordinary mutable state; treat it exactly like any other unsynchronized object shared across threads — don't.
- Assuming Singleton means “one JVM-wide instance.” It means one per classloader. In multi-classloader deployments, verify with
getClass().getClassLoader()before trusting shared state across boundaries. - Trusting
clone()/deserialization to preserve invariants. Neither runs your constructor. If the class enforces anything security- or correctness-critical, that enforcement is bypassed unless you explicitly re-implement it inreadObject()or use a copy constructor instead. - Serialization-based deep copy on a hot path. It's correct but slow and garbage-heavy — fine for an occasional deep clone (e.g. undo/redo snapshots), wrong for a tight loop.
Judgment layer
Factory Method vs dependency injection
Choose Factory Method when the Creator has real algorithmic logic surrounding the creation step and the product choice is bound to which Creator subclass you are — the decision belongs inside the class hierarchy because it's entangled with the hierarchy's own behavior. Choose DI when creation is pure external wiring with no surrounding template-method logic — you gain simpler code, no forced subclassing, and trivial testability (swap the injected dependency for a mock), at the cost of the decision now living outside the type system, in whatever wires the composition root. Choose a parameterized factory method (a single method keyed by a type value) when one Creator must produce several enumerable, runtime-selected product types that don't map onto per-Creator-subclass specialization.
Copy constructor vs clone() vs serialization round-trip
Copy constructor: re-runs every constructor invariant check on each copy — the correct default whenever the class enforces anything, at the cost of writing (and maintaining) the copy logic by hand for each field, including nested deep copies you must call explicitly. clone(): fast, built-in, but a raw memory copy that bypasses the constructor entirely (shallow by default — nested mutable fields are shared unless you override clone() to deep-copy them one by one) — use only for simple, non-security-sensitive value holders. Serialization round-trip: the least code to write for a genuinely deep copy of an arbitrarily nested graph, at the cost of speed, a Serializable requirement across the whole graph, silent loss of transient fields, and — like clone() — no constructor re-validation. Use it for occasional, off-hot-path deep copies (undo stacks, snapshotting for a what-if computation) where correctness of the copy's independence matters more than speed and where the class has no invariants a corrupted byte stream could violate.
Takeaways
- Factory Method earns its keep when creation is entangled with the Creator's own algorithm; when it's pure wiring, inject the product instead — and when one Creator needs several runtime-selected types, use a parameterized factory method, not more subclasses.
- Abstract Factory's rigidity is on the product axis, not the family axis — pick a mitigation (generic
create(kind), defaulted methods, or deliberately accepting the rigidity) before the second product kind arrives. - A fluent Builder is unsynchronized mutable state — one instance per thread per build; the staged/step-builder idiom moves required-field-and-order enforcement from a runtime check into a compile error.
- Singleton guarantees one instance per classloader, not per JVM — and neither
clone()nor deserialization runs your constructor, so a copy constructor is the only one of the three copy mechanisms that re-validates invariants by construction.
Related pages
- Singleton — Thread-Safe Variants (DCL, Holder, Enum) — the thread-safety mechanics assumed by the classloader-caveat section
- Behavioral Patterns — Senior Gotchas & Follow-ups (Deep Dive) — companion deep dive, same format, for behavioral patterns
- Structural Patterns — The Discriminators & Senior Gotchas (Deep Dive) — companion deep dive for structural patterns
- Design Patterns — The Discriminators & Taxonomy (Deep Dive) — the taxonomy this deep dive assumes as background
- Race Conditions — A Traced Interleaving of counter++ — background on the unsynchronized-write race discussed in the Builder section
Re-authored/Deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive)** (OO & Low-Level Design) and want to truly understand it. Explain Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Creational Patterns — Senior Objections, Concurrency & Edge Gotchas (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.