After going through https://python-patterns.guide/, I decide to use some simple, contrived Python programs to get familiar with several Python-compatible patterns.

In this article, I'll show four design patterns that are fully compatible with Python and very easy to use: the observer pattern, the strategy pattern, the decorator pattern and the flyweight pattern.

The observer pattern

The observer pattern is a behavioral design pattern where an object, known as the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes.

Think of it like subscribing to a YouTube channel. The channel is the subject, and you are an observer. When the channel uploads a new video (a state change), you get a notification.

Python Example: A Simple News Agency

In this example, a NewsAgency (the subject) sends out news alerts to various NewsOutlets (the observers) whenever a new story breaks.

  # The Subject (or Observable)
  class NewsAgency:
      """The subject that observers will watch."""
      def __init__(self):
          self._observers = []  # A list to hold all observers
          self._latest_news = None

      def attach(self, observer):
          """Attaches a new observer."""
          if observer not in self._observers:
              self._observers.append(observer)
              print("Attached an observer.")

      def detach(self, observer):
          """Detaches an existing observer."""
          try:
              self._observers.remove(observer)
              print("Detached an observer.")
          except ValueError:
              pass

      def notify(self):
          """Notifies all attached observers of a state change."""
          print("\nNotifying all observers about new story...")
          for observer in self._observers:
              observer.update(self)

      def add_news(self, news):
          """Sets a new state and notifies observers."""
          self._latest_news = news
          self.notify()

  # A concrete Observer
  class NewsOutlet:
      """An observer that receives updates from the subject."""
      def __init__(self, name):
          self.name = name

      def update(self, subject):
          """The update method called by the subject's notify()."""
          print(f"**{self.name} Breaking News:** {subject._latest_news}")

  # --- Let's see it in action ---

  # 1. Create the subject (the news agency)
  agency = NewsAgency()


  # 2. Create the observers (the news outlets)
  cnn = NewsOutlet("CNN")
  bbc = NewsOutlet("BBC")
  fox = NewsOutlet("Fox News")

  # 3. Attach the observers to the subject to start receiving updates
  agency.attach(cnn)
  agency.attach(bbc)
  agency.attach(fox)

  # 4. The subject's state changes, and it notifies all observers
  agency.add_news("Python becomes the world's most popular language!")

  # 5. Detach an observer. It will no longer get updates.
  agency.detach(fox)

  # 6. Another state change occurs
  agency.add_news("A new AI model has been released!")

The strategy pattern

The strategy pattern is a behavioral design pattern that lets you define a family of algorithms, put each of them into a separate class, and make their objects interchangeable.

Think of it like using a navigation app. You can choose your mode of travel (the strategy): walking, driving, or public transit. The app (the context) will calculate the route based on the strategy you selected, but the core function of "getting directions" remains the same.

Python Example: A Simple Shipping Cost Calculator

In this example, a ShippingCostCalculator (the context) calculates the shipping cost for an order. The calculation method (the strategy) can be changed on the fly, for example, based on the shipping company (FedEx, UPS, etc.).

  from abc import ABC, abstractmethod

  # The Strategy Interface (defines what all strategies must do)
  class ShippingStrategy(ABC):
      """An abstract base class for all shipping strategies."""
      @abstractmethod
      def calculate(self, order_weight_kg):
          """Calculates the shipping cost."""
          pass

  # --- Concrete Strategies ---

  # A concrete strategy for FedEx shipping
  class FedExStrategy(ShippingStrategy):
      """Calculates shipping cost using FedEx rates."""
      def calculate(self, order_weight_kg):
          # A simple calculation for demonstration
          cost = 5.00 + (order_weight_kg * 1.50)
          print(f"FedEx shipping cost: ${cost:.2f}")
          return cost

  # A concrete strategy for UPS shipping
  class UPSStrategy(ShippingStrategy):
      """Calculates shipping cost using UPS rates."""
      def calculate(self, order_weight_kg):
          cost = 4.00 + (order_weight_kg * 1.75)
          print(f"UPS shipping cost: ${cost:.2f}")
          return cost
          
  # A concrete strategy for Postal Service shipping
  class PostalStrategy(ShippingStrategy):
      """Calculates shipping cost using Postal Service rates."""
      def calculate(self, order_weight_kg):
          cost = 3.00 + (order_weight_kg * 1.25)
          print(f"Postal Service shipping cost: ${cost:.2f}")
          return cost

  # --- The Context ---

  # The context uses a strategy to do its job
  class ShippingCostCalculator:
      """The context that uses a shipping strategy."""
      def __init__(self, strategy: ShippingStrategy):
          self._strategy = strategy

      def set_strategy(self, strategy: ShippingStrategy):
          """Allows changing the strategy at runtime."""
          self._strategy = strategy
          print(f"\nSwitched to {strategy.__class__.__name__} strategy.")

      def get_cost(self, order_weight_kg):
          """Delegates the calculation to the current strategy."""
          return self._strategy.calculate(order_weight_kg)

  # --- Let's see it in action ---

  # 1. Define an order weight
  order_weight = 2  # in kg

  # 2. Start by using the FedEx strategy
  fedex_strategy = FedExStrategy()
  calculator = ShippingCostCalculator(fedex_strategy)
  calculator.get_cost(order_weight)

  # 3. The customer wants to switch to a cheaper option. Change the strategy at runtime!

  ups_strategy = UPSStrategy()
  calculator.set_strategy(ups_strategy)
  calculator.get_cost(order_weight)

  # 4. Let's try one more strategy
  postal_strategy = PostalStrategy()
  calculator.set_strategy(postal_strategy)
  calculator.get_cost(order_weight)

The decorator pattern

The decorator pattern is a structural design pattern that lets you attach new behaviors to objects by placing these objects inside special wrapper objects that contain the behaviors.

Think of it like getting a gift wrapped. You start with the base gift (the component). Then, you can wrap it in paper, add a ribbon, and then a bow (the decorators). Each layer adds something new without changing the gift itself.

Python Example: A Coffee Shop

In this example, we'll start with a simple cup of coffee. Then, we'll "decorate" it with add-ons like milk and sugar, each of which adds to the total cost and description.

  from abc import ABC, abstractmethod

  # The Component Interface (the base thing we are decorating)
  class Coffee(ABC):
      """The base component interface for a coffee."""
      @abstractmethod
      def get_cost(self):
          pass

      @abstractmethod
      def get_description(self):
          pass

  # --- Concrete Component ---

  # The base object we will wrap
  class SimpleCoffee(Coffee):
      """A plain, simple coffee. This is the object we will decorate."""
      def get_cost(self):
          return 5.00

      def get_description(self):
          return "Simple Coffee"

  # --- Base Decorator ---

  # The base decorator also follows the component interface
  class CoffeeDecorator(Coffee, ABC):
      """Abstract decorator class that wraps a coffee component."""
      def __init__(self, coffee_component: Coffee):
          self._component = coffee_component

      def get_cost(self):
          # Delegate the call to the wrapped component
          return self._component.get_cost()

      def get_description(self):
          # Delegate the call to the wrapped component
          return self._component.get_description()

  # --- Concrete Decorators ---

  # These are the "add-ons"
  class MilkDecorator(CoffeeDecorator):
      """Adds milk to the coffee."""
      def get_cost(self):
          # Add the cost of milk to the wrapped component's cost
          return super().get_cost() + 1.50

      def get_description(self):
          return super().get_description() + ", with Milk"

  class SugarDecorator(CoffeeDecorator):
      """Adds sugar to the coffee."""
      def get_cost(self):
          # Add the cost of sugar
          return super().get_cost() + 0.75

      def get_description(self):
          return super().get_description() + ", with Sugar"

  # --- Let's see it in action ---

  # 1. Start with a simple, undecorated coffee
  my_coffee = SimpleCoffee()
  print(f"Order: {my_coffee.get_description()}")
  print(f"Cost: ${my_coffee.get_cost():.2f}\n")

  # 2. Now, let's decorate it with milk
  # We wrap the original coffee object inside the MilkDecorator
  my_coffee = MilkDecorator(my_coffee)

  print(f"Order: {my_coffee.get_description()}")
  print(f"Cost: ${my_coffee.get_cost():.2f}\n")

  # 3. Let's decorate it again, this time with sugar!
  # Notice we are wrapping the *already decorated* coffee object.
  my_coffee = SugarDecorator(my_coffee)
  print(f"Order: {my_coffee.get_description()}")
  print(f"Cost: ${my_coffee.get_cost():.2f}\n")

The flyweight pattern

The flyweight pattern is a structural design pattern used to minimize memory usage or computational expense by sharing as much as possible with other similar objects. It's a classic optimization pattern.

Think of rendering a huge forest in a video game. Instead of creating a unique object for every single tree with all its data (mesh, textures, color), you create a few flyweight objects for each type of tree (e.g., one for "Oak", one for "Pine"). Then, you just apply the unique data for each tree (its x, y, z coordinates) at render time.

This way, you can have a million trees on screen but only a few tree-type objects in memory.

Python Example: Drawing Trees in a Forest

In this example, we'll create a Forest that can plant and draw many trees. The TreeType is the flyweight (the shared, intrinsic state), and each individual tree's position is the extrinsic state.

  # The Flyweight
  class TreeType:
      """The Flyweight class. It contains the shared, intrinsic state."""
      def __init__(self, name, color, texture):
          print(f"Creating a new TreeType: {name}")
          self.name = name
          self.color = color
          self.texture = texture

      def draw(self, canvas, x, y):
          """Draws the tree on the canvas using its unique extrinsic state."""
          print(f"Drawing a '{self.name}' tree at ({x}, {y}) with color '{self.color}'.")

  # The Flyweight Factory
  class TreeFactory:
      """Manages and creates flyweight objects (TreeType)."""
      _tree_types = {}

      @classmethod
      def get_tree_type(cls, name, color, texture):
          """
          Returns a TreeType. If one doesn't exist, it creates and stores it.
          Otherwise, it returns the existing one.
          """
          if name not in cls._tree_types:
              cls._tree_types[name] = TreeType(name, color, texture)
          else:
              print(f"Reusing existing TreeType: {name}")
          return cls._tree_types[name]

  # The Context (Client)
  class Tree:
      """
      The Context class. It contains the extrinsic state (unique to each tree)
      and a reference to a flyweight object.
      """
      def __init__(self, x, y, tree_type: TreeType):
          self.x = x
          self.y = y
          self.tree_type = tree_type # Reference to the shared flyweight

      def draw(self, canvas):
          self.tree_type.draw(canvas, self.x, self.y)

  # The Main Client Code
  class Forest:
      """The client that uses the flyweights."""
      def __init__(self):
          self._trees = []

      def plant_tree(self, x, y, name, color, texture):
          # Use the factory to get a shared TreeType object
          tree_type = TreeFactory.get_tree_type(name, color, texture)
          # Create the context object with its unique state
          tree = Tree(x, y, tree_type)
          self._trees.append(tree)

      def draw(self, canvas):
          print("\n--- Drawing Forest ---")
          for tree in self._trees:
              tree.draw(canvas)

  # --- Let's see it in action ---

  forest = Forest()
  canvas = "Main Game Canvas" # A placeholder for a drawing surface

  # Plant several trees of the same type
  forest.plant_tree(10, 20, "Oak", "Dark Green", "OakTexture.png")
  forest.plant_tree(50, 80, "Oak", "Dark Green", "OakTexture.png")
  forest.plant_tree(100, 30, "Oak", "Dark Green", "OakTexture.png")

  # Plant a different type of tree
  forest.plant_tree(200, 150, "Pine", "Light Green", "PineTexture.png")
  forest.plant_tree(220, 180, "Pine", "Light Green", "PineTexture.png")

  # Draw the entire forest
  forest.draw(canvas)


  print(f"\nTotal TreeType objects created: {len(TreeFactory._tree_types)}")
  print(f"Total Tree objects in forest: {len(forest._trees)}")