C++ Part I · Module 24 · C++ Build and Project Architecture
Organizing a C++ Engineering Project
Every decision in this module shows up in a project's directory listing. One header per type, so two translation units cannot disagree about a layout. Public headers in a directory consumers can be pointed at, implementation details in one they cannot. Dependencies tracked, so a header edit rebuilds what it must. Warnings on, both configurations buildable, tests runnable by a command rather than by a procedure. The measure of whether you got it right is specific and testable: can somebody who has never seen the repository clone it, build it and run the tests without asking anyone a question? That is a low bar and most projects fail it, usually because the knowledge needed to build exists only in the memory of whoever set it up. This chapter assembles a small, complete, working project with that property, measures its build and its dependency structure, and then says plainly what each layout decision is for — including which file in it turns out to be the expensive one to change, which is not the file anyone would guess.
Advanced19 min readC++Part IFundamentalsBuild
Part I · Module 24 · Chapter 24.9 · Organizing a C++ Engineering Project
1. The Test of a Layout
A project is organised well if a new engineer can do three things without asking:
git clone …
make # or: cmake -S . -B build && cmake --build build
make checkThat is the whole criterion. It is not about elegance, and it rules out a surprising number of real repositories — the ones where building requires a wiki page, a specific compiler nobody wrote down, an environment variable, or a colleague.
Two corollaries that are easy to miss. The build must work from a clean clone, not from your machine's accumulated state. And the tests must be a target, not a procedure — if running them means knowing which binary to invoke and with what arguments, they will stop being run.
2. The Layout
project/
├── include/vm/ # PUBLIC headers — consumers get this on their include path
│ ├── types.h
│ ├── bus.h
│ └── report.h
├── src/ # implementation, plus private headers
│ ├── bus.cpp
│ ├── report.cpp
│ └── main.cpp
├── tests/
│ └── test_bus.cpp
├── build/ # generated, git-ignored, deletable
└── MakefileSix decisions, each with a reason from this module rather than a convention:
include/vm/ with the project name as a subdirectory. Consumers are given -Iinclude and write #include "vm/bus.h". The prefix is what stops a collision with somebody else's bus.h, and it makes every include site say which library it came from. Chapter 24.3 §3's guard-name collision is the same problem one level down, and the same answer.
Public headers separated from private ones. A header in include/ is a promise; a header in src/ is an implementation detail you can change without breaking anyone. Having one directory for both means every header is public by accident.
One header per type. Chapter 24.1 §4's silent ODR violation becomes unrepresentable when there is exactly one definition in exactly one place that everyone includes.
Tests in their own directory, built as a target. They link the same library the application does, so they test what ships.
build/ is generated and deletable. Chapter 24.8 §5's argument: rm -rf build is a complete clean with no chance of deleting a source file, and nothing generated is ever mixed with anything tracked.
One build file at the root. The entry point is obvious without being told.
3. The Headers
// include/vm/types.h
#ifndef VM_TYPES_H
#define VM_TYPES_H
#include <cstdint>
namespace vm {
using Addr = std::uint32_t;
using Cycle = std::uint64_t;
enum class Resp { Okay, SlvErr, DecErr };
}
#endif// include/vm/report.h
#ifndef VM_REPORT_H
#define VM_REPORT_H
#include "vm/types.h"
#include <iosfwd> // not <ostream> — Chapter 24.4 §4
#include <string>
namespace vm {
const char* respName(Resp r);
void writeRow(std::ostream& os, Cycle c, Addr a, Resp r);
}
#endifFour details are deliberate, and each is a measurement from earlier in the module.
The guard name matches the path — vm/types.h becomes VM_TYPES_H — which makes it unique and mechanically checkable, per Chapter 24.3 §7.
<iosfwd> rather than <ostream>. The header only names std::ostream& in a signature, so the forward-declaration header is enough. Chapter 24.4 §1 measured what the wrong choice costs: 87,964 preprocessed lines against 14 for a consumer that needed no more than a name.
The include is "vm/types.h", written the way a consumer writes it, so the header works identically inside the project and outside it.
Everything is in namespace vm. Chapter 24.5 §2 shows the namespace in the mangled name, which is what makes the collision impossible rather than merely unlikely.
4. The Build File
CXX ?= clang++
CXXSTD ?= -std=c++20
WARN ?= -Wall -Wextra -Wpedantic
OPT ?= -O2
CPPFLAGS += -Iinclude -MMD -MP
CXXFLAGS += $(CXXSTD) $(WARN) $(OPT)
LIB_SRC := src/bus.cpp src/report.cpp
LIB_OBJ := $(LIB_SRC:src/%.cpp=build/%.o)
APP_OBJ := build/main.o
TEST_OBJ := build/test_bus.o
all: build/vmsim build/test_bus
build/libvm.a: $(LIB_OBJ)
ar rcs $@ $^
build/vmsim: $(APP_OBJ) build/libvm.a
$(CXX) $(CXXFLAGS) $< -Lbuild -lvm -o $@
build/test_bus: $(TEST_OBJ) build/libvm.a
$(CXX) $(CXXFLAGS) $< -Lbuild -lvm -o $@
build/%.o: src/%.cpp | build
$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@
build/%.o: tests/%.cpp | build
$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@
build:
mkdir -p build
check: build/test_bus
./build/test_bus
clean:
rm -rf build
-include $(LIB_OBJ:.o=.d) $(APP_OBJ:.o=.d) $(TEST_OBJ:.o=.d)
.PHONY: all check clean buildFive things in there are load-bearing.
?= for the toolchain and flags, so make CXX=g++ OPT=-O0 works without editing the file. That is what makes Chapter 24.7 §6's two configurations available from one build file.
-MMD -MP in CPPFLAGS, and the .d files included by explicit name. Chapter 24.8 §3 is the measurement of what happens without dependency tracking, and this is the form that has it.
An order-only prerequisite | build for the directory. Without the |, the directory's modification time — which changes whenever a file is written into it — would make every object look out of date.
check is a target. Per §1, the tests must be a command rather than a procedure.
clean removes the whole directory, which deletes the .o and .d files together so they can never be out of step — the bound Chapter 24.8 §3 identified.
One honest note: my first version of this Makefile had a defect. It included the dependency files as -include $(wildcard build/*.d), which is the form Chapter 24.8 §3's retraction note discusses, and I replaced it with the explicit form before publishing. The lesson is not about that particular idiom — the measurement showed both behaving the same — but that I had written a build rule I had not tested, in a chapter about build rules.
5. The Build, Measured
mkdir -p build
c++ -Iinclude -MMD -MP -std=c++20 -Wall -Wextra -Wpedantic -O2 -c src/main.cpp -o build/main.o
c++ -Iinclude -MMD -MP -std=c++20 -Wall -Wextra -Wpedantic -O2 -c src/bus.cpp -o build/bus.o
c++ -Iinclude -MMD -MP -std=c++20 -Wall -Wextra -Wpedantic -O2 -c src/report.cpp -o build/report.o
ar rcs build/libvm.a build/bus.o build/report.o
c++ -std=c++20 -Wall -Wextra -Wpedantic -O2 build/main.o -Lbuild -lvm -o build/vmsim
c++ -Iinclude -MMD -MP -std=c++20 -Wall -Wextra -Wpedantic -O2 -c tests/test_bus.cpp -o build/test_bus.o
c++ -std=c++20 -Wall -Wextra -Wpedantic -O2 build/test_bus.o -Lbuild -lvm -o build/test_busZero warnings at -Wall -Wextra -Wpedantic -O2, which is the state Chapter 24.7 §3 argues you have to maintain for the next warning to be visible.
The application and the test:
[000004823994] 0x4000A010 OKAY
[000004823995] 0x00000063 DECERRtest_bus: 4 assertions passedThe output format is Chapter 23.2 §4's register idiom — fixed-width zero-padded hex, uppercase, and a zero-padded cycle so that a text sort is a time sort. The second row is a deliberate out-of-range access returning DECERR, which is the behaviour the test asserts.
Note what make check gives you: the test's exit status is the verdict, which is Chapter 23.8 §6's contract and what lets a continuous-integration system decide pass or fail without parsing anything.
6. The Dependency Structure
The .d files record what each translation unit actually read:
| translation unit | headers it depends on |
|---|---|
bus | vm/bus.h, vm/types.h |
main | vm/bus.h, vm/report.h, vm/types.h |
report | vm/report.h, vm/types.h |
test_bus | vm/bus.h, vm/types.h |
And the fan-out that follows — what a change to each header costs:
touch vm/types.h -> 4 TU(s): src/bus.cpp src/main.cpp src/report.cpp tests/test_bus.cpp
touch vm/bus.h -> 3 TU(s): src/bus.cpp src/main.cpp tests/test_bus.cpp
touch vm/report.h -> 2 TU(s): src/main.cpp src/report.cppA source file change rebuilds 1 translation unit, and no change rebuilds 0.
vm/types.h is nine lines long and is the most expensive file in the project to edit. Two type aliases and an enum, included by everything, so touching it recompiles four translation units out of four — while a 400-line implementation file costs one. That is Chapter 24.4 §5's point made concrete on a real project: a dependency's cost is its size multiplied by its fan-out, and the cheap-looking file is the expensive one.
Two measurement notes, because both figures above were wrong on the first attempt.
The fan-out was verified two independent ways — by asking make -n what it would recompile, and by deriving the answer from the .d files — and the two agree on 4, 3 and 2. The first attempt reported 2, 2 and 1, which the .d files contradicted; that contradiction is what revealed the broken instrument.
GNU Make 3.81 compares whole-second timestamps, which is what broke it: a touch landing inside the build's own second is invisible. The header has to be stamped into the future for this measurement to mean anything. The same effect made make skip a link when the binary shared a second with the objects it had just rebuilt, which silently ran a stale executable in two of three attempts, and it is why no wall-clock incremental build times are published in this module — a no-op make once measured slower than a four-translation-unit rebuild at a load average of 20 on 8 CPUs. The translation-unit counts are deterministic; the times are not.
7. What Else Belongs at the Root
Beyond the build file, four files do most of the work of making a project navigable:
README.md with the three commands from §1 and nothing else required to get started. The test of a README is whether it is sufficient, not whether it is complete.
.gitignore containing build/ at minimum. A repository with committed object files has usually also committed somebody's absolute paths.
.clang-format so formatting is a tool's output rather than a review topic. The specific style matters far less than its being automatic.
.clang-tidy if you want static analysis, enabled incrementally — a full ruleset on an existing codebase produces thousands of findings and gets switched off.
Two directories worth adding as a project grows: cmake/ or scripts/ for build helpers, so the root stays readable, and docs/ for anything longer than a README. And one habit: a tools/ or examples/ directory for small programs that exercise the library, which are the cheapest documentation there is because a build failure tells you they are out of date.
8. Scaling Up
The layout above is right for one library and one application. Three changes come with growth.
Several libraries, each with its own directory, each with its own include/<name>/ and src/. The dependency direction between them should be explicit and acyclic — and a cycle between two libraries is usually a sign that one type belongs in a third, lower one.
A third_party/ or a package manager. Vendoring is simple and reproducible and gets stale; a package manager is the reverse. Either is defensible; having both, or neither and a wiki page, is not. Chapter 24.3 §4's measurement is relevant to vendoring specifically: a copied header is a different file, so #pragma once does not deduplicate it against the original.
Tests organised to match the source. tests/test_bus.cpp beside src/bus.cpp scales to tests/bus/test_decode.cpp beside src/bus/decode.cpp. The property worth preserving is that finding the test for a file requires no search.
What does not scale is a flat directory of forty .cpp files, a single all.h that everything includes — Chapter 24.4 §7's worst case, maximum fan-out by construction — and a build file that lists sources by wildcard, which silently stops building a file somebody moves.
9. The Table
| decision | what it prevents | measured |
|---|---|---|
| one header per type | Chapter 24.1 §4's silent ODR violation | sizeof 8 vs 12, 0 diagnostics |
include/vm/ prefix | a header-name collision | Chapter 24.3 §3 |
| path-derived guard names | a guard-macro collision | Chapter 24.3 §3 |
| public and private headers separated | an accidental ABI promise | Chapter 24.5 §7 |
<iosfwd> in headers | 87,964 preprocessed lines per consumer | Chapter 24.4 §1 |
-MMD -MP + explicit .d include | a stale object file | Chapter 24.8 §3, 0 TUs rebuilt |
clean deletes .o and .d together | the two getting out of step | Chapter 24.8 §3 |
-Wall -Wextra -Wpedantic | 7 of 9 defects going unreported | Chapter 24.7 §2 |
?= on flags | one configuration being unbuildable | Chapter 24.7 §6 |
check as a target | tests quietly stopping | §1 |
build/ separate and git-ignored | an unclean clean | Chapter 24.8 §5 |
order-only | build | every object looking out of date | §4 |
10. What to Actually Do
- Make
clone,build,testwork with no questions asked, from a clean clone. - Put public headers in
include/<project>/and private ones insrc/, and write includes the way a consumer will. - One header per type, with a path-derived guard name, so Chapter 24.1 §4 cannot happen.
- Use
<iosfwd>and forward declarations in headers; measure the fan-out before deciding which header to work on. - Put
-MMD -MPin the build and include the.dfiles by name, and makecleandelete.oand.dtogether. - Warnings on in both configurations, with the flags overridable via
?=. - Make the tests a target that links the same library the application does.
- Keep
build/generated, git-ignored and deletable. - Use an order-only prerequisite for the output directory.
- Measure your fan-out from the
.dfiles and treat the widely-included header as the expensive one, whatever its size. - Stamp inputs into the future when measuring a build, and do not publish wall-clock build times from a loaded machine.
- Test the build rules you write. Mine had a defect in a chapter about build rules.
11. Mistakes Worth Making Once
A build that needs a wiki page. The project fails §1's test and nobody notices until a new engineer arrives.
Headers and sources in one directory. Every header is public, so every change is an ABI change.
No project prefix on the include path. Your bus.h and somebody else's are the same include.
build/ not in .gitignore. Object files and absolute paths in the repository.
Tests you run by remembering a command. They stop being run.
$(wildcard src/*.cpp) in the build file. A moved file is silently no longer built.
A single all.h. Maximum fan-out, by construction.
Judging a header by its length. The nine-line one recompiled everything.
Timing an incremental build on a loaded machine. A no-op make measured slower than a four-file rebuild.
Publishing a build rule you have not tested. I did this, in this module.
12. Interview Relevance
The frozen curriculum marks this chapter Medium, and it comes up as a design discussion rather than a quiz — often as "how would you lay out a new project?" or "what is wrong with how ours is organised?"
The answer that lands opens with the criterion rather than the directories: a new engineer should be able to clone, build and test with no questions, which is a testable property most repositories fail. Then the layout, with each decision tied to what it prevents — one header per type so two translation units cannot disagree about a layout, public and private headers separated so you do not promise what you did not mean to, dependency tracking in the build because a stale object file is an ODR violation nothing can detect, and the tests as a target because a remembered command stops being run.
The thing most candidates will not say, and which is worth saying, is the fan-out observation: the most expensive file in a project to change is the most-included one, not the largest, and you can measure it from the compiler's own dependency files. On the project here, a nine-line types.h recompiled four translation units out of four while a 400-line source file recompiled one.
13. What You Should Be Able to Say Now
- The criterion: clone, build, test, with no questions, from a clean clone.
- Why each layout decision exists —
include/<project>/, public versus private headers, one header per type, path-derived guards,build/deletable, one build file at the root. - That the headers use
<iosfwd>and that Chapter 24.4 §1 measured the alternative at 87,964 preprocessed lines against 14. - What the build file must do: pin the standard, warnings on, flags overridable with
?=,-MMD -MPwith the.dfiles included by name,cleandeleting.oand.dtogether, an order-only prerequisite for the output directory, andcheckas a target. - That the build produces zero warnings at
-Wall -Wextra -Wpedantic -O2and that the test target reports 4 assertions passed through its exit status. - The fan-out table — types.h 4, bus.h 3, report.h 2, a source file 1, nothing 0 — verified two independent ways, and that the nine-line header is the most expensive file to edit.
- That GNU Make 3.81's whole-second timestamps broke that measurement twice and also made
makeskip a link, which is why TU counts are published and wall-clock times are not. - That my own first Makefile contained an untested dependency-include idiom, and that a build is measured rather than inspected.
14. Exercises
Exercise 1 — the criterion. Take a project you work on, clone it into an empty directory on a machine you have not built it on, and try to build and test it with no help. Write down every question you had to ask.
Exercise 2 — build it. Reproduce §2's layout and §4's Makefile from scratch. Confirm the build order, zero warnings, and make check.
Exercise 3 — fan-out. Compute every header's fan-out from the .d files, then verify two of them with make -n, stamping the header into the future. Then add a member to vm/types.h and confirm four translation units rebuild.
Exercise 4 — break the build file. Replace the explicit .d include with $(wildcard build/*.d), then delete the .d files, edit a header, and see what happens. Then restore it. This is the defect my first version had.
Exercise 5 — the order-only prerequisite. Remove the | from | build and rebuild twice in a row. Explain why everything recompiles.
Exercise 6 — two configurations. Build the project with make OPT=-O0 and with the full debug flag set from Chapter 24.7 §6, both into separate directories, and confirm both work and the tests pass in each.
15. Summary
- The test of a layout is that a new engineer can clone, build and test with no questions asked, from a clean clone. Most projects fail it.
- The layout:
include/<project>/for public headers,src/for implementation and private headers,tests/for tests, a deletablebuild/, one build file at the root — and one header per type, which makes Chapter 24.1 §4's undetectable ODR violation unrepresentable rather than merely unlikely. - The build file pins the standard, keeps warnings on with
?=-overridable flags, carries-MMD -MPwith the.dfiles included by explicit name, deletes.oand.dtogether inclean, uses an order-only prerequisite for the output directory, and exposescheckas a target. - Measured: the clean build compiles three sources, archives
libvm.a, linksvmsimandtest_bus, with zero warnings at-Wall -Wextra -Wpedantic -O2; the application prints[000004823994] 0x4000A010 OKAYand[000004823995] 0x00000063 DECERR;make checkreportstest_bus: 4 assertions passed. - The fan-out table is a cost model, not documentation:
vm/types.h4 translation units,vm/bus.h3,vm/report.h2, a source file 1, nothing 0 — verified both frommake -nand from the.dfiles, which agree. The nine-linetypes.his the most expensive file in the project to edit, which no inspection of file sizes would tell you. - Three of this module's measurements were wrong on the first attempt — the fan-out (whole-second timestamps), the header-only comparison (identical arms), and a claim about two
-includeidioms — and each was caught by computing the answer a second way. A build is measured, not inspected, and my own first Makefile shipped an untested idiom in a module about build files.
That closes Module 24. The module's through-line was a single uncomfortable fact: the toolchain can only check what the artefacts record, so a disagreement between translation units is caught exactly when it survives into a symbol — and layouts, extern "C" signatures, unreferenced initialisers and stale object files all fail that test. Every recommendation here is a way of not needing the check.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
