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.
Three packages
members holds Member, Plan and Directory; billing prices plans and runs invoices; doors opens the front door.
1 · The situation
A coworking app in three packages
A coworking space's app is split into three packages, one per job:
members:Member, theirPlan(a hot desk or a fixed desk) and theDirectoryof everyone;billing:Pricing, which knows what each plan costs, andInvoiceRun, 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.
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));
}
}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:
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 |
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.
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;
}
}The program prints exactly what it printed before:
Ana: $150.00 Ben: $250.00 Door opens for Ana Door stays shut for Zoe
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,PlanandDirectorydo. - 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.
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 |
|
Invert with an interface | The basic package must call out, such as to notify |
|
Extract a shared package | Both packages need the same small piece | A shared |
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 | 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 |
Python | Names and modules with a leading underscore; | 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
Pricingmeans billing's tests must sit inside billing or test it throughInvoiceRun. - 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
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.