Adopting C++20 Modules: A Practical Migration Guide for Medium-Sized Codebases
Step‑by‑step guide to adopting C++20 modules in a medium‑sized codebase: pick a leaf library, write module interface and implementation files, update consumers to import, configure CMake 3.28+, verify encapsulation and incremental build speedups, and recover with header shims if needed.
18 Dec 2025, 13:36 UTC

Desired Outcome
Adopting C++20 modules reduces incremental build times, eliminates header‑inclusion order bugs, and enforces encapsulation by exposing only explicitly exported names. For a medium‑sized codebase (50k–500k lines), the payoff is measurable: changing a private implementation detail no longer triggers recompilation of every translation unit that included the header.
Prerequisites
- Compiler: GCC 11+ (
-std=c++20 -fmodules), Clang 13+ (-std=c++20 -fmodules), or MSVC 19.28+ (/std:c++20 /experimental:module). - Build system: CMake 3.28+ with
CMAKE_CXX_SCAN_FOR_MODULES, Meson 1.0+, or Bazel 6+. - Codebase structure: Logical components with clear public APIs. Modules work best when a library has a well‑defined surface area.
- No cyclic dependencies: Modules cannot form import cycles. Refactor any header cycles before starting.
Procedure: Migrate a Leaf Library First
Start with a leaf component—one that no other internal library depends on. This limits blast radius and lets you validate the toolchain.
1. Create the module interface file
Choose an extension your build system recognizes: .ixx (MSVC convention), .cppm (Clang/GCC), or .mxx. Place it alongside the library’s public headers.
// math.ixx
export module math;
export namespace math {
double add(double a, double b);
double multiply(double a, double b);
// Private helpers stay unexported
namespace detail {
double validate(double v);
}
}Run this step in the library’s source directory. No elevated permissions needed.
2. Implement in a module implementation file
// math_impl.cpp
module math;
namespace math {
double add(double a, double b) { return a + b; }
double multiply(double a, double b) { return a * b; }
namespace detail {
double validate(double v) { return v; }
}
}The implementation file uses module math; (no export). It can include standard headers or legacy headers inside the global module fragment if needed:
// math_impl.cpp
module;
#include <legacy_config.h> // Only here, not in the interface
module math;
// ... implementationRisk: Anything in the global module fragment (module;) is visible to all modules in the same translation unit, weakening encapsulation. Use sparingly.
3. Update consumers to import
Replace #include "math.h" with import math; in every translation unit that uses the library.
// consumer.cpp
import math;
#include <iostream>
int main() {
std::cout << math::add(1.0, 2.0) << '\n';
// math::detail::validate(3.0); // Compile error: not exported
}4. Configure the build system (CMake 3.28+ example)
cmake_minimum_required(VERSION 3.28)
project(MathLib LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_SCAN_FOR_MODULES ON) # Enables module discovery
add_library(math math.ixx math_impl.cpp)
target_compile_features(math PUBLIC cxx_std_20)
add_executable(consumer consumer.cpp)
target_link_libraries(consumer PRIVATE math)Run cmake -B build -S . from the project root, then cmake --build build. CMake scans .ixx/.cppm files, compiles module interfaces before dependents, and generates BMI (Binary Module Interface) files in the build tree.
Expected Checks
- Module compilation succeeds:
cmake --build build --target mathproducesmath.pcm(Clang) or.ifc(MSVC) in the build directory. - Encapsulation enforced: Attempting to use
math::detail::validateinconsumer.cppyields a compile error (unknown symbol). - Incremental build speedup: Touch a private implementation file (
math_impl.cpp) and rebuild. Only that TU and the module interface recompile;consumer.cppdoes not. Measure withtime ninja -C build -j1(Linux/macOS) ormsbuild /t:Rebuild /p:Configuration=Release(Windows). - Static analysis clean: Run
clang-tidy -p build consumer.cppandcppcheck --std=c++20 --enable=all .to catch ODR violations or missing exports.
Recovery Options
- Revert to headers: If a module breaks ABI or causes ODR issues, rename
math.ixxtomath.h, restore#includein consumers, and drop the module build rules. - Header shim: Keep the module but provide a thin header that does
#include <module_interface>via#ifdef __has_includeguards for consumers not yet migrated. - Global module fragment fallback: For a single troublesome legacy header, wrap it in
module; #include "legacy.h"inside the implementation file only. This is a temporary bridge, not a long‑term pattern.
Limitations and Portability Notes
- BMIs are compiler‑specific. Do not distribute
.pcm,.ifc, or.bmifiles. Ship source module interfaces (.ixx/.cppm) and let each consumer’s toolchain build its own BMIs. - Third‑party libraries: For headers without module support, use
import <vector>;(header units) where the compiler supports it, or fall back to#include <vector>in the global module fragment. - Mixed translation units: Avoid
importand#includeof the same logical component in one TU during transition. It creates two definitions of the same entities. - Compiler flag drift: GCC 11–12 require
-fmodules-tsfor the TS dialect; GCC 13+ uses-fmoduleswith-std=c++20. Clang needs-fmodules -fimplicit-module-mapsfor header units. Pin compiler versions in CI.
Verification Checklist
| Check | Command | Pass Criteria |
|---|---|---|
| Clean build | cmake --build build --clean-first | All targets build without errors |
| Incremental build (private change) | touch math_impl.cpp && time ninja -C build -j1 | Only math_impl.cpp and module interface recompile |
| Encapsulation test | Add math::detail::validate(0); to consumer | Compilation fails with unknown symbol |
| Static analysis | clang-tidy -p build $(find . -name '*.cpp' -o -name '*.ixx') | No ODR or missing-export warnings |
Next Steps
After validating the leaf library, migrate the next layer upward. Each step follows the same pattern: interface file, implementation file, consumer updates, build config. Track incremental build times per component to quantify the investment. For large codebases, expect the migration to span multiple sprints; treat each module as an independent deliverable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.