Project Layout Conventions
As CMake projects grow beyond a single directory, a well-defined layout becomes essential for maintainability. The C++ community has converged on conventions documented in resources like the official CMake tutorial and tools like cmake-init.
The Canonical Directory Structure
The widely-adopted layout separates concerns into distinct directories:
myproject/
├── CMakeLists.txt # Top-level: project(), options, add_subdirectory()
├── cmake/ # Custom .cmake modules (Find*.cmake, helpers)
│ ├── MyProjectConfig.cmake.in
│ └── CompilerWarnings.cmake
├── src/ # Library source files
│ ├── CMakeLists.txt
│ ├── core/
│ │ ├── CMakeLists.txt
│ │ ├── engine.cpp
│ │ └── parser.cpp
│ └── utils/
│ ├── CMakeLists.txt
│ └── string_utils.cpp
├── include/ # Public headers (installed with the library)
│ └── myproject/
│ ├── engine.h
│ ├── parser.h
│ └── string_utils.h
├── apps/ # Application executables
│ ├── CMakeLists.txt
│ └── main.cpp
├── tests/ # Unit and integration tests
│ ├── CMakeLists.txt
│ ├── test_engine.cpp
│ └── test_parser.cpp
├── docs/ # Documentation (Doxygen, Sphinx)
├── extern/ # Third-party dependencies (submodules, vendored)
└── CMakePresets.json # Build presets for reproducibility
flowchart TD
ROOT[myproject/] --> TLC[CMakeLists.txt
project, options]
ROOT --> CMAKE[cmake/
modules, helpers]
ROOT --> SRC[src/
library sources]
ROOT --> INC[include/
public headers]
ROOT --> APPS[apps/
executables]
ROOT --> TESTS[tests/
test suites]
ROOT --> EXT[extern/
third-party]
SRC --> CORE[core/]
SRC --> UTILS[utils/]
CORE --> CORECML[CMakeLists.txt]
UTILS --> UTILCML[CMakeLists.txt]
Each directory has a clear responsibility:
| Directory | Purpose | Contains |
|---|---|---|
src/ | Implementation files | .cpp, private headers |
include/ | Public API headers | .h/.hpp installed for consumers |
apps/ | Application entry points | main.cpp and executables |
tests/ | Test code | Unit tests, integration tests |
cmake/ | Build infrastructure | Find modules, helper functions |
extern/ | Third-party code | Git submodules, vendored libraries |
The top-level CMakeLists.txt orchestrates everything:
cmake_minimum_required(VERSION 3.21)
project(MyProject
VERSION 2.1.0
DESCRIPTION "A well-structured C++ project"
LANGUAGES CXX
)
# Make our cmake/ directory available for include()
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# Project-wide settings
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Options
option(MYPROJECT_BUILD_TESTS "Build unit tests" ON)
option(MYPROJECT_BUILD_APPS "Build applications" ON)
# Libraries (always built)
add_subdirectory(src)
# Applications (optional)
if(MYPROJECT_BUILD_APPS)
add_subdirectory(apps)
endif()
# Tests (optional)
if(MYPROJECT_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
add_subdirectory
The add_subdirectory() command introduces a child directory into the build. Each child gets its own scope for variables while sharing access to targets defined elsewhere.
# Basic usage — directory must contain a CMakeLists.txt
add_subdirectory(src)
# Specify a custom binary directory (useful for out-of-tree sources)
add_subdirectory(src ${CMAKE_BINARY_DIR}/src-build)
# Add an external directory (not a subdirectory of current source)
add_subdirectory(/path/to/external/lib external_lib_build)
Scope Isolation and Variable Propagation
When CMake enters a subdirectory, it creates a new variable scope — a copy of all parent variables. Changes inside the child do not affect the parent unless explicitly propagated:
# parent/CMakeLists.txt
set(MY_VAR "parent_value")
message(STATUS "Before: ${MY_VAR}") # "parent_value"
add_subdirectory(child)
message(STATUS "After: ${MY_VAR}") # Still "parent_value"
# parent/child/CMakeLists.txt
set(MY_VAR "child_value") # Only modifies child's copy
message(STATUS "In child: ${MY_VAR}") # "child_value"
# To propagate back to parent:
set(MY_VAR "child_value" PARENT_SCOPE)
PARENT_SCOPE only sets the variable in the parent — it does NOT also set it in the current scope. If you need both, use two set() calls.
Functions
CMake functions encapsulate reusable logic with their own variable scope. They are the primary mechanism for DRY (Don't Repeat Yourself) build code.
# Define a function
function(my_add_library)
# ARGC — number of arguments
# ARGV — all arguments as a list
# ARGN — arguments after named parameters
# ARGVn — individual arguments (ARGV0, ARGV1, etc.)
set(target_name ${ARGV0})
set(sources ${ARGN}) # Everything after first arg
add_library(${target_name} ${sources})
target_compile_features(${target_name} PRIVATE cxx_std_20)
target_include_directories(${target_name}
PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
endfunction()
# Call the function
my_add_library(mylib src/foo.cpp src/bar.cpp)
Named Arguments with cmake_parse_arguments
For functions with complex signatures, use cmake_parse_arguments():
function(my_add_library)
# Parse arguments:
# Options (booleans): STATIC, SHARED
# One-value keywords: NAME, NAMESPACE
# Multi-value keywords: SOURCES, DEPENDS, PUBLIC_HEADERS
cmake_parse_arguments(ARG
"STATIC;SHARED" # Options
"NAME;NAMESPACE" # One-value
"SOURCES;DEPENDS;PUBLIC_HEADERS" # Multi-value
${ARGN}
)
# Validate required arguments
if(NOT ARG_NAME)
message(FATAL_ERROR "my_add_library: NAME is required")
endif()
if(NOT ARG_SOURCES)
message(FATAL_ERROR "my_add_library: SOURCES is required")
endif()
# Determine library type
if(ARG_STATIC)
set(lib_type STATIC)
elseif(ARG_SHARED)
set(lib_type SHARED)
else()
set(lib_type "") # Let BUILD_SHARED_LIBS decide
endif()
# Create the library
add_library(${ARG_NAME} ${lib_type} ${ARG_SOURCES})
# Link dependencies
if(ARG_DEPENDS)
target_link_libraries(${ARG_NAME} PRIVATE ${ARG_DEPENDS})
endif()
# Namespace alias
if(ARG_NAMESPACE)
add_library(${ARG_NAMESPACE}::${ARG_NAME} ALIAS ${ARG_NAME})
endif()
endfunction()
# Usage with named arguments
my_add_library(
NAME core
NAMESPACE MyProject
SOURCES src/engine.cpp src/parser.cpp
DEPENDS fmt::fmt
PUBLIC_HEADERS include/myproject/engine.h
)
Try It: Build a Reusable Function
Create a function called add_project_test that accepts NAME, SOURCES, and DEPENDS keywords. Have it create a test executable, link dependencies, and register it with CTest via add_test(). Use cmake_parse_arguments to handle the keyword arguments cleanly.
Test it by adding 3 test targets to your project with a single line each.
Macros
CMake macros look similar to functions but operate differently — they perform text substitution rather than creating a new scope.
# Define a macro
macro(set_project_defaults)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
endmacro()
# Call it — variables are set in the CALLER's scope
set_project_defaults()
When to Use Macros vs Functions
| Aspect | function() | macro() |
|---|---|---|
| Variable scope | New scope (isolated) | Caller's scope (no isolation) |
return() | Returns from function | Returns from caller |
ARGN, ARGV | Real variables | String substitutions (not true variables) |
| Best for | Complex logic, target creation | Setting variables in caller's scope |
| Debugging | Predictable scope | Can cause surprising side effects |
CMake Modules
CMake modules are .cmake files containing reusable functions, macros, and logic. They are loaded with include() and execute in the current scope (like macros, not like add_subdirectory()).
# cmake/CompilerWarnings.cmake
function(set_project_warnings target)
set(MSVC_WARNINGS /W4 /WX /permissive-)
set(CLANG_WARNINGS -Wall -Wextra -Wpedantic -Werror)
set(GCC_WARNINGS ${CLANG_WARNINGS} -Wmisleading-indentation)
if(MSVC)
set(warnings ${MSVC_WARNINGS})
elseif(CMAKE_CXX_COMPILER_ID MATCHES ".*Clang")
set(warnings ${CLANG_WARNINGS})
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
set(warnings ${GCC_WARNINGS})
endif()
target_compile_options(${target} PRIVATE ${warnings})
endfunction()
CMAKE_MODULE_PATH
Tell CMake where to find your custom modules:
# Top-level CMakeLists.txt
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
# Now you can include your modules by name
include(CompilerWarnings) # Loads cmake/CompilerWarnings.cmake
# Use the function defined in the module
set_project_warnings(mylib)
CMake also ships with dozens of built-in modules:
# Built-in modules (no path configuration needed)
include(GNUInstallDirs) # Provides CMAKE_INSTALL_LIBDIR, etc.
include(CMakePackageConfigHelpers) # version_file, configure_package_config
include(CheckCXXCompilerFlag) # Check if compiler supports a flag
include(FetchContent) # Download dependencies at configure time
cmake_parse_arguments In Depth
The cmake_parse_arguments() command is the standard way to create professional-quality CMake functions with keyword-based interfaces:
cmake_parse_arguments(
<prefix> # Variable prefix for parsed results
"<options>" # Boolean flags (present = TRUE)
"<one_value>" # Keywords expecting exactly one value
"<multi_value>" # Keywords expecting one or more values
${ARGN} # Arguments to parse
)
After parsing, variables are set as <prefix>_<KEYWORD>:
function(deploy_target)
cmake_parse_arguments(DEPLOY
"STRIP;SIGN" # Options → DEPLOY_STRIP, DEPLOY_SIGN
"DESTINATION;CONFIG" # One-value → DEPLOY_DESTINATION, DEPLOY_CONFIG
"TARGETS;RESOURCES" # Multi-value → DEPLOY_TARGETS, DEPLOY_RESOURCES
${ARGN}
)
# DEPLOY_UNPARSED_ARGUMENTS — anything that didn't match
if(DEPLOY_UNPARSED_ARGUMENTS)
message(WARNING "Unknown arguments: ${DEPLOY_UNPARSED_ARGUMENTS}")
endif()
# DEPLOY_KEYWORDS_MISSING_VALUES — keywords provided without values
if(DEPLOY_KEYWORDS_MISSING_VALUES)
message(FATAL_ERROR "Missing values for: ${DEPLOY_KEYWORDS_MISSING_VALUES}")
endif()
message(STATUS "Deploying to: ${DEPLOY_DESTINATION}")
message(STATUS "Targets: ${DEPLOY_TARGETS}")
message(STATUS "Strip: ${DEPLOY_STRIP}")
endfunction()
# Usage
deploy_target(
TARGETS myapp mylib
DESTINATION /opt/myproject
CONFIG Release
STRIP
RESOURCES assets/logo.png assets/config.json
)
Try It: Create a Reusable Module
Create cmake/AddSanitizers.cmake that defines a function enable_sanitizers(target). The function should accept ADDRESS, UNDEFINED, and THREAD options and apply the appropriate -fsanitize= flags. Include it in your top-level CMakeLists.txt and apply it to your test targets.
Variable Scope
Understanding CMake's scope model is critical for avoiding surprises in multi-directory projects. CMake has three scope mechanisms:
flowchart TD
CACHE[Cache Variables
CMakeCache.txt
Global, persistent] --> DIR_ROOT[Root Directory Scope]
DIR_ROOT --> DIR_SRC[src/ Directory Scope
add_subdirectory creates copy]
DIR_ROOT --> DIR_APPS[apps/ Directory Scope]
DIR_ROOT --> DIR_TESTS[tests/ Directory Scope]
DIR_SRC --> FUNC_A[function_a Scope
Isolated copy]
DIR_SRC --> FUNC_B[function_b Scope]
FUNC_A -.->|PARENT_SCOPE| DIR_SRC
DIR_SRC -.->|PARENT_SCOPE| DIR_ROOT
PARENT_SCOPE and Cache Variables
# Directory scope — each add_subdirectory creates a child scope
# Variables set in child do NOT propagate up automatically
# PARENT_SCOPE — set a variable in the immediate parent
function(compute_version)
# ... compute version string ...
set(COMPUTED_VERSION "2.1.3" PARENT_SCOPE) # Sets in caller, NOT here
endfunction()
# Cache variables — global escape hatch (persists in CMakeCache.txt)
set(MY_GLOBAL_SETTING "value" CACHE STRING "A project-wide setting")
# FORCE overwrites even if already in cache
set(MY_GLOBAL_SETTING "new_value" CACHE STRING "Updated" FORCE)
# INTERNAL cache variables — hidden from cmake-gui
set(MY_INTERNAL_VAR "computed" CACHE INTERNAL "Not user-visible")
PARENT_SCOPE sparingly and only when a child directory genuinely needs to communicate a result back to its parent.
Project Decomposition
A well-decomposed project separates libraries, applications, and tests into independent subdirectories, each with its own CMakeLists.txt:
# src/core/CMakeLists.txt — defines the core library
add_library(core
engine.cpp
parser.cpp
)
target_include_directories(core
PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
add_library(MyProject::core ALIAS core)
# src/utils/CMakeLists.txt — defines the utils library
add_library(utils
string_utils.cpp
)
target_include_directories(utils
PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_link_libraries(utils PUBLIC MyProject::core)
add_library(MyProject::utils ALIAS utils)
# apps/CMakeLists.txt — builds executables
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE MyProject::core MyProject::utils)
# tests/CMakeLists.txt — builds and registers tests
find_package(GTest REQUIRED)
add_executable(test_engine test_engine.cpp)
target_link_libraries(test_engine PRIVATE MyProject::core GTest::gtest_main)
add_test(NAME test_engine COMMAND test_engine)
add_executable(test_parser test_parser.cpp)
target_link_libraries(test_parser PRIVATE MyProject::core GTest::gtest_main)
add_test(NAME test_parser COMMAND test_parser)
include() vs add_subdirectory()
These two commands both bring external CMake code into your project but behave very differently:
| Aspect | include() | add_subdirectory() |
|---|---|---|
| Scope | Executes in current scope | Creates new child scope |
| Binary directory | No separate binary dir | Gets own binary subdirectory |
| Input | .cmake file (any path) | Directory with CMakeLists.txt |
| Variables | Affects current scope directly | Isolated (needs PARENT_SCOPE) |
| Use case | Utility functions, settings | Sub-projects, libraries, apps |
# include() — for loading utility modules
include(cmake/CompilerWarnings.cmake) # Runs in current scope
include(GNUInstallDirs) # Built-in module
# add_subdirectory() — for sub-projects with their own targets
add_subdirectory(src) # Has its own CMakeLists.txt + scope
add_subdirectory(tests)
# AVOID: file(GLOB) for source files (fragile, CMake doesn't re-run on new files)
file(GLOB sources CONFIGURE_DEPENDS "src/*.cpp") # Less bad with CONFIGURE_DEPENDS
# PREFER: Explicit source lists
set(SOURCES src/foo.cpp src/bar.cpp) # Always up-to-date
Try It: Restructure a Flat Project
Take a single-directory project with 3 source files and a test. Restructure it into the canonical layout: move sources to src/, headers to include/project/, tests to tests/, and the main application to apps/. Create a CMakeLists.txt in each directory. Verify all targets still build and tests pass.
Real-World Layout
Here's a complete example of a multi-library project with proper target organization, inspired by real-world open-source projects:
# Top-level CMakeLists.txt
cmake_minimum_required(VERSION 3.21)
project(NetworkKit VERSION 3.0.0 LANGUAGES CXX)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(CompilerWarnings)
include(Sanitizers)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
option(NETKIT_BUILD_TESTS "Build tests" ON)
option(NETKIT_BUILD_EXAMPLES "Build examples" ON)
option(NETKIT_ENABLE_ASAN "Enable AddressSanitizer" OFF)
# Core libraries
add_subdirectory(src/io) # NetKit::io
add_subdirectory(src/protocol) # NetKit::protocol
add_subdirectory(src/http) # NetKit::http (depends on io, protocol)
# Applications
if(NETKIT_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
# Tests
if(NETKIT_BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()
# src/http/CMakeLists.txt
add_library(http
client.cpp
server.cpp
request.cpp
response.cpp
)
target_include_directories(http
PUBLIC
$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/detail
)
target_link_libraries(http
PUBLIC NetKit::io NetKit::protocol
PRIVATE OpenSSL::SSL
)
set_project_warnings(http)
# Namespace alias for internal and external consumers
add_library(NetKit::http ALIAS http)
CMakeLists.txt is small, focused, and understandable in isolation. Targets explicitly declare their dependencies, and consumers link against namespace aliases (NetKit::http) making it clear the dependency is a project target vs a system library.