Abstract Factory pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- An abstract factory is one object that creates a whole family of related objects: a blob store, a queue and a secrets client for one cloud, or a connection, statements and transactions for one database driver. Callers get every product through the same factory, so the products are guaranteed to belong together.
- Factory Method answers "which implementation of this one thing?". Abstract Factory answers "which consistent set of things?", and it turns several configuration choices that must agree into a single choice.
- In modern code it is usually an interface with a few
new_x()methods, a struct of constructor functions, or a dependency-injection module or profile. The most common second family is the in-memory one used by tests. - The pattern makes adding a family easy and adding a product type expensive: a new product means a new method on every family. Keep families small, and only group products that really share state or conventions.
- Agents scatter
if cloud == "aws"checks across each product's construction site. Review asks for one place that picks the family and for products that can only be obtained from it.
How it works
The problem
A document-processing service runs on AWS for most customers, on GCP for one large customer with a data-residency contract, and fully in memory in tests. It needs three things from its environment: object storage for uploaded files, a queue to hand work to processors, and a secrets client. The first version picks each one separately:
store = S3Store(cfg.bucket) if cfg.storage == "s3" else GcsStore(cfg.bucket)
queue = SqsQueue(cfg.queue_url) if cfg.queue == "sqs" else PubSubQueue(cfg.topic)
secrets = AwsSecrets(cfg.region) if cfg.secrets == "aws" else GcpSecrets(cfg.project)Three independent switches allow eight combinations, and only two work. With storage=gcs and queue=sqs, uploads land in GCS while processors try to read them with AWS credentials. Nothing fails at startup; jobs fail one by one at runtime. The products are related in ways the code does not express: shared credentials, region, HTTP client, and the format of an object reference.
The shape
Make the family a type:
class CloudKit(Protocol):
def blob_store(self) -> BlobStore: ...
def queue(self) -> Queue: ...
def secrets(self) -> Secrets: ...
kit = {"aws": AwsKit, "gcp": GcpKit, "memory": MemoryKit}[cfg.cloud](cfg)
service = DocumentService(kit)AwsKit builds one boto3 session and hands it to every product; GcpKit does the same with one set of Google credentials. Configuration has one key, cloud, so broken combinations cannot be expressed, and DocumentService never names a vendor. Choosing the kit is a factory-method decision (see the Factory Method lesson); what the kit adds is that every later creation agrees with that one choice.
Factories you already use
- Database drivers. In Go's
database/sql/driver, aDriveropens aConn, and theConncreates theStmtandTxobjects that only make sense on that connection. JDBC'sConnection.prepareStatementand Python DB-API'sconn.cursor()follow the same idea: the connection is the factory, and its products are bound to it. - Event loops. A Python asyncio loop creates futures, tasks, timers and transports (
loop.create_future(),loop.create_task(),loop.create_connection()). Installing uvloop swaps the whole family at once. Mixing families is detected: awaiting a future from another loop raisesRuntimeErrorsaying the future is attached to a different loop. - DI profiles. A Spring
@Profile("test")or a Guice module swaps a whole set of bindings at once, which is an abstract factory at application scale.
The axis problem
Picture a table with families as rows and products as columns. Adding a row (a new cloud) is one new class that fills in every column, and nothing else changes. Adding a column (a new product, say a cache) means editing every row, including families owned by other teams. This is the expression problem in miniature. Three things keep it manageable: keep each family to the few products that genuinely share state; split unrelated products into separate, smaller factories; and make a missing product a compile-time or startup error (Python ABCs refuse to instantiate a class with an unimplemented abstract method, TypeScript checks implements, and Go code can assert var _ CloudKit = (*GcpKit)(nil)).
When not to use it
- The products are unrelated. If storage and queue share nothing and could be mixed safely, two independent factories are simpler and let each vary on its own.
- There is one family and no tests that need another. Create the objects directly in the composition root.
- The "family" is really configuration. If AWS and GCP differ only in an endpoint URL and a region, one implementation with two configs beats two class hierarchies.
Reviewing agent-written code
Asked to "add GCP support", an agent will usually add an elif in each place a client is built, plus a new config key per product. The diff looks small and correct, and it re-creates the eight-combination problem. Steer it to one cloud key, one kit per cloud, and a service that receives the kit or the products. Then check three things: the products a kit creates share its session and credentials rather than each building their own; a product from one kit handed to another kit's product fails loudly; and the in-memory kit is used in tests instead of patching vendor clients.
The same shape elsewhere
This is close to the multi-step consistency shape: several choices must agree, so you collapse them into one. TLS 1.2 cipher suites do the same: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 names a key exchange, authentication method, cipher and hash analysed together, instead of negotiating four independent choices. A hardware vendor's board support package bundles drivers tested together for the same reason.
Down the stack
Layer 1The contract
Every product created by one factory works with every other product of that factory. Products from different factories are not required to interoperate, and when they are mixed the result must be a clear error at the point of mixing, not silent corruption later. The factory owns resources its products share (sessions, credential caches, connection pools); closing the factory closes them, so products must not outlive it. Whether repeated calls return new products or handles to shared state (two queue() calls returning views of the same queue) is part of the contract.
Layer 2The mechanism
In Go's database/sql/driver, Driver.Open(name) returns a driver.Conn whose Prepare returns a driver.Stmt and whose Begin returns a driver.Tx. The sql package keeps each Stmt tied to the connection that prepared it and re-prepares it on another pooled connection when needed, because a statement handle from one server session is meaningless on another. In CPython's asyncio, Task checks each awaited future's loop with futures._get_loop(fut) and raises if it is not the task's own loop. Java's DocumentBuilderFactory.newInstance() chooses a family at runtime: a system property, then a jaxp.properties file, then ServiceLoader, then the platform default. In Python, abc.ABCMeta computes __abstractmethods__ when the class is created, and object.__new__ raises TypeError if any remain, which is how a family missing a product fails at construction time.
Layer 3The machine
A kit is a hand-built vtable: an object whose methods each return a product. In Go, an interface value is two words, an itab pointer and a data pointer. The itab holds the concrete type, a hash, and the method addresses for that (interface, type) pair; conversions known at compile time use statically generated itabs, and dynamic ones are computed once by runtime.getitab and cached in a global hash table. Each product call is a load from the itab and an indirect call, about 1 to 2 ns when predicted.
That prediction is nearly perfect here. A process runs with one family, so every call site sees one concrete type for its whole life. CPU branch predictors learn the target, JVM and V8 inline caches stay monomorphic and inline the method, and Go's profile-guided optimisation can devirtualise hot sites. The dispatch cost of the pattern rounds to zero.
The real costs are resources. If each product builds its own SDK client, a kit with three products opens three connection pools, resolves credentials three times and pays three sets of TLS handshakes, each one or two round trips plus a few milliseconds of CPU. Sharing one session or HTTP client across the family is the main runtime benefit of the pattern, and forgetting to do it is the main runtime mistake.
Why it's built this way
- Some objects are only correct in combination: they share credentials, sessions, connections or reference formats.
- Choosing each one separately makes invalid combinations expressible, and they fail late, at runtime.
- So the choice is made once, as a family, and every product is created through that family.
- Products of a family should share expensive resources, so the factory owns them and controls their lifetime.
- Adding a product type touches every family, so families stay small and limited to products that truly belong together.
Diagram
flowchart LR
CFG["config: cloud=gcp"] --> MAIN["composition root"]
MAIN --> SEL{"pick family"}
SEL -- "aws" --> AK["AwsKit (one session)"]
SEL -- "gcp" --> GK["GcpKit (one credential)"]
SEL -- "memory" --> MK["MemoryKit (tests)"]
GK --> BS["blob_store()"]
GK --> Q["queue()"]
GK --> SC["secrets()"]
BS --> SVC["DocumentService"]
Q --> SVC
SC --> SVC
SVC -. "foreign ref" .-> ERR["error at publish"]Worked example
The service above had 38 places that built a storage, queue or secrets client from separate config keys. During the rollout for the data-residency customer, one region was configured with storage=gcs and queue=sqs. Uploads succeeded, processors failed on every message, and 11,000 documents waited six hours before someone traced the mismatch.
The fix introduced CloudKit with three products and three families. Configuration went from three keys per region to one, and the 38 construction sites to zero: services take a kit or its products in their constructors, and main builds the kit once. Sharing one SDK session per kit cut processor cold start from 2.1 s to 1.4 s, since credentials were resolved once instead of three times. References carry the kit's scheme (gs://, s3://) and queues reject foreign ones at publish, so a mismatch now fails on the first upload in staging.
Tests moved from LocalStack containers to MemoryKit, and the integration suite dropped from 14 minutes to 3. The cost appeared six months later when a cache product was proposed: three kits to update, one owned by another team. The cache got its own factory instead, because it shared nothing with the others.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Construct each product directly | Simplest; explicit | Invalid combinations possible; vendor names everywhere | One environment, no test doubles |
| Independent factory per product | Each varies freely | Nothing prevents mixing families | Products share nothing |
| Abstract factory (kit interface) | One choice; consistent products; shared resources | New product edits every family | Products share state or conventions |
| Struct or record of constructor functions | Light; easy to override one entry in tests | Consistency depends on how it is filled | Go and TS code that wants a family without classes |
| DI container profiles or modules | Swaps large graphs; lifetimes managed | Indirection; errors at container start | Large applications already using DI |
Pitfalls in production
- Separate config keys for products that must agree. Fix: one family key, validated at startup.
- Each product creating its own client, pool or credential chain. Fix: the kit creates them once and passes them to every product.
- Products that outlive the kit that owns their session, failing after
close(). Fix: tie lifetimes together and close the kit last in shutdown. - Mixed-family objects accepted silently (a
s3://reference in a GCP queue). Fix: tag products or references with their family and reject foreign ones where they meet. - One kit growing a method for every new dependency until it is a service locator. Fix: keep products that share state; move unrelated ones to their own factories.
- Families drifting apart in behaviour (in-memory queue delivers exactly once, real one at least once). Fix: one contract test suite run against every family.
Leadership lens
- Ask which products actually share state. That answer decides the family boundary, and a family drawn too wide makes every new dependency a cross-team change.
- Treat each family as a supported environment with an owner and contract tests. An untested family is a production incident scheduled for its first real use.
- Count configuration combinations. If the number of valid combinations is much smaller than the number of expressible ones, collapse the keys into a family choice.
- For agent work, make the instruction concrete: "new environment = new kit implementing the interface plus contract tests; no environment checks outside
main."
Check yourself
Three config keys select storage, queue and secrets separately, and only two of eight combinations work. What does an abstract factory change?
Show answer
It replaces three choices with one family choice, so the six broken combinations can no longer be configured.
Why is adding a new product type harder than adding a new family?
Show answer
A new family is one new class implementing existing methods, while a new product adds a method that every existing family, including other teams', must implement.
Where does the pattern's runtime cost really come from?
Show answer
Not dispatch, which is monomorphic per process and well predicted, but resources: products that each build their own client, pool or credential chain instead of sharing the kit's.
When should two products not be in the same factory?
Show answer
When they share no state or conventions and mixing implementations is safe; then separate factories let each vary independently and keep the family small.
Watch
- Abstract Factory Pattern, Design Patterns ep 5 (Christopher Okhravi): the Gang of Four structure built up on a whiteboard, with how it differs from Factory Method.
Free resources
- The Abstract Factory Pattern (Brandon Rhodes, python-patterns.guide): why Python often passes classes or callables instead, and where the pattern still fits.
- database/sql/driver (Go docs):
Driver,Conn,StmtandTx, a real product family bound to one connection. - Event loop (Python docs): the loop as a factory for futures, tasks and transports, and how alternative loops replace the family.
- DocumentBuilderFactory (Java SE docs): runtime selection of an implementation family through properties and
ServiceLoader. - Go Data Structures: Interfaces (Russ Cox): how itabs are built and cached, which is the cost of calling through a kit.
- Environment abstraction and profiles (Spring docs): swapping whole sets of beans per environment, the pattern at application scale.