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.