Lesson 3 - Design Patterns I: Creational and Structural

Welcome to Design Patterns I: Creational and Structural

A design pattern is a proven solution to a problem that comes up again and again in software design. It is not finished code you paste into your project. It is a shape you adapt to your own classes and your own naming. The previous lesson gave you the object-oriented building blocks: classes, interfaces, inheritance, and composition. This lesson shows you six common ways to arrange those building blocks, using Ledgerly, the small invoicing and subscription-billing app you have followed since Lesson 1.

This lesson covers two of the three usual pattern families. Creational patterns control how an object gets created, so the rest of the system does not need to know the messy details of construction. Structural patterns control how existing objects combine into a larger piece, so each piece stays simple even as the whole system grows. A later lesson in this module covers the third family, behavioral patterns, which control how objects communicate with each other.

By the end of this lesson, you will be able to:

  • Use Factory Method to pick the right class to create based on a name or condition, without hardcoding that class everywhere
  • Use Builder to construct a complex object step by step, when not every instance needs every optional part
  • Explain what Singleton guarantees, and why teams should reach for it carefully
  • Use Adapter to make a third-party interface fit the shape your own code already expects
  • Use Decorator to add optional behavior to an object without subclassing it
  • Use Facade to hide a multi-step workflow behind one simple method call

Factory Method: Picking the Right Class by Name

Ledgerly needs to charge customers through more than one payment provider: Stripe, PayPal, and a bank transfer option some enterprise customers request. Each provider needs its own class, because each one talks to a different external API. Without a pattern, code that starts a charge would need an if/elif chain checking the provider name every single place a charge happens.

The Factory Method pattern puts that decision in exactly one place: a factory class that turns a plain string into the right object. Callers ask the factory for a gateway and never mention a concrete class name themselves.

from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    """Defines what any payment gateway must do."""

    @abstractmethod
    def charge(self, amount_cents, customer_token):
        pass


class StripeGateway(PaymentGateway):
    """Charges customers through Stripe."""

    def charge(self, amount_cents, customer_token):
        return {"provider": "stripe", "charge_id": f"stripe_ch_{customer_token}", "amount": amount_cents}


class PaypalGateway(PaymentGateway):
    """Charges customers through PayPal."""

    def charge(self, amount_cents, customer_token):
        return {"provider": "paypal", "charge_id": f"paypal_ch_{customer_token}", "amount": amount_cents}


class BankTransferGateway(PaymentGateway):
    """Charges customers through a direct bank transfer."""

    def charge(self, amount_cents, customer_token):
        return {"provider": "bank_transfer", "charge_id": f"bank_ch_{customer_token}", "amount": amount_cents}


class PaymentGatewayFactory:
    """Creates the right gateway from a plain string, so callers never import a concrete class."""

    _gateways = {
        "stripe": StripeGateway,
        "paypal": PaypalGateway,
        "bank_transfer": BankTransferGateway,
    }

    @classmethod
    def create(cls, provider_name):
        gateway_class = cls._gateways.get(provider_name)
        if gateway_class is None:
            raise ValueError(f"Unknown payment provider: {provider_name}")
        return gateway_class()


def charge_customer(provider_name, amount_cents, customer_token):
    gateway = PaymentGatewayFactory.create(provider_name)
    return gateway.charge(amount_cents, customer_token)


print(charge_customer("stripe", 4200, "cust_88"))
print(charge_customer("paypal", 1500, "cust_12"))

try:
    charge_customer("crypto", 1000, "cust_99")
except ValueError as error:
    print(f"Blocked: {error}")
{'provider': 'stripe', 'charge_id': 'stripe_ch_cust_88', 'amount': 4200}
{'provider': 'paypal', 'charge_id': 'paypal_ch_cust_12', 'amount': 1500}
Blocked: Unknown payment provider: crypto

charge_customer() never names StripeGateway or PaypalGateway directly. It asks PaymentGatewayFactory for whichever gateway matches the string it was given. If Ledgerly’s team adds a fourth provider next quarter, they add one class and one dictionary entry. No code that calls charge_customer() needs to change at all.


Builder: Assembling a Complex Object Step by Step

An Invoice in Ledgerly can be simple or complicated. Some invoices have one line item and no discount. Others have several line items, a loyalty discount, and sales tax. Cramming every combination into one constructor with many optional parameters gets confusing fast, and it is easy to pass arguments in the wrong order.

The Builder pattern solves this by adding parts one method call at a time, then producing the finished object only when you call build(). Each method returns the builder itself, so calls can chain together in a readable sequence.

class Invoice:
    """A finished invoice: line items, an optional discount, and tax."""

    def __init__(self, customer_id, line_items, discount_rate, tax_rate):
        self.customer_id = customer_id
        self.line_items = line_items
        self.discount_rate = discount_rate
        self.tax_rate = tax_rate

    @property
    def subtotal_cents(self):
        return sum(item["price_cents"] * item["quantity"] for item in self.line_items)

    @property
    def total_cents(self):
        discounted = self.subtotal_cents * (1 - self.discount_rate)
        return round(discounted * (1 + self.tax_rate))

    def __repr__(self):
        return (f"Invoice(customer_id={self.customer_id!r}, items={len(self.line_items)}, "
                 f"total_cents={self.total_cents})")


class InvoiceBuilder:
    """Builds an Invoice step by step, since not every invoice needs every part."""

    def __init__(self, customer_id):
        self._customer_id = customer_id
        self._line_items = []
        self._discount_rate = 0.0
        self._tax_rate = 0.0

    def add_line_item(self, description, price_cents, quantity=1):
        self._line_items.append({
            "description": description,
            "price_cents": price_cents,
            "quantity": quantity,
        })
        return self

    def with_discount(self, rate):
        self._discount_rate = rate
        return self

    def with_tax(self, rate):
        self._tax_rate = rate
        return self

    def build(self):
        if not self._line_items:
            raise ValueError("An invoice needs at least one line item")
        return Invoice(self._customer_id, self._line_items, self._discount_rate, self._tax_rate)


# A plain invoice with no discount and no tax
plain_invoice = (
    InvoiceBuilder("cust_88")
    .add_line_item("Design consultation", 15000, quantity=2)
    .build()
)
print(plain_invoice)
print(plain_invoice.total_cents)

# A discounted, taxed invoice for a repeat customer
full_invoice = (
    InvoiceBuilder("cust_12")
    .add_line_item("Website redesign", 80000)
    .add_line_item("Logo package", 20000)
    .with_discount(0.10)
    .with_tax(0.08)
    .build()
)
print(full_invoice)
print(full_invoice.total_cents)

try:
    InvoiceBuilder("cust_99").build()
except ValueError as error:
    print(f"Blocked: {error}")
Invoice(customer_id='cust_88', items=1, total_cents=30000)
30000
Invoice(customer_id='cust_12', items=2, total_cents=97200)
97200
Blocked: An invoice needs at least one line item

plain_invoice skips with_discount() and with_tax() entirely, and the defaults handle it correctly. full_invoice chains four calls before build() produces one finished, valid Invoice. The builder also refuses to build an invoice with zero line items, catching a mistake before a broken invoice ever reaches a customer.


Singleton: One Shared Instance, Used With Care

Ledgerly’s three-person team keeps a small set of app-wide settings: the default currency, the default tax rate, and a few feature flags. Every part of the app — billing, invoicing, notifications — needs to read the same values. Creating a fresh settings object in each module risks two copies drifting apart, where one module sees an old currency setting and another sees a new one.

The Singleton pattern guarantees that a class has exactly one instance for the whole running program, and gives every part of the code the same reference to it.

class AppConfig:
    """Holds app-wide settings that every part of Ledgerly reads, like currency and tax defaults."""

    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._settings = {}
        return cls._instance

    def set(self, key, value):
        self._settings[key] = value

    def get(self, key, default=None):
        return self._settings.get(key, default)


config_at_startup = AppConfig()
config_at_startup.set("default_currency", "EUR")
config_at_startup.set("default_tax_rate", 0.08)

config_in_billing_module = AppConfig()
print(config_in_billing_module.get("default_currency"))
print(config_at_startup is config_in_billing_module)
EUR
True

config_at_startup and config_in_billing_module are two variable names, but __new__ returns the same underlying object both times, so they point at one shared instance. Setting default_currency once, at startup, makes it visible everywhere in the app that later asks AppConfig() for a value.

Be careful with Singleton

Singleton is easy to overuse. Once a class is a Singleton, any code anywhere in the app can reach it directly, which hides a real dependency behind a global lookup. That makes unit tests harder to write, because you cannot easily swap in a fake AppConfig for a test without resetting shared state between tests. Reserve Singleton for a small number of truly global, shared resources, like configuration. For most other cases, pass the object in explicitly as an argument, the way BillingService in Lesson 1 accepted a PaymentGateway instead of reaching for a global one.


Adapter: Making a Third-Party Interface Fit

Ledgerly’s team wants to add a new payment provider, VendorPay, but its SDK was written by someone else with a different shape than Ledgerly’s own PaymentGateway interface. VendorPay’s method is called submit_transaction(), takes different argument names, and returns a dictionary with different keys. Rewriting VendorPay’s SDK is not an option, since Ledgerly does not own that code.

The Adapter pattern wraps the mismatched interface in a thin class that translates calls and results, so the rest of Ledgerly can treat VendorPay exactly like any other gateway.

from abc import ABC, abstractmethod


class VendorPaySDK:
    """A third-party SDK Ledgerly did not write and cannot change."""

    def submit_transaction(self, cents, token):
        return {"txn_ref": f"vp_{token}", "cents_charged": cents, "state": "ok"}


class PaymentGateway(ABC):
    """The interface every gateway in Ledgerly's billing code already expects."""

    @abstractmethod
    def charge(self, amount_cents, customer_token):
        pass


class VendorPayAdapter(PaymentGateway):
    """Wraps VendorPaySDK so it looks like a normal PaymentGateway to the rest of Ledgerly."""

    def __init__(self, vendor_sdk):
        self._vendor_sdk = vendor_sdk

    def charge(self, amount_cents, customer_token):
        raw_result = self._vendor_sdk.submit_transaction(amount_cents, customer_token)
        return {
            "charge_id": raw_result["txn_ref"],
            "amount": raw_result["cents_charged"],
            "success": raw_result["state"] == "ok",
        }


class BillingService:
    """Only knows the PaymentGateway interface, never a specific vendor's SDK shape."""

    def __init__(self, gateway):
        self.gateway = gateway

    def collect_payment(self, amount_cents, customer_token):
        return self.gateway.charge(amount_cents, customer_token)


adapted_gateway = VendorPayAdapter(VendorPaySDK())
billing = BillingService(adapted_gateway)
print(billing.collect_payment(4200, "cust_88"))
{'charge_id': 'vp_cust_88', 'amount': 4200, 'success': True}

BillingService calls charge(), the method every PaymentGateway promises. VendorPayAdapter is the only class that knows submit_transaction() exists, or that VendorPay calls its result field txn_ref instead of charge_id. If VendorPay changes its SDK later, only the adapter needs updating.


Decorator: Adding Behavior Without Subclassing

An invoice total in Ledgerly sometimes needs a discount applied, sometimes a late fee added, and occasionally both at once. Building a separate subclass for every combination — DiscountedInvoiceTotal, LateInvoiceTotal, DiscountedLateInvoiceTotal — multiplies class after class as more combinations show up.

The Decorator pattern wraps a base object in one or more decorator objects, and each layer adds its own adjustment. Layers stack in any combination, without a new subclass for each one.

class BaseInvoiceTotal:
    """Wraps a plain subtotal so it can be decorated with extra charges."""

    def __init__(self, subtotal_cents):
        self._subtotal_cents = subtotal_cents

    def amount_cents(self):
        return self._subtotal_cents

    def description(self):
        return "Subtotal"


class InvoiceTotalDecorator:
    """Base decorator: passes through unless a subclass adds a charge."""

    def __init__(self, wrapped_total):
        self._wrapped_total = wrapped_total

    def amount_cents(self):
        return self._wrapped_total.amount_cents()

    def description(self):
        return self._wrapped_total.description()


class DiscountDecorator(InvoiceTotalDecorator):
    """Subtracts a percentage discount from whatever total it wraps."""

    def __init__(self, wrapped_total, rate):
        super().__init__(wrapped_total)
        self._rate = rate

    def amount_cents(self):
        base = self._wrapped_total.amount_cents()
        return round(base * (1 - self._rate))

    def description(self):
        return f"{self._wrapped_total.description()} + {int(self._rate * 100)}% discount"


class LateFeeDecorator(InvoiceTotalDecorator):
    """Adds a flat late fee on top of whatever total it wraps."""

    def __init__(self, wrapped_total, fee_cents):
        super().__init__(wrapped_total)
        self._fee_cents = fee_cents

    def amount_cents(self):
        return self._wrapped_total.amount_cents() + self._fee_cents

    def description(self):
        return f"{self._wrapped_total.description()} + late fee"


base = BaseInvoiceTotal(10000)
print(base.description(), base.amount_cents())

discounted = DiscountDecorator(base, 0.15)
print(discounted.description(), discounted.amount_cents())

overdue_and_discounted = LateFeeDecorator(DiscountDecorator(base, 0.15), 500)
print(overdue_and_discounted.description(), overdue_and_discounted.amount_cents())
Subtotal 10000
Subtotal + 15% discount 8500
Subtotal + 15% discount + late fee 9000

Each decorator only knows about the total it wraps, not the whole chain behind it. LateFeeDecorator wrapping DiscountDecorator wrapping BaseInvoiceTotal produces one number, 9000, but Ledgerly’s team never wrote a class called DiscountedLateInvoiceTotal to get it. Any new combination is just a new stacking order, not a new class.


Facade: One Call for a Multi-Step Workflow

Sending an invoice in Ledgerly is not one step. It means saving the invoice record, charging the customer’s card, and emailing a receipt, in that order, and skipping the email if the charge fails. Code that calls all three steps directly needs to know the right order and the right conditions every single time it sends an invoice.

The Facade pattern wraps a multi-step workflow behind one simple method, so callers only need to know that one method’s name.

class InvoiceRepository:
    """Saves and loads invoices. Stands in for a real database."""

    def __init__(self):
        self._invoices = {}

    def save(self, invoice_id, total_cents):
        self._invoices[invoice_id] = total_cents
        print(f"  [Repository] Saved invoice {invoice_id} for {total_cents} cents")


class PaymentGateway:
    """Charges the customer. Stands in for a real payment provider."""

    def charge(self, amount_cents, customer_token):
        print(f"  [PaymentGateway] Charged {amount_cents} cents to {customer_token}")
        return {"charge_id": f"ch_{customer_token}", "success": True}


class NotificationService:
    """Emails the customer a receipt."""

    def send_receipt(self, customer_email, invoice_id):
        print(f"  [NotificationService] Emailed receipt for {invoice_id} to {customer_email}")


class InvoiceSendingFacade:
    """Hides the repository, gateway, and notification steps behind one call."""

    def __init__(self):
        self._repository = InvoiceRepository()
        self._gateway = PaymentGateway()
        self._notifier = NotificationService()

    def send_invoice(self, invoice_id, total_cents, customer_token, customer_email):
        print(f"Sending invoice {invoice_id}...")
        self._repository.save(invoice_id, total_cents)
        result = self._gateway.charge(total_cents, customer_token)
        if result["success"]:
            self._notifier.send_receipt(customer_email, invoice_id)
        return result["success"]


facade = InvoiceSendingFacade()
sent = facade.send_invoice("INV-2001", 9700, "cust_88", "[email protected]")
print(f"Invoice sent: {sent}")
Sending invoice INV-2001...
  [Repository] Saved invoice INV-2001 for 9700 cents
  [PaymentGateway] Charged 9700 cents to cust_88
  [NotificationService] Emailed receipt for INV-2001 to [email protected]
Invoice sent: True

Whatever calls facade.send_invoice(...) does not need to know that saving, charging, and emailing happen in that specific order, or that the email step depends on the charge succeeding. InvoiceSendingFacade hides that sequencing, so a new developer joining Ledgerly’s team can send an invoice correctly on their first day, without reading the three collaborator classes first.

A diagram titled 'Three pattern shapes, one Ledgerly problem each' showing three side-by-side panels. Left panel, Factory Method: a PaymentGatewayFactory box labeled create('stripe') with an arrow down to a StripeGateway box, captioned that the caller passes a string and the factory decides which concrete class to instantiate, so the caller never names the class. Middle panel, Builder: a vertical chain of three method calls, add_line_item, with_discount(0.10), and with_tax(0.08), with an arrow down to a final build() to Invoice box, captioned that each call configures one optional part and only build() produces the final, immutable Invoice. Right panel, Decorator: three nested boxes, an outer LateFeeDecorator box wrapping a DiscountDecorator box wrapping an inner BaseInvoiceTotal box with amount_cents(), captioned that each layer wraps the one inside it and adjusts amount_cents(), with no Invoice subclass needed. A caption strip below reads: creational patterns, Factory Method and Builder, control how an object comes into existence, while structural patterns, Decorator, Adapter, and Facade, control how existing objects are arranged and combined.
Three pattern shapes side by side: Factory Method picks a class from a name, Builder assembles an object through a chain of configuration calls, and Decorator stacks wrapper objects around one core object to add behavior without new subclasses.

Practice Exercises

Exercise 1: Add a fourth payment provider with Factory Method

Ledgerly wants to support a fourth provider called Wise, using a class WiseGateway that implements charge(). Using the PaymentGatewayFactory from this lesson, describe every change you would make, and confirm whether charge_customer() needs any changes at all.

Hint

Write WiseGateway(PaymentGateway) with its own charge() method, then add one line to the _gateways dictionary: "wise": WiseGateway. That is the entire change. charge_customer() and PaymentGatewayFactory.create() stay exactly as written, because they already work with any class registered in the dictionary. This is the payoff of Factory Method: new types plug in without touching existing code.

Exercise 2: Extend the Builder with a shipping address

Ledgerly’s InvoiceBuilder currently supports line items, a discount, and tax. Sketch a new method, with_shipping_address(address), that stores an optional shipping address on the builder, and explain how build() and the Invoice class need to change to include it.

Hint

Add self._shipping_address = None in __init__, then a method with_shipping_address(self, address) that sets self._shipping_address = address and returns self, matching the chaining style of with_discount() and with_tax(). build() needs to pass self._shipping_address into the Invoice constructor, and Invoice.__init__ needs a new parameter, shipping_address=None, so invoices without one still build correctly.

Exercise 3: Choose between Decorator and Facade

A teammate wants to add a “priority processing” fee that applies whenever an invoice is both discounted and marked urgent, and separately wants to simplify the three-step “send invoice” workflow so junior developers cannot get the step order wrong. Which pattern from this lesson fits each problem, and why does the other pattern not fit as well?

Hint

The priority fee is a Decorator problem: it is one more optional adjustment to a total, stackable with DiscountDecorator and LateFeeDecorator, without a new subclass. The step-ordering problem is a Facade problem: it is about hiding a fixed sequence of calls to different collaborators behind one method, not about adjusting a number. Decorator would not simplify step ordering, since decorators do not enforce call sequence, and Facade would not help with stacking optional total adjustments, since a facade wraps a workflow, not a value.


Summary

Creational and structural patterns solve two different problems that show up constantly in Ledgerly’s codebase. Factory Method centralizes the decision of which class to instantiate, so PaymentGatewayFactory is the only place that maps a provider name to a class. Builder assembles a complex Invoice through a readable chain of optional method calls, instead of one constructor with many parameters. Singleton guarantees a single shared AppConfig instance, though it deserves caution because of the hidden dependencies it creates. Adapter translates a third-party interface, like VendorPay’s SDK, into the shape Ledgerly’s own code already expects. Decorator stacks optional behavior, like discounts and late fees, around a base object without a new subclass for every combination. Facade hides a multi-step workflow, like sending an invoice, behind one method call that is hard to get wrong.

Key Concepts

  • Factory Method — delegates the decision of which concrete class to instantiate to one centralized method.
  • Builder — constructs a complex object through a sequence of configuration calls, finished by one build() call.
  • Singleton — guarantees exactly one instance of a class, shared everywhere it is requested, and should be used sparingly.
  • Adapter — wraps a mismatched interface so it fits the shape existing code already expects.
  • Decorator — wraps a base object in optional layers that each add one adjustment, without subclassing.
  • Facade — hides a multi-step workflow across several collaborators behind one simple method.

Why This Matters

These six patterns solve problems that appear in nearly every real codebase, not just Ledgerly’s. Factory Method and Builder keep object creation flexible as new types and new optional configurations appear. Singleton, used carefully, keeps shared state consistent without scattering copies of it everywhere. Adapter, Decorator, and Facade all manage growing complexity: Adapter isolates a mismatch at the boundary with outside code, Decorator lets features combine without a class explosion, and Facade keeps a complicated process simple for whoever calls it. Recognizing these shapes lets you read unfamiliar code faster and choose a proven structure instead of inventing a new one under deadline pressure. The next lesson turns to behavioral patterns, which govern how Ledgerly’s objects communicate and coordinate with each other.


Next Steps

Lesson 4: Design Patterns II - Behavioral

Learn Strategy, Observer, Command, and other behavioral patterns that govern how Ledgerly's objects communicate.

Back to Module Overview

Return to the Design, Architecture & OOP module overview


Continue Building Your Skills

You now have six patterns for creating and structuring objects: Factory Method, Builder, Singleton, Adapter, Decorator, and Facade. Each one gave Ledgerly’s codebase a proven, well-understood shape instead of an ad hoc solution invented on the spot. In the next lesson, you will meet behavioral patterns, which shift the focus from how objects are created and combined to how they communicate, using the same Ledgerly classes you have already worked with.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com