Modern CMake: Target-Based API & Generator Expressions for Clean, Maintainable Builds
Modern CMake’s target‑based API replaces global settings with per‑target visibility, using PUBLIC/PRIVATE/INTERFACE and generator expressions for conditional compile flags. A three‑target example shows how the rules propagate and keep Debug/Release builds separate.
10 Aug 2026, 16:28 UTC

The legacy trap: directory-scoped commands at scale
Most CMake projects start with include_directories() and add_definitions() at the directory level. This works for a single executable but breaks down as soon as you add libraries with different include requirements. Directory-scoped commands apply globally, forcing you to manage visibility manually with if() branches or separate CMakeLists.txt files. The result is configuration drift: a header path added for one target leaks into another, and Debug/Release flags get mixed because there's no per-configuration scope.
Modern CMake (3.0+) solves this with target-based commands: target_include_directories(), target_compile_definitions(), and target_link_libraries(). Each command attaches usage requirements directly to a target, and CMake propagates them transitively based on visibility keywords. No more global variables, no more manual propagation.
Visibility keywords model library boundaries
The three visibility specifiers — PUBLIC, PRIVATE, INTERFACE — are the core of the target-based model:
- PUBLIC: the requirement is used when building this target and propagated to anything that links to it.
- PRIVATE: used only when building this target; consumers never see it.
- INTERFACE: not used when building this target (typical for header-only libraries), but propagated to consumers.
This mirrors how you think about library boundaries. A logging library's public headers are PUBLIC; its internal implementation headers are PRIVATE. A header-only utility is an INTERFACE library with INTERFACE include directories. The visibility choice is explicit in the CMakeLists.txt, so future maintainers (including you) can read the intent without tracing variable assignments.
Generator expressions: conditional configuration without branching
Generator expressions evaluate at build-system generation time, not at CMake configure time. This means a single target_compile_definitions() call can set different values for Debug vs Release, Windows vs Linux, or even per-generator (Ninja vs Xcode) without any if() logic in your CMakeLists.txt.
Common patterns:
$— only when building from the source tree$— only when installed$— only for Debug configuration (works with multi-config generators like Visual Studio and Ninja Multi-Config)$— only when targeting Windows$— read a property from another target (CMake 3.12+)
Because they evaluate late, generator expressions cannot depend on CMake variables that change after the generate step (e.g., results of try_compile()). Syntax errors often surface only in the generated build system (build.ninja, .vcxproj), not during cmake configure — always inspect the generated files or run cmake --build --target help to validate.
Worked example: three-target chain with mixed visibility
Consider a minimal project: libA (static), libB (static, depends on libA), myapp (executable, depends on libB). libA has public headers in include/libA and private headers in src/libA. libB adds a Debug-only definition. myapp links both.
cmake_minimum_required(VERSION 3.15)
project(Demo LANGUAGES CXX)
# libA: public + private includes
add_library(libA STATIC src/libA/libA.cpp)
target_include_directories(libA
PUBLIC
$
$
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
# libB: depends on libA, adds Debug-only definition
add_library(libB STATIC src/libB/libB.cpp)
target_link_libraries(libB PUBLIC libA)
target_compile_definitions(libB
PRIVATE $ LIBB_DEBUG_LOGGING=1
)
# myapp: links libB (transitively gets libA's public includes)
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE libB)
Key observations:
libA's public include path uses$for the source tree and$for the install tree — consumers get the right path in both contexts without duplication.libBlinkslibAwithPUBLIC, somyappinheritslibA's public includes automatically.libB's own Debug definition isPRIVATE— it doesn't leak tomyapp.myappusesPRIVATEforlibBbecause it doesn't expose libB's API in its own public headers.
Build with a multi-config generator to verify Debug/Release isolation:
cmake -G "Ninja Multi-Config" -B build
cmake --build build --config Debug
cmake --build build --config Release
Inspect build/compile_commands.json (or build.ninja) to confirm LIBB_DEBUG_LOGGING=1 appears only in Debug compile commands for libB sources, not for myapp or Release builds.
Trade-offs and limitations
- Debugging generator expressions: errors appear in the generated build files, not at configure time. Always verify with
cmake --build --target helpor by readingbuild.ninja/.vcxproj. - Version requirements:
$and$need CMake 3.12+. RHEL 8 ships 3.11 by default — plan accordingly or use toolchain-specific backports. - INTERFACE libraries: cannot have source files. Works for header-only libraries, but you must use
INTERFACEvisibility for includes/definitions;PUBLICon an INTERFACE target is an error. - Migration is all-or-nothing per target: mixing
include_directories()withtarget_include_directories()on the same target creates undefined precedence. Migrate one target completely before moving to the next. - FetchContent diamond dependencies: if two upstream projects expose the same third-party target with different visibility, you get conflicts. Prefer
find_packagewith config-mode packages when available.
Actionable closing: migration checklist
- Pick one target (leaf library or executable) and convert all its
include_directories/add_definitionsto target-based commands with explicit visibility. - Run
cmake --graphviz=deps.dotand render with GraphViz to verify the dependency graph matches your mental model. - Build with a multi-config generator (
Ninja Multi-Configor Visual Studio) and inspectcompile_commands.jsonfor propagated flags. - Enable policy
CMP0126(target_link_libraries()rejects file paths) andCMP0077(option()honors normal variables) withcmake_policy(SET CMP0126 NEW)to lock in modern behavior. - Repeat for the next target. Don't mix old and new on the same target.
The target-based API isn't just syntactic sugar — it makes the build graph explicit, gives IDEs accurate IntelliSense data, and lets you express per-configuration logic without branching. Start small, verify with the generated build system, and expand incrementally.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.