Monorepo with Multiple Libraries
Why this matters
As a C++ project grows, you inevitably face a choice: split each library into its own repo (multi-repo), or keep everything together (monorepo). Both are valid at different scales, but a monorepo has compelling advantages for tightly-coupled libraries that evolve together:
- Atomic changes — a single commit can update the API in
libs/logger, fix all callers inlibs/storageandapps/service, and add tests. In multi-repo, coordinating this across three repos requires careful version bumping and synchronization. - Local development is fast — in-repo dependencies are built from source directly, no publishing/updating cycle.
- Consistent standards — one
.clang-format, onecode-standards.md, one CI pipeline governs everything.
The cost is a larger, slower-to-clone repo and more complex CI. That trade-off is usually worth it when libraries are consumed primarily by apps in the same repo.
CMake's add_subdirectory is the mechanism: the top-level CMakeLists.txt pulls in every library and app, wires them together through namespaced targets, and runs the full build in a single cmake --build invocation.
Layout
acme/
CMakeLists.txt # top-level: standards, find_package, add_subdirectory
CMakePresets.json
conanfile.py
cmake/ # shared helper modules (optional)
AcmeHelpers.cmake
libs/
logger/
CMakeLists.txt
include/ src/
storage/
CMakeLists.txt # depends on logger
include/ src/ tests/
apps/
service/
CMakeLists.txt # links the libs
src/ tests/The separation between libs/ and apps/ is a convention, not a CMake requirement. It signals intent: libs/ contains reusable, potentially installable targets; apps/ contains final executables that consume them.
Top-level CMakeLists.txt
The rule for the top-level file: set every shared property once, find shared deps once, then add subdirectories. Subdirectory files declare targets — they don't set standards, find packages, or configure build behavior.
cmake_minimum_required(VERSION 3.24)
project(acme LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Runtime linker finds shared deps from both build and install trees.
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)
set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE)
# Shared helper modules under cmake/.
list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
# Find shared deps ONCE here — never repeat find_package in subdirectory files.
find_package(nlohmann_json REQUIRED)
find_package(Catch2 3 REQUIRED)
find_package(spdlog REQUIRED)
# Project-wide warnings target — all libs and apps link this PRIVATE.
add_library(acme_warnings INTERFACE)
if(MSVC)
target_compile_options(acme_warnings INTERFACE /W4 /permissive-)
else()
target_compile_options(acme_warnings INTERFACE
-Wall -Wextra -Wpedantic -Wconversion -Wshadow)
endif()
enable_testing()
# Libraries first — declaration order matters. A target must exist before any subdir references it.
add_subdirectory(libs/logger)
add_subdirectory(libs/storage) # storage depends on logger, so logger must come first
# Apps last — they consume the libs declared above.
add_subdirectory(apps/service)Declaration order is load-bearing. If storage depends on logger, libs/logger must appear before libs/storage in the add_subdirectory calls. CMake processes subdirectories immediately when it encounters add_subdirectory, so a target referenced by storage's CMakeLists.txt must already be defined.
Why call find_package at the top level rather than in each subdirectory? Two reasons:
- It's faster —
find_packagesearches the filesystem; once is better than three times. - It's clear — you see the full dependency surface of the monorepo in one file.
Leaf library — no in-repo deps
libs/logger/CMakeLists.txt:
add_library(logger STATIC src/Logger.cpp)
target_include_directories(logger PUBLIC include)
target_link_libraries(logger
PUBLIC nlohmann_json::nlohmann_json
spdlog::spdlog
PRIVATE acme_warnings)This file is deliberately small. No cmake_minimum_required, no project(), no find_package — those are all in the top-level file. This file's only job is to declare what logger is, what it includes, and what it links.
PUBLIC nlohmann_json::nlohmann_json means consumers of logger (like storage) automatically get nlohmann_json's headers and link flags without having to declare the dependency themselves.
Library depending on another in-repo lib
libs/storage/CMakeLists.txt:
add_library(storage STATIC src/Storage.cpp)
target_include_directories(storage PUBLIC include)
target_link_libraries(storage
PUBLIC
logger # in-repo lib — bare target name
PRIVATE
acme_warnings)
add_subdirectory(tests)In-repo libs are referenced by bare target name (logger). External deps are referenced by their imported target (nlohmann_json::nlohmann_json). This distinction is important: the bare name is a CMake target defined within the same build tree. The imported namespaced target is found via find_package.
PUBLIC logger means storage's consumers also link logger transitively — which is correct if storage.hpp includes logger.hpp. If logger is only used in storage.cpp and doesn't appear in public headers, use PRIVATE logger.
libs/storage/tests/CMakeLists.txt:
add_executable(storage_unit_tests Storage.unit.tests.cpp)
target_link_libraries(storage_unit_tests
PRIVATE storage Catch2::Catch2WithMain)
include(Catch)
catch_discover_tests(storage_unit_tests PROPERTIES LABELS "unit")The LABELS "unit" property lets ctest -L unit run only unit tests, skipping integration or end-to-end tests.
Application
apps/service/CMakeLists.txt:
add_executable(service src/main.cpp)
target_link_libraries(service PRIVATE storage logger acme_warnings)
add_subdirectory(tests)This app links both storage and logger explicitly. Even though storage already has a PUBLIC dependency on logger, it's clearer to list both when main.cpp directly #includes logger headers. Explicit is better than implicit.
Configure, build, and test the whole tree
# Without Conan (if all deps are system-installed):
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
# With Conan:
conan install . --profile:host=profiles/linux-x86_64 \
--profile:build=profiles/linux-x86_64 --build=missing
cmake --preset conan-release
cmake --build --preset conan-release -j
ctest --preset conan-release --output-on-failureSlice by test label:
ctest --test-dir build -L unit # only unit tests
ctest --test-dir build -LE e2e # everything except end-to-end testsConan for the monorepo
One top-level conanfile.py resolves all dependencies across the entire monorepo. Conan deduplicates — if both logger and storage need nlohmann_json, it appears once in the graph:
from conan import ConanFile
from conan.tools.cmake import CMakeToolchain, CMakeDeps, cmake_layout
class Acme(ConanFile):
settings = "os", "arch", "compiler", "build_type"
generators = "CMakeToolchain", "CMakeDeps"
def requirements(self):
# Union of every in-repo target's deps. Conan deduplicates.
self.requires("nlohmann_json/3.11.3")
self.requires("spdlog/1.14.1")
def build_requirements(self):
self.test_requires("catch2/3.7.1")
def layout(self):
cmake_layout(self)Shared helper module
As the monorepo grows, you'll find yourself repeating the same target_link_libraries(${target} PRIVATE acme_warnings) and target_compile_features calls in every leaf CMakeLists.txt. Factor them out into a helper function:
cmake/AcmeHelpers.cmake:
# Apply the project's common per-target settings in one call.
function(acme_configure_target target)
target_link_libraries(${target} PRIVATE acme_warnings)
target_compile_features(${target} PUBLIC cxx_std_17)
set_target_properties(${target} PROPERTIES
POSITION_INDEPENDENT_CODE ON)
endfunction()Use it in a leaf:
include(AcmeHelpers)
add_library(logger STATIC src/Logger.cpp)
target_include_directories(logger PUBLIC include)
target_link_libraries(logger PUBLIC nlohmann_json::nlohmann_json)
acme_configure_target(logger)The top-level list(APPEND CMAKE_MODULE_PATH ...) makes include(AcmeHelpers) resolve without a path prefix.
Versioning across packages
For independent library releases (each lib has its own version):
# libs/storage/CMakeLists.txt
project(storage VERSION 1.4.0) # sub-project() sets PROJECT_VERSION locally
add_library(storage STATIC src/Storage.cpp)
set_target_properties(storage PROPERTIES
VERSION ${PROJECT_VERSION}
SOVERSION ${PROJECT_VERSION_MAJOR})A sub-project() call inside add_subdirectory is legal. It sets PROJECT_VERSION locally for that lib without affecting the top-level project's version.
For lockstep releases (every lib shares the monorepo's calendar version):
# Top-level CMakeLists.txt
project(acme VERSION 2025.06.01 LANGUAGES CXX)
# libs/storage/CMakeLists.txt — inherit the parent version.
add_library(storage STATIC src/Storage.cpp)
set_target_properties(storage PROPERTIES
VERSION ${PROJECT_VERSION} # resolves to the top-level 2025.06.01
SOVERSION ${PROJECT_VERSION_MAJOR})Key conventions
- One place sets standards, RPATH,
enable_testing(), and sharedfind_packagecalls: the top-level file. Subdirectory files stay minimal — declare a target, set includes, list links. - In-repo libs linked by bare target name (
logger); external deps by their imported target (nlohmann_json::nlohmann_json). The difference is intentional and visible. - Declaration order is load-bearing — add a comment when ordering is non-obvious:
# storage depends on logger. - Internal libs are usually
STATIC— linked into the final binaries. Only install/export libs meant to be consumed outside the monorepo (seecmake-structure.mdinstall rules). - Shared CMake helper modules go under
cmake/and are added toCMAKE_MODULE_PATHonce at the top level.