skip to content

Abstract Factory is said to enforce consistency within a product family. What exactly does that guarantee cover, and what are its limits?

level: principalimportance: nice to knowfreq 30%

answer

  1. guarantee = same origin, not same semantics
  2. holds only if one factory is in scope
  3. leaks: direct new, cache, clone, sibling construction
  4. phantom type parameter makes it compile-time
  5. one composition root + arch test = real enforcement

basics

~20 s

Because one factory object produces every part, code that uses that factory gets parts from a single variant. The guarantee is only as strong as the discipline of always going through the factory — direct construction, caching, serialization, or two factories in scope can still mix variants.

solid answer

~50 s

The guarantee is *conditional and local*: for any client that obtains all its products from one factory instance, those products belong to the same variant. It is enforced by construction (a `DarkFactory` simply cannot return a `LightCheckbox`), not by the type system in general — abstract product types carry no variant tag, so `Button` and `Checkbox` from different factories still type-check when combined. Limits worth naming: (1) two factories in the same scope re-open the mixing hole; (2) any code path that bypasses the factory — direct `new`, deserialization, clones, a cached instance from a previous variant, a static/global default — escapes it; (3) splitting a fat factory into smaller ones weakens it to a wiring convention; (4) it says nothing about *behavioral* compatibility beyond origin. To make it structural rather than conventional you need variant-typed products (phantom type parameters, e.g. `Factory<D>` producing `Button<D>`/`Checkbox<D>`), or runtime assertions on a variant tag. Most systems accept the conventional guarantee and defend it with a single composition root plus tests.

code

typescript · 10 lines
typescript
// Origin consistency is conventional here:
const b = darkFactory.createButton()
const c = lightFactory.createCheckbox()   // compiles — Button/Checkbox carry no variant

// Structural version: variant as a phantom type parameter
interface Button<V> { render(): void }
interface Checkbox<V> { render(): void }
interface GuiFactory<V> { createButton(): Button<V>; createCheckbox(): Checkbox<V> }

function assemble<V>(b: Button<V>, c: Checkbox<V>) { /* mixed V now fails to compile */ }

go deeper

for a junior

Say that since one factory makes all the parts, they all come from the same set — that is the consistency the pattern gives you.

for a middle

Add the conditional: it only holds if all products come from that one factory, and note that direct construction elsewhere breaks it.

for a senior

Enumerate the leak paths (multiple factories in scope, caches, clones, products building their own siblings) and the enforcement tools: module visibility, architecture tests, a single composition root.

for a principal

Distinguish conventional from structural enforcement, describe phantom-typed families and variant tags with their costs, reason about variant scope (process vs. request vs. tenant) and cache-key implications, and prescribe per-variant contract tests as the governance mechanism.

### What the guarantee actually is The claim "Abstract Factory enforces consistency" is precise if stated carefully: > If a client obtains **every** family member from **one** concrete factory instance, then those members are all of that factory's variant. The enforcement mechanism is trivial — `DarkFactory.createCheckbox()` is hardcoded to return `DarkCheckbox` — but the *scope* is what matters: it converts a per-call-site discipline ("remember to use Dark everywhere") into a single decision ("use this factory"). Correctness now depends on one choice instead of N. ### What it is not It is **not** a type-level guarantee in the usual formulation. Consider: ``` Button b = darkFactory.createButton() Checkbox c = lightFactory.createCheckbox() screen.add(b); screen.add(c) // compiles fine, mixed variants ``` Because the abstract product types (`Button`, `Checkbox`) are variant-agnostic, nothing in the signature stops mixing. The pattern removes the *opportunity* by ensuring only one factory is in scope; it does not make mixing unrepresentable. ### The five leaks 1. **Multiple factories in scope.** Passing two factories into the same object — often after an interface split for Interface Segregation reasons — restores the original hazard. Mitigation: pass exactly one factory, or a composite that itself is a single family. 2. **Bypass paths.** Direct construction anywhere (`new LightButton()`), a static default instance, a service locator lookup, or a legacy code path that predates the factory. Mitigation: language-level visibility (make concrete products package-private/internal so only the factory can build them), architecture tests/lint rules banning `new` on concrete product types outside their package. 3. **Persisted or cached instances.** An object created under the old variant and cached, serialized, or restored from a session outlives the variant switch. Mitigation: version/tag caches by variant key; invalidate on switch. 4. **Clones and copies.** `Prototype`-style copying of a product yields the source's variant regardless of the current factory — usually fine, occasionally surprising when a copy is made across a variant boundary. 5. **Products that construct siblings.** A `DarkButton` that internally does `new DefaultTooltip()` breaks the family from the inside. Mitigation: pass the factory (or the needed sibling) into the product at construction so it stays within the family. ### Making the guarantee structural Several techniques, increasing in cost: - **Variant tag + runtime assertion.** Each product exposes `variantId()`; the assembling component asserts all parts agree. Cheap, catches leaks in tests and dev, costs a check. - **Phantom/marker type parameters.** Parameterize both the factory and the products by a variant type: `interface Factory<V> { Button<V> createButton(); Checkbox<V> createCheckbox(); }`. Now `screen.add(Button<Dark>, Checkbox<Light>)` fails to compile if the consuming API also requires a single `V`. This genuinely moves the invariant into the type system, at the cost of generics noise that many teams reject, and it does not survive erasure-based reflection or serialization. - **Sealed families / module boundaries.** Keep each variant's concrete products in their own module and export only the factory, so the compiler and build graph prevent naming them elsewhere. ### Consistency of *origin* vs. consistency of *behavior* Even a perfect origin guarantee does not mean the parts *work well together*. Two products from the same variant can still be misconfigured (a Postgres connection to the wrong schema, a Dark button with a hardcoded light icon). The family invariant covers provenance, not semantics. When behavioral compatibility matters, you need integration tests per variant — and a practical benefit of the pattern is that the variant axis makes those tests easy to parameterize: run the same suite against every concrete factory. ### Architectural consequences worth naming at a senior/principal level - **Single composition root** is the real enforcement point; the factory is just the vehicle. Governance (lint rule, ArchUnit-style test, code review) is what keeps bypass paths from accumulating. - **Variant scope** must be explicit: process-wide (a config flag), per-request (tenant, locale, region), or per-test. Mixing scopes — a process-wide cache holding per-request-variant products — is a recurring production bug class. - **Contract tests per variant.** A shared test suite executed against every concrete factory both verifies the family and documents its expectations; it is the standard companion to this pattern in driver/plugin SDKs.

  • How would you detect mixed-variant usage in a large codebase that already uses the pattern?
    Ban direct construction of concrete products outside their own module (visibility modifiers plus an architecture test or lint rule), add a variant tag with assertions on assembly in dev/test builds, and run a shared contract-test suite parameterized over every concrete factory.
  • Does splitting a large factory interface into smaller ones preserve the consistency guarantee?
    Only if the split parts are still handed out together as one variant — e.g. by a composite factory or a single container module. Injecting two independently chosen sub-factories reduces the guarantee to a wiring convention.
  • What breaks when the variant is per-request but a product is cached process-wide?
    The cached product outlives its request and gets served to a request of a different variant, producing exactly the mixed-family bug the pattern exists to prevent. Cache keys must include the variant, or the products must not be cached across variant scope.

A single supplier contract keeps parts matched only while you buy everything from that supplier. One emergency purchase from the shop next door, or one part left over in the warehouse from the previous contract, and the assembly no longer fits — the contract never physically prevented it.

saying these in an interview costs you the question

  • Stating the guarantee is enforced by the type system without qualification — abstract product types are variant-agnostic.
  • Ignoring bypass paths (direct construction, caches, deserialization, static defaults) when claiming consistency.
  • Assuming same-variant implies compatible behavior; provenance is not semantics.
  • Splitting the factory for Interface Segregation and not noticing the invariant degraded to convention.
  • Treating the factory as a global mutable singleton so the current variant becomes ambient state that changes under running code.

context