Appearance
9.4.3 — Abstract Factory
What the original Gang of Four book says: Provide an interface for creating families of related or dependent objects, without naming their concrete classes.
What that means when you are actually writing code: When several objects all have to come from the same world, hand out the whole set together. That way, mixing worlds becomes impossible rather than just discouraged.
Factory Method, in the last chapter, answered "which one concrete class?". Abstract Factory answers a harder question: "which whole set of matching classes?". The two patterns look similar on paper, and people confuse them constantly, but they solve genuinely different problems. Factory Method stops a single wrong class. Abstract Factory stops a wrong combination — the kind of bug that assembles cleanly, starts up fine, and then fails an hour later in a part of the system nobody was looking at.
1. The story: the outage caused by one correct-looking line
A team runs on Amazon Web Services and wants the option to run on Google Cloud one day. They do the sensible thing and put each dependency behind a role, each with its own factory (9.4.2):
typescript
const blobs = createBlobStore(config.blobDriver); // "s3" | "gcs"
const queue = createQueue(config.queueDriver); // "sqs" | "pubsub"
const secrets = createSecrets(config.secretsDriver); // "kms" | "gcp-kms"Three factories, three clean roles, and no vendor imports anywhere outside them. By every rule from the previous chapter, this is good code. Then, during an incident, somebody edits config.yaml in a hurry and writes:
yaml
blobDriver: gcs # ← migrated
queueDriver: sqs # ← forgotten
secretsDriver: kms # ← forgottenThe process starts. Nothing fails at startup. Blob writes land in Google Cloud Storage. The "file uploaded" events go to Amazon SQS. The worker processes reading those events then call Google Cloud Storage using Amazon credentials — and the failure surfaces forty minutes later, as a slow trickle of 403 errors in a background consumer nobody was watching. The postmortem's key line reads: "the configuration allowed a combination that cannot work."
Here is the point that makes this pattern worth its extra machinery. No single factory was wrong. Each one faithfully produced exactly the object it was asked for. The bug lives in the space between the factories — in a rule that spans all three of them ("all three must come from the same provider") that no type, no test, and no individual factory owned.
Abstract Factory is the pattern that gives that spanning rule an owner.
2. How you arrive at the pattern
Step 1 — Start naive. Use N independent factories, each choosing its own implementation from its own configuration key. This is genuinely correct until the products have to agree with each other.
Step 2 — Wait for the force. A constraint appears that ties the products together: these objects must come from the same family. This shows up in many disguises, and learning to hear them is the real skill:
- the storage client, the queue client, and the secret manager must share a cloud provider and its credentials;
- the button, the input, and the modal must share a design system, or the modal looks like Material Design while the button looks like iOS;
- the connection, the migration runner, and the query builder must share a SQL dialect;
- the tokenizer, the embedder, and the reranker must share a model family, or the vectors they produce are meaningless together.
Step 3 — Draw the line between what varies and what stays fixed. Notice that this is a different line than Factory Method draws.
| Factory Method | Abstract Factory | |
|---|---|---|
| What varies | which class implements one role | which world supplies all the roles |
| What stays fixed | one role's operations | the shape of the set — which roles exist |
| The unit of choice | one product | one family |
The insight is this: the decision is not made per product. It is made once, for the whole set. So the factory should not be N functions selected N separate times. It should be one object holding N creation methods, selected once.
Step 4 — Decide when the choice is made. Choose the family at startup, at the composition root, usually from a single configuration value. After that moment, the family is fixed and cannot be mixed — which is exactly the property the incident needed and did not have.
Step 5 — Name the pattern and say what it costs. The name is Abstract Factory. Its cost is a famous asymmetry, and you must be able to state it, because interviewers probe it directly: adding a new family is cheap (one new object implementing the interface), but adding a new product to the family is expensive (every existing family must now implement the new method). The pattern is open for extension along one axis and closed along the other. If your set of products is still changing week to week, you are paying that cost every week.
3. The mental model
In one sentence: an Abstract Factory is a boxed set. You choose the box, and everything inside is guaranteed to fit together.
The analogy that makes it stick — the travel adapter kit. When you travel to Japan, you do not buy a plug, a voltage converter, and a cable separately and hope they work together. You buy the Japan kit. You make one choice, "Japan", and every piece inside matches every other piece by construction. Choosing wrong is now a single, visible, early decision, instead of three independent chances to be subtly wrong.
When to reach for it. Listen for the word "and" binding creation requirements together with an unspoken must match: "we need a storage client and a queue and a secrets manager — for the same cloud." Or run the test in the negative, which is even sharper: can you name a combination that must never happen? ("Google blob storage with an Amazon queue.") If you can, that impossible combination is the tension, and Abstract Factory's job is to make it impossible to express, rather than merely documented as forbidden.
The distinction from Factory Method, in one breath: Factory Method hides which class; Abstract Factory hides which world. A single factory can hand you a wrong-but-usable object. A mismatched set hands you a system that assembles, starts, and then fails somewhere else entirely — which is why this pattern's payoff is measured in incidents avoided, not in lines of code saved.
4. Structure
CloudKit returned per family, there is simply no way to express a Google blob store next to an Amazon queue.Here are the participants.
| GoF name | Plain name | In the example |
|---|---|---|
| AbstractFactory | the kit interface | interface CloudKit |
| ConcreteFactory | one family | AwsKit, GcpKit, LocalKit |
| AbstractProduct | each role | BlobStore, Queue, SecretStore |
| ConcreteProduct | each family's implementations | S3Store, SqsQueue, KmsSecrets |
| Client | your application | receives a CloudKit, never names a vendor |
5. The code, walked through line by line
typescript
// ── The roles (AbstractProducts) — what the application actually depends on
interface BlobStore { put(key: string, body: Buffer): Promise<void>; get(key: string): Promise<Buffer>; }
interface Queue { publish(topic: string, msg: unknown): Promise<void>; }
interface SecretStore { read(name: string): Promise<string>; }
// ── The kit (AbstractFactory): the SHAPE of the family, not its contents
export interface CloudKit { // (1)
readonly provider: "aws" | "gcp" | "local"; // (2) for logs and health checks
blobs(): BlobStore;
queue(): Queue;
secrets(): SecretStore;
}
// ── One family (ConcreteFactory). Credentials are created ONCE and shared.
class AwsKit implements CloudKit {
readonly provider = "aws" as const;
#creds: AwsCredentials; // (3) the family's shared context
constructor(cfg: AwsConfig) { this.#creds = resolveAwsCredentials(cfg); }
blobs() { return new S3Store(cfg.bucket, this.#creds); } // (4) every product gets THE SAME creds
queue() { return new SqsQueue(cfg.queueUrl, this.#creds); }
secrets() { return new KmsSecrets(this.#creds); }
}
class GcpKit implements CloudKit {
readonly provider = "gcp" as const;
#auth: GoogleAuth;
constructor(cfg: GcpConfig) { this.#auth = new GoogleAuth(cfg.serviceAccountJson); }
blobs() { return new GcsStore(cfg.bucket, this.#auth); }
queue() { return new PubSubQueue(cfg.topic, this.#auth); }
secrets() { return new GcpKmsSecrets(this.#auth); }
}
// ── Family selection: ONE decision, made ONCE // (5)
export function createCloudKit(cfg: Config): CloudKit {
switch (cfg.cloud) {
case "aws": return new AwsKit(cfg.aws);
case "gcp": return new GcpKit(cfg.gcp);
case "local": return new LocalKit(cfg.localPaths); // (6) dev + CI, no cloud account
}
}Now the numbered lines.
(1) The kit interface is the pattern itself. It names which roles exist in this world. Everything else follows from that. Notice it is deliberately small — three products, chosen because they genuinely share a provider constraint. Roles that do not share the constraint, like a Clock or a Logger, must stay out of it. Putting them in would couple them to a decision they play no part in.
(2) A provider tag on the kit is a small, high-value addition the Gang of Four never mentioned. It lets you write log.info({ provider: kit.provider }) at startup and expose a field on /health. That is precisely the observability that would have caught the section 1 incident in seconds rather than forty minutes.
(3) The shared context is the real reason this pattern exists. The #creds object is created once per family and handed to every product. That is the mechanism that enforces "these objects come from the same world" — not merely the same vendor, but the same account, region, and credential. Three independent factories cannot express this. Each of them would resolve credentials separately, and each could resolve them differently.
(4) Every creation method is trivial. If a kit method ever starts branching internally, the family boundary is wrong — you have two families pretending to be one.
(5) One switch, one decision, one place. Compare this with the incident. Three configuration keys allowed 2³ = 8 combinations, of which only 2 were valid. Now the configuration surface is the valid set: cloud: "aws" | "gcp" | "local". That is six impossible states deleted by construction, which is exactly the sentence to say in a design review.
(6) LocalKit is not a toy. A filesystem blob store, an in-memory queue, and a .env secret reader together let the whole application run on a laptop or in CI with no cloud account at all. This is usually the family that pays for the pattern within a month, and it is worth proposing the whole pattern on that basis alone.
What this does when you run it: createCloudKit({ cloud: "gcp", … }) returns an object typed as CloudKit. kit.blobs() is a GcsStore, kit.queue() is a PubSubQueue, and both authenticate with the same GoogleAuth object. There is no expression anywhere in the program that yields a GcsStore alongside an SqsQueue.
5.1 The lightweight spelling — a frozen record of factories
Classes are not required. When families have no shared mutable state to manage, a function returning a frozen object is the honest TypeScript spelling:
typescript
type CloudKit = {
provider: Provider;
blobs: () => BlobStore;
queue: () => Queue;
secrets: () => SecretStore;
};
const kits: Record<Provider, (cfg: Config) => CloudKit> = { // ← registry of families
aws: (cfg) => {
const creds = resolveAwsCredentials(cfg.aws); // shared context via a closure
return Object.freeze({
provider: "aws",
blobs: () => new S3Store(cfg.aws.bucket, creds),
queue: () => new SqsQueue(cfg.aws.queueUrl, creds),
secrets: () => new KmsSecrets(creds),
});
},
gcp: (cfg) => { /* … */ },
local: (cfg) => { /* … */ },
};
export const createCloudKit = (cfg: Config): CloudKit => kits[cfg.cloud](cfg);The closure over creds does exactly what the private field did (3.6.2). The shared family context lives in the closure instead of on an instance. Same pattern, same guarantee, less ceremony. Use classes when a family needs a lifecycle — a close(), a connection pool, reconnection logic — and use the frozen record when it does not.
5.2 Python
python
from typing import Protocol
class CloudKit(Protocol):
provider: str
def blobs(self) -> BlobStore: ...
def queue(self) -> Queue: ...
def secrets(self) -> SecretStore: ...
class AwsKit:
provider = "aws"
def __init__(self, cfg): self._creds = resolve_aws_credentials(cfg)
def blobs(self): return S3Store(cfg.bucket, self._creds)
def queue(self): return SqsQueue(cfg.queue_url, self._creds)
def secrets(self): return KmsSecrets(self._creds)
KITS = {"aws": AwsKit, "gcp": GcpKit, "local": LocalKit}
def create_cloud_kit(cfg) -> CloudKit:
return KITS[cfg.cloud](cfg) # the class object itself is the family factoryPython's "classes are objects" property makes the registry especially clean: KITS maps a name to a class, and calling the class is calling the factory.
6. Five domains, the same shape
(a) Design systems — the classic Gang of Four example, still valid.
typescript
interface UiKit {
button(props: ButtonProps): Element;
input(props: InputProps): Element;
modal(props: ModalProps): Element;
}
class MaterialKit implements UiKit { /* Material-styled trio */ }
class CupertinoKit implements UiKit { /* iOS-styled trio */ }
function renderCheckout(ui: UiKit) { // ← cannot mix: a Material input inside
return ui.modal({ children: [ // a Cupertino modal is unrepresentable
ui.input({ label: "Card number" }),
ui.button({ label: "Pay" }),
]});
}This is exactly why React Native's platform-specific component sets and Flutter's MaterialApp and CupertinoApp exist: the set must agree, or the interface looks like it was assembled from spare parts.
(b) SQL dialect families — a case where a mismatch is silent and expensive.
typescript
interface DbKit {
dialect: "postgres" | "mysql" | "sqlite";
pool(): ConnectionPool;
migrator(): Migrator; // must emit DDL correct for the dialect
qb(): QueryBuilder; // must quote identifiers the dialect's way
}Pairing a Postgres pool with a MySQL query builder does not fail at startup. It fails on the first query that uses a "quoted" identifier, or worse, it succeeds while quietly producing different ORDER BY semantics. A family makes the pairing impossible.
(c) Test-versus-production service families.
typescript
const kit: ServiceKit = process.env.NODE_ENV === "test"
? new FakeKit() // in-memory clock, deterministic ids, no network, recording emailer
: new RealKit(cfg); // system clock, uuid v7, SES, live payment gatewayHalf-faked systems are a classic source of flaky tests. A real clock combined with fake ids produces test data that is deterministic in one dimension and not the other. Choosing the whole family per environment removes an entire class of flakiness.
(d) AI model families — the modern instance.
typescript
interface EmbeddingKit {
model: string;
dimensions: number;
embedder(): Embedder; // must produce vectors of `dimensions`
reranker(): Reranker; // must be trained against the same space
tokenizer(): Tokenizer; // must match the model's vocabulary
}Mixing an embedder from one model with a reranker from another produces results that are plausible and wrong — the worst possible failure mode, because nothing throws an error. The kit also carries dimensions, which the vector index needs at creation time (11.22).
(e) Multi-tenant contexts. Each tenant gets a kit binding its schema-scoped query runner, its feature-flag client, and its rate-limit policy. Mixing tenant A's database handle with tenant B's flag client is a data-isolation incident with regulatory consequences. A per-tenant kit makes it impossible to express rather than merely something to review for.
7. Ways to write the same idea
There are four spellings you will meet. They differ in how the set is held together, not in what it does.
| Spelling | What it looks like | Pick it when |
|---|---|---|
| A class | class AwsSet implements CloudSet | the set owns something long-lived, like an open connection |
| A function returning an object | makeAwsSet(config) | the everyday TypeScript way; nothing to keep alive |
| A lookup table | Record<Name, () => CloudSet> | you want the compiler to check that every name has a set |
| A generic set | interface Set<D extends Dialect> | you want the family name to show up in the types, not only in the values |
The middle one is the default. Reach for the class only when the set holds a resource that has to be opened and closed.
8. Adding a new family is cheap, adding a new member is expensive
This is the pattern's defining trade, and interviewers probe it, so it is worth being able to state both halves.
A new family is cheap. A fourth cloud provider is one new class that implements CloudSet, plus one line in the lookup table. Nothing that already exists is edited. This is being open for extension, which is the Open/Closed idea (9.3.6).
A new member is expensive. Suppose you decide the set also needs a cdn(). A CDN, short for content delivery network, is a set of servers spread around the world that keep copies of your files close to your users. The moment cdn() joins the set, every family must supply one immediately, including the local development set that has no CDN at all. With three families that is three edits. With twelve families it is a project.
Two ways to soften that, each with its own cost.
Give the new member a default. A base class, or spreading a shared object, can provide a cdn() that throws "not supported here". Families then adopt the real thing one at a time. The cost is that a set can now claim a capability it does not really have, so pair the default with a flag callers can check before using it.
Split the set. Put cdn() in a separate, smaller set that only the families with a CDN implement. Each set then keeps a promise it can actually keep. This is the Interface Segregation idea (9.3.8) applied to families, and it is usually the right answer when the new member is not universal.
The decision rule: only box a group of things together once the group has stopped changing. While you are still discovering which members belong, keep the factories separate and accept the risk of mixing, guarded by the startup check in section 9. Box them when the set settles.
9. Where you have already seen this shape
Three examples, all of them things you can check yourself today.
Light mode and dark mode. A theme is not one colour. It is a matched set — background, text, borders, shadows, icons — and every piece has to come from the same set. Take the text colour from the dark theme and the background from the light theme and you get white on white. That is exactly the mixing failure this pattern prevents, and it is why theme systems hand you a whole theme object rather than letting you pick colours one at a time.
Formatting for one language. Say you are showing a price, a date and a number of items to a user in France. There are three separate formatters involved, and all three must be built for the same language. If the date is formatted for France and the currency for the United States, the page shows "14 mars 2026" next to "$45.00", which looks broken. So the language is chosen once, and every formatter is built from that one choice. The language is the family.
Swapping the whole world in a test. When you want to run your order code without touching anything real, you do not swap the clock and hope somebody remembers the network and the storage. You swap all of them together, as one set, so there is no way to end up with a fake clock and a real payment provider. That combination is the one that charges a real card during a test run.
The tell that runs through all three: something is being handed to every member of the group — a theme name, a language, the word "fake". When you notice yourself passing the same argument into three constructors in a row, you have found this pattern waiting to be named. That is the cheapest way to recognise it.
10. Ways to get it wrong
No must-match constraint. If nothing bad happens when products come from different families, then there is no tension and the kit is ceremony — use independent factories (9.4.2).
The test: name the impossible combination. If you cannot, stop.
The kitchen-sink kit. Adding
logger(),clock(), andconfig()toCloudKitbecause they happened to be nearby. Now unrelated roles are coupled to the cloud decision, and swapping the logger means touching every family.The fix: a kit contains only the roles that share the family constraint.
Ignoring the product-axis cost. Boxing a set of products that is still being discovered means every new idea edits every family.
The fix: delay boxing, or split kits (section 7).
A leaky family.
CloudKitgrows ans3PresignUrl()method because one caller needed it. Now every non-AWS family has to throw or lie.The fix: model it generically (
signedUrl(key, ttl)), or accept that this role does not belong in the family.Choosing the family per call. Calling
createCloudKit(cfg)inside a request handler re-resolves credentials on every request and re-opens the mixing question.The fix: select once at the composition root and inject the kit.
The family choice is not observable. If nothing logs or exposes which family is active, misconfiguration is silent — which is the section 1 incident exactly.
The fix: the
providertag, in startup logs and on/health.Confusing it with Factory Method in review. Calling every factory an "Abstract Factory" muddies design conversations. The distinction — one product versus a matched set — is exactly the information a reviewer needs.
11. Abstract Factory compared with its neighbours
| Compared with | The difference | Choose Abstract Factory when |
|---|---|---|
| Factory Method | one product versus a matched family | more than one product must share a world |
| Builder | Builder assembles one complex object step by step; Abstract Factory produces several simple ones at once | the complexity is in the set, not in one object's assembly |
| Prototype | Prototype copies existing examples; kits construct fresh matched sets | there is no example to copy |
| Facade | Facade simplifies calling a subsystem; Abstract Factory controls creating one | the risk is mismatched construction, not call complexity |
| Bridge (9.4.1 section 2) | Bridge lets two choices pair freely | certain pairings must be impossible |
One question tells you whether you want a kit at all. Ask it out loud about the two things that vary: should these combine freely, or must they agree?
If the answer is "combine freely" — any message kind may use any sender — then you do not want a kit. You want each object to simply hold the other one, which is the Bridge idea from 9.4.1 section 2.
If the answer is "they must agree" — storage, queue and secrets all have to come from the same cloud account — then a kit is exactly right, because a kit is the only shape here that makes the wrong pairing impossible to write down rather than merely against the rules.
A word on wiring tools. Some projects wire their objects with a library that builds the whole object graph for you from a configuration file. Those tools do the same job as a kit, only for every object in the application at once. A kit is still worth writing on top of one, because the kit puts this one must-match rule into a small interface you can read in five seconds, instead of leaving it implied by configuration spread across a file nobody reads.
12. Interview calibration
The 45-second answer, in the order you would say it:
Abstract Factory is for families of objects that have to match each other — a storage client, a queue, and a secrets manager that must all come from the same cloud with the same credentials. Instead of three independent factories, which allow eight combinations of which two are valid, you expose one kit interface with three creation methods and pick the family once at startup.
The mismatched combination stops being something you review for and becomes something you cannot express. The known cost is the asymmetry: adding a new family is one class, but adding a new product to the family forces every existing family to implement it — so I only box a set of products once it is stable and genuinely universal.
Follow-up questions, with the seed of each answer:
- "Why not just three factories and a config check?" — A check is a runtime assertion somebody can forget or bypass; the kit removes the invalid states from the type system. Both is best: the kit and a startup log of
provider. - "What breaks when you add a fourth product?" — Every concrete factory. Mitigations: default implementations plus a capability flag, or split the kit (Interface Segregation for families).
- "Is a dependency injection container the same thing?" — Not quite. A container (9.4.2 section 10) builds every object in the app and manages how long each one lives. A kit does one narrow job: it keeps a single must-match rule visible in one small interface. Use a container to do the wiring, and a kit on top of it to hold the rule.
- "Where does the shared context live?" — In the concrete factory: one credential, auth, or locale object created per family and handed to every product. That is the real enforcement mechanism, more than the interface shape.
Recall
- Abstract Factory means one choice yields a matched set. The tension is a cross-object rule ("all products must come from the same world") that no single factory can own. The failure mode is a system that assembles fine and fails elsewhere — Google blobs plus an Amazon queue plus Amazon credentials.
- The mechanism: a kit interface naming which roles exist (
blobs(),queue(),secrets()), one concrete factory per family holding the shared context (credential, auth, locale, or dialect) and handing it to every product, and one selection at the composition root. N config keys that allowed 2ⁿ combinations collapse to one key whose type is the valid set. - The asymmetry, always state it: adding a family is cheap (one class plus one registry line); adding a product is expensive (every family must implement it). Mitigate with default implementations plus capability flags, or by splitting kits (9.3.8, Interface Segregation for families). Box a set of products only once it is stable and universal.
- Recognition heuristics: the word "and" plus an unspoken must-match; "name the combination that must never happen" — if you can, this is the pattern; and passing the same argument to three constructors in a row is an unnamed kit.
- In practice: tag the kit with
providerfor startup logs and/health(the observability that catches misconfiguration in seconds); ship aLocalKitorFakeKitfamily (usually what pays for the pattern); test the must-match rule across all families, and check it again at startup, because configuration can still express what the code cannot. The one question that decides it: should these combine freely, or must they agree? Freely means you do not want a kit; must agree means you do.
Self-test: What kind of bug does Abstract Factory prevent that per-product factories cannot? Where does the family's shared context live, and why is that the real enforcement? State the extension asymmetry and two mitigations. Give the negative recognition test in one sentence. Why is a startup assertion still needed when the types already forbid mixing?
Quiz Bank
FoundationalExplain the failure that per-product factories allow and Abstract Factory prevents, using a concrete example.
Per-product factories each answer which class correctly and independently — and that independence is the hole. With createBlobStore(cfg.blobDriver), createQueue(cfg.queueDriver), and createSecrets(cfg.secretsDriver), three configuration keys allow 2³ = 8 combinations, of which only 2 are coherent. A partial migration — blobDriver: gcs while the other two stay on AWS — starts cleanly and fails much later, when Google Cloud Storage calls are made with Amazon credentials, surfacing as scattered 403 errors in a background consumer.
The defect is a rule that spans the objects ("all products share one provider, region, and credential") that no individual factory owns, so no individual factory can be blamed or fixed. Abstract Factory gives that rule a home: one CloudKit interface, one concrete factory per family that resolves credentials once and passes the same object to every product, and a single cloud: "aws" | "gcp" | "local" configuration value. Six impossible states disappear because they cannot be expressed, not because a reviewer noticed.
The residual risk worth naming: configuration is not code, so also log kit.provider at startup and expose it on /health. A type system cannot police a YAML file; it can only shrink what the YAML is allowed to say.
FoundationalState the Abstract Factory extension asymmetry and how you would mitigate the expensive direction.
The cheap direction — a new family: adding Azure is one class implementing CloudKit plus one registry entry. No existing code changes, so the pattern is open for extension along the family axis (9.3.6, Open/Closed).
The expensive direction — a new product: deciding the kit also needs cdn(): Cdn forces every family to implement it at once, including the local and fake families. With twelve families that is a project, and it lands as a broad, risky pull request.
Mitigation one — default implementations: a base class or object spread supplying a no-op or UnsupportedOperationError version, so families adopt it incrementally. The cost is that a kit can now claim a capability it lacks, so pair it with an explicit capability flag (supports: { cdn: boolean }) that callers check, rather than letting them discover the gap by exception at runtime.
Mitigation two — split the kit: define a separate CdnKit, implemented only by families that have a CDN, and require it only where a CDN is genuinely needed. This is Interface Segregation applied to families, and it is usually the better answer, because it keeps every kit's guarantee tight and honest.
Mitigation three — timing: do not box a set of products that is still being discovered. Keep independent factories plus a startup consistency assertion until the set stabilises, then box it. The general principle: Abstract Factory encodes a decision that the set of roles is settled — pay for it when that is true, and not before.
AppliedDesign a kit for a system that must run against real infrastructure in production and entirely in-memory in tests. What goes in the kit, what stays out, and what does the test family buy?
In the kit: roles whose implementations must be consistent with each other because they share a world and often share state. For a typical backend: clock() (deterministic time), ids() (deterministic identifiers), blobs(), queue(), emailer(), payments(). These belong together because a half-faked world is a known flakiness generator — a real clock with fake ids produces test data that is deterministic in one dimension and not the other, and a real queue with a fake emailer produces tests that pass locally and hang in CI.
Out of the kit: roles with no cross-consistency constraint and no environment split — a pure Logger, a Config object, domain services. Including them couples unrelated decisions to the environment choice and turns the kit into a kitchen sink (section 10.2).
What the test family buys, concretely: first, one substitution swaps an entire world, so tests read const kit = new FakeKit() rather than assembling eight mocks. Second, determinism becomes structural — FakeKit's clock is a controllable value and its id generator is a counter, so snapshot tests stop flaking. Third, the fakes can be stateful and shared inside the family, which is what makes them useful — FakeKit's emailer records into the same in-memory store the assertions read, and its queue can be drained synchronously (await kit.queue().drain()), turning async choreography into a deterministic step. Fourth, module mocking (jest.mock) largely disappears, and with it the fragility of tests coupled to import paths.
The discipline that keeps it honest: the fakes must be behavioural, not empty. The fake blob store should reject oversized bodies and return 404 on a missing key, or tests pass against a world that is kinder than production. Run a shared contract test suite against both families, so the fake and the real implementation are provably interchangeable. Without that, the fake drifts, and the test family becomes a source of false confidence rather than speed.
InterviewYou have two things that both vary. How do you tell whether you need a kit here at all?
Say it out loud about the two things that vary: should these combine freely, or must they agree?
Sometimes every pairing is fine. Three kinds of message — text, email, push — and two versions of the sending service. All six pairings are legitimate; you just do not want to write six classes for them. The answer there is not a kit. Each message kind holds a sender, you pick one of each, and you hand one to the other. That is the Bridge idea from 9.4.1 section 2, and its whole point is freedom.
Sometimes a pairing is simply a bug. A Google storage client next to an Amazon queue is not an interesting combination anybody might want; it is an outage waiting for traffic. That is when you want a kit, because a kit's whole point is the opposite of freedom — it removes the bad pairing from the set of things you are able to type.
There is a second tell if the first one leaves you unsure. Look at what the two varying things are. When they are one idea and the way that idea gets carried out — a message, and the service that sends it — you are looking at the Bridge situation. When they are several different roles that all belong to the same world — storage, queue and secrets, all belonging to one cloud account — you are looking at a kit.
The two happily coexist. A kit can hand you objects that internally pair things up freely, and nothing about that is contradictory: the rule lives between the objects, and the freedom lives inside one of them.
StaffYour company runs one product on AWS and has just acquired a company running the same product on GCP. Leadership wants one codebase serving both, with per-customer deployment targets, within two quarters. Lay out the design, the sequencing, and the honest risks.
Design. The unit of choice is a deployment kit selected per environment at the composition root, not per request: interface PlatformKit { provider; blobs(); queue(); secrets(); warehouse(); cdn?; } with AwsKit, GcpKit, and a LocalKit for developer machines and CI. Each concrete kit resolves its credential or auth object once and shares it with every product — the mechanism that makes "same world" true rather than merely intended. The roles are cut from the union of actual usage discovered by auditing both codebases, minus vendor leaks: anything like s3PresignUrl is remodelled generically (signedUrl(key, ttl)) or, if genuinely not universal, moved out of the kit and behind a capability flag.
Sequencing, and this order is the plan's substance. First, inventory and role extraction — enumerate every cloud call in both codebases, because this discovery is what prevents cutting the interface twice, which is the most common way this project slips a quarter. Second, land LocalKit first. It is the cheapest family, it forces the roles to be honest (a role that cannot be implemented in-memory is usually leaking vendor semantics), and it immediately buys faster CI and offline development — an early, visible win that funds the rest politically. Third, wrap the incumbent (AWS) as AwsKit using adapters over the existing clients — pure addition, no behaviour change, shippable in small pull requests. Fourth, implement GcpKit against the now-proven role set, driven by a shared contract test suite every family must pass; this suite, not code review, is what makes the families genuinely interchangeable.
Fifth, cut over the acquired product to the shared codebase with its kit, one bounded context at a time, with the old system running until parity is measured. Sixth, ratchet: a lint rule forbidding vendor SDK imports outside platform/, with a shrinking allowlist.
Honest risks, named up front because leadership will hear about them eventually. Least-common-denominator drift — a portable interface can quietly give up the platform-specific features that made each cloud worth using (S3 Select, BigQuery streaming inserts); mitigate with explicit, flagged escape hatches rather than pretending the abstraction is total. Semantic mismatch beneath identical signatures — consistency models, ordering guarantees, and error taxonomies differ between SQS and Pub/Sub; a publish() that returns successfully means different things, and the contract test suite must assert behaviour (ordering, at-least-once redelivery, visibility timeouts) rather than shapes, or the abstraction is a lie that surfaces as a production incident. Cost and operational asymmetry — egress pricing, quota models, and IAM semantics differ, so one codebase does not imply one runbook, and the on-call burden roughly doubles. The product-axis cost (section 7) — every new platform capability now requires two implementations, permanently taxing feature velocity.
The recommendation to state plainly: adopt the kit, because per-customer deployment targets are a genuine business requirement; size it to the roles both products actually use; keep the escape hatches explicit and few; and budget the contract test suite as a first-class deliverable. The kit interface is the cheap part; the proof that two families behave identically is the project.
Flashcards
FlashAbstract Factory in one line
One choice yields a matched set. Products that must share a world (credential, locale, dialect, model) are created together, so mixing is unrepresentable.
FlashThe negative recognition test
Name the combination that must never happen. If you can name one, you have this pattern. If you cannot, use independent factories.
FlashWhere the enforcement really lives
In the concrete factory's shared context — one credential, auth, or locale object created once and handed to every product. The interface shape alone does not enforce it.
FlashThe extension asymmetry
New family = cheap (one class plus one registry line). New product = expensive (every family must implement it). Mitigate: default impls plus capability flags, or split the kit.
FlashDo I need a kit here?
Ask: should these two things combine freely, or must they agree? Freely means no kit — each object just holds the other. Must agree means a kit, because a kit makes the bad pairing impossible to write.
FlashThe cheapest heuristic
Passing the same argument to three constructors in a row = an unnamed kit.
Scenario Drill
DrillA payments platform integrates with card networks. Each integration needs a client, a webhook signature verifier, a currency/settlement rule set, and a sandbox credential set — and a production incident occurred when a sandbox verifier was paired with production clients, silently accepting forged webhooks. Design the fix, decide what belongs in the family, and say how you would prove the invariant holds in production, not just in tests.
Diagnosis first, because it names the family boundary. The incident is a cross-object rule violation of the most dangerous kind: the mismatched pair did not fail, it succeeded incorrectly, accepting forged webhooks, which turns a configuration error into a security event. Two separate dimensions were being chosen independently when they must agree: the network (Visa, Mastercard, Amex) and the environment (sandbox, production). Independent choice permits many combinations, and exactly the ones that pair a permissive sandbox verifier with a live client are catastrophic.
The kit, and the crucial detail: the family key is the pair. createNetworkKit(network, environment) returns a NetworkKit { network; environment; client(); verifier(); settlementRules(); }, with the credential and keyset resolved once per kit and shared by the client and the verifier — because it is the shared keyset, not the interface shape, that makes forgery-checking and calling agree. Modelling environment as a separate global flag rather than as part of the family key is precisely the mistake that caused the incident. The type must be a NetworkKit for a (network, environment) pair, and the registry Record<Network, Record<Environment, Builder>> makes every required pair compile-checked.
In the family: the client, the webhook verifier, the settlement and currency rules, and the keyset. These share the network's protocol version and the environment's trust boundary. Out of the family: the ledger, the idempotency store, the retry policy, and logging — they are environment-agnostic in behaviour, and coupling them to the network would mean re-implementing them per network, an immediate kitchen-sink smell (section 10.2). Settlement rules are the interesting judgment call: they belong in, because they are network-specific and must match the client's protocol version — pairing v2 client semantics with v1 settlement rounding produces cent-level reconciliation drift that is expensive to detect and embarrassing to explain.
Making the invariant provable in production, which is the actual question. Tests are necessary but not sufficient, because the incident came from configuration, and configuration is edited by humans under pressure. Four production-side controls, in increasing strength. First, startup refusal — the composition root builds every configured kit and asserts kit.client.environment === kit.verifier.environment === expectedEnvironment, failing the deploy rather than the webhook. Second, a cryptographic self-check at boot — verify a known-good signed fixture per network with the live verifier, so a sandbox keyset in production fails immediately and unambiguously; this is the single highest-value control here, because it tests the actual trust boundary rather than a label. Third, request-time attestation — every accepted webhook logs {network, environment, keyId}, and an alert fires on any environment !== "production" in the production account, turning a silent acceptance into a page within one event.
Fourth, blast-radius reduction — sandbox credentials are stored in a different secret namespace with an IAM boundary the production role cannot read, so the dangerous pairing is not merely unrepresentable in types but unreachable at the infrastructure layer.
Contract tests across families ensure that each network's verifier genuinely rejects a valid-looking signature made with another network's keyset — the property the incident proved was untested.
The sentence for the postmortem: the vulnerability was not a missing check, it was a family with two axes that the code allowed to be chosen separately; the fix makes the pair the unit of choice, and then proves the pairing cryptographically at every boot, rather than trusting the configuration that failed us.