Engineering Atlas › Low-Level Design

Abstract Factory pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

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

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

  1. Some objects are only correct in combination: they share credentials, sessions, connections or reference formats.
  2. Choosing each one separately makes invalid combinations expressible, and they fail late, at runtime.
  3. So the choice is made once, as a family, and every product is created through that family.
  4. Products of a family should share expensive resources, so the factory owns them and controls their lifetime.
  5. 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

OptionStrengthsWeaknessesChoose when
Construct each product directlySimplest; explicitInvalid combinations possible; vendor names everywhereOne environment, no test doubles
Independent factory per productEach varies freelyNothing prevents mixing familiesProducts share nothing
Abstract factory (kit interface)One choice; consistent products; shared resourcesNew product edits every familyProducts share state or conventions
Struct or record of constructor functionsLight; easy to override one entry in testsConsistency depends on how it is filledGo and TS code that wants a family without classes
DI container profiles or modulesSwaps large graphs; lifetimes managedIndirection; errors at container startLarge applications already using DI

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →