Abstract Factory¶
Intent¶
Provide one object that creates every member of a family of related products, so the code that uses them gets a consistent set without naming a concrete class. The guarantee is about matching, not about hiding constructors: a pipeline that got its bucket from one provider has no way to get its queue from another.
When to use and when not to¶
Use it when
- Two or more products must agree with each other: a database driver's connection and cursor, a payment provider's processor and webhook parser, a cloud's storage and queue, a widget toolkit's button and entry.
- The family is chosen once, from configuration or environment, and used everywhere; switching families must touch one line.
- Tests need a whole fake family at once: every in-memory repository plus the in-memory unit of work that knows how to commit them.
Leave it out when
- There is one product. One key, one class is Factory Method; an abstract factory with one method is that pattern in a heavier coat.
- Products change more often than families. Every new product adds a method to the interface and to every concrete factory; the pattern is built for adding families, not members.
- The families never switch at runtime. A module per family and one
importline is the same guarantee with no classes.
Structure¶
One abstract factory, one method per product; each concrete factory returns products from a single family; the client asks once and holds only the products.
classDiagram
class CloudProviderFactory {
<<interface>>
+create_storage(bucket: str) Storage
+create_queue(name: str) MessageQueue
}
class AwsFactory {
+region: str
+create_storage(bucket: str) Storage
+create_queue(name: str) MessageQueue
}
class GcpFactory {
+project: str
+create_storage(bucket: str) Storage
+create_queue(name: str) MessageQueue
}
class Storage {
<<interface>>
+put(key: str, data: bytes) str
+get(uri: str) bytes
}
class MessageQueue {
<<interface>>
+publish(message: str) str
+receive() str
}
class S3Storage
class GcsStorage
class SqsQueue
class PubSubQueue
class IngestPipeline {
-_storage: Storage
-_queue: MessageQueue
+ingest(key: str, data: bytes) str
+process_next() bytes
}
CloudProviderFactory <|.. AwsFactory
CloudProviderFactory <|.. GcpFactory
Storage <|.. S3Storage
Storage <|.. GcsStorage
MessageQueue <|.. SqsQueue
MessageQueue <|.. PubSubQueue
AwsFactory ..> S3Storage : creates
AwsFactory ..> SqsQueue : creates
GcpFactory ..> GcsStorage : creates
GcpFactory ..> PubSubQueue : creates
IngestPipeline ..> CloudProviderFactory : asks once
IngestPipeline o-- Storage
IngestPipeline o-- MessageQueue
Read it by rows: the top row is the family chooser, the middle row the two product interfaces, the bottom row the concrete products. IngestPipeline touches the factory in __init__ only; afterwards it holds a Storage and a MessageQueue that came from the same row.
Canonical example in Python¶
The two product interfaces and their in-memory stand-ins come first (code/patterns/abstract_factory.py, tested by code/patterns/tests/test_abstract_factory.py):
@runtime_checkable
class Storage(Protocol):
"""Abstract product: a blob store. ``put`` returns the object's URI."""
def put(self, key: str, data: bytes) -> str: ...
def get(self, uri: str) -> bytes: ...
@runtime_checkable
class MessageQueue(Protocol):
"""Abstract product: a queue of small messages, here the URIs of uploaded blobs."""
def publish(self, message: str) -> str: ...
def receive(self) -> str | None: ...
class _InMemoryStorage:
"""Stand-in for a cloud bucket. The URI scheme is what a mismatched consumer trips over.
``_lock`` protects ``_objects``; a bucket is shared by every thread that uploads.
"""
scheme: ClassVar[str]
def __init__(self, bucket: str) -> None:
self.bucket = bucket
self._objects: dict[str, bytes] = {}
self._lock = threading.Lock()
def put(self, key: str, data: bytes) -> str:
with self._lock:
self._objects[key] = data
return f"{self.scheme}://{self.bucket}/{key}"
def get(self, uri: str) -> bytes:
prefix = f"{self.scheme}://{self.bucket}/"
if not uri.startswith(prefix):
raise ValidationError(f"{uri!r} is not an object of {prefix!r}")
with self._lock:
try:
return self._objects[uri.removeprefix(prefix)]
except KeyError:
raise NotFoundError(f"no object at {uri!r}") from None
class S3Storage(_InMemoryStorage):
scheme = "s3"
class GcsStorage(_InMemoryStorage):
scheme = "gs"
class _InMemoryQueue:
"""Stand-in for a managed queue. ``_lock`` protects the deque and the id counter."""
id_prefix: ClassVar[str]
def __init__(self, name: str) -> None:
self.name = name
self._messages: deque[str] = deque()
self._published = 0
self._lock = threading.Lock()
def publish(self, message: str) -> str:
with self._lock:
self._published += 1
self._messages.append(message)
return f"{self.id_prefix}-{self._published}"
def receive(self) -> str | None:
with self._lock:
return self._messages.popleft() if self._messages else None
class SqsQueue(_InMemoryQueue):
id_prefix = "sqs"
class PubSubQueue(_InMemoryQueue):
id_prefix = "pubsub"
The stand-ins differ only in URI scheme and message-id format. That is deliberate: it is exactly enough for a consumer from the wrong family to break, which is the bug the pattern exists to prevent. The factories and the client:
@runtime_checkable
class CloudProviderFactory(Protocol):
"""The Abstract Factory: one factory method per product in the family."""
def create_storage(self, bucket: str) -> Storage: ...
def create_queue(self, name: str) -> MessageQueue: ...
@dataclass(frozen=True, slots=True)
class AwsFactory:
"""Concrete factory. The family's configuration (the region) lives here, not in the client."""
region: str = "us-east-1"
def create_storage(self, bucket: str) -> Storage:
return S3Storage(f"{bucket}-{self.region}")
def create_queue(self, name: str) -> MessageQueue:
return SqsQueue(f"{name}.{self.region}")
@dataclass(frozen=True, slots=True)
class GcpFactory:
project: str = "handbook"
def create_storage(self, bucket: str) -> Storage:
return GcsStorage(f"{self.project}-{bucket}")
def create_queue(self, name: str) -> MessageQueue:
return PubSubQueue(f"projects/{self.project}/topics/{name}")
def factory_for(provider: str, **options: str) -> CloudProviderFactory:
"""A Factory Method that picks the Abstract Factory: called once, at the composition root."""
factories: dict[str, Callable[..., CloudProviderFactory]] = {"aws": AwsFactory, "gcp": GcpFactory}
try:
return factories[provider](**options)
except KeyError:
raise ValidationError(f"unknown provider {provider!r}") from None
class IngestPipeline:
"""The client: asks one factory for everything it needs, so the products always match.
The factory is used in ``__init__`` and not kept; the pipeline holds products,
and there is no code path through which a second family could get in.
"""
def __init__(self, provider: CloudProviderFactory, bucket: str = "uploads", topic: str = "uploaded") -> None:
self._storage = provider.create_storage(bucket)
self._queue = provider.create_queue(topic)
@property
def storage(self) -> Storage:
return self._storage
@property
def queue(self) -> MessageQueue:
return self._queue
def ingest(self, key: str, data: bytes) -> str:
"""Producer side: store the blob, publish its URI, return the message id."""
return self._queue.publish(self._storage.put(key, data))
def process_next(self) -> bytes | None:
"""Consumer side: take the next URI and read the blob it names."""
uri = self._queue.receive()
return None if uri is None else self._storage.get(uri)
Four decisions to say out loud:
- The factory is the family boundary.
IngestPipelineasks one factory for both products in__init__and never keeps the factory. There is no constructor parameter through which a second family could arrive, so "can the pipeline mix providers?" has a structural answer: no. - Configuration lives in the concrete factory.
AwsFactory(region=...)andGcpFactory(project=...)are frozen dataclasses; the bucket and topic names they derive are a family-level decision the client should not know about. - The family is chosen once, by a Factory Method.
factory_for("aws")runs at the composition root and returns the Protocol type; the rest of the program never seesAwsFactory. Abstract Factory is usually implemented with factory methods, which is why the two get confused. - The mismatch is loud.
GcsStorage.getrejects ans3://URI withValidationError, so if someone wires products by hand the failure names the two families instead of returning empty bytes.
Running python -m patterns.abstract_factory prints:
--- aws: one factory, two products that match ---
published sqs-1
consumed 27 bytes through S3Storage and SqsQueue
--- gcp: one factory, two products that match ---
published pubsub-1
consumed 27 bytes through GcsStorage and PubSubQueue
--- the bug the pattern prevents: an AWS URI handed to a GCP consumer ---
rejected: 's3://uploads-us-east-1/invoices/2026-08.pdf' is not an object of 'gs://handbook-uploads/'
--- Pythonic: a frozen dataclass of callables is the same family as data ---
kit satisfies CloudProviderFactory: True
published sqs-1 via probe.eu-west-1
rejected: unknown provider 'azure'
Pythonic variant¶
A family is a fixed set of constructors, and Python can hold constructors in fields. Name the fields after the Protocol's methods and the dataclass is a concrete factory:
@dataclass(frozen=True, slots=True)
class ProviderKit:
"""A family as data: two factory callables in a frozen dataclass.
The field names match the Protocol's methods, so ``ProviderKit`` satisfies
``CloudProviderFactory`` structurally and ``IngestPipeline`` cannot tell it
from ``AwsFactory``. A new family is a function returning a kit, not a class;
a test family is two lambdas.
"""
create_storage: Callable[[str], Storage]
create_queue: Callable[[str], MessageQueue]
def aws_kit(region: str = "us-east-1") -> ProviderKit:
return ProviderKit(
create_storage=lambda bucket: S3Storage(f"{bucket}-{region}"),
create_queue=lambda name: SqsQueue(f"{name}.{region}"),
)
def gcp_kit(project: str = "handbook") -> ProviderKit:
return ProviderKit(
create_storage=lambda bucket: GcsStorage(f"{project}-{bucket}"),
create_queue=lambda name: PubSubQueue(f"projects/{project}/topics/{name}"),
)
- Structural typing does the work.
ProviderKithas attributes calledcreate_storageandcreate_queue, soisinstance(kit, CloudProviderFactory)is true andIngestPipeline(kit)runs unchanged. - A new family is a function.
aws_kit(region)closes over the region the wayAwsFactorystored it; a test family is two lambdas built inside the test. - The trade. A kit cannot carry behaviour shared by the family (a retry policy, a credentials refresh) without growing back into a class; when the methods need each other, write the class.
The second form is the standard library's: a module as the factory. A module is a namespace of callables, so one module per provider plus importlib.import_module is an abstract factory with no classes:
import importlib
provider = importlib.import_module(f"cloud.{settings.provider}") # cloud/aws.py, cloud/gcp.py
pipeline = IngestPipeline(provider) # the module has create_storage and create_queue
| Reach for | When |
|---|---|
| A module per family and one import | The family is fixed per deployment and never switches at runtime |
| A dataclass of callables | Several families, no shared behaviour, test families built inline |
| Concrete factory classes | Families carry configuration and behaviour; you want a readable repr and equality |
| Factory Method alone | There is one product |
Real-world usage¶
- DB-API modules (PEP 249): every driver module exposes
connect(), whoseConnectioncreates matchingCursors and raises the module's own exception family; swapping the import swaps the whole family. - SQLAlchemy dialects: one
Dialectobject per database supplies a statement compiler, a DDL compiler and a type compiler that agree with each other, selected from the URL scheme. multiprocessing.get_context("spawn")returns a context whoseProcess,Queue,LockandPoolmatch the start method; mixing two contexts is the same bug as mixing clouds.tkinterandtkinter.ttk:Button,LabelandEntryexist in both; which module you build widgets from decides the family's look.
Related patterns and confusions¶
| Looks like Abstract Factory | How to tell them apart |
|---|---|
| Factory Method | One product chosen by key versus a family that must match. Abstract Factory is usually several factory methods on one object, and the concrete factory is itself picked by one. |
| Builder | Builder assembles one complex object step by step; Abstract Factory returns several simple objects at once, each complete. |
| Strategy | A concrete factory is injected exactly like a strategy, but it is chosen once at start-up and creates objects rather than performing the algorithm. |
| Dependency Injection | Injection hands the client the products; an abstract factory hands it a maker of products. Inject directly when the client needs one of each; use the factory when it creates many over its lifetime. |
| Bridge | Both keep an abstraction away from an implementation. Bridge is about one abstraction delegating to one implementor at runtime; Abstract Factory is about creating a consistent set. |
| Prototype | A family can be a set of configured prototypes that the factory clones, which is how toolkits avoid one subclass per theme. |
Where it appears in LLD problems¶
Nowhere, and that is the honest answer worth giving in an interview. Two problems come close and both stop short:
- Design a payment gateway and digital wallet —
PaymentProcessorFactorylooks like the entry point to a family, but it is a single-product registry: one lookup,for_method(method) -> PaymentProcessor, keyed by payment method. There is no second product to keep consistent, so Factory Method is the pattern actually in play. - Design Uber (LLD) with driver matching — a ride type does select a vehicle requirement, a fare rule and a matching rule, but they are three independent Strategy objects chosen per request, not one factory that guarantees they came from the same set.
Reach for Abstract Factory when a second product joins the first and mixing families is a real bug — a vendor's processor paired with a different vendor's webhook parser. Until then it is a factory with extra ceremony.
Interview tips¶
Interview tip
Say "family" and "must match" before you say "factory": "storage and queue have to come from the same provider, so one CloudProviderFactory creates both, chosen once from config; adding a provider is a new factory class and the pipeline does not change." Then offer the Python shortcut: a module per provider, or a frozen dataclass of constructors, satisfies the same Protocol.
Common mistake
Passing the products into the client separately "for flexibility". IngestPipeline(storage, queue) makes the family guarantee disappear at the one place it mattered, and the mismatch resurfaces as empty reads in production. Runner-up: an abstract factory for one product, or one whose product list keeps growing, which means families were the wrong axis and a Factory Method per product would have been lighter.
Related¶
- Factory Method — one product chosen by key, and how a concrete factory gets chosen
- Builder — one complex object assembled in steps
- Dependency Injection — when to inject the products instead of their maker
- Design a payment gateway and digital wallet — provider families behind one interface
- Design Uber (LLD) with driver matching — ride-type families
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Abstract Factory
- PEP 249 — Python Database API Specification v2.0
- Python documentation:
multiprocessing— contexts and start methods