As I was setting up my first non-trivial C++ project, my guiding principal became, “do whatever works, address problems as they arise”. I wrote a quick CMake file to compile a short list of source files and dependencies into a binary, which I used to create my first working graphics application. However, when I tried to add some basic unit testing capabilities, I hit a wall. This led to some significant configuration and organization improvements that I think are worth sharing.

Stepping back, I really just needed an easy way to compile multiple binaries and share code between them. That way, I would be able to:

  • Organize my code and build configurations into self-contained libraries
  • Compile test and application binaries against the same core library code
  • Write custom tools to be executed during development or at build time
  • Create runnable examples to demonstrate library APIs

As luck would have it, plenty of other people had been thinking about the same problem. I came across The Pitchfork Layout (PFL), a rather opinionated set of guidelines for organizing C++ projects written by Colby Pike. Although PFL didn’t address my scalability issues directly, it contains plenty of useful ideas that I was able to adapt to my project. Most importantly, the PFL project centralized some of the the conversations happening around C++ project structure (I found this GitHub issue to be the most relevant to my problem).

If you prefer exploring real-world examples over reading guided explanations, look here. Otherwise, here’s how I structured my project:

myproject/
├─ apps/
│  ├─ app1.cpp
│  ├─ app2.cpp
│  ├─ CMakeLists.txt
├─ build/
├─ external/
│  ├─ Catch2/
│  ├─ imgui/
│  ├─ stb/
│  ├─ CMakeLists.txt
├─ libs/
│  ├─ mylib/
│  │  ├─ examples/
│  │  │  ├─ example1.cpp
│  │  ├─ src/
│  │  │  ├─ mylib/
│  │  │  │  ├─ srcfile.cpp
│  │  │  │  ├─ srcfile.hpp
│  │  │  │  ├─ srcfile.test.cpp
│  │  ├─ test/
│  │  │  ├─ testsetup.cpp
│  │  ├─ CMakeLists.txt
├─ tools/
│  ├─ tool1.cpp
│  ├─ tool2.cpp
│  ├─ CMakeLists.txt
├─ CMakeLists.txt

Some things to note that may not be obvious at first glance:

  • The build directory houses CMake and compiler outputs. It is not checked into source control.
  • The external and tools directories must be added first so that their outputs are available to subsequent build targets.
  • The apps and tools directories contain thin application entrypoints. Each entrypoint implements a main function and produces an executable, optionally linked to libraries in external and libs. Almost all business logic is pushed down into libs.
  • Dependencies are cloned into external as git submodules, versioned by SHA. Most C++ libraries contain a top-level CMake file and can be made available via the add_subdirectory command.
  • Example programs within a library directory are written and compiled similarly to tools and apps.
  • Unit tests sit adjacent to their corresponding source file but are compiled into a separate executable that runs them.
  • Each library’s src directory contains an extra nested directory that matches the name of the library itself. This allows src to be passed to the target_include_directories CMake command, which also means all headers are public. To support private headers, an include directory can be added at the library root.
  • The test directory within each library includes global test setup and teardown code, e.g. custom Catch2 main functions.

After working under this project structure for several months, I can say that I don’t spend very much time putzing around with build system configuration anymore. As my project grows, I am able to partition logic into libraries and apps as needed, and installing dependencies is straightforward. My development process now centers around building and debugging lightweight unit test binaries rather than full applications. Although there are undoubtedly improvements to be made, in the spirit of, “do whatever works, address problems as they arise”, I feel that this solution has accomplished my goal.