programming · level 9

Modules & Imports

Public API vs implementation, circular imports, and the Unix philosophy at module scale.

175 XP

Modules & Imports

A module is the smallest unit of "this code goes together" in your program. How you split your code into modules — and how those modules talk to each other — is the most important architecture decision you make. It's also one of the easiest to get wrong.

The mental model

A module has two faces:

  • Implementation — the code inside it. Function bodies, helper types, constants. This is private.
  • Public API — the names the module exports. This is what other modules import.

Other modules don't (and shouldn't) care about the implementation. They depend on the API. As long as you keep the API stable, you can reorganise the implementation freely.

Every language has slightly different syntax for distinguishing the two:

// TypeScript
export function findUser(id: string) { /* ... */ }   // public
function internalHelper() { /* ... */ }              // private
# Python — convention by underscore prefix
def find_user(id: str): ...    # public
def _helper(): ...             # private (convention only — Python doesn't enforce)
__all__ = ["find_user"]        # explicit public API for `from m import *`
// Go — capitalization is the convention AND enforced
func FindUser(id string) {}    // exported (capital first letter)
func internalHelper() {}       // unexported
// Rust — explicit pub keyword
pub fn find_user(id: &str) {}
fn internal_helper() {}

Same idea in every language: a small public surface, a larger private implementation.

The Unix-philosophy lens

The Unix philosophy as applied to modules:

Each module should do one thing, do it well, and communicate with other modules through small, clear interfaces.

In practice this means:

  • A module should have a theme — "users", "payments", "rendering". If you can't summarise the theme in 5 words, the module is doing too much.
  • A module's public API should be small relative to its implementation. If 90% of the file is exported, the file is just a flat namespace, not a module.
  • Dependencies should flow one way. Module A imports B; B doesn't import A. Loops mean the boundary is wrong.

This is the same principle as good function design, applied at a bigger scale.

When to split a module

Three signals it's time to split:

  1. The file is too long to scan. If you're searching within the file to find functions you wrote yourself, the module is too big. ~300-500 lines is a soft limit; the right number is "however much you can keep in your head."

  2. Two callers each use a non-overlapping subset. Module users is imported by auth (which uses findUserByEmail and verifyPassword) and by dashboard (which uses listUsers and userStats). Neither caller cares about the other half. That's two modules.

  3. You feel the urge to write if (mode === ...) at module scope. A module trying to serve two modes is two modules.

The corollary: don't split modules just because the file is "long." Split when the cohesion drops.

When NOT to split

Three signals you should NOT split (or should reverse a split):

  1. Every consumer imports both halves. If splitting module a into a-core and a-utils results in everyone importing both, it wasn't a split — it was just two files.

  2. The split creates new circular imports. Module A and module B that you just created import each other. The boundary is wrong; merge or re-extract.

  3. Each module is fewer than ~50 lines and has only 1-2 functions. That's not modules — that's a fragmented file. Cohesion is a virtue.

Circular imports

Circular dependency: module A imports B; B imports A. Why this happens:

auth.ts:    import { User } from "./users";   // need the User type
users.ts:   import { Session } from "./auth";  // need to attach Session to User

In some languages this works at runtime. In others (Python, especially) it produces ImportError or partially-loaded modules. Either way, it's a smell — the modules' boundary is in the wrong place.

The three fixes:

Fix 1 — Extract the shared piece

auth and users both want a User type. Move it to a third module:

types.ts:    export interface User { ... }
auth.ts:     import { User } from "./types";
users.ts:    import { User } from "./types";

The dependency graph is now a fan-out — both modules depend on a common parent. Tree-shaped. Healthy.

Fix 2 — Move the import inside the function

If you only need the imported value at runtime (not at module-load time), defer the import:

# users.py
def get_session_for(user):
    from auth import Session    # imported when called, not when module loads
    return Session(user)

Lazy imports break cycles at the cost of slight runtime overhead. Use sparingly.

Fix 3 — Reverse a dependency

Sometimes one module shouldn't depend on the other at all. Look at it carefully — does users REALLY need to know about Session? Maybe the dependency should only go one way.

Barrel files

A barrel is a module whose only job is re-exporting from other modules:

// users/index.ts
export { findUser, listUsers, createUser } from "./api";
export type { User, UserRole } from "./types";

Pro:

  • Consumers have one stable import path. import { findUser } from "./users"; instead of "./users/api".
  • You can rearrange internal files without breaking external imports.

Con:

  • Tree shaking is harder for some bundlers (importing a barrel may pull in everything it re-exports).
  • Easy to over-use; deep barrel chains are a debugging nightmare.

The pragmatic stance: use barrels for the top of a feature/package, not at every level of the tree.

Public API design

A module's public API is a contract. Three rules:

  1. Smaller is better. Every exported name is a future commitment. If you don't need to export it, don't.
  2. Stable names matter. Renaming an export is a breaking change for every importer. Pick names you'll keep.
  3. Hide internal types. A function exported by your module shouldn't take a "private" type as an argument — that forces consumers to deal with it.

A useful exercise: look at a module's exports list. Could a new engineer use the module by reading only that list? If not, the API is leaking implementation details.

Naming

Modules should be named after their theme, not their kind:

✓ users.ts          (theme — what it's about)
✗ helpers.ts        (kind — what it is)
✗ utils.ts
✗ misc.ts

A utils.ts becomes a junk drawer over time — anything goes there because there's no rule against it. A users.ts has a sharp boundary; code that doesn't fit goes elsewhere.

Module boundaries as a design tool

A clean module boundary is the cheapest documentation in your codebase. It tells the next reader:

  • These five functions belong together.
  • They share state / types / helpers.
  • The rest of the code talks to them only through the public API.

Modules that you can't summarise in a sentence are modules whose boundary is wrong. The fix isn't more code — it's redrawing the boundary.

A diagnostic question

When you find yourself reading a module and thinking "this could just as easily live in another file" — that's signal. Either:

  • Move it to the file it would more naturally belong to (and reduce this module).
  • Or accept that it really does belong here and update your understanding of this module's theme.

Don't leave the awkward thing where it is.

Common bugs

  • Importing across feature boundaries. Feature A imports a private helper of feature B. Now B can't refactor that helper without breaking A. Tighten exports.
  • Circular imports. Restructure (extract shared) or use lazy imports.
  • Importing a barrel for one symbol. Barrel pulls in everything; bundle size grows. Import directly when bundle size matters.
  • The 2000-line utils.ts. Symptom of no boundary — split into themed modules.
  • Two modules that always change together. They might want to be one module.

What to internalise

  • A module is a public API + a private implementation. The API is the contract.
  • Split when cohesion drops; don't split for line count alone.
  • Circular imports are a smell — usually fixed by extracting a shared third module.
  • Name modules for their theme, not their kind.
  • The Unix philosophy at module scale: do one thing, do it well, talk through small interfaces.

Tools in the wild

4 tools
  • madgefree tier

    Visualise the import graph of a JS/TS project. Spot circular imports.

    cli
  • Quick scanner specifically for finding TS/JS circular imports.

    cli
  • pydepsfree tier

    Render Python module dependency graphs. Same idea, Python flavour.

    cli
  • ESLint rule that fails the build on detected circular imports.

    library