3  Three Programming Paradigms

Figure 3.1: Programming Types Example

3.1 1 – Programming to an implementation

┌──────────────┐          (no abstraction layer)
│   Client     ├─────────────┐
└──────────────┘             │   “knows concrete Dog”
                              ▼
                        ┌──────────┐
                        │   Dog    │
                        ├──────────┤
                        │ +bark()  │
                        └──────────┘
# --- rigid, concrete design ---------------------------------
class Dog:
    def bark(self) -> None:
        print("Woof!")

# Client
d = Dog()       # must be a Dog here
d.bark()

3.2 2 – Programming to an interface / super-type

                    «interface»
                 ┌──────────────┐
                 │   Animal     │
                 ├──────────────┤
                 │ +makeSound() │
                 └──────────────┘
                     ▲       ▲
                     │       │
            ┌────────┘       └────────┐
        ┌──────────┐             ┌──────────┐
        │   Dog    │             │   Cat    │
        ├──────────┤             ├──────────┤
        │+makeSound│             │+makeSound│
        └──────────┘             └──────────┘
                 ▲
                 │  (depends on interface only)
           ┌──────────────┐
           │    Client    │
           └──────────────┘
from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def make_sound(self) -> None: ...

class Dog(Animal):
    def make_sound(self) -> None:
        print("Woof!")

class Cat(Animal):
    def make_sound(self) -> None:
        print("Meow!")

# Client sees only the Animal API
animal: Animal = Dog()
animal.make_sound()

3.3 3 – Binding the concrete type at runtime (Factory / DI)

                    «interface»
                 ┌──────────────┐
                 │   Animal     │
                 ├──────────────┤
                 │ +makeSound() │
                 └──────────────┘
                     ▲       ▲
                     │       │
            ┌────────┘       └────────┐
        ┌──────────┐             ┌──────────┐
        │   Dog    │             │   Cat    │
        ├──────────┤             ├──────────┤
        │+makeSound│             │+makeSound│
        └──────────┘             └──────────┘

        (creates & returns Animal)
        ┌─────────────────────────┐
        │    AnimalFactory        │
        ├─────────────────────────┤
        │ +create() : Animal      │
        └─────────────────────────┘
                       │
                       ▼
                 ┌──────────────┐
                 │    Client    │
                 └──────────────┘
import random
from abc import ABC, abstractmethod
from typing import List

# interface
class Animal(ABC):
    @abstractmethod
    def make_sound(self) -> None: ...

# concrete types
class Dog(Animal):
    def make_sound(self) -> None:
        print("Woof!")

class Cat(Animal):
    def make_sound(self) -> None:
        print("Meow!")

# factory (could also be config, DI container, etc.)
def animal_factory() -> Animal:
    return random.choice([Dog(), Cat()])

# Client code—zero knowledge of concrete classes
pet = animal_factory()
pet.make_sound()

3.3.1 At a glance

Style Client depends on Who decides the concrete class? Flexibility
Implementation Dog directly The client in source code Rigid
Interface / super-type Animal The client, but can swap per call-site Moderately flexible
Runtime binding Animal A factory / DI / config outside the client Highly flexible

Choose the lightest approach that meets your project’s change-tolerance needs.

3.4 Factory vs DI vs Config

Below is a quick orientation first, then deeper dives with side-by-side code for three common ways to decouple creation from use of an object when you “bind the concrete type at runtime.”

Aspect Factory Dependency Injection (DI) Config-driven
Where does the creation logic live? In a factory function/class that returns the object In the composition root (or a DI container) that builds the object and injects it into clients Outside the codebase— a file/env/CLI flag decides what to build; code interprets that config
What does the client know? Only the factory’s API Only the interface (Animal) Only the interface and a config accessor
How do you swap implementations? Change the factory’s internals Wire a different object when composing or configure the container Edit the config file / env var (no code change)
Typical patterns/frameworks Factory Method, Abstract Factory Manual DI, dependency_injector, injector, FastAPI/Flask providers .ini, .yaml, .toml, environment variables, feature-flags

3.4.1 Shared setup: the interface and two concrete classes

from abc import ABC, abstractmethod

class Animal(ABC):
    @abstractmethod
    def make_sound(self) -> None: ...

class Dog(Animal):
    def make_sound(self) -> None:
        print("Woof!")

class Cat(Animal):
    def make_sound(self) -> None:
        print("Meow!")

3.4.2 1 Factory pattern

A factory owns the decision and the construction.

import random

def animal_factory(kind: str | None = None) -> Animal:
    """Return an Animal, choice decided here."""
    if kind == "dog":
        return Dog()
    if kind == "cat":
        return Cat()
    # default = random
    return random.choice([Dog(), Cat()])

# ─── client ───────────────────────────────────────────────
pet = animal_factory("dog")      # caller just says “give me a dog”
pet.make_sound()

Change point: only the animal_factory body.


3.4.3 2 Dependency-Injection (manual or with a container)

Creation happens outside the part of the code that uses the object. The client receives an Animal via parameter or constructor.

# business logic — agnostic of concrete type
def play_with_pet(pet: Animal) -> None:
    pet.make_sound()
    # …more work with pet…

# ─── composition root (main.py, framework bootstrap, etc.) ─
def main() -> None:
    # choose concrete class *here*
    my_pet: Animal = Dog()              # or Cat(), or TestDummyAnimal()
    play_with_pet(my_pet)               # injected dependency

if __name__ == "__main__":
    main()

3.4.3.1 Using a tiny DI container (optional)

from dependency_injector import containers, providers  # pip install dependency-injector

class Container(containers.DeclarativeContainer):
    animal = providers.Factory(Dog)   # switch to Cat by editing one line

container = Container()
play_with_pet(container.animal())

Change point: a single provider registration line—tests can override it.


3.4.4 3 Config-driven instantiation

The choice is moved to data. Code merely reads the config and instantiates accordingly (often using a factory under the hood).

import json
from pathlib import Path

# config.json  →  {"animal": "cat"}
config = json.loads(Path("config.json").read_text())
kind = config.get("animal", "dog")     # default if key missing

# Usually you still reuse a factory or a mapping:
ANIMAL_MAP: dict[str, type[Animal]] = {"dog": Dog, "cat": Cat}

pet_class = ANIMAL_MAP[kind]
pet = pet_class()          # build the concrete object dictated by config

play_with_pet(pet)

Now non-developers (DevOps, operators, even a feature-flag service) can switch the implementation without touching code or redeploying.


3.4.5 Putting it all together

  • Factory centralises construction logic—good when you own the factory and callers shouldn’t care about the creation details.
  • Dependency Injection pushes creation entirely outside the consumer; the object arrives “pre-built.” Ideal for testability and pluggable architectures—frameworks often supply DI containers to automate the wiring.
  • Config-driven externalises the decision to data; your code becomes generic, and runtime behaviour changes by editing config, environment variables, or a feature-flag service. Often, a DI container or factory uses that config under the hood.

Use whichever mechanism minimises coupling and fits the complexity budget of your project.

3.5 DI container

3.5.1 What exactly is a “DI container”?

Dependency-Injection (DI) container A software component (library or framework) that automates the two jobs at the heart of dependency injection:

  1. Construction – It creates all the concrete objects your application needs.
  2. Wiring – It resolves every object’s dependencies and supplies them at the right place (constructor, method, attribute, etc.).

Because the container, not your business code, “owns” object creation and composition, we say it inverts control of that concern—hence the alternative name IoC container (Inversion-of-Control container).


3.5.2 Core responsibilities

Responsibility What it does Why it helps
Registration You register mappings: “When someone asks for Animal, build a Dog (or whatever).” Centralises knowledge of concrete classes—no scattering Dog() calls.
Resolution / Object-graph building When code needs an Animal, the container recursively instantiates Animal and everything it depends on. You write plain constructors; the container builds the full graph.
Lifetime / scope management Decides whether each object is Singleton, Transient, Request-scoped, etc. Prevents global singletons and accidental resource leaks.
Optionally: interception, configuration, auto-wiring, health checks… Advanced containers add AOP-style interceptors, config binding, reflection-based auto-wiring, etc. Cuts boilerplate and enables cross-cutting concerns (logging, metrics, caching).

3.5.3 Mini Python example with dependency-injector

# pip install dependency-injector
from dependency_injector import containers, providers
from abc import ABC, abstractmethod


# ── Interfaces & implementations ────────────────────────────
class Animal(ABC):
    @abstractmethod
    def make_sound(self) -> None: ...


class Dog(Animal):
    def __init__(self, name: str) -> None:
        self.name = name

    def make_sound(self) -> None:
        print(f"{self.name}: Woof!")


# ── Business logic (no knowledge of concrete classes) ───────
def play_with_pet(pet: Animal) -> None:
    pet.make_sound()


# ── DI container (composition root) ─────────────────────────
class Container(containers.DeclarativeContainer):
    config = providers.Configuration()          # binds env / YAML / etc.
    animal = providers.Factory(                 # every call returns *new* Dog
        Dog,
        name=config.pet_name.from_value("Buddy")
    )


# ── Application bootstrap ───────────────────────────────────
if __name__ == "__main__":
    container = Container()
    # could load YAML or env vars here → container.config.from_yaml("cfg.yml")

    pet = container.animal()       # container resolves & builds Dog("Buddy")
    play_with_pet(pet)

What happened?

  1. Registrationanimal = providers.Factory(Dog, …) tells the container how to make an Animal.
  2. Resolutioncontainer.animal() asks the container for an Animal; it constructs a Dog, providing the name parameter from config.
  3. Injectionplay_with_pet receives the ready-to-use Dog without importing or instantiating it.

Swap Dog for Cat, change lifetimes, or override providers in tests without touching play_with_pet.


3.5.4 Benefits & trade-offs at a glance

👍 Pros 👎 Cons
Loosened coupling: business code depends only on interfaces Extra abstraction—may feel heavy for small scripts
Centralised wiring: one place to see/alter composition Reflection-based auto-wiring can hide dependencies (magic)
Testability: easily inject mocks/stubs/fakes Learning curve; container misuse can hurt performance
Lifecycle control: singleton vs per-request vs transient Another runtime dependency to manage

In short, a DI container packages the “who builds what, and when” logic into a reusable, configurable service—letting your domain code focus purely on what it needs to do.