Stopping Dependency Leakage with Target-Based CMake
Learn how to eliminate 'Include Hell' in CMake by moving from global directory settings to target-based dependency management using PUBLIC, PRIVATE, and INTERFACE keywords.
19 Aug 2025, 11:03 UTC

The Problem: The Global Include Leak
In legacy CMake projects, it is common to see include_directories() called at the top of a root CMakeLists.txt. While this seems convenient, it creates a global state. Every target defined in that directory and its subdirectories inherits those include paths, regardless of whether they actually need them.
This leads to Include Hell: a scenario where a developer adds a heavy third-party library for a single utility module, and suddenly every executable in the project is linked against it or, worse, can accidentally include its headers. This obscures actual dependencies, slows down compilation, and makes it nearly impossible to extract a single module into a separate project without dragging the entire dependency tree with it.
The Thesis: Targets as Objects
Modern CMake (3.0+) shifts the paradigm from global configuration to target-based management. Instead of thinking of CMake as a script that sets global flags, treat targets (libraries and executables) as objects that encapsulate their own requirements. By using target-specific commands, you define not just how a library is built, but how any other target should behave when it consumes that library.
Controlling Propagation with Visibility Keywords
The core of this approach lies in three keywords: PRIVATE, INTERFACE, and PUBLIC. These determine how properties—such as include directories or compiler flags—propagate through the dependency graph.
- PRIVATE: The requirement is used to build the target itself, but is not needed by anyone linking to it. (e.g., an internal header used only in
.cppfiles). - INTERFACE: The requirement is not needed to build the target, but is required by anyone who uses it. (e.g., a header‑only library).
- PUBLIC: A combination of both. The target needs it to build, and anyone linking to the target also needs it. (e.g., a public header that includes a dependency's header).
Worked Example: Encapsulated Dependency
Consider a scenario where we have a MathLib that uses an internal FastFloat library, and an App that uses MathLib. We want App to know about MathLib, but we want to hide FastFloat from App.
# Minimum version required for target-based commands
cmake_minimum_required(VERSION 3.0)
project(DependencyExample)
# 1. Define the internal dependency
add_library(FastFloat STATIC fastfloat.cpp)
target_include_directories(FastFloat PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/fastfloat/include)
# 2. Define the main library
add_library(MathLib STATIC mathlib.cpp)
# MathLib needs FastFloat to build, but App doesn't need to see FastFloat headers
target_link_libraries(MathLib PRIVATE FastFloat)
# MathLib defines its own public API headers
target_include_directories(MathLib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/mathlib/include)
# 3. Define the executable
add_executable(App main.cpp)
# App only links to MathLib. It automatically inherits MathLib's PUBLIC includes,
# but NOT FastFloat's includes because they were linked as PRIVATE.
target_link_libraries(App PRIVATE MathLib)
Verification
To verify this is working, attempt to #include "fastfloat.h" inside main.cpp. The compiler should throw a file not found error. However, including "mathlib.h" should work perfectly. This confirms that FastFloat is an implementation detail of MathLib and has not leaked to the App target.
Trade-offs and Limitations
The primary trade-off is verbosity. In a legacy setup, one include_directories() call might cover ten libraries. In a target-based setup, you must explicitly define the properties for each of those ten libraries. This increases the initial boilerplate in your CMakeLists.txt files.
Additionally, mixing legacy global commands with target-based commands can lead to confusing precedence. If both are used, the global include paths are typically added to the compiler command line regardless of the target's PRIVATE settings, effectively neutralizing the benefits of the modern approach.
Actionable Closing
To migrate your project, start by replacing include_directories() with target_include_directories() and link_libraries() with target_link_libraries(). Audit your dependencies: if a header is not exposed in your library's public API, mark the dependency as PRIVATE. This creates a clean, modular build graph that scales as your project grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.