The problem with flat projects

Most C++ projects start with everything in one directory:

project/
├── main.cpp
├── sensor.cpp
├── sensor.h
├── logger.cpp
├── logger.h
├── uart.cpp
...
CMakeLists.txt   ← one file, 300 lines

It works until the project grows. Then you get:

  • a monolithic CMakeLists.txt that nobody wants to touch
  • headers with #include "../../../sensor.h" relative paths
  • no way to reuse modules in another project
  • tests that include half the codebase to test one function

The fix is a layered directory layout with one CMakeLists.txt per module.


The layout

project/
├── CMakeLists.txt          ← top-level: orchestrates, sets policy
├── cmake/
│   └── CompilerFlags.cmake ← shared compiler options
├── src/
│   ├── sensor/
│   │   ├── CMakeLists.txt
│   │   ├── sensor.cpp
│   │   └── sensor.h
│   ├── logger/
│   │   ├── CMakeLists.txt
│   │   ├── logger.cpp
│   │   └── logger.h
│   └── app/
│       ├── CMakeLists.txt
│       └── main.cpp
├── lib/
│   └── etl/                ← third-party, vendored
│       └── CMakeLists.txt
└── test/
    ├── CMakeLists.txt
    ├── test_sensor.cpp
    └── test_logger.cpp

Each directory under src/ is a CMake library target — a self-contained module with its own include paths, sources, and dependencies.


Top-level CMakeLists.txt

 1cmake_minimum_required(VERSION 3.21)
 2project(MyFirmware CXX)
 3
 4set(CMAKE_CXX_STANDARD 17)
 5set(CMAKE_CXX_STANDARD_REQUIRED ON)
 6set(CMAKE_EXPORT_COMPILE_COMMANDS ON)   # for clangd, clang-tidy
 7
 8include(cmake/CompilerFlags.cmake)
 9
10add_subdirectory(lib/etl)
11add_subdirectory(src/sensor)
12add_subdirectory(src/logger)
13add_subdirectory(src/app)
14
15option(BUILD_TESTS "Build unit tests" ON)
16if(BUILD_TESTS)
17    enable_testing()
18    add_subdirectory(test)
19endif()

No source files listed here. The top level only sets policy and composes modules.


Module CMakeLists.txt

 1# src/sensor/CMakeLists.txt
 2add_library(sensor STATIC
 3    sensor.cpp
 4)
 5
 6target_include_directories(sensor
 7    PUBLIC  ${CMAKE_CURRENT_SOURCE_DIR}   # consumers get this path
 8    PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}   # internal headers
 9)
10
11target_link_libraries(sensor
12    PRIVATE etl::etl                      # implementation detail, not exposed
13)
14
15target_compile_options(sensor
16    PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-Wall -Wextra>
17)

PUBLIC vs PRIVATE vs INTERFACE on target_include_directories is the key:

Scope Meaning
PRIVATE Only this target uses this include path
PUBLIC This target and consumers use this path
INTERFACE Only consumers use this path (header-only library)

When sensor is PUBLIC, any target that target_link_libraries(... sensor) gets sensor’s include path automatically — no manual path wrangling.


App target

 1# src/app/CMakeLists.txt
 2add_executable(firmware
 3    main.cpp
 4)
 5
 6target_link_libraries(firmware
 7    PRIVATE
 8        sensor
 9        logger
10)

main.cpp just does #include "sensor.h" — CMake handles the path because sensor declared its include directory as PUBLIC.


Enforcing layer boundaries

CMake’s target model allows you to enforce boundaries — but only if you’re deliberate. A flat include_directories() call leaks everything to everyone. Use target_include_directories with PRIVATE wherever the path shouldn’t propagate to consumers.

For stricter enforcement, add a cmake/LayerCheck.cmake that asserts no application-layer target is linked as a dependency of a lower-layer module:

1# Conceptually: app can depend on sensor, but sensor must not depend on app
2# Enforce by code review or a custom check target, not CMake itself

In practice, the layout itself enforces this: if sensor/CMakeLists.txt tries to link against app, the dependency cycle will fail the build.


Compiler flags module

Centralise all compiler options so modules don’t repeat them:

 1# cmake/CompilerFlags.cmake
 2add_library(compiler_flags INTERFACE)
 3
 4target_compile_options(compiler_flags INTERFACE
 5    $<$<COMPILE_LANGUAGE:CXX>:
 6        -Wall
 7        -Wextra
 8        -Wpedantic
 9        -Wconversion
10        -Wshadow
11        -fno-exceptions      # for embedded targets
12        -fno-rtti
13    >
14    $<$<CONFIG:Debug>:-g3 -O0>
15    $<$<CONFIG:Release>:-O2 -DNDEBUG>
16)
17
18target_compile_features(compiler_flags INTERFACE cxx_std_17)

Then link every module against it:

1target_link_libraries(sensor PRIVATE compiler_flags)

INTERFACE libraries have no source files — they’re just a bag of properties that propagate to consumers. This is the idiomatic CMake way to share settings.


Test CMakeLists.txt

 1# test/CMakeLists.txt
 2find_package(GTest REQUIRED)
 3
 4add_executable(test_sensor
 5    test_sensor.cpp
 6)
 7
 8target_link_libraries(test_sensor
 9    PRIVATE
10        sensor
11        GTest::gtest_main
12)
13
14add_test(NAME SensorTests COMMAND test_sensor)

Tests link against the module library directly — no need to include source files again, no duplicate compilation.


Third-party libraries

Vendored libraries (ETL, FreeRTOS, Eigen) go under lib/ and get their own CMakeLists.txt that wraps them as an imported or interface target:

1# lib/etl/CMakeLists.txt
2add_library(etl INTERFACE)
3target_include_directories(etl INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/include)
4add_library(etl::etl ALIAS etl)

The :: alias is a CMake convention for “real imported target” — it produces a better error message if the target is missing, compared to a plain string name.


Build types

Configure debug and release from the command line, not from CMakeLists:

 1# Debug build
 2cmake -B build/debug -DCMAKE_BUILD_TYPE=Debug
 3cmake --build build/debug
 4
 5# Release (minified firmware)
 6cmake -B build/release -DCMAKE_BUILD_TYPE=Release
 7cmake --build build/release
 8
 9# Cross-compile for STM32
10cmake -B build/stm32 \
11  -DCMAKE_TOOLCHAIN_FILE=cmake/stm32_toolchain.cmake \
12  -DCMAKE_BUILD_TYPE=Release \
13  -DBUILD_TESTS=OFF
14cmake --build build/stm32

Multiple build directories coexist — one for unit tests on the host, one for the target firmware, no cleaning required between them.


Common mistakes

1. Using include_directories() instead of target_include_directories()

include_directories() is directory-scoped — it leaks to every target in the subtree. One call can break include isolation across the whole project.

2. Globbing sources

1# DO NOT do this:
2file(GLOB SOURCES "*.cpp")
3add_library(sensor ${SOURCES})

CMake won’t re-run when you add a new .cpp file — the build silently misses it. List sources explicitly or use target_sources() in subdirectories.

3. Setting properties without targets

1# Wrong: affects everything
2set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")
3
4# Right: scoped to a target
5target_compile_options(sensor PRIVATE -Wall)

4. Monolithic top-level file

If your root CMakeLists.txt lists source files directly, you’ve already lost. The moment a module needs reuse, you’ll have to refactor. Start with modules from day one.


The payoff

With this layout you can:

  • Build just the sensor module and its tests: cmake --build build --target test_sensor
  • Swap the HAL by changing one add_subdirectory line
  • Reuse logger in another project by copying the directory and linking to it
  • Onboard a new team member by pointing them to src/<module>/ — self-contained

The structure scales from a 5-file hobby project to a 500-file production firmware without restructuring. Set it up correctly at the start and it mostly manages itself.

What’s next