C++, Rust & systems
CMake target-based project structure for C++ projects
How to structure a small cross-platform C++ project around CMake targets: scope keywords, header file sets, aliases, tests and common failure modes.
A target-based CMake project describes every library and executable as a target that owns its sources, include directories, compile definitions and dependencies. Other targets consume it by linking to its name, and the PUBLIC, PRIVATE and INTERFACE keywords decide what propagates. Component-specific requirements stay on their targets, so each boundary is explicit and checkable.
Quick answer
- One
add_library()oradd_executable()per component, each in the CMakeLists.txt of its own directory. - Set properties only with
target_*commands, never withinclude_directories()oradd_definitions(). - Use PUBLIC for what appears in your headers, PRIVATE for what stays in your .cpp files, INTERFACE for header-only or forwarding cases.
- Link with
target_link_libraries()to target names, preferably namespaced aliases such asapp::core. - Keep platform differences inside the target that needs them, not in the root file.
Why targets are the unit of structure
The CMake buildsystem manual defines binary targets through add_library() and add_executable(), with dependencies declared by target_link_libraries(). Properties come in two flavors: build properties that apply to the target itself, and usage requirements that consumers inherit through the INTERFACE_ variants.
That split is the whole idea. A target says what it needs to compile and what a consumer needs to use it. Everything else is an implementation detail that should not leak.
A small layout
Assumptions for every example here: CMake 3.23 or newer (needed for header file sets), a C++17 compiler, and any of Linux, macOS or Windows with MSVC, Clang or GCC. The project and names are illustrative, not taken from a real codebase.
shapes/
CMakeLists.txt
libs/
core/
CMakeLists.txt
include/shapes/core/area.h
src/area.cpp
src/platform_win32.cpp
src/platform_posix.cpp
apps/
cli/
CMakeLists.txt
src/main.cpp
tests/
CMakeLists.txt
area_test.cpp
The public headers live under include/shapes/core/, so an include reads #include <shapes/core/area.h> and cannot collide with another library's area.h. Private sources and headers stay under src/.
The root file stays thin
# CMakeLists.txt (CMake 3.23+)
cmake_minimum_required(VERSION 3.23)
project(shapes VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_subdirectory(libs/core)
add_subdirectory(apps/cli)
include(CTest)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
The root only sets project-wide policy and wires directories together. It does not list source files or include paths.
The library target
# libs/core/CMakeLists.txt
add_library(shapes_core
src/area.cpp)
add_library(shapes::core ALIAS shapes_core)
target_sources(shapes_core
PUBLIC
FILE_SET HEADERS
BASE_DIRS include
FILES include/shapes/core/area.h)
if(WIN32)
target_sources(shapes_core PRIVATE src/platform_win32.cpp)
target_compile_definitions(shapes_core PRIVATE NOMINMAX)
else()
target_sources(shapes_core PRIVATE src/platform_posix.cpp)
endif()
target_compile_features(shapes_core PUBLIC cxx_std_17)
Three details are worth knowing, all from the reference pages:
- Header file sets arrived in CMake 3.23, and per the target_sources documentation a set named
HEADERSneeds noTYPE. TheBASE_DIRSentry is resolved against the current source directory, and every file must sit inside one of the base directories. The buildsystem manual notes that the base directories then become include directories for consumers. - An alias creates a second name for an existing target. The same manual says this lets you link unconditionally to a name that may be an in-tree target or an imported package target. A name containing
::must resolve to an alias or imported target, otherwise CMake reports an error, which turns a typo into a configure-time failure instead of a mysterious linker flag. NOMINMAXis PRIVATE because it only matters while compiling the Windows source file. Consumers do not inherit it.
If you prefer the older style, target_include_directories(shapes_core PUBLIC include) still works. The target_include_directories page adds that for installable packages you wrap paths in $<BUILD_INTERFACE:...> and $<INSTALL_INTERFACE:...>, and that relative paths should not be used inside BUILD_INTERFACE. A header file set avoids hand-writing that pair for the library's own headers.
Choosing PUBLIC, PRIVATE or INTERFACE
| Keyword | Applies to the target itself | Inherited by consumers | Typical use |
|---|---|---|---|
| PRIVATE | Yes | No | Internal dependencies, platform sources, warning flags |
| PUBLIC | Yes | Yes | Types that appear in your public headers |
| INTERFACE | No | Yes | Header-only libraries, forwarding dependencies |
The target_link_libraries reference describes how these scopes control dependencies. PUBLIC dependencies supply requirements to both the target and its consumers; PRIVATE dependencies supply implementation requirements; INTERFACE dependencies are for consumers. Static libraries need an extra distinction: their PRIVATE link dependencies can still reach the final executable so its linker can resolve symbols, without exposing those dependencies' compile requirements.
A practical test: if a dependency's type shows up in a signature in your public header, it is PUBLIC. If it appears only in a .cpp file, it is PRIVATE. Getting this wrong in the PUBLIC direction is the common mistake, because it forces every consumer to carry include paths and link inputs they never asked for.
The executable and the tests
# apps/cli/CMakeLists.txt
add_executable(shapes_cli src/main.cpp)
target_link_libraries(shapes_cli PRIVATE shapes::core)
# tests/CMakeLists.txt
add_executable(area_test area_test.cpp)
target_link_libraries(area_test PRIVATE shapes::core)
add_test(NAME area COMMAND area_test)
Neither file mentions an include path. Linking to shapes::core brings in the header directory, the C++17 requirement and any PUBLIC dependencies. Build and run on any platform with the same commands:
cmake -S . -B build
cmake --build build
ctest --test-dir build
On Windows with a multi-config generator such as Visual Studio, add --config Debug to the build step and -C Debug to ctest. On Linux and macOS with single-config generators, set -DCMAKE_BUILD_TYPE=Debug at configure time.
Failure modes to check for
Directory-scoped commands creeping back
Search for
include_directories(,link_directories(andadd_definitions(. These apply to every target in a directory, which hides which target actually needs what.Linking raw paths or plain names
A plain name is passed to the linker as is (
-lfooorfoo.lib). A target name adds build ordering and carries usage requirements, so prefer it over file paths.Over-wide PUBLIC
Review public headers for dependency types, inline code, templates and required compile definitions. After narrowing a dependency to PRIVATE, build a small consumer that includes those headers and links only your target. A successful build is useful evidence, but does not prove the dependency is private across every configuration or platform; another dependency may also be providing it transitively.
Platform logic in the root file
Keep
if(WIN32)blocks next to the sources they select, as in the library above. A consumer should not need to know which backend was chosen.
When one target per directory is not enough
A library with many optional backends, or a plugin system, may want an OBJECT library, which per the buildsystem manual compiles sources without archiving them, so other targets can reuse the objects. Header-only code fits an INTERFACE library, which produces no artifact and carries only usage requirements. For a project with one executable and a few source files, a single target is perfectly reasonable. Split when a boundary has a real consumer, such as a test binary or a second application.
Where this fits in my work
I work in C++ on OmniRift, my cross-platform game engine with an editor and a 2D/3D rendering pipeline, which is in development and tied to my BSc thesis. This article is general CMake guidance based on the official documentation, not a description of that codebase's build files.
At Legacies, which I co-founded with Horia Stan, I am the Technical Lead: backend architecture, integrations and product stability. Clear component boundaries matter in that role whether the boundary is a CMake target, an API contract or an integration. If you want to see that range, the selected work section lists projects, Legacies explains the company, and legacies.ro is its site. For a project you want to discuss, use contact.
Frequently Asked Questions
Should I use a CMake target per directory?
Usually one target per cohesive component is the right granularity, and that often maps to a directory. The test is whether something else consumes it, such as an executable or a test. Do not split purely to mirror folders.
What is the difference between PUBLIC and INTERFACE?
PUBLIC applies a requirement to the target itself and to its consumers. INTERFACE applies it only to consumers, which is why it suits header-only libraries that have nothing to compile.
Why use an ALIAS target with a namespace?
The :: name must resolve to an alias or imported target, so a typo fails at configure time. It also lets consumers link the same name whether the library is built in-tree or found as an installed package.
Do I need CMake 3.23 for this structure?
Only for header file sets with FILE_SET HEADERS. The rest, including the scope keywords and aliases, works on much older versions, and you can use target_include_directories() if you must support an older CMake.