You have a working CMake project. It builds. Tests pass. The CI pipeline is green. But the CMakeLists.txt is 400 lines long, find_package calls break on different systems, and nobody on the team fully understands the build configuration. Sound familiar?
This guide walks through migrating a real-world CMake project to cmod, step by step. We'll convert a project that uses fmt, nlohmann/json, and has both a library and a binary target.
The Starting Point
Our example CMake project:
myapp/
├── CMakeLists.txt
├── cmake/
│ └── FindFmt.cmake
├── include/
│ └── myapp/
│ ├── config.h
│ └── processor.h
├── src/
│ ├── main.cpp
│ ├── processor.cpp
│ └── config.cpp
└── tests/
└── test_processor.cpp
The CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(myapp VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Dependencies — the pain begins
find_package(fmt REQUIRED)
find_package(nlohmann_json 3.11 REQUIRED)
# Library target
add_library(myapp_lib
src/config.cpp
src/processor.cpp
)
target_include_directories(myapp_lib PUBLIC include)
target_link_libraries(myapp_lib PUBLIC fmt::fmt nlohmann_json::nlohmann_json)
# Binary target
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE myapp_lib)
# Tests
enable_testing()
add_executable(test_processor tests/test_processor.cpp)
target_link_libraries(test_processor PRIVATE myapp_lib)
add_test(NAME test_processor COMMAND test_processor)
The problems: find_package depends on system-installed libraries (or vcpkg/Conan), the header-based includes are slow, and there's no lockfile.
Step 1: Convert Headers to Module Interfaces
The first and most impactful change is converting your headers to C++20 module interfaces.
Before (include/myapp/processor.h):
#pragma once
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
namespace myapp {
struct ProcessResult {
std::string output;
int status;
};
ProcessResult process(const std::string& input);
std::vector<ProcessResult> batch_process(const nlohmann::json& config);
}
After (src/myapp.cppm):
export module com.github.user.myapp;
import std; // C++23 standard library module (or individual imports)
export namespace myapp {
struct ProcessResult {
std::string output;
int status;
};
ProcessResult process(const std::string& input);
std::vector<ProcessResult> batch_process(/* json param */);
}
Key changes:
#pragma onceis gone — modules don't need include guards#includebecomesimportexportmarks public API explicitly- Implementation details stay private without any extra effort
Step 2: Create the cmod.toml Manifest
[package]
name = "myapp"
version = "1.0.0"
edition = "2024"
license = "MIT"
[module]
name = "com.github.user.myapp"
root = "src/myapp.cppm"
[dependencies]
"github.com/fmtlib/fmt" = ">=10.0"
"github.com/nlohmann/json" = "^3.11"
[toolchain]
compiler = "clang"
version = ">=18.0"
std = "c++23"
[build]
type = "binary"
Compare this with the 30+ line CMakeLists.txt. The cmod manifest is declarative, readable, and complete.
Step 3: Restructure the Source Tree
Reorganize to follow cmod conventions:
myapp/
├── cmod.toml
├── src/
│ ├── myapp.cppm # Module interface (was include/myapp/*.h)
│ ├── main.cpp # Binary entry point
│ ├── processor.cpp # Module implementation
│ └── config.cpp # Module implementation
└── tests/
└── test_processor.cpp
The include/ directory is gone. With modules, the interface is defined in .cppm files, not headers. The cmake/ directory is gone too — cmod handles dependency resolution.
Step 4: Update Source Files
Update implementation files to use module syntax:
Before (src/processor.cpp):
#include "myapp/processor.h"
#include <fmt/format.h>
#include <algorithm>
namespace myapp {
ProcessResult process(const std::string& input) {
auto output = fmt::format("Processed: {}", input);
return {output, 0};
}
}
After (src/processor.cpp):
module com.github.user.myapp;
import com.github.fmtlib.fmt;
import std;
namespace myapp {
ProcessResult process(const std::string& input) {
auto output = fmt::format("Processed: {}", input);
return {output, 0};
}
}
Update main.cpp:
import com.github.user.myapp;
import com.github.fmtlib.fmt;
int main() {
auto result = myapp::process("hello");
fmt::print("{}\n", result.output);
return result.status;
}
Step 5: Resolve and Build
# Resolve dependencies (fetches from Git)
cmod resolve
# Build
cmod build
# Run
cmod run
# Test
cmod test
That's it. No cmake -B build, no cmake --build build, no figuring out where CMake put the binary. Just cmod build and cmod run.
Step 6: Set Up CI
Replace your CMake CI pipeline:
Before:
- name: Install dependencies
run: |
apt-get install -y libfmt-dev nlohmann-json3-dev
- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build --parallel
- name: Test
run: cd build && ctest --output-on-failure
After:
- name: Build
run: cmod build --locked --release
- name: Test
run: cmod test --locked --release
- name: Verify
run: cmod verify
No system-level dependency installation. cmod resolves everything from Git.
Step 7: Keep CMake Compatibility (Optional)
If some consumers still need CMake, cmod can generate a CMakeLists.txt:
cmod compile-commands # For IDE integration
This generates compile_commands.json that CMake-based IDEs can consume. You can maintain a minimal CMakeLists.txt that wraps cmod for teams that haven't migrated yet.
Common Migration Challenges
Dependencies that don't support modules yet
Not all C++ libraries have module interfaces. For header-only libraries, you can use header units as a bridge:
import <legacy_header.h>; // Import as header unit
For popular libraries, the community is creating module wrappers. cmod's Git-based dependency model makes it easy to point to a fork with module support.
Macro-heavy code
Modules don't export macros (by design — this is a feature, not a bug). If your code relies on macros defined in headers, you'll need to refactor. Common patterns:
- Replace
#define CONSTANT 42withexport constexpr int CONSTANT = 42; - Replace macro functions with
constexprorconstevalfunctions - Replace conditional compilation macros with
if constexpr
Build time comparison
After migration, expect:
- First build: Slightly slower (dependency cloning + BMI generation)
- Incremental builds: Significantly faster (module-aware caching)
- Clean builds: Comparable or faster (parallel module compilation)
The Result
After migration:
- 400-line CMakeLists.txt replaced by a 20-line cmod.toml
- Dependencies resolved from Git, not system packages
- Lockfile ensures reproducible builds
- Faster incremental builds via module-aware caching
- Explicit public API via module exports
- No more "it works on my machine" debugging
Get started with cmod and simplify your C++ build.