Low-level designDeep dive9 min

Packages and dependency rules

Group classes into packages with a clear job, make dependencies between packages run one way with no cycles, and hide what other packages shouldn't use. Then a package can be built, tested and reused without dragging the rest of the program along.

Watch it evolve1 / 10
The problem

Three packages

members holds Member, Plan and Directory; billing prices plans and runs invoices; doors opens the front door.

80
Start
Stage 1
Final

1 · The situation

A coworking app in three packages

A coworking space's app is split into three packages, one per job:

  • members: Member, their Plan (a hot desk or a fixed desk) and the Directory of everyone;
  • billing: Pricing, which knows what each plan costs, and InvoiceRun, which bills every member each month;
  • doors: BadgeReader, which opens the front door for members.

Each language has its own unit for this. Java has packages, C++ has folders and namespaces, JavaScript has folders of modules, and Python has packages of modules. The code here is split into files the same way in all four.

Over time a convenience crept in: Member got a bill() method that asks billing for its own price, so the invoice run only has to call it.

A member that bills itselfJava
public class Member {
    private final String name;
    private final Plan plan;

    public Member(String name, Plan plan) {
        this.name = name;
        this.plan = plan;
    }

    public String name() {
        return name;
    }

    public Plan plan() {
        return plan;
    }

    public String bill() {
        return name + ": " + Pricing.format(Pricing.priceFor(this));
    }
}
*InvoiceRun− directory: Directory+ lines(): List<String>Pricing+ priceFor(Member): int+ format(int): StringBadgeReader− directory: Directory+ tap(String): StringDirectory− members: List<Member>+ add(Member)+ all(): List<Member>+ has(String): booleanMember− name: String− plan: Plan+ name(): String+ plan(): Plan+ bill(): String«enum»PlanHOT_DESKFIXED_DESK

The class diagram of this version, drawn from its code: Member depends on Pricing, and Pricing depends on Member.

Ana: $150.00
Ben: $250.00
Door opens for Ana
Door stays shut for Zoe

2 · The change

The door team can't take members alone

The door team wants to ship the badge reader as its own small program on the door controller. It needs members and doors, and nothing about billing. But members imports billing, and billing imports members:

doorsBadgeReadermembersMember, Plan, DirectorybillingPricing, InvoiceRunusesMember.bill()reads MemberdoorsBadgeReadermembersMember, Plan, DirectorybillingPricing, InvoiceRunusesMember.bill()reads Member

Package dependencies in the first version. The loop between members and billing means neither exists without the other.

Compile members and doors without billing, in Java with no source path to find the rest, and the compiler stops at once: "package billing does not exist". The door controller would have to carry billing, and every change to billing would mean rebuilding and retesting the door program.

Cycles cause trouble in every language, in different ways:

Language

What a cycle between modules can do

Java

Compiles when all of it is present; no part can be built, tested or shipped alone

C++

Mutual includes don't resolve, so one side needs a forward declaration, as Member::bill() did here

JavaScript

Modules that import each other can see a binding before it's set: "Cannot access 'PRICES' before initialization"

Python

Modules that import names from each other fail: "cannot import name ... from partially initialized module (most likely due to a circular import)"

The JavaScript and Python versions here run because their cycle is between packages, not between two modules: members/member imports billing/pricing, which imports members/plan. One more import in the wrong place, and they'd fail as the table shows. The practice checks see the loop at the class level: Pricing and Member depend on each other.

3 · The fix

Make billing depend on members, never back

Start from the first version. Members are the more basic idea: billing needs to know about members, but a member doesn't need to know what anyone pays. Point the dependency that way, and hide what's internal to billing:

Member loses bill() and with it the import of billing. The members package now depends on nothing else in the program.

Java
public class Member {
    private final String name;
    private final Plan plan;

    public Member(String name, Plan plan) {
        this.name = name;
        this.plan = plan;
    }

    public String name() {
        return name;
    }

    public Plan plan() {
        return plan;
    }
}
*InvoiceRun− directory: Directory+ lines(): List<String>Pricing~ priceFor(Member): int~ format(int): StringBadgeReader− directory: Directory+ tap(String): StringDirectory− members: List<Member>+ add(Member)+ all(): List<Member>+ has(String): booleanMember− name: String− plan: Plan+ name(): String+ plan(): Plan«enum»PlanHOT_DESKFIXED_DESK

The program prints exactly what it printed before:

Ana: $150.00
Ben: $250.00
Door opens for Ana
Door stays shut for Zoe
doorsBadgeReaderbillingInvoiceRun; Pricing hiddenmembersMember, Plan, DirectoryusesusesdoorsBadgeReaderbillingInvoiceRun; Pricing hiddenmembersMember, Plan, Directoryusesuses

After the fix: both packages depend on members, and members depends on nothing. Compiling members and doors alone now succeeds.

4 · The rules

A package's job, its public face, and its direction

Three rules keep packages useful:

  • One job per package. Classes that change together and are used together go together, as Member, Plan and Directory do.
  • A small public face. A package exposes what others need and hides the rest, so its insides can change without breaking anyone.
  • No cycles. The graph of which package depends on which must have no loops; this is the acyclic dependencies principle. Dependencies point toward the more stable, more basic packages.
*InvoiceRun− directory: Directory+ lines(): List<String>Pricing~ priceFor(Member): int~ format(int): StringBadgeReader− directory: Directory+ tap(String): StringDirectory− members: List<Member>+ add(Member)+ all(): List<Member>+ has(String): booleanMember− name: String− plan: Plan+ name(): String+ plan(): Plan«enum»PlanHOT_DESKFIXED_DESK

The fixed version's classes: the members classes depend on nothing outside their package.

Way to break a cycle

When it fits

Here

Move the code that crosses back

The crossing code belongs to the other package

bill() moved from Member into billing

Invert with an interface

The basic package must call out, such as to notify

members would declare a PlanPricing interface that billing implements

Extract a shared package

Both packages need the same small piece

A shared money package for formatting cents

5 · Visibility

How each language hides a package's insides

A dependency rule is only as good as what stops people from breaking it. Each language draws the line in a different place:

Language

Hidden inside a package

Enforced by

Java

Classes and members with no access modifier: visible only in their own package

The compiler; Java modules can also hide whole packages

C++

Functions and classes in an unnamed namespace, or only in a .cpp file

The linker; C++20 modules add exported and unexported names

JavaScript

Anything a module doesn't export, or a package entry that re-exports only its public names

The module system, and a package.json exports map for published packages

Python

Names and modules with a leading underscore; __all__ lists the public names

Convention only: tools and readers respect it, the interpreter doesn't

Where the language can't enforce direction, a tool can. Architecture tests and import linters read the code and fail the build when, say, a module in members imports one in billing.

6 · When breaking it is fine

Packages that are too small, and rules nobody needs

Package rules cost something too:

  • Too many tiny packages. A package per class makes every change cross package lines, and the rules turn into paperwork.
  • Hidden things that tests need. Hiding Pricing means billing's tests must sit inside billing or test it through InvoiceRun.
  • A cycle inside a package is often fine. Two classes in the same package that know each other, such as a member and its directory, are one unit anyway; the rule is about packages.

In a small program of a dozen classes in one package, there's no graph to keep clean. The rules start paying off when separate people own separate packages, or when one package must ship, build or be tested without another.

7 · Look-alikes

Rules about direction at every scale

The same idea appears at several sizes.

Idea

What it constrains

Packages and dependency rules

Which packages may import which, and what each exposes

Coupling and cohesion

How tightly classes depend on each other, and how well a package's contents belong together

Layered architecture

One specific direction: presentation, then domain, then data

Ports and adapters

Another direction: everything points in toward the core

Dependency inversion

The tool for turning an arrow around when it points the wrong way

Layers and hexagons are particular dependency rules. This page is the general mechanism under them: name the units, decide which way the arrows go, and let the compiler or a test hold the line.

8 · In practice

What the checks report

The practice checks report classes that depend on each other in a loop, as "Pricing and Member depend on each other", with the advice to let one side depend on an interface or to move the shared part into a class of its own. A loop between two classes in different packages is a loop between the packages.

In the parking-lot problem, the reference solution puts vehicles and pricing in packages of their own, and the lot uses both while neither uses the lot. A spot's size lives with the vehicles, because a vehicle needs it to say what it fits, and the ticket stays in the lot's package, because it points at a spot. Design the parking lot with the arrows pointing one way.

Check yourself

4 questions

1. The door team now ships the badge reader on its own. In the fixed version, which packages does it need?
2. The members package must send an email when a member joins, and the email code lives in a notices package that already uses members. How do you avoid a cycle?
3. Why does the fixed version hide Pricing inside billing?
4. Which of these is a cycle that matters?

Takeaways

Remember this

  • Group classes that change and are used together into a package with one job.
  • Keep the package graph free of cycles; point dependencies toward the more basic, more stable packages.
  • Break a cycle by moving the code that crosses back, inverting it with an interface, or extracting a shared package.
  • Hide what a package doesn't offer: package-private in Java, unnamed namespaces in C++, unexported modules in JavaScript, underscores and __all__ in Python.
  • Let the compiler or an architecture test enforce the direction, so it doesn't depend on everyone remembering it.