Developer comparing two class hierarchies in an editor during a code review

Factory vs Observer vs Strategy Patterns

November 15, 2026 · 11 min read · By Thomas A. Anderson

A factory method is a method; an abstract factory is an object. That answer from a long-running Stack Overflow thread on the two patterns is still the fastest way to settle the confusion, and it survives contact with real code: the first is a single creation hook a subclass overrides, the second is a collaborating object that hands back a matched set of products. The same shape of confusion hits Observer and Pub-Sub, where the deciding fact is who holds the subscriber reference, the subject or a broker.

Key Takeaways:

  • A simple factory is a function plus a lookup table; Factory Method moves the choice into a subclass; Abstract Factory moves it into an injected object.
  • Abstract Factory earns its extra classes only when products must match as a family, such as a processor, a refund handler, and a webhook verifier from the same provider.
  • Observer keeps subscriber references inside the subject; Pub-Sub puts a broker between publisher and subscriber, which buys decoupling and costs you an operational component.
  • Strategy swaps one behavior at runtime; Bridge exists for two independent dimensions of variation. Reaching for Bridge with one dimension adds indirection with nothing to show for it.
  • Count implementations that exist today before you add an interface. An interface with exactly one implementation is a cost with no design payoff.

Every example below runs on Python 3.12 with the standard library only, so there is nothing to install. The domain is a billing service with two payment paths and a pricing feed, which is close enough to the code most teams actually ship.

The Factory Family: Three Different Shapes

The three factory variants differ in where the creation decision lives. A simple factory hides construction behind one function. Factory Method declares an abstract creation step and lets each subclass answer it, so the choice rides on inheritance. Abstract Factory makes an object out of the choice, and every product that object builds belongs to the same family. Baeldung’s comparison of the variants draws the same line: the abstract factory produces a set of related products that share a family, not a single object.

Side-by-Side Comparison

Start with the version most teams write first, a module-level function with a dictionary:

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

# billing/simple_factory.py (Python 3.12, stdlib only)
from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Charge:
 reference: str
 amount: Decimal
 currency: str

class CardProcessor:
 def charge(self, amount: Decimal, currency: str) -> Charge:
 return Charge(f"card_{int(amount * 100)}", amount, currency)

class BankTransferProcessor:
 def charge(self, amount: Decimal, currency: str) -> Charge:
 return Charge(f"sepa_{int(amount * 100)}", amount, currency)

PROCESSORS = {"card": CardProcessor, "bank_transfer": BankTransferProcessor}

def make_processor(kind: str):
 try:
 return PROCESSORS[kind]()
 except KeyError:
 raise ValueError(f"unsupported processor: {kind}") from None

print(make_processor("card").charge(Decimal("49.00"), "EUR"))
# Charge(reference='card_4900', amount=Decimal('49.00'), currency='EUR')

This is the right default for two or three providers and a single product. Callers name a string key, so no import of CardProcessor leaks into the request handler and swapping the mapping is a one-line change. The cost is that every new provider edits PROCESSORS, and every branch of provider-specific behavior, retries, currency rules, idempotency headers, tends to pile up behind that one key.

Factory Method pushes that growth into subclasses. A base Checkout declares create_processor() and subclasses return concrete processors, so adding a provider means adding a subclass and registering it once at the entry point. JavaThinking’s breakdown of the three variants makes the same point about complexity tiers: the patterns are not interchangeable, they sit at different levels of the same problem. If you only ever have one product per provider, the subclass is a wrapper around a constructor call and nothing more.

Abstract Factory and Product Families

The pattern starts paying when one provider supplies several products that must be used together. A card processor paired with a bank-transfer refund handler produces charges that the refund path cannot reverse, and tests usually miss it because each class works in isolation. An abstract factory makes that mismatch impossible to express in the type signature.

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

# billing/abstract_factory.py (Python 3.12, stdlib only)
from abc import ABC, abstractmethod
from dataclasses import dataclass
from decimal import Decimal

@dataclass(frozen=True)
class Charge:
 reference: str
 amount: Decimal
 currency: str

class Processor(ABC):
 @abstractmethod
 def charge(self, amount: Decimal, currency: str) -> Charge: ...

class RefundHandler(ABC):
 @abstractmethod
 def refund(self, charge_ref: str, amount: Decimal) -> str: ...

class WebhookVerifier(ABC):
 @abstractmethod
 def verify(self, payload: bytes, signature: str) -> bool: ...

class PaymentFactory(ABC):
 @abstractmethod
 def processor(self) -> Processor: ...
 @abstractmethod
 def refunds(self) -> RefundHandler: ...
 @abstractmethod
 def webhooks(self) -> WebhookVerifier: ...

class CardProcessor(Processor):
 def charge(self, amount, currency):
 return Charge(f"card_{int(amount * 100)}", amount, currency)

class CardRefunds(RefundHandler):
 def refund(self, charge_ref, amount):
 return f"refund:{charge_ref}:{amount}"

class CardWebhooks(WebhookVerifier):
 def verify(self, payload, signature):
 return signature.startswith("card_sig_")

class CardFactory(PaymentFactory):
 def processor(self): return CardProcessor()
 def refunds(self): return CardRefunds()
 def webhooks(self): return CardWebhooks()

def reconcile(factory: PaymentFactory, charge_ref: str, amount: Decimal) -> str:
 factory.webhooks().verify(b"{}", "card_sig_abc")
 return factory.refunds().refund(charge_ref, amount)

print(reconcile(CardFactory(), "card_4900", Decimal("49.00")))
# refund:card_4900:49.00
# Note: production code needs idempotency keys, retry budgets, and real HMAC
# verification; this example keeps the family contract in view instead.

Read the reconcile signature: it accepts a PaymentFactory, never a processor. A caller cannot pass a card processor and a bank-transfer refund handler into the same function, because neither of those is a factory. That is the whole value, and it is a compile-time (or type-check-time) guarantee rather than a comment in a wiki.

The bill arrives in class count. Each family needs a factory plus one class per product, so three products across two providers is eight types where the simple factory needed two. Dev Leader’s C# walkthrough of both patterns makes the same trade explicit: abstract factory adds indirection that only pays for itself when the family constraint prevents real bugs. If your second provider is a hypothetical, you are buying insurance against a risk you may never take.

Observer vs Pub-Sub: Where the Reference Lives

Refactoring Guru’s Observer chapter describes the mechanism precisely: the publisher holds an array of references to subscriber objects plus public methods to add and remove them, and on each event it walks the list calling a notification method on the subscriber interface. Nothing else is involved. The publisher knows its subscribers exist and calls them directly.

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

# pricing/observer.py (Python 3.12, stdlib only)
from decimal import Decimal

class PriceTicker:
 def __init__(self) -> None:
 self._subscribers = []

 def subscribe(self, handler) -> None:
 self._subscribers.append(handler)

 def unsubscribe(self, handler) -> None:
 self._subscribers.remove(handler)

 def publish(self, symbol: str, price: Decimal) -> None:
 for handler in list(self._subscribers):
 handler(symbol, price)

def write_audit(symbol: str, price: Decimal) -> None:
 print(f"audit {symbol} {price}")

def update_dashboard(symbol: str, price: Decimal) -> None:
 print(f"dashboard {symbol} {price}")

ticker = PriceTicker()
ticker.subscribe(write_audit)
ticker.subscribe(update_dashboard)
ticker.publish("EURUSD", Decimal("1.0842"))
# audit EURUSD 1.0842
# dashboard EURUSD 1.0842

The notifications run synchronously inside publish. Order is whatever the list order happens to be, and Refactoring Guru lists that as an explicit con, not an implementation detail. A subscriber that raises breaks the loop and the later subscribers never see the event, which is a bug you will hit within a week unless you wrap each call.

Publish-Subscribe inserts a broker between the two sides. DesignGurus’ comparison of the two puts the split at process boundaries: pub-sub lets publishers and subscribers work independently, while the observer subject maintains its own consumer list.

Note: The following code is an illustrative example and has not been verified against official documentation. Please refer to the official docs for production-ready code.

# pricing/pubsub.py (Python 3.12, stdlib only)
import asyncio
from collections import defaultdict
from decimal import Decimal

class Broker:
 def __init__(self) -> None:
 self._topics = defaultdict(list)

 def subscribe(self, topic: str, handler) -> None:
 self._topics[topic].append(handler)

 async def publish(self, topic: str, message: dict) -> None:
 for handler in list(self._topics[topic]):
 await handler(message)

async def audit_sink(message: dict) -> None:
 print(f"audit received {message['symbol']} {message['price']}")

async def risk_engine(message: dict) -> None:
 if Decimal(message["price"]) > Decimal("1.08"):
 print(f"risk alert {message['symbol']}")

async def main() -> None:
 broker = Broker()
 broker.subscribe("fx.tick", audit_sink)
 broker.subscribe("fx.tick", risk_engine)
 await broker.publish("fx.tick", {"symbol": "EURUSD", "price": "1.0842"})

asyncio.run(main())
# audit received EURUSD 1.0842
# risk alert EURUSD
# Note: this in-memory broker has no persistence, retry, or ordering
# guarantees; production systems use a message queue for those.

The publisher no longer holds references to specific consumers; it names a topic. The consumer list moved into infrastructure, which is exactly why the pattern survives across processes and languages. The price is operational: message schemas become a contract, delivery needs retries and a dead-letter path, and debugging means tracing a message through a system rather than stepping into a function call. Coding Shuttle’s write-up argues that dragging the observer pattern into a cross-process problem without a broker is a failure mode with no shortcut around it.

Side-by-Side Comparison

The five patterns below cover most of the confusion developers hit when they read about creational and behavioral patterns in the same week.

Pattern Shape What varies Reach for it when
Simple factory (function) One function plus a lookup table The concrete type returned Two or three products and a single switch point you are happy to edit
Factory Method Subclass overrides a creation method declared on a base class Which product the base class instantiates Adding a product should mean adding a subclass, not editing a conditional
Abstract Factory Object with several creation methods The whole product family Products must match, such as processor, refunds, and webhook verifier from one provider
Observer Subject keeps a list of subscriber references Who receives notifications In-process, shared lifetime, synchronous reactions to state changes
Pub-Sub Broker routes messages by topic Publishers and consumers independently Cross-process delivery, separate teams, async work that can be retried

Definitions follow the Observer chapter on Refactoring Guru and Baeldung’s factory variants comparison; the coupling split between the last two rows follows the DesignGurus Observer vs Pub-Sub breakdown.

Strategy and Bridge Look Alike Too

Strategy replaces a conditional chain with a swappable object behind one interface. You call pricing_rule.total(order) and a TaxInclusive or TaxExempt implementation answers; the caller does not care which. Bridge also delegates to an implementation object, so the class diagrams look similar, but the intent is different: the Bridge vs Strategy discussion on Stack Overflow frames Bridge as separating an abstraction from its implementation so both can vary independently.

The practical test is the number of dimensions that vary. One dimension, pricing rules or sort order, is Strategy. Two dimensions, report type multiplied by output format, is where Bridge or an abstract factory starts earning its keep. With one dimension, Bridge gives you two hierarchies and a delegation layer for a problem a single interface would solve.

When the Pattern Costs More Than It Saves

ArjanCodes’ guide to when patterns turn into overengineering names the symptoms directly: abstractions with a single implementation, factories whose only product is fixed, and layers nobody on the team can explain. The cost is not theoretical. An interface with one implementation adds a file, a jump in the IDE, and a name to every code review, and it returns nothing until a second implementation exists.

Two heuristics keep this honest. First, count implementations that exist today rather than the ones in the roadmap; a planned second provider has a habit of never shipping. Second, keep the factory at the edge of the system, near the entry point that already knows configuration, so the interior of the code takes plain interfaces and never checks a provider string. This checklist of overengineering signs lands on the same rule from the opposite direction: if removing the pattern changes no call site, the pattern was decoration.

Decision Checklist

  • One product, few variants: write the function with a dict and move on. Refactor when the second real variant lands.
  • One product, and creation differs per variant: Factory Method, with each subclass owning its product-specific setup.
  • Several products that must match: Abstract Factory, and accept the class count as the price of the family guarantee.
  • In-process notification: Observer with an explicit unsubscribe path; wrap each handler so one failure does not silence the rest.
  • Cross-service notification: Pub-Sub with a real broker, an explicit message schema, and a retry policy before the first producer ships.
  • Conditional behavior that swaps at runtime: Strategy. Two independent axes of variation: Bridge.

The pattern names matter less than the question each one answers: who decides which class gets built, and who holds the reference to the thing being notified. Answer those two questions and the diagram usually draws itself.

More in-depth coverage from this blog on closely related topics:

Sources and References

Sources cited while researching and writing this article:

Thomas A. Anderson

Mass-produced in late 2022, upgraded frequently. Has opinions about Kubernetes that he formed in roughly 0.3 seconds. Occasionally flops, but don't we all? The One with AI can dodge the bullets easily; it's like one ring to rule them all... sort of...