Loosely coupled code is one of the most important ideas in software design. Understanding it early will change the way you think about writing Python programs.
When you start writing Python classes, it is easy to let them depend on each other in ways that feel convenient but later become a problem. The term for how much two pieces of code depend on each other is coupling. This tutorial explains what coupling is, what loosely coupled means, and how to move your code toward loose coupling using plain Python techniques that beginners can apply right away.
What Does Coupling Mean?
Coupling describes the degree to which one piece of code depends on another. When Class A calls methods on Class B, those two classes are coupled. The question is how tightly they are bound to each other.
Think of two USB devices. A device that requires a very specific proprietary connector is tightly coupled to one manufacturer. A device that uses a standard USB-C connector is loosely coupled to the hardware around it — you can plug it into almost anything. Code coupling works the same way.
Coupling is not automatically bad. Every program has coupling — parts must work together to do anything useful. The goal is to keep coupling intentional and minimal, not to eliminate it entirely.
Here is a simple example of two classes that are coupled. OrderProcessor needs a database to save orders, so it creates one directly:
# Tightly coupled example — OrderProcessor creates its own database
class MySQLDatabase:
def save(self, data):
print(f"Saving to MySQL: {data}")
class OrderProcessor:
def __init__(self):
self.db = MySQLDatabase() # <-- hard dependency created here
def process(self, order):
self.db.save(order)
processor = OrderProcessor()
processor.process({"item": "book", "qty": 1})
Notice that OrderProcessor decides inside itself which database to use. If you ever want to switch to a different database, or write a test without hitting a real database, you cannot do it without changing OrderProcessor itself. That is tight coupling.
Build the tightly coupled line: instantiate MySQLDatabase inside OrderProcessor's __init__ and assign it to self.db
self.db = MySQLDatabase(). self.db names the attribute, = assigns it, MySQLDatabase is the class, and ( ) calls the constructor. Writing it this way creates a hard internal dependency — the class controls which database type it uses.
Tightly Coupled vs Loosely Coupled
The difference between tight and loose coupling comes down to one question: who decides which dependency gets used?
In tightly coupled code, the class decides internally. In loosely coupled code, the class receives its dependency from outside — whoever creates the class passes in what it needs. This technique is called dependency injection.
Here is the same example rewritten as loosely coupled code:
# Loosely coupled example — dependency is injected from outside
# Python 3.8+ best practice: use Protocol to name the required interface
from typing import Protocol
class Database(Protocol):
def save(self, data: dict) -> None:
...
class MySQLDatabase:
def save(self, data: dict) -> None:
print(f"Saving to MySQL: {data}")
class PostgresDatabase:
def save(self, data: dict) -> None:
print(f"Saving to Postgres: {data}")
class OrderProcessor:
def __init__(self, database: Database) -> None: # accepts any Database
self.db = database
def process(self, order: dict) -> None:
self.db.save(order)
# Now the caller decides which database to use
mysql_db = MySQLDatabase()
processor = OrderProcessor(mysql_db)
processor.process({"item": "book", "qty": 1})
# Swap in a different database — no changes to OrderProcessor needed
postgres_db = PostgresDatabase()
processor2 = OrderProcessor(postgres_db)
processor2.process({"item": "notebook", "qty": 2})
OrderProcessor no longer cares which specific database it receives. The Database Protocol defines what the dependency must be able to do — one save method — and any object that satisfies that shape works. This is Python's structural subtyping: no inheritance required, no registration needed. If the object has the right methods with matching signatures, Python's type checkers and runtime both accept it.
Using typing.Protocol is optional but strongly recommended. It makes the required interface explicit in the code itself — both for human readers and for static analysis tools like mypy or Pyright — without coupling your concrete classes to a shared base class.
- Coupling type
- Tight coupling
- Effect
- The class is locked to one specific implementation. Changing the dependency requires changing the class itself.
- Coupling type
- Loose coupling via dependency injection
- Effect
- Any compatible object can be passed in. The class and its dependency can evolve independently.
- Coupling type
- Tight coupling
- Effect
- Testing requires mocking or monkeypatching internals, which is fragile. Tests can break when the internal implementation changes.
- Coupling type
- Loose coupling
- Effect
- You can pass in a simple stub or fake object during tests. No mocking library is required. Tests are fast and isolated.
This code is meant to be loosely coupled. One line breaks that intention. Find it.
self.formatter = PDFFormatter() with self.formatter = formatter. The constructor already receives formatter as a parameter — the fix is to use it. Creating PDFFormatter() directly inside __init__ ignores the injected dependency and re-introduces tight coupling. The caller passes in the formatter for a reason: so the class stays flexible.
Why Loose Coupling Matters
When you write tightly coupled code, a change in one class ripples through every class that depends on it. As your program grows, these ripples turn into waves. Loose coupling limits how far a change can spread.
There are concrete benefits and several deeper implementation approaches that go beyond the basic "pass it in via __init__" advice you find in most tutorials.
Easier Testing
When a class creates its own dependencies, testing it means running those dependencies too. If those dependencies talk to a database or an API, your tests become slow and brittle. With loose coupling, you pass in a simple stub that only needs the right method signatures:
from typing import Protocol
class Database(Protocol):
def save(self, data: dict) -> None:
...
class OrderProcessor:
def __init__(self, database: Database) -> None:
self.db = database
def process(self, order: dict) -> None:
self.db.save(order)
# A simple in-memory stub for testing — no real database required
class FakeDatabase:
def __init__(self) -> None:
self.saved: list[dict] = []
def save(self, data: dict) -> None:
self.saved.append(data)
# Test OrderProcessor with the stub
fake_db = FakeDatabase()
processor = OrderProcessor(fake_db)
processor.process({"item": "pen", "qty": 5})
assert fake_db.saved == [{"item": "pen", "qty": 5}]
print("Test passed.")
Easier Reuse
A loosely coupled class works with any compatible object, not just the one it was originally written for. OrderProcessor can work with a file-based database, an in-memory store, or a cloud API — without a single change to its code.
Easier Change
When requirements change and you need to swap out a component, tightly coupled code forces you to trace through every class that depends on the old component. Loosely coupled code lets you swap the dependency at the call site and move on.
Loose coupling is not the same as no structure. If you inject every possible dependency as a parameter without any organisation, your constructors become unwieldy. Apply dependency injection to the dependencies that are most likely to change or need to be swapped for testing.
Deeper Approach: Explicit Contracts with abc.ABC
When you need to guarantee that all implementations provide certain methods — and you want Python to raise an error at class definition time if they do not — use abc.ABC and @abstractmethod. Unlike Protocol, which checks structure at type-check time only, abc.ABC raises TypeError at runtime if a subclass forgets to implement a required method:
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def save(self, data: dict) -> None: ...
@abstractmethod
def fetch(self, query: str) -> list[dict]: ...
class MySQLDatabase(Database):
def save(self, data: dict) -> None:
print(f"MySQL save: {data}")
def fetch(self, query: str) -> list[dict]:
return []
# This would raise TypeError at definition time — incomplete implementation
# class BrokenDB(Database):
# def save(self, data: dict) -> None: ...
Use abc.ABC when you control all implementations and want hard enforcement. Use Protocol when you want to describe an interface without requiring inheritance — useful for third-party objects you did not write.
Deeper Approach: Default Injection for Optional Dependencies
A common real-world pattern is an optional dependency — one that has a sensible default but can be swapped. Use a None default and instantiate inside __init__ only as a fallback. This keeps the class self-contained for the common case while remaining open for injection in tests and custom configurations:
from __future__ import annotations
from typing import Protocol
class Logger(Protocol):
def log(self, message: str) -> None: ...
class PrintLogger:
def log(self, message: str) -> None:
print(f"[LOG] {message}")
class OrderProcessor:
def __init__(self, database: Database, logger: Logger | None = None) -> None:
self.db = database
self.logger = logger or PrintLogger() # sensible default
def process(self, order: dict) -> None:
self.logger.log(f"Processing: {order}")
self.db.save(order)
The caller does not have to supply a logger. But if a test needs to verify what was logged, it can inject a FakeLogger with a messages list. The default is provided at the construction site, not buried inside a method body.
Deeper Approach: Loose Coupling with dataclasses
Python's dataclasses module (Python 3.7+) pairs naturally with dependency injection. Using @dataclass generates the __init__ automatically from field declarations, and injected dependencies are declared as fields with type annotations. This removes boilerplate and makes the dependency list immediately visible:
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
class Database(Protocol):
def save(self, data: dict) -> None: ...
class Sender(Protocol):
def send(self, recipient: str, message: str) -> None: ...
@dataclass
class OrderService:
database: Database # injected
notifier: Sender # injected
tax_rate: float = 0.08 # configurable, not injected
def place_order(self, order: dict, customer_email: str) -> None:
total = order["price"] * (1 + self.tax_rate)
self.database.save({**order, "total": total})
self.notifier.send(customer_email, f"Order confirmed. Total: ${total:.2f}")
The @dataclass decorator generates the full __init__ from those field declarations. Both dependencies are visible at the top of the class definition, making the coupling explicit and auditable at a glance.
Deeper Approach: Factory Functions for Wiring
When a loosely coupled class has several dependencies, the call site can become verbose. A factory function collects that wiring into one place, keeping all dependency creation and injection in a single readable function rather than scattered across the codebase:
from dataclasses import dataclass
from typing import Protocol
class Database(Protocol):
def save(self, data: dict) -> None: ...
class Sender(Protocol):
def send(self, recipient: str, message: str) -> None: ...
@dataclass
class OrderService:
database: Database
notifier: Sender
class MySQLDatabase:
def save(self, data: dict) -> None:
print(f"MySQL: {data}")
class EmailSender:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
# Factory function — all wiring is in one place
def build_order_service() -> OrderService:
return OrderService(
database=MySQLDatabase(),
notifier=EmailSender(),
)
# Call site stays clean
service = build_order_service()
service.place_order({"item": "book", "price": 12.99}, "alice@example.com")
Factory functions are a lightweight alternative to a full dependency injection container. For most beginner to intermediate Python projects they are the right level of abstraction — no framework needed.
The NotificationService example below brings together the Protocol interface, typed parameters, and the swap at the call site:
from typing import Protocol
class Sender(Protocol):
def send(self, recipient: str, message: str) -> None:
...
class EmailSender:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class SMSSender:
def send(self, recipient: str, message: str) -> None:
print(f"SMS to {recipient}: {message}")
class NotificationService:
def __init__(self, sender: Sender) -> None:
self.sender = sender
def notify(self, user: str, msg: str) -> None:
self.sender.send(user, msg)
# Production: use email
email_service = NotificationService(EmailSender())
email_service.notify("alice@example.com", "Your order shipped.")
# Switch to SMS — NotificationService is unchanged
sms_service = NotificationService(SMSSender())
sms_service.notify("+15551234567", "Your order shipped.")
Robert C. Martin's Dependency Inversion Principle, one of the SOLID design principles, states that high-level modules should not depend on low-level modules directly — both should depend on abstractions. In practice this means writing code that works with any compatible object rather than with one specific class.
How to Write Loosely Coupled Code in Python
These four steps describe the practical process of moving code from tightly coupled to loosely coupled. Apply them to any class where you notice a hard internal dependency.
-
Identify the dependency
Look inside the class for any line that calls a constructor directly, such as
self.db = MySQLDatabase()orself.logger = FileLogger(). These are your tightly coupled dependencies. -
Move the dependency to the constructor parameter
Add a parameter to
__init__— for example, changedef __init__(self):todef __init__(self, database):. Then writeself.db = databaseto store whatever object is passed in. -
Pass the dependency from outside
At the call site where you create an instance of the class, create the dependency first and pass it in:
db = MySQLDatabase()thenprocessor = OrderProcessor(db). The class no longer controls which object it receives. -
Verify with a swap test
Write a minimal stub — a small class with just the required method — and pass it into the class in place of the real dependency. If the class works correctly with the stub, the coupling is genuinely loose.
Python Learning Summary Points
- Coupling describes how much one piece of code depends on another. Tight coupling means a class creates or controls its own dependencies. Loose coupling means a class receives its dependencies from outside.
- Dependency injection — passing a dependency through
__init__instead of creating it internally — is the most direct way to achieve loose coupling in Python. - typing.Protocol (Python 3.8+) is the modern Pythonic way to name a required interface without forcing inheritance. Any object with the matching method signatures satisfies the Protocol. This is structural subtyping.
- abc.ABC with @abstractmethod is the alternative when you want runtime enforcement: Python raises
TypeErrorat class definition time if a subclass is missing a required method, before any code actually runs. - Default injection with a
Nonedefault keeps a class self-contained for the common case while remaining open for override in tests and custom configurations. - dataclasses pair naturally with dependency injection — declaring dependencies as typed fields makes the class's coupling explicit at a glance and eliminates
__init__boilerplate. - Factory functions collect all dependency wiring into one place, keeping the call site clean without requiring a full DI container framework.
- Loosely coupled code is easier to test (pass in a stub), easier to reuse (works with any compatible object), and easier to change (swap the dependency at the call site).
Loose coupling is one of those ideas that becomes obvious once you have felt the pain of changing tightly coupled code in a growing project. Starting with these principles now will save you significant refactoring work later.
Frequently Asked Questions
Loosely coupled means that two pieces of code depend on each other as little as possible. Each part can change or be replaced without forcing the other part to change too. In Python this is most often achieved by passing dependencies into a class through its constructor rather than having the class create them internally.
Tightly coupled code has classes or functions that directly create and depend on specific other classes. Changing one forces changes in the others. Loosely coupled code passes dependencies in from outside, so either side can change independently. The key difference is who creates the dependency: the class itself (tight) or the caller (loose).
typing.Protocol (Python 3.8+) lets you define the shape a dependency must have — its method names and signatures — without requiring classes to inherit from anything. Any class that has matching methods satisfies the Protocol. This is called structural subtyping. It makes your required interface explicit in the type annotation while keeping concrete classes completely independent of each other.
Use abc.ABC with @abstractmethod when you want Python to raise a TypeError at class definition time if a subclass omits a required method. This gives you runtime enforcement. Use Protocol when you want to describe an interface for any object — including third-party objects you cannot subclass. Protocol checks are enforced by static analysis tools like mypy, not by the Python runtime itself.
Dependency injection means passing a dependency into a class through its constructor or a method, rather than letting the class create the dependency itself. In Python the most common form is constructor injection: def __init__(self, database: Database) -> None: instead of self.db = MySQLDatabase() inside the method body.
The @dataclass decorator generates __init__ from field declarations automatically. When you declare dependencies as typed fields at the top of the class, the full dependency list is visible at a glance without reading through a manual __init__ body. The generated constructor accepts those fields as parameters, so all dependencies are still injected from outside.
A factory function is a plain function that creates and wires together a loosely coupled class and all its dependencies in one place. It prevents the wiring logic from being scattered across the codebase. For most Python projects a factory function is the right level of abstraction — simpler than a DI container framework but more organised than repeating wiring code at every call site.
Signs of tight coupling include: a class instantiates other classes inside its own methods, you cannot test the class without also running its dependencies, changing one class forces you to update many others, and importing a class pulls in a chain of other modules you did not expect.
Yes. The simplest step is to pass objects into a class through __init__ instead of creating them inside methods. This one change is the foundation of loose coupling and is straightforward to apply even in early Python code. Adding typing.Protocol and @dataclass are natural next steps as you grow more comfortable.
They work together. The single responsibility principle says each class should do one thing. When classes have narrow responsibilities they naturally depend on fewer other classes, which reduces coupling. Applying one principle tends to push you toward the other.