Table of Contents

  1. Project Layout
  2. add_subdirectory
  3. Functions
  4. Macros
  5. CMake Modules
  6. cmake_parse_arguments
  7. Variable Scope
  8. Project Decomposition
  9. include() vs add_subdirectory()
  10. Real-World Layout
Back to CMake Mastery Series

Part 16: Structuring Projects

June 4, 2026 Wasil Zafar 45 min read

Master professional CMake project layout conventions, functions, macros, modules, variable scope, and multi-library decomposition for maintainable build systems.

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.

Key Insight: A good project layout makes the build system predictable. When developers can guess where files live without reading documentation, onboarding becomes trivial and maintenance stays manageable as the codebase scales.

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
Project Directory Hierarchy
        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:

DirectoryPurposeContains
src/Implementation files.cpp, private headers
include/Public API headers.h/.hpp installed for consumers
apps/Application entry pointsmain.cpp and executables
tests/Test codeUnit tests, integration tests
cmake/Build infrastructureFind modules, helper functions
extern/Third-party codeGit 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)
Important: 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
)
Hands-On Function Argument Parsing
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.

function cmake_parse_arguments reusable

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

Aspectfunction()macro()
Variable scopeNew scope (isolated)Caller's scope (no isolation)
return()Returns from functionReturns from caller
ARGN, ARGVReal variablesString substitutions (not true variables)
Best forComplex logic, target creationSetting variables in caller's scope
DebuggingPredictable scopeCan cause surprising side effects
Warning: Prefer functions over macros. Macros' lack of scope makes them harder to reason about and debug. Use macros only when you intentionally need to modify the caller's scope — such as setting project-wide defaults.

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
)
Hands-On Module Creation
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.

module sanitizer reusable

Variable Scope

Understanding CMake's scope model is critical for avoiding surprises in multi-directory projects. CMake has three scope mechanisms:

CMake Variable Scope Hierarchy
        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")
Best Practice: Avoid cache variables as a scope-escape mechanism. Instead, design your project so targets carry all necessary information through their properties. Use 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:

Aspectinclude()add_subdirectory()
ScopeExecutes in current scopeCreates new child scope
Binary directoryNo separate binary dirGets own binary subdirectory
Input.cmake file (any path)Directory with CMakeLists.txt
VariablesAffects current scope directlyIsolated (needs PARENT_SCOPE)
Use caseUtility functions, settingsSub-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
Hands-On Full Project Setup
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.

structure refactoring multi-directory

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)
Key Takeaway: The hallmark of a well-structured CMake project is that each 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.