Abstract Factory (create matching families together)

When to use

  • You need several related objects that must fit together (e.g., storage + secrets for a cloud).
  • You want to switch environments (AWS vs GCP vs Local) without changing client code.
  • You want tests to swap the whole family (fake storage + fake secrets) at once.

Avoid when you only create one object—use Factory Method or a plain function instead.

Diagram (text)

Client ──> CloudFactory (interface)
             ├─ storage() → Storage
             └─ secrets() → Secrets
              ▲                  ▲
        AwsFactory          DictSecrets
           │                     │
        S3Store             (shared impl)
              ▲
         GcpFactory → GCSStore

Step-by-step idea

  1. Define tiny product interfaces (here: Storage, Secrets).
  2. Define a factory interface that builds those products.
  3. Implement one factory per environment (AWS, GCP, Local).
  4. Client code asks the factory for products and stays agnostic.

Python example (≤40 lines, type-hinted)

from typing import Protocol
from dataclasses import dataclass

class Storage(Protocol):
    def put(self, bucket: str, key: str, data: bytes) -> None: ...

class Secrets(Protocol):
    def get(self, name: str) -> str: ...

class CloudFactory(Protocol):
    def storage(self) -> Storage: ...
    def secrets(self) -> Secrets: ...

@dataclass
class S3Store:
    c: object
    def put(self, b: str, k: str, d: bytes) -> None: self.c.put_object(Bucket=b, Key=k, Body=d)

@dataclass
class GCSStore:
    c: object
    def put(self, b: str, k: str, d: bytes) -> None: self.c.upload_blob(b, k, d)

@dataclass
class DictSecrets:
    data: dict[str, str]
    def get(self, name: str) -> str: return self.data[name]

@dataclass
class AwsFactory:
    s3: object; secrets_dict: dict[str, str]
    def storage(self) -> Storage: return S3Store(self.s3)
    def secrets(self) -> Secrets: return DictSecrets(self.secrets_dict)

@dataclass
class GcpFactory:
    gcs: object; secrets_dict: dict[str, str]
    def storage(self) -> Storage: return GCSStore(self.gcs)
    def secrets(self) -> Secrets: return DictSecrets(self.secrets_dict)

def backup(factory: CloudFactory, data: bytes) -> None:
    token = factory.secrets().get("metrics_token")
    factory.storage().put("metrics", f"{token}.json", data)

Usage idea

  • In prod: pass real clients (boto3.client("s3"), Google client).
  • In tests: pass fakes with put_object(...) / upload_blob(...) methods; backup code doesn’t change.

Trade-offs & pitfalls

  • Pros: Clean environment switching; keeps related objects consistent; test-friendly.
  • Cons: More types than a single factory function; some indirection.
  • Pitfalls:
    • Factories that build too many products—keep the family small and cohesive.
    • Mixing vendor details into client code—only the factory/adapters should know vendors.
    • Skipping interfaces—use Protocol so all factories return compatible products.

Pythonic alternatives

  • Configuration + registry: REGISTRY["aws"]() returns a tuple (storage, secrets).
  • Dataclass constructor injection: build products once and inject them, skip factories entirely.
  • fsspec + secret managers: if libraries already unify APIs, a plain function might be enough.

Mini exercise

Add a LocalFactory that returns a FileStore(root_path) + DictSecrets. Make FileStore.put(...) write to disk. Verify backup(LocalFactory(...), b"{}") creates metrics/<token>.json.

Checks (quick checklist)

  • Small, clear product interfaces (e.g., Storage, Secrets).
  • One factory per environment returning compatible products.
  • Client uses only the factory and product interfaces—no vendor code leaks.
  • Tests swap the whole family in one go.
  • Keep factories thin; real logic lives in products/adapters.