keyboard-shortcut
d

Small Modules Are the Lego Bricks of Code

4 min read

Good software is assembled from understandable pieces. A module does one cohesive job, other modules do theirs, and a larger feature composes them into something useful. The result resembles Lego: simple pieces connected through deliberate interfaces.

The useful rule is not ā€œone public class or function per fileā€. A better rule is ā€œone primary public concept per moduleā€. That concept should represent one cohesive responsibility. A module may contain a public class, several related functions and private helpers when they all belong to the same concept. The boundary should make the code easier to name, test and change.

Why small modules help

Small, focused modules give us:

  • A clear place to find a behaviour, such as parse_date.py or calculate_total.py.
  • Narrower contracts and fewer unrelated dependencies.
  • Tests that can exercise one responsibility without constructing the whole application.
  • Smaller version-control changes that are easier to review.
  • Components that can be reused in a web request, command-line tool, job or test.

Lots of files are not automatically a problem. A well-named file tree is a map of the system; one huge file often hides more complexity than a directory full of coherent modules.

How SOLID supports the approach

SOLID gives us useful questions to ask about each module:

  • Single Responsibility: Does it have one main reason to change?
  • Open/Closed: Can a new implementation be added without editing a large conditional?
  • Liskov Substitution: Can implementations genuinely replace one another behind the same contract?
  • Interface Segregation: Are consumers forced to depend only on methods they need?
  • Dependency Inversion: Does high-level policy depend on small abstractions rather than concrete infrastructure?

For example, checkout logic can depend on small collaborators without knowing whether they call a bank API or a test double:

class Checkout:
    def __init__(self, payments, receipt_writer):
        self.payments = payments
        self.receipt_writer = receipt_writer

    def complete(self, order):
        self.payments.charge(order.total)
        return self.receipt_writer.write(order)

The principles support focused boundaries, but they do not require every line or every class to have its own file.

The trade-offs

Small modules introduce costs. The way to manage them is to keep boundaries meaningful:

  • Indirection: Several files can obscure a simple flow. Keep composition visible, use behaviour-focused names and avoid wrappers that add no value.
  • Dependency wiring: Many collaborators can make constructors verbose. Use one composition root where dependencies are assembled.
  • Interface overhead: Every public boundary needs a contract and tests. See the article on ā€œslim interfaces, fat implementationsā€. Keep the public surface small in number, complexity and configuration; let the implementation carry the detail. Expose only concepts with a meaningful identity or independent reason to change.
  • Debugging: A stack trace may cross many modules. Preserve error context and add structured logging or tracing around important workflows.
  • Fragmentation: Related code can become scattered. Keep code together when it always changes together; split it when ownership or independent change is clearer.
  • Premature abstraction: Two similar functions do not necessarily need an interface. Wait for real variation, a second use case or a genuine testing boundary.
  • Dependency tangles: More modules can create circular imports. Establish dependency direction—for example, keep domain code independent of infrastructure—and check it in CI.
  • Mock-heavy tests: Replacing every collaborator with a fake can test implementation details. Combine focused unit tests with integration tests that exercise real module boundaries.
  • Navigation fatigue: A forest of similarly named files is hard to scan. Group modules by feature or domain and use consistent names.

The target is not the maximum number of modules. It is a codebase whose boundaries explain the design.

Small modules are easier for AI to scan

Clear names and narrow responsibilities also help AI assistants build a map of an unfamiliar project:

  • fd --type f --extension py src quickly inventories Python files.
  • rg --glob '*.py' '^class |^def |^async def |^from |^import ' src finds definitions and dependencies.
  • Names such as parse_date.py, calculate_total.py and send_receipt.py provide an initial architectural outline before every file is read.
  • The assistant can then inspect only the modules relevant to a question, summarise their responsibilities and suggest where a change belongs.

This helps human readers too. The file tree is lightweight documentation, but it is only a starting point: names can be misleading and responsibilities can still leak across boundaries.

The caveat: Lego can leave performance on the table

Building everything from small, generic blocks can lead to code that uses no meaningful optimisations:

  • Extra layers may add allocations or indirection.
  • Data may be parsed or traversed more than once.
  • A hot path may need a specialised implementation that combines steps.

Modularity is a default for managing change, not a ban on optimisation. Measure first, find the genuine bottleneck and make the smallest deliberate exception necessary. Keep the simple bricks where they help the design, and optimise the few places where the running program proves that composition costs too much.