Decoupling Patterns¶
Problem statement¶
A payment processing platform supports three payment methods: credit card, ACH bank transfer, and store credit. Each method has different validation rules, different API calls to external providers, different fee calculations, and different receipt formats. The initial implementation is straightforward:
def process_payment(order, method, details):
if method == "credit_card":
if not details.get("card_number") or not details.get("expiry"):
raise ValidationError("Card number and expiry required")
if details.get("amount", 0) > 10000:
raise ValidationError("Credit card limit exceeded")
response = stripe_client.charge(
amount=order.total_cents,
card=details["card_number"],
expiry=details["expiry"],
cvv=details["cvv"],
)
fee = order.total_cents * 0.029 + 30
receipt = f"Card ending {details['card_number'][-4:]}: ${order.total_cents / 100:.2f}"
elif method == "ach":
if not details.get("routing_number") or not details.get("account_number"):
raise ValidationError("Routing and account numbers required")
response = plaid_client.initiate_transfer(
amount=order.total_cents,
routing=details["routing_number"],
account=details["account_number"],
)
fee = min(order.total_cents * 0.008, 500)
receipt = f"ACH transfer from account ending {details['account_number'][-4:]}: ${order.total_cents / 100:.2f}"
elif method == "store_credit":
balance = get_store_credit_balance(order.customer_id)
if balance < order.total_cents:
raise ValidationError("Insufficient store credit")
response = deduct_store_credit(order.customer_id, order.total_cents)
fee = 0
receipt = f"Store credit: ${order.total_cents / 100:.2f}"
else:
raise ValueError(f"Unknown payment method: {method}")
record_transaction(order, method, response, fee)
send_receipt(order.customer, receipt)
return response
This is 45 lines and perfectly readable. A junior engineer can trace
every path. The else clause catches unknown methods with an explicit
error. For three payment methods, this is arguably the right
implementation — any further abstraction would be premature.
Then the platform grows.
Payment method four arrives: PayPal. Method five: Apple Pay. Method
six: cryptocurrency. Method seven: buy-now-pay-later through a
partner. Each one adds another elif block with its own validation,
its own API client, its own fee calculation, its own receipt format.
The function is now 200 lines.
Then the variations multiply within each method. Credit cards need
different processing for different card networks (Visa, Mastercard,
Amex) because the interchange fees differ. ACH needs same-day vs.
standard settlement paths. BNPL has different terms depending on the
partner. Each variation adds nested conditionals inside the existing
elif blocks:
elif method == "credit_card":
# ...validation...
if card_network(details["card_number"]) == "amex":
fee = order.total_cents * 0.035 + 30
if order.total_cents > 5000:
fee += 50 # high-value Amex surcharge
elif card_network(details["card_number"]) == "visa":
fee = order.total_cents * 0.029 + 30
if order.customer.is_preferred:
fee = order.total_cents * 0.025 + 30
elif card_network(details["card_number"]) == "mastercard":
# ...
The function is now 400 lines. It processes payments, but it is no longer readable, no longer testable in isolation, and no longer safe to modify. Adding payment method eight requires reading and understanding methods one through seven to ensure the new block does not interfere with existing logic. A bug fix to the Amex fee calculation requires navigating a 400-line function to find the right nested conditional. A change to receipt formatting requires touching every branch.
The function is doing one conceptual thing — processing a payment —
but it is doing it by enumerating every possible case. The if/
elif chain is a manual dispatch table maintained by the programmer.
Every new case is a modification to the function. Every modification
risks every existing case.
The first refactor: extract and name¶
The team's first instinct is to extract each branch into its own function:
def process_payment(order, method, details):
if method == "credit_card":
return process_credit_card(order, details)
elif method == "ach":
return process_ach(order, details)
elif method == "store_credit":
return process_store_credit(order, details)
elif method == "paypal":
return process_paypal(order, details)
elif method == "apple_pay":
return process_apple_pay(order, details)
elif method == "crypto":
return process_crypto(order, details)
elif method == "bnpl":
return process_bnpl(order, details)
else:
raise ValueError(f"Unknown payment method: {method}")
This is better — each method's logic is isolated in its own function,
testable independently, and modifiable without reading the others. But
the dispatch is still manual. Adding method eight still requires
editing process_payment. The else clause still exists because the
function has no way to know at runtime which methods are available.
The function is a hard-coded routing table.
The class hierarchy attempt¶
The next evolution introduces classes. Each payment method gets its own class with a common interface:
class CreditCardProcessor:
def validate(self, details):
if not details.get("card_number") or not details.get("expiry"):
raise ValidationError("Card number and expiry required")
def process(self, order, details):
self.validate(details)
response = stripe_client.charge(
amount=order.total_cents,
card=details["card_number"],
expiry=details["expiry"],
cvv=details["cvv"],
)
return response
def calculate_fee(self, order, details):
return order.total_cents * 0.029 + 30
def format_receipt(self, order, details):
return f"Card ending {details['card_number'][-4:]}: ${order.total_cents / 100:.2f}"
class ACHProcessor:
def validate(self, details):
if not details.get("routing_number") or not details.get("account_number"):
raise ValidationError("Routing and account numbers required")
def process(self, order, details):
self.validate(details)
response = plaid_client.initiate_transfer(
amount=order.total_cents,
routing=details["routing_number"],
account=details["account_number"],
)
return response
def calculate_fee(self, order, details):
return min(order.total_cents * 0.008, 500)
def format_receipt(self, order, details):
return f"ACH transfer from account ending {details['account_number'][-4:]}: ${order.total_cents / 100:.2f}"
class StoreCreditProcessor:
# ...same shape, different implementation
pass
Each processor encapsulates its own validation, API interaction, fee logic, and receipt formatting. This is a genuine improvement: each class has a defined boundary, a clear contract (four methods), and internal coherence. A change to ACH processing does not risk credit card processing.
But the dispatch problem remains:
def process_payment(order, method, details):
if method == "credit_card":
processor = CreditCardProcessor()
elif method == "ach":
processor = ACHProcessor()
elif method == "store_credit":
processor = StoreCreditProcessor()
elif method == "paypal":
processor = PayPalProcessor()
# ...seven more elif blocks...
else:
raise ValueError(f"Unknown payment method: {method}")
processor.validate(details)
response = processor.process(order, details)
fee = processor.calculate_fee(order, details)
receipt = processor.format_receipt(order, details)
record_transaction(order, method, response, fee)
send_receipt(order.customer, receipt)
return response
The branching has moved from "what to do for each method" to "which
class to instantiate for each method" — but it is still a manual
dispatch on a string. The else clause is still there. Adding a new
payment method still requires editing this function. The classes are
well-structured, but the system that selects them is the same
hand-maintained routing table.
This is where many codebases stop. The team has clean classes, each with a clear interface, and a central dispatch function that maps strings to classes. It works. It is maintainable enough. The dispatch function is ugly but small.
But it is also where a subtle problem takes root.
The string-matching disease¶
The dispatch function maps method names (strings) to processor classes. Other parts of the codebase need to make decisions based on payment method too. The reporting module needs to know which methods are refundable. The admin panel needs to display method-specific configuration. The webhook handler needs to route provider callbacks to the right processor.
Each of these call sites faces the same question: given a method name string, what should I do? And each one answers it the same way:
# In the reporting module
def is_refundable(method):
if method == "credit_card":
return True
elif method == "ach":
return True
elif method == "store_credit":
return False
elif method == "paypal":
return True
elif method == "crypto":
return False
elif method == "bnpl":
return False
else:
return False
# In the admin panel
def get_config_fields(method):
if method == "credit_card":
return ["api_key", "webhook_secret", "capture_mode"]
elif method == "ach":
return ["plaid_client_id", "plaid_secret", "settlement_speed"]
elif method == "paypal":
return ["client_id", "client_secret", "sandbox_mode"]
# ...
# In the webhook handler
def route_webhook(provider, payload):
if provider == "stripe":
return CreditCardProcessor().handle_webhook(payload)
elif provider == "plaid":
return ACHProcessor().handle_webhook(payload)
elif provider == "paypal":
return PayPalProcessor().handle_webhook(payload)
# ...
The string-matching dispatch, which was contained in one function, has
metastasized. Every module that touches payment methods has its own
if/elif chain matching the same strings to the same concepts.
When payment method eight arrives, an engineer must find and update
every one of these scattered switch statements — and the compiler
will not help, because strings have no type system. Forgetting one
produces a silent default (return False, return []) or a runtime
error that surfaces only when a user selects the new payment method
in a specific context.
The class hierarchy exists. The processors are well-defined. But the system does not use them as types. It uses them as passive containers that are selected by string matching at every call site. The classes have behaviors that should be queried polymorphically — "are you refundable?", "what are your config fields?" — but instead the calling code answers those questions itself, by checking the method name string and hard-coding the answer.
This is the worst of both worlds: the ceremony of a class hierarchy with none of the polymorphic dispatch benefit.
The registry: letting the system discover behavior¶
The fix is to eliminate the string-matching dispatch entirely. Instead of a function that maps strings to classes, the system maintains a registry that classes populate themselves:
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
_registry: dict[str, type["PaymentProcessor"]] = {}
def __init_subclass__(cls, method_name: str = "", **kwargs):
super().__init_subclass__(**kwargs)
if method_name:
PaymentProcessor._registry[method_name] = cls
@classmethod
def for_method(cls, method: str) -> "PaymentProcessor":
processor_class = cls._registry.get(method)
if not processor_class:
raise ValueError(
f"Unknown payment method: {method}. "
f"Available: {', '.join(cls._registry)}"
)
return processor_class()
@classmethod
def available_methods(cls) -> list[str]:
return list(cls._registry.keys())
@abstractmethod
def validate(self, details: dict) -> None: ...
@abstractmethod
def process(self, order, details: dict): ...
@abstractmethod
def calculate_fee(self, order, details: dict) -> int: ...
@abstractmethod
def format_receipt(self, order, details: dict) -> str: ...
@property
@abstractmethod
def refundable(self) -> bool: ...
@property
@abstractmethod
def config_fields(self) -> list[str]: ...
@property
@abstractmethod
def provider_name(self) -> str: ...
Each processor registers itself by declaring its method name:
class CreditCardProcessor(PaymentProcessor, method_name="credit_card"):
provider_name = "stripe"
refundable = True
config_fields = ["api_key", "webhook_secret", "capture_mode"]
def validate(self, details):
if not details.get("card_number") or not details.get("expiry"):
raise ValidationError("Card number and expiry required")
def process(self, order, details):
self.validate(details)
return stripe_client.charge(
amount=order.total_cents,
card=details["card_number"],
expiry=details["expiry"],
cvv=details["cvv"],
)
def calculate_fee(self, order, details):
return order.total_cents * 0.029 + 30
def format_receipt(self, order, details):
return f"Card ending {details['card_number'][-4:]}: ${order.total_cents / 100:.2f}"
class ACHProcessor(PaymentProcessor, method_name="ach"):
provider_name = "plaid"
refundable = True
config_fields = ["plaid_client_id", "plaid_secret", "settlement_speed"]
def validate(self, details):
if not details.get("routing_number") or not details.get("account_number"):
raise ValidationError("Routing and account numbers required")
def process(self, order, details):
self.validate(details)
return plaid_client.initiate_transfer(
amount=order.total_cents,
routing=details["routing_number"],
account=details["account_number"],
)
def calculate_fee(self, order, details):
return min(order.total_cents * 0.008, 500)
def format_receipt(self, order, details):
return f"ACH transfer from account ending {details['account_number'][-4:]}: ${order.total_cents / 100:.2f}"
class StoreCreditProcessor(PaymentProcessor, method_name="store_credit"):
provider_name = "internal"
refundable = False
config_fields = []
def validate(self, details):
pass # validated at process time against live balance
def process(self, order, details):
balance = get_store_credit_balance(order.customer_id)
if balance < order.total_cents:
raise ValidationError("Insufficient store credit")
return deduct_store_credit(order.customer_id, order.total_cents)
def calculate_fee(self, order, details):
return 0
def format_receipt(self, order, details):
return f"Store credit: ${order.total_cents / 100:.2f}"
The dispatch function becomes trivial:
def process_payment(order, method, details):
processor = PaymentProcessor.for_method(method)
processor.validate(details)
response = processor.process(order, details)
fee = processor.calculate_fee(order, details)
receipt = processor.format_receipt(order, details)
record_transaction(order, method, response, fee)
send_receipt(order.customer, receipt)
return response
No if/elif. No else. No string matching. The function does not
know which payment methods exist. It does not need to. It asks the
registry for a processor, and the processor provides everything the
system needs through a defined interface.
The scattered string-matching call sites disappear:
# Reporting — was a 20-line if/elif chain
def is_refundable(method):
return PaymentProcessor.for_method(method).refundable
# Admin panel — was a 15-line if/elif chain
def get_config_fields(method):
return PaymentProcessor.for_method(method).config_fields
# Webhook routing — was a 10-line if/elif chain
def route_webhook(provider, payload):
for processor_class in PaymentProcessor._registry.values():
if processor_class.provider_name == provider:
return processor_class().handle_webhook(payload)
raise ValueError(f"Unknown provider: {provider}")
Adding payment method eight requires one action: writing a new class
that subclasses PaymentProcessor with the appropriate
method_name. No existing code changes. No dispatch function to
update. No scattered string-matching sites to find and extend. The
new processor registers itself by existing — __init_subclass__ runs
when the class is defined, and the registry updates automatically.
What changed¶
The progression from the original function to the registry is not about adding abstraction for its own sake. Each step addressed a specific failure mode:
Extract and name addressed readability — each method's logic in its own function, independently understandable.
Class hierarchy addressed encapsulation — each method's validation, processing, fees, and receipts behind a single boundary with a defined contract.
Registry addressed dispatch — the system discovers available methods at runtime rather than enumerating them in a hard-coded routing table.
The critical transition is the last one, because it changes the character of the system. The branching versions — whether functions or classes — are declarative: the programmer has listed every possible case and coded the response to each. The registry version is adaptive: the system does not know its own capabilities at write time. It discovers them at runtime by inspecting what has been registered. A new capability appears by being defined, not by being added to an enumeration.
This is the distinction between a program that selects behavior and a system that resolves behavior. Selection requires the programmer to anticipate every case. Resolution requires only that each case conform to a contract.
Beyond the registry¶
The registry pattern is one mechanism. The underlying principle — replace enumeration with resolution — manifests in several forms depending on the language and the problem.
Dispatch tables¶
When the behavior associated with each case is a single function rather than a class with multiple methods, a dictionary is sufficient:
FEE_CALCULATORS: dict[str, Callable[[Order], int]] = {
"credit_card": lambda order: order.total_cents * 0.029 + 30,
"ach": lambda order: min(order.total_cents * 0.008, 500),
"store_credit": lambda _: 0,
}
def calculate_fee(order, method):
calculator = FEE_CALCULATORS.get(method)
if calculator is None:
raise ValueError(f"No fee calculator for method: {method}")
return calculator(order)
The dispatch table is still manually maintained, but the dispatch is data rather than control flow. Adding a new entry is adding a line to a dictionary, not adding a branch to a function. The dictionary is inspectable, iterable, and testable as data — you can verify at startup that every registered payment method has a corresponding fee calculator.
Decorators as registration¶
Python decorators make registration explicit at the definition site:
WEBHOOK_HANDLERS: dict[str, Callable] = {}
def handles_webhook(provider: str):
def decorator(func):
WEBHOOK_HANDLERS[provider] = func
return func
return decorator
@handles_webhook("stripe")
def handle_stripe_webhook(payload):
# ...process Stripe event
@handles_webhook("plaid")
def handle_plaid_webhook(payload):
# ...process Plaid event
def route_webhook(provider, payload):
handler = WEBHOOK_HANDLERS.get(provider)
if handler is None:
raise ValueError(f"No handler for provider: {provider}")
return handler(payload)
The decorator pattern makes the registration visible at the point
where the handler is defined. An engineer reading handle_stripe_
webhook sees immediately that it is registered as the Stripe webhook
handler. The routing function does not need to know which handlers
exist.
Protocol-based dispatch¶
Python's Protocol (from typing) allows structural typing — a
class satisfies a protocol if it has the right methods, without
needing to inherit from a base class:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Refundable(Protocol):
def refund(self, transaction_id: str, amount: int) -> dict: ...
def refund_deadline(self, transaction_id: str) -> datetime | None: ...
def attempt_refund(processor, transaction_id, amount):
if not isinstance(processor, Refundable):
raise TypeError(f"{type(processor).__name__} does not support refunds")
deadline = processor.refund_deadline(transaction_id)
if deadline and datetime.now() > deadline:
raise RefundError("Refund deadline has passed")
return processor.refund(transaction_id, amount)
The Refundable protocol defines a contract without requiring
inheritance. A processor is refundable if it implements refund and
refund_deadline — regardless of its class hierarchy, regardless of
whether it was designed with refunds in mind. This is capability
discovery: the system asks "can you do this?" rather than checking
"are you on the list of things that can do this?"
The distinction matters when the set of capabilities is not known at design time. A processor written by a third-party plugin can be refundable without the core system knowing about it — it just needs to implement the protocol.
Event-driven dispatch¶
When the response to a condition is not a single function call but a coordination across multiple subsystems, events replace direct dispatch:
class PaymentEventBus:
def __init__(self):
self._listeners: dict[str, list[Callable]] = {}
def subscribe(self, event_type: str, handler: Callable):
self._listeners.setdefault(event_type, []).append(handler)
def publish(self, event_type: str, payload: dict):
for handler in self._listeners.get(event_type, []):
handler(payload)
event_bus = PaymentEventBus()
event_bus.subscribe("payment.completed", update_order_status)
event_bus.subscribe("payment.completed", send_receipt_email)
event_bus.subscribe("payment.completed", sync_to_accounting)
event_bus.subscribe("payment.failed", notify_customer)
event_bus.subscribe("payment.failed", log_failure_metrics)
No call site decides what should happen when a payment completes.
The event bus resolves the handlers at runtime based on what has
subscribed. Adding a new reaction to payment.completed — say,
updating a real-time dashboard — is adding a subscription, not
modifying the payment processing code.
Event-driven dispatch introduces genuine complexity: ordering
guarantees, error handling (does one failed handler prevent others
from running?), debugging (the execution path is no longer visible
in a stack trace). These costs are real and they are the reason
event-driven architecture is not the default answer. But for systems
where multiple independent subsystems must react to the same
condition, events eliminate the coordination code that would otherwise
live in a central orchestrator — the function that calls
update_order_status and then send_receipt_email and then
sync_to_accounting and must be edited every time a new reaction is
added.
When to stop¶
Every pattern in this progression adds indirection. Indirection has a cost: the execution path is less visible, debugging requires understanding the dispatch mechanism, and a new engineer must learn the registration/resolution system before they can trace behavior.
The original 45-line function with three elif branches had no
indirection cost. An engineer could read it top to bottom and know
exactly what happened for every payment method. That directness has
genuine value, and losing it is a real price paid for the ability to
extend the system without modifying existing code.
The heuristic is not "always use a registry" or "always use events." It is: the dispatch mechanism should match the rate of change.
Low rate of change: Three payment methods that have been stable
for two years. The elif chain is fine. The cost of indirection is
not justified by the nonexistent cost of modifying the dispatch.
Moderate rate of change: A new payment method every few months, integrated by the core team. A class hierarchy with a simple registry gives each method its own boundary and makes adding a new one mechanical. The dispatch function does not need to be a registry — a dictionary mapping strings to classes, maintained alongside the classes, is sufficient.
High rate of change: Payment methods added by external partners via a plugin system, with capabilities that vary (some support refunds, some do not). Protocol-based dispatch and a self-registering class hierarchy let the system discover capabilities without the core code knowing what plugins exist.
Coordination across subsystems: Multiple independent modules must react to payment events, and the set of reactions changes independently of the payment processing code. Event-driven dispatch decouples the reactions from the trigger.
The mistake is reaching for the sophisticated mechanism before the
simpler one has failed. A registry for three stable payment methods is
over-engineering. An elif chain for twenty payment methods added by
external partners is under-engineering. The right mechanism is the one
whose complexity matches the problem's complexity — no more, no less.
The testing signal¶
Testing is often treated as an afterthought — something bolted on after the design is complete. But testability is one of the strongest signals that a design's boundaries are correctly drawn. If a component cannot be tested without scaffolding the entire system around it, the component does not have real boundaries. Its implementation has leaked into its dependencies, or its dependencies have leaked into it, and the test is forced to reproduce the entire entangled context to exercise any single behavior.
The untestable design¶
Consider a Rails controller that enrolls a customer. The controller sets attribute accessor flags on the model to control which callbacks fire, because the model's callback chain triggers different side effects depending on the context of the save:
class EnrollmentsController < ApplicationController
def create
@customer = Customer.find(params[:customer_id])
@customer.skip_crm_sync = true
@customer.skip_messaging_provision = false
@customer.enrolled_by_admin = current_user.admin?
@customer.suppress_welcome_email = params[:silent].present?
@customer.enrolled = true
@customer.enrolled_at = Time.current
@customer.save!
redirect_to customer_path(@customer)
end
end
Testing this controller requires:
- A real (or carefully mocked) database with a customer record, a tenant, and tenant configuration
- The
Customermodel's full callback chain to be loaded, because the test must verify which callbacks fired and which were suppressed - External service stubs for CRM, messaging provider, and email delivery — because any callback that was not suppressed will call them
- Tenant fixture data, because some callbacks are conditional on tenant configuration
- Knowledge of which
attr_accessorflags interact — doesskip_crm_syncaffect the audit log callback? Doesenrolled_by_adminchange the welcome email logic?
RSpec.describe EnrollmentsController, type: :controller do
let(:tenant) { create(:tenant, messaging_enabled: true, double_opt_in: false) }
let(:customer) { create(:customer, tenant: tenant, enrolled: false) }
before do
stub_crm_sync
stub_messaging_provider
stub_email_delivery
sign_in(create(:admin_user))
end
it "enrolls the customer" do
post :create, params: { customer_id: customer.id }
customer.reload
expect(customer.enrolled).to be true
expect(customer.enrolled_at).to be_present
end
it "skips CRM sync" do
expect(CrmSync).not_to receive(:push)
post :create, params: { customer_id: customer.id }
end
it "does not skip messaging provision" do
expect(MessagingProvider).to receive(:provision)
post :create, params: { customer_id: customer.id }
end
it "suppresses welcome email when silent" do
expect(WelcomeMailer).not_to receive(:enrollment_email)
post :create, params: { customer_id: customer.id, silent: "1" }
end
end
The test must know the internals of the model — which flags suppress which side effects — to verify the controller's behavior. When a new callback is added to the model, every controller test that saves a customer must be re-evaluated: does the new callback need a new flag? Does the existing flag configuration accidentally trigger or suppress it?
The complexity grows exponentially. If there are five flags and ten
controllers that save customers, each with different flag
configurations, the interaction matrix is fifty cells — each of which
represents a potential bug when any callback changes. The attr_
accessor flags are the symptom. The disease is that the controller
is reaching into the model's internal state-management mechanism to
control behavior that should not be the controller's responsibility.
Dependency injection as design discipline¶
The fix is not "write better tests." The fix is a design that does not require the test to reproduce the entangled context. Dependency injection is the mechanism, but the value is not testability alone — it is the design discipline that DI enforces.
Returning to the payment processor example in Python:
class PaymentWorkflow:
def __init__(
self,
processor: PaymentProcessor,
transaction_store: TransactionStore,
receipt_sender: ReceiptSender,
event_bus: EventBus,
):
self.processor = processor
self.transaction_store = transaction_store
self.receipt_sender = receipt_sender
self.event_bus = event_bus
def execute(self, order, details):
self.processor.validate(details)
response = self.processor.process(order, details)
fee = self.processor.calculate_fee(order, details)
self.transaction_store.record(
order=order,
method=self.processor.method_name,
response=response,
fee=fee,
)
receipt = self.processor.format_receipt(order, details)
self.receipt_sender.send(order.customer, receipt)
self.event_bus.publish("payment.completed", {"order_id": order.id})
return response
Every dependency is explicit. The workflow does not instantiate its own transaction store, does not know which receipt delivery mechanism is in use, does not reach into a global event bus. It receives what it needs through its constructor.
Testing with an in-memory substitute:
class InMemoryTransactionStore:
def __init__(self):
self.transactions = []
def record(self, order, method, response, fee):
self.transactions.append({
"order_id": order.id,
"method": method,
"response": response,
"fee": fee,
})
class InMemoryReceiptSender:
def __init__(self):
self.sent = []
def send(self, customer, receipt):
self.sent.append({"customer": customer, "receipt": receipt})
class InMemoryEventBus:
def __init__(self):
self.published = []
def publish(self, event_type, payload):
self.published.append({"type": event_type, "payload": payload})
def test_payment_workflow_records_transaction():
store = InMemoryTransactionStore()
sender = InMemoryReceiptSender()
bus = InMemoryEventBus()
processor = StoreCreditProcessor()
workflow = PaymentWorkflow(
processor=processor,
transaction_store=store,
receipt_sender=sender,
event_bus=bus,
)
order = Order(id=1, total_cents=5000, customer=Customer(id=1))
workflow.execute(order, {})
assert len(store.transactions) == 1
assert store.transactions[0]["fee"] == 0
assert store.transactions[0]["method"] == "store_credit"
def test_payment_workflow_publishes_event():
store = InMemoryTransactionStore()
sender = InMemoryReceiptSender()
bus = InMemoryEventBus()
processor = StoreCreditProcessor()
workflow = PaymentWorkflow(
processor=processor,
transaction_store=store,
receipt_sender=sender,
event_bus=bus,
)
order = Order(id=1, total_cents=5000, customer=Customer(id=1))
workflow.execute(order, {})
assert len(bus.published) == 1
assert bus.published[0]["type"] == "payment.completed"
No database. No external service stubs. No mocking framework. No
setup that must be re-evaluated when an unrelated component changes.
The in-memory array is as effective as a transactional database for
verifying the workflow's behavior — because the workflow's contract
with TransactionStore is "call record with these arguments." It
does not care how record is implemented. An array, a PostgreSQL
table, a Redis stream — the workflow behaves identically.
The canary¶
The in-memory substitutes work because the boundary is honored. The
workflow interacts with TransactionStore through a defined interface
— record(order, method, response, fee) — and never reaches past it.
The moment a design starts to depend on a dependency's internal state, the in-memory substitute breaks:
# This test would fail with InMemoryTransactionStore
def test_transaction_ordering():
store = InMemoryTransactionStore()
# ...
workflow.execute(order_1, {})
workflow.execute(order_2, {})
# BAD: depending on auto-increment IDs from the store
assert store.transactions[1]["id"] > store.transactions[0]["id"]
# BAD: depending on the store's timestamp behavior
assert store.transactions[0]["recorded_at"] < store.transactions[1]["recorded_at"]
# BAD: depending on the store's deduplication logic
workflow.execute(order_1, {}) # same order again
assert len(store.transactions) == 2 # expects store to deduplicate
Each of these assertions depends on behavior that belongs to the store's internals — auto-increment sequencing, timestamp generation, deduplication logic. The in-memory array does not implement these because they are not part of the contract. The test failure is the canary: it signals that the workflow has coupled itself to implementation details that it has no business knowing about.
This is why testability is a design signal, not just a testing concern. When a test requires elaborate setup, mocked internal state, or knowledge of a dependency's implementation to pass, the code under test has a boundary violation. The test is not too hard to write — the design is too entangled to test.
Factories and builders¶
Dependency injection solves the wiring problem — how does the
PaymentWorkflow get its dependencies? In a test, the answer is
obvious: the test constructs them. In production, the answer is a
factory:
class PaymentWorkflowFactory:
def __init__(self, config):
self.config = config
def build(self, method: str) -> PaymentWorkflow:
processor = PaymentProcessor.for_method(method)
return PaymentWorkflow(
processor=processor,
transaction_store=PostgresTransactionStore(self.config.db_url),
receipt_sender=self._build_receipt_sender(),
event_bus=RabbitMQEventBus(self.config.amqp_url),
)
def _build_receipt_sender(self):
if self.config.receipt_mode == "email":
return EmailReceiptSender(self.config.smtp_config)
elif self.config.receipt_mode == "sms":
return SmsReceiptSender(self.config.twilio_config)
return NoOpReceiptSender()
The factory centralizes construction decisions. The workflow does not know whether it is talking to PostgreSQL or an in-memory array. It does not know whether receipts go by email or SMS. It does not know whether events go to RabbitMQ or a test bus. The factory decides, and the workflow operates against the contract.
For complex object graphs where construction has multiple steps, conditional logic, or shared dependencies, the builder pattern provides a fluent construction interface:
class PaymentWorkflowBuilder:
def __init__(self):
self._processor = None
self._store = None
self._sender = None
self._bus = None
def with_processor(self, processor):
self._processor = processor
return self
def with_store(self, store):
self._store = store
return self
def with_receipt_sender(self, sender):
self._sender = sender
return self
def with_event_bus(self, bus):
self._bus = bus
return self
def build(self):
return PaymentWorkflow(
processor=self._processor,
transaction_store=self._store or InMemoryTransactionStore(),
receipt_sender=self._sender or NoOpReceiptSender(),
event_bus=self._bus or NoOpEventBus(),
)
The builder makes construction explicit and composable. A test that only cares about transaction recording can build a workflow with defaults for everything else:
def test_fee_calculation():
store = InMemoryTransactionStore()
workflow = (
PaymentWorkflowBuilder()
.with_processor(CreditCardProcessor())
.with_store(store)
.build()
)
order = Order(id=1, total_cents=10000, customer=Customer(id=1))
workflow.execute(order, {"card_number": "4111111111111111", "expiry": "12/27", "cvv": "123"})
assert store.transactions[0]["fee"] == 10000 * 0.029 + 30
Production-optimized, not test-optimized¶
The factory and builder patterns are not testing utilities. They are
production architecture. The factory that wires PostgresTransaction
Store in production and InMemoryTransactionStore in tests is the
same factory that wires SmsReceiptSender for one tenant and
EmailReceiptSender for another. The builder that lets tests omit
irrelevant dependencies is the same builder that lets a background job
construct a workflow without a receipt sender because the job does not
send receipts.
The design is production-optimized. The testability is a consequence, not a goal. When a system is built from components with explicit dependencies and defined contracts, testing becomes simple because the design is simple — each component does one thing, receives what it needs, and is agnostic to how those needs are fulfilled.
This reinforces strict boundaries in a way that no other practice does. If a dependency's internal state starts to become part of your implementation — if you find yourself checking the database's auto-increment counter, or relying on the event bus's ordering guarantee, or depending on the receipt sender's retry logic — the in-memory substitute surfaces the violation immediately. The test fails not because the test is wrong but because the code has reached past a boundary.
It also promotes idempotency naturally. When your component receives its dependencies rather than reaching for global state, it is less tempted to depend on where in a sequence it runs. It processes the order it is given, records the transaction it is told to record, sends the receipt it is told to send. Whether it runs first or last, once or twice, in a test or in production — the behavior is the same, because the behavior depends only on the inputs and the contracts, not on accumulated state in systems it does not control.
Thinking back to the Rails attr_accessor pattern: the controller
that sets five flags before saving a customer is a controller that has
absorbed knowledge of the model's internal callback orchestration. It
cannot be tested without reproducing that orchestration. It cannot be
reasoned about without understanding which flags interact. Every new
controller that saves a customer must learn the flag protocol —
which flags to set, in which combination, for which context. The
complexity grows exponentially with the number of controllers and
models, because each controller-model pair represents a unique
configuration of internal state that must be held in the programmer's
head.
The DI-based design eliminates this entirely. The enrollment workflow receives its dependencies. The controller calls the workflow. The controller does not know what the workflow does internally — it does not set flags, does not suppress callbacks, does not configure internal state. It provides inputs and receives outputs. If the enrollment workflow changes its internal behavior — adds a new side effect, removes an old one, reorders its operations — no controller changes. The boundary holds. The contract holds. The system remains composable.
The living system¶
There is a qualitative difference between a program that enumerates its behaviors and a system that discovers them.
The enumerating program is fully specified at write time. Every case
is known, every response is coded, every path is visible in the
source. This is the if/elif chain, the match/case statement,
the hard-coded dispatch table. It is easy to read, easy to debug, and
easy to reason about — as long as the set of cases is small and
stable. When the set grows or changes frequently, the enumerating
program requires modification at every extension point and risks
every existing behavior with each modification.
The discovering system is specified at write time only in terms of contracts. The actual behaviors are resolved at runtime from whatever has been registered, subscribed, or implemented. The source code does not contain a list of payment methods — it contains the rules for what a payment method must provide. The system handles payment method eight not because a programmer added an eighth branch, but because an eighth class conformed to the contract and the system found it.
This is where programming transitions from pure computation — take these inputs, apply these rules, produce this output — to something that behaves more like a living system. The individual components are simple: each processor validates, processes, calculates fees, and formats receipts. The emergent behavior — a payment platform that handles any method conforming to its contract, routes webhooks from any provider, and coordinates reactions across independent subsystems — arises from the composition of those components through defined interfaces.
The skill is not knowing the patterns. The patterns are well
documented, widely taught, and straightforward to implement. The skill
is recognizing the moment when a system's rate of change has outgrown
its dispatch mechanism — when the elif chain that was fine last year
is now a liability, when the class hierarchy that was sufficient is
now missing a capability-discovery layer, when the direct function
calls that were clear are now a coordination bottleneck. That
recognition comes from experience: from maintaining systems that grew
past their dispatch mechanisms and paying the cost, from watching a
well-placed registry eliminate an entire category of bugs, from
over-engineering a simple system with events and watching the
debugging cost exceed the extension cost it was supposed to eliminate.
There are no rules for this. There are patterns, guidelines, and hard-learned judgment about when each tool earns its keep. The preceding pages established the vocabulary: Separation of Concerns identifies what should be apart, Locality determines where it lives, and this page addresses how the parts communicate once they are separated. The thread that runs through all three is the same question, asked at different scales: what code belongs together, and what code belongs apart? The decoupled system's answer is: the parts belong apart, the contract belongs between them, and the system's behavior emerges from honoring those contracts at runtime.
Questions to ask¶
- How many
if/eliformatch/casechains in the codebase dispatch on the same value? Each duplicate chain is a maintenance liability — when a new case arrives, every chain must be found and updated. - When a new variant is added (a new payment method, a new notification channel, a new report type), how many files change? If the answer is more than one or two, the dispatch mechanism is too distributed.
- Do the classes in the system carry behavior that is queried polymorphically, or are they passive containers selected by external string matching? If the calling code checks the type name and hard-codes the answer, the class hierarchy is ceremony.
- Does the system know its own capabilities? Can it enumerate what payment methods are available, what webhook providers are supported, what report types exist — without a hard-coded list somewhere?
- Is the dispatch mechanism's complexity proportional to the
problem's rate of change? A registry for three stable variants is
over-engineering. An
elifchain for twenty dynamic variants is under-engineering. - When an unexpected input arrives — a payment method the system has never seen, a webhook from an unknown provider — does the system produce a clear, diagnosable error, or does it fall through a default branch silently?