Software Architecture · Modularity

Physical Modularity: Compilation Firewalls and Rebuild Cost

How declaration-level dependencies turn a header edit into a full rebuild, what a pimpl or interface boundary costs at runtime, and how package compilers avoid the same trap.

A module boundary that exists only in a diagram does not stop the compiler from reading across it. Logical modularity is about what depends on what in the design; physical modularity is about what the build system must re-read when a file changes. The two can diverge sharply, and the divergence has a measurable cost: in a codebase where every translation unit includes a common header chain, editing one declaration in that chain recompiles every translation unit, even though no behaviour changed and no interface moved.

C and C++ expose this most clearly because #include is textual substitution performed before parsing. The preprocessor copies the included file’s tokens into the including file, so a translation unit is the concatenation of everything it includes, transitively. Include guards prevent multiple inclusion within one translation unit; they do nothing about the number of translation units that include the file. A header included by 800 source files creates 800 compilation dependencies on its contents, whether those files use one declaration from it or all of them.

The rebuild cost follows from the arithmetic. If a project has T translation units and the average translation unit preprocesses H tokens, a full build parses on the order of T times H tokens. Change a header included by all of them and the same work repeats. Restructure so that most translation units see only a small interface header, and the sum falls: files that need the implementation details still pay, files that only need a pointer or a function signature do not.

What the compiler actually needs

The reason a header edit cascades is that the compiler needs declarations, not definitions, for most uses. To call a function, it needs the return type and parameter types. To declare a pointer to a class, it needs the class name, not its layout. To define a member of that class by value, it needs the complete type. To call a virtual method, it needs the vtable layout, which the class definition supplies.

Lakos’s formulation of the problem is that a dependency exists whenever one component needs another to compile, and the physical form of that dependency is an include. The design question is how much of the dependency is essential: a source file that stores a Widget* and calls one function on it needs far less of Widget’s header than a source file that embeds a Widget by value. The complete-type requirement is what drags the definition in.

Forward declarations exploit this. Declaring class Widget; and using Widget* removes the need for the definition, and therefore the include, from every file that only handles pointers or references. The cost is that the pointer must be allocated and destroyed somewhere that does see the definition, which is usually a constructor and destructor defined in the implementation file.

The compilation firewall

The pimpl idiom applies the same idea to a class’s own representation. The public class holds a pointer to an incomplete implementation type and forwards calls to it.

// widget.h — the only header dependents include
class Widget {
public:
    Widget();
    ~Widget();
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;

    void resize(int width, int height);
    int width() const;

private:
    struct Impl;            // incomplete here; defined in widget.cpp
    Impl* impl_;
};
// widget.cpp
#include "widget.h"
#include "layout_engine.h"   // heavy header, not exposed to dependents

struct Widget::Impl {
    LayoutEngine engine;
    int w = 0, h = 0;
};

Widget::Widget() : impl_(new Impl) {}
Widget::~Widget() { delete impl_; }

Widget::Widget(Widget&& other) noexcept : impl_(other.impl_) {
    other.impl_ = nullptr;
}

Widget& Widget::operator=(Widget&& other) noexcept {
    if (this != &other) {
        delete impl_;
        impl_ = other.impl_;
        other.impl_ = nullptr;
    }
    return *this;
}

void Widget::resize(int width, int height) {
    impl_->engine.arrange(width, height);
    impl_->w = width;
    impl_->h = height;
}

int Widget::width() const { return impl_->w; }

Changing LayoutEngine’s header now recompiles widget.cpp and nothing else. The scalar members moved into Impl so their layout never appears in the public header; changing w from int to long no longer changes sizeof(Widget) for any dependent. The costs are concrete: one allocation and one pointer indirection per Widget, a lost opportunity to inline width() across translation units unless link-time optimisation recovers it, and the need to define the destructor out of line so that deletion sees the complete Impl.

An abstract interface achieves the same separation without exposing the pointer in the class definition. Dependents include a header with a pure-virtual base and a factory function returning a smart pointer to the base; the implementation lives in a source file that includes everything heavy. The trade is the same indirection plus a virtual call per operation, and the benefit is that the interface can have multiple implementations and can be substituted in tests. The contract obligations of that interface are what preserve the separation over time; without them, the first dependent that downcasts to the concrete type re-establishes the physical dependency the firewall was built to remove.

Languages with package-level compilation

Java, C#, Go, and Rust avoid textual inclusion, and their build graphs are keyed on something closer to an interface than a file. Java compiles each class to a .class file and recompiles a dependent when the referenced class’s binary changes. Go compiles packages and caches results keyed on the package’s source content and the export data of its dependencies, so a change to a package’s internals that does not alter its exported API still invalidates the package itself but not necessarily its dependents’ compilation. Rust compiles crates as units and uses fingerprints that include compiler flags and dependency metadata.

These systems still have physical dependencies, and they still have traps.

Constant inlining. Java compiles static final constants of primitive and string type into the consuming class file. The dependent does not recompile when the constant’s value changes, and it continues to use the old value until it is rebuilt. This is a physical modularity hazard in the opposite direction: the dependency is too weak, so the change does not propagate. Referencing the constant through a method call restores the dependency at the cost of a call that the just-in-time compiler may or may not eliminate.

Generic instantiation. In Rust and C++, generic code is monomorphised in the consuming compilation unit, which means the consumer compiles the generic’s body. A generic with a large body, used with many type parameters, multiplies compilation work by the number of instantiations. The boundary hides the interface but not the cost, and moving the generic into a separate crate does not help when the instantiation happens downstream.

Export data granularity. Go’s build cache is keyed per package, so touching an unexported function in a low-level package invalidates that package and relinks dependents, but does not recompile them. Widening the exported surface of a package, by contrast, can change the export data and force downstream recompilation. The practical rule is to keep exported surfaces small and stable, which is the same rule that package encapsulation states for design reasons.

Header-only libraries. A library distributed entirely as headers pushes all of its compilation into every consumer. Its build-time cost is paid by the consumer, not the library, which makes the library attractive to publish and expensive to use. The technique is justified when the code is small and heavily templated; when it is neither, the distribution choice has moved a cost onto every downstream build without showing it in the library’s own metrics.

Where the firewall costs more than it saves

The pimpl boundary is not free at runtime, and the cost is per object, per call, or both. A class with a pimpl pointer pays an allocation on construction, an indirection on every member access, and a cache miss when the Impl is not adjacent to the handle. For an object created once and called thousands of times, the allocation is noise and the indirection is a few cycles. For an object created millions of times in a tight loop, both matter, and the header that would have saved them is the one being avoided.

The judgement is per type, not per project. Types that many dependents reach through an include chain are the best candidates for a firewall because the rebuild saving multiplies across those dependents. Types included by a single implementation file gain nothing: the header is read once, and the indirection is paid forever. Applying the idiom uniformly produces a codebase where every class is a handle to another allocation, with no corresponding build-time benefit.

The same trade appears at the module level. A C API boundary with opaque handles hides the layout and gives binary stability, at the cost of manual memory management, an error-code convention, and no templates or inline functions across the boundary. The price is worth paying for a library consumed by programs the library author does not compile; it is usually not worth paying inside a single build.

Measuring the physical graph

The build system already knows the physical graph, and exposing it is cheaper than guessing. Dependency files emitted by the compiler (-MMD in GCC and Clang) list every header each translation unit read. Aggregating them gives the fan-out of each header and the set of translation units that a change would recompile; the largest fan-out values are the candidates for a firewall, and they are frequently headers that no one designed as interfaces.

Two numbers make the trade legible. The first is the number of translation units that include a header, which is the rebuild multiplier, and it comes straight from the dependency files. The second is the size of the preprocessed translation unit, which is the per-unit cost; measuring it takes one -E pass rather than a full compile. The decision to introduce a pimpl or an interface then has an estimate attached: it removes some fraction of the multiplier at the cost of one indirection per operation on that type.

Modules as a partial answer

C++20 modules replace textual inclusion with compiled interface units. A module exports a set of declarations; importers read the compiled interface rather than re-parsing the header text, so a change to the module’s implementation does not force importers to re-parse it. This removes the token-amplification problem and much of the rebuild cascade, and it does not remove the design problem: a module that exports a class’s full layout still forces importers to recompile when that layout changes, and a module whose interface imports another module still creates an edge. The boundary is still a decision about what to expose, which is the same decision the header firewall was making by hand.

The physical and logical graphs converge when every exported declaration is deliberate. Where they diverge — heavy headers included for one enum, module interfaces that re-export everything — the build time is the first symptom and the dependency graph is where the cause lives.

References

  1. Large-Scale C++ Software Design — John Lakos, Addison-Wesley, 1996
  2. ISO/IEC 14882:2020 — Programming Languages: C++ — ISO/IEC
  3. Command go — The Go Project