Tldr

Modularity needs attention as software grows. Keep related behaviour together, limit what callers need to know, and watch the direction of dependencies. When a small change repeatedly spreads across unrelated modules, reconsider the boundaries before adding another workaround.

Modular Design introduces functions, modules, and classes as ways to organise code. Maintaining those boundaries is the next challenge. A tidy set of files can gradually become a system in which almost everything depends on everything else.

Consider a device-inventory application. It enrols routers, validates their addresses, stores records, and produces reports. These jobs can start out separate. Later, a report imports a database helper, the helper imports an application setting, and the application imports the report. Each shortcut looks harmless on its own. Together, they make changes harder to reason about.

Watch how far a change travels

A dependency exists when one piece of code relies on another’s data, behaviour, or contract. Imports reveal some dependencies. Shared tables, global state, file formats, and assumptions about call order can create others.

Suppose the inventory starts storing addresses in a different database column. If only the storage module knows the column name, the change can remain there. If enrolment, reports, and the user interface all construct their own queries, all three may need updating.

This is the ripple effect: a change spreads through the assumptions other components have made. Indirect dependencies matter too. A reporting module may need work because its data-access module changed, even though the report never imported the original component.

Modularity cannot eliminate every ripple. Changing a public operation from returning one address to returning several genuinely affects callers. The aim is to contain implementation changes and make contract changes deliberate.

A useful review question is: What would have to change if we replaced this implementation? If the answer includes unrelated business rules and screens, too much detail may be crossing the boundary.

Keep dependency paths free of cycles

The Acyclic Dependencies Principle (ADP) says that package dependencies should not form a loop. The same check is useful between modules: follow their dependency arrows and see whether a path returns to its starting point. An acyclic graph can branch and share dependencies; it need not be a single chain. Robert C. Martin: Design Principles and Design Patterns

How a cycle develops

In our inventory application:

  • app.py calls the inventory module.
  • inventory.py uses a seed module to create initial records during setup.
  • seed.py imports a hostname-checking helper from app.py.

In these two diagrams, an arrow means depends on, not “runs next.”

flowchart TD
    APP["app.py"] --> INV["inventory.py"]
    INV --> SEED["seed.py"]
    SEED --> APP

The seed module only wanted one helper, but it now depends on the application that starts the whole process. Testing or reusing seed creation can pull in application-level concerns. Releasing these components separately would also require care around their mutual dependencies; putting them in different repositories would not remove the loop.

Cycles make isolated testing and reuse harder, not impossible. In Python, they can also produce import errors when one module tries to access a name before the other module has finished initialising. Some circular imports work; moving an import inside a function can defer the failure point, but it does not by itself remove the architectural dependency. Python programming FAQ: imports

Move the shared rule to its own home

Hostname validation does not need to know how the application starts. Move it into inventory_rules.py, then let both callers import it there.

flowchart TD
    APP["app.py"] --> INV["inventory.py"]
    INV --> SEED["seed.py"]
    SEED --> RULES["inventory_rules.py"]
    APP --> RULES

There is no return path to app.py. The rules module can be tested without starting the application or preparing a database.

Extraction works when the shared behaviour has a clear purpose. A growing common.py containing validation, email delivery, database access, and random helpers would create another problem. Name the extracted module after the responsibility it owns.

Other cycles need a different repair. Startup code may need to assemble the components explicitly, or a caller may need an abstraction instead of a concrete implementation. If two modules continually need each other’s internals and always change together, combining them may be more honest than maintaining an artificial split.

Choose dependencies that can support their callers

The Stable Dependencies Principle (SDP) concerns the direction of dependencies: a component should depend on components that are more stable than itself. Here, stability means resistance to change arising from the dependency structure, not simply how long a file has remained untouched. Many incoming dependencies make change costly; outgoing dependencies expose a component to changes elsewhere. Robert C. Martin: Design Principles and Design Patterns

For example, several inventory workflows might rely on an address-validation contract. That contract should not import settings from an experimental dashboard. Otherwise, dashboard changes can force changes into a dependency shared by the rest of the application.

Keep the shared rule small and protect its agreed behaviour. Let the dashboard supply its own display preferences. If the rule genuinely needs a policy choice, pass that choice explicitly instead of having it reach back into the dashboard.

A stable contract is not a frozen implementation. The validator can become faster or clearer without changing what inputs it accepts or what errors callers must handle. Stability also does not prove quality: a poorly designed module can be very difficult to change precisely because so much code depends on it.

Separate the work from the implementation it uses

The Dependency Inversion Principle (DIP) keeps high-level rules from depending directly on low-level details. The rules describe the capability they need through an abstraction; implementations satisfy that abstraction. An interface is useful only if it hides the relevant detail. An interface full of SQL operations still makes its caller understand the database. Dependency Inversion in the Wild

For device enrolment, the useful capability is “add this device to inventory.” The workflow should not need to know a table name, an HTTP endpoint, or a database-driver class.

A small contract and a replaceable store

The following example uses three files in one directory and Python 3.9 or later.

In enrolment.py, the workflow owns the contract it needs:

from ipaddress import IPv4Address
from typing import Protocol
 
 
class InventoryStore(Protocol):
    def add(self, hostname: str, address: str) -> None:
        """Store a new device; raise ValueError for a duplicate hostname."""
        ...
 
 
def enrol_device(hostname: str, address: str, store: InventoryStore) -> None:
    name = hostname.strip()
    if not name:
        raise ValueError("Hostname must not be empty")
 
    valid_address = str(IPv4Address(address))
    store.add(name, valid_address)

The address is validated before storage is attempted. This example checks only that a hostname is non-empty, not every rule a production inventory might require.

In memory_store.py, a simple implementation keeps records in memory:

class MemoryInventory:
    def __init__(self) -> None:
        self.devices: dict[str, str] = {}
 
    def add(self, hostname: str, address: str) -> None:
        if hostname in self.devices:
            raise ValueError(f"Device already exists: {hostname}")
        self.devices[hostname] = address

In app.py, startup code chooses the implementation and supplies it:

from enrolment import enrol_device
from memory_store import MemoryInventory
 
store = MemoryInventory()
enrol_device("edge-01", "192.0.2.10", store)
 
assert store.devices == {"edge-01": "192.0.2.10"}

Python’s Protocol describes the required methods for a static type checker. MemoryInventory can satisfy it without inheriting from it. The annotation does not automatically enforce the contract at runtime. Python documentation: Protocol

The relationship is:

flowchart TD
    WORKFLOW["Enrolment workflow"] -->|uses| CONTRACT["InventoryStore contract"]
    CONTRACT -.->|implemented by| MEMORY["Memory store"]
    CONTRACT -.->|implemented by| DATABASE["DB adapter"]

The memory store is MemoryInventory; the database adapter is a possible future implementation, not part of the code above. The solid arrow means “uses”; the dotted arrows mean “is implemented by.” They do not mean the contract imports either adapter. These are design relationships, not a runtime call sequence or a literal import graph.

Keeping the contract beside its consumer avoids making the workflow import a database package just to describe its needs. A separate contracts module can also work when several consumers genuinely share the same abstraction.

Replacing the in-memory store would change the startup wiring and require tests for the new adapter. The enrolment rule could stay the same. Both implementations must honour the agreed behaviour, including duplicate handling; matching method names is not enough.

Inversion and injection answer different questions

Dependency injection (DI) means supplying a collaborator from outside instead of constructing or looking it up inside the component. In this example, the store argument is injected. Injection can use a function argument or a constructor; a framework is not required. Martin Fowler: Dependency Injection

IdeaQuestion it answersIn the example
Dependency inversionWhat should the workflow depend on?The InventoryStore capability, not a particular database
Dependency injectionWho provides the implementation?app.py creates the store and passes it in

Injecting a raw database connection into a function that builds SQL is still injection, but the function remains coupled to SQL and the schema. Injection alone does not establish dependency inversion or guarantee an acyclic design. Dependency Inversion in the Wild

For a small calculation with no external dependency, neither an interface nor a container may be needed. Introduce the boundary where it gives callers a useful separation.

Give a module a coherent reason to change

The Single Responsibility Principle (SRP) is about reasons for change. Behaviour that changes for the same business reason belongs together; behaviour driven by different concerns may need separate homes. It does not mean every class must have one method or every function must contain one operation. Robert C. Martin: The Single Responsibility Principle

Imagine one inventory class that checks enrolment rules, constructs database queries, and formats an operations email. A new onboarding policy, a schema migration, and an email redesign would all require editing that class. Those are three different pressures on the same code.

Separating enrolment rules, storage, and notification delivery gives each change a more suitable home. There can still be a workflow that coordinates all three.

For example, “enrol a device and notify operations after it has been stored” is a coherent application operation. Its coordinator may call a validator, a store, and a notifier. It owns their sequence and failure policy, while delegating the mechanics. If notification fails after storage succeeds, the workflow must decide what happens next; splitting the code into modules does not make those actions atomic.

Reduce what crosses the boundary

Coupling is the degree of dependence between components. Loose coupling means a caller relies on a limited, deliberate contract, rather than on many details of another component.

Three checks help make that practical:

CheckLook for
SizeHow much data, behaviour, or state must cross the boundary?
VisibilityAre dependencies explicit, or hidden in globals, private fields, and side effects?
FlexibilityCan another implementation meet the contract without rewriting callers?

Suppose a function needs to check an address. Passing an entire network-interface object makes that function depend on the object’s shape. Passing the address itself may be enough:

from ipaddress import IPv4Address
 
 
def validate_address(address: str) -> str:
    return str(IPv4Address(address))
 
 
assert validate_address("192.0.2.20") == "192.0.2.20"

The caller can pass interface.address, a form field, or a value read from a file. The validator does not need to know where it came from.

That does not make primitive values universally better than objects. A reservation operation may legitimately need a cohesive request object with several related fields. Nor does one argument guarantee low coupling: a single application argument that exposes the database, configuration, and user session can hide enormous dependence.

Look at what the callee actually needs, not just the parameter count. Prefer public operations over reaching into another object’s private state. Make mutation visible too: a function called export_events should not silently empty the caller’s event list unless that is an explicit part of its contract.

In Python, assigning events = [] inside a function only rebinds that local name. Calling events.clear() mutates the existing list, so other holders see the change. Neither behaviour should be left for callers to discover accidentally.

Cohesion describes how strongly the contents of a component belong together. Coupling looks across a boundary; cohesion looks inside it.

A module containing address parsing, address validation, and address formatting has a recognisable focus. A module containing address parsing, invoice calculation, and email-template editing does not become cohesive because all three functions are short.

Some less obvious cases need judgment:

  • A workflow with several steps: backing up a device, recording the result, and reporting success can belong in one coordinator. The backup transport and report formatting can still live elsewhere.
  • A function with an action flag: handle_device(action, payload) may be a legitimate dispatcher if it routes to focused handlers. It becomes harder to maintain when unrelated implementations accumulate inside its branches.
  • An export that also clears state: writing events and deleting them may be a valid “drain the queue” operation. Name and document that behaviour, and define what happens if writing fails. An ordinary export should not imply deletion.

Low coupling and high cohesion support each other. Keeping related work together reduces the need for distant modules to share internal details. Splitting every small operation into its own file can have the opposite effect, forcing readers to chase one idea across many locations.

Check the boundaries while making real changes

These principles are most useful during implementation, refactoring, and code review. Start with the dependency paths affected by the current change rather than trying to redesign the whole system at once.

Before merging, ask:

  • Did this change introduce a dependency path that loops back to its starting point?
  • Does a shared rule now depend on an application-specific detail?
  • Can the core behaviour be tested without starting unrelated infrastructure?
  • Are inputs, outputs, errors, and mutations part of a clear contract?
  • Do callers need the whole object, or only a smaller piece of information?
  • Do the parts of this module still belong together?

Unit tests can check the workflow against a small in-memory collaborator. Adapter and integration tests must separately check real storage and other external services. An easy-to-fake interface helps with testing, but passing fake-based tests does not prove the real integration works.

A good boundary earns its place when a real change becomes easier to contain, explain, and verify.