Skip to content
C++

Migrating a Library from Headers to Modules

The theoretical benefits of C++20 modules — compile-once semantics, no macro leakage, order-independent imports — only materialise once you actually convert your code. This page walks through migrating a real geometry library step by step: you will see what the header version looks like, how each piece maps to a module interface unit, and which migration decisions arise along the way. We then look at how to split a module into partitions once it grows large enough to warrant it.

The starting point: a header-based library

Consider a small geometry library distributed as a single header, geometry.h. It provides a point template, a type alias for integer coordinates, a compile-time zero constant, a distance function, and a user-defined literal for constructing integer points. This is a realistic mix: templates, constexpr objects, and UDLs — all things that appear in real library headers.

// geometry.h — header-based library (before migration)
#pragma once
#include <cmath>
#include <string>

namespace Geometry
{
    // Generic 2-D point
    template <typename T>
    struct point
    {
        T x { };
        T y { };
    };

    // Convenience alias for integer coordinates
    using int_point = point<int>;

    // Compile-time zero point
    constexpr int_point int_point_zero { 0, 0 };

    // Distance between two points
    template <typename T>
    double distance(point<T> p1, point<T> p2)
    {
        return std::sqrt(
            std::pow(p2.x - p1.x, 2) +
            std::pow(p2.y - p1.y, 2)
        );
    }
}

// User-defined literal in a nested namespace
namespace Geometry::geometry_literals
{
    Geometry::int_point operator""_ip(const char* str, std::size_t len)
    {
        // "3,5" → int_point{3, 5}
        std::string s { str, len };
        auto comma = s.find(',');
        return Geometry::int_point {
            std::stoi(s.substr(0, comma)),
            std::stoi(s.substr(comma + 1))
        };
    }
}
// main.cpp — consuming the header
#include "geometry.h"
#include <format>
#include <print>

using namespace Geometry;
using namespace Geometry::geometry_literals;

int main()
{
    auto p1 { "3,4"_ip };
    auto p2 = int_point { 7, 1 };
    std::println("distance = {:.2f}", distance(p1, p2));
}

This is entirely typical header code. The migration to a module will not change any of these APIs — only the way the library is packaged and imported.

Anatomy of a Module Interface Unit — four parts

A module interface file (conventionally .cppm) has up to four distinct sections that must appear in a fixed order. Understanding the sections makes migration mechanical: you are not rewriting logic, only reshuffling declarations into the right slots.

Global module fragment

module; … (preprocessor directives only)

Everything before the named module declaration. The only valid content here is preprocessor directives — #include, #define, #pragma. Use it when a legacy header you cannot import defines macros that your module needs. This section is optional; omit it if you have no such headers.

Named module declaration

export module geometry;

The mandatory first non-preprocessor line. It establishes the module's identity. Everything after this point is inside the module's purview — it cannot be reached by the preprocessor from outside. Changing this line to module geometry; (no export) turns the file into a module implementation file.

Module preamble

import <cmath>; import std;

Import declarations that must follow the module declaration but precede any other declarations. You can import named modules, importable standard library headers, or header units here. These imports are inherited by module implementation files but not by module consumers.

Module purview

export class Point { … }; (rest of file)

The rest of the file: your exported types, functions, aliases, and constants, plus any module-private helpers. Only entities explicitly marked export (or inside an export namespace or export block) are visible to importers. Everything else has module linkage — invisible outside.

Most libraries won't need the global module fragment at all — use import <cmath>; instead of #include <cmath> in the preamble. The global module fragment exists solely for legacy headers whose correct behaviour depends on preprocessor macros that the importer must define before the header is included.

The geometry library as a module

With the four-part structure in mind, the migration is straightforward. Replace #pragma once and #includes with the module declaration and import declarations. Add export to everything that should be visible to importers. Template definitions stay in the interface file — unlike function bodies of non-template functions, template full definitions must be visible at the call site, so they live in the module interface, not a separate implementation file.

// geometry.cppm — module interface file (migrated)

// ① Global module fragment — empty here; <cmath> is importable
// module;
// #include <some_legacy_macro_header.h>  ← only if needed

// ② Named module declaration
export module geometry;

// ③ Module preamble — imports only
import <cmath>;
import <string>;

// ④ Module purview — exports + module-private helpers
export namespace Geometry
{
    // Template — full definition must live in the interface file
    template <typename T>
    struct point
    {
        T x { };
        T y { };
    };

    using int_point = point<int>;

    constexpr int_point int_point_zero { 0, 0 };

    template <typename T>
    double distance(point<T> p1, point<T> p2)
    {
        return std::sqrt(
            std::pow(p2.x - p1.x, 2) +
            std::pow(p2.y - p1.y, 2)
        );
    }
}

export namespace Geometry::geometry_literals
{
    Geometry::int_point operator""_ip(const char* str, std::size_t len)
    {
        std::string s { str, len };
        auto comma = s.find(',');
        return Geometry::int_point {
            std::stoi(s.substr(0, comma)),
            std::stoi(s.substr(comma + 1))
        };
    }
}
// main.cpp — consuming the module
import geometry;    // replaces #include "geometry.h"
import std;         // needed to name std::println by name

using namespace Geometry;
using namespace Geometry::geometry_literals;

int main()
{
    auto p1 { "3,4"_ip };
    auto p2 = int_point { 7, 1 };
    std::println("distance = {:.2f}", distance(p1, p2));
}

The consumer code changes in exactly one line: #include "geometry.h" becomes import geometry;. Everything else — type aliases, UDLs, template functions — works identically.

Modules and namespaces are orthogonal

A common confusion: does the module name create a namespace? No. The module name geometry has no effect on namespaces at all. The dot in a module name like mycompany.geometry.core is purely conventional — the compiler treats the whole string as an atomic identifier. A module named geometry can export things in the Geometry namespace, the global namespace, or any other namespace; there is no coupling between the two concepts.

Module name ≠ namespace

// Module is named "geometry"
// but exports into the "Geo" namespace
export module geometry;

export namespace Geo
{
    template <typename T>
    struct Point { T x, y; };
}

// Consumer:
import geometry;
Geo::Point<int> p;  // namespace is "Geo"

One module, multiple namespaces

export module geometry;

export namespace Geometry       { /* … */ }
export namespace Geometry::literals { /* … */ }

// Also perfectly legal:
export void globalHelper() { /* … */ }
// exports into global namespace
// from a named module

This orthogonality matters when migrating: you don't need to rename your namespaces to match the module name, and you don't need your module name to match your namespace hierarchy. Choose module names for build-system granularity; choose namespace names for API clarity — they are independent decisions.

Templates — must stay in the interface file

One of the most commonly misunderstood migration constraints is template placement. The rule is the same as with headers: the compiler needs the complete template definition at the point of instantiation. In the module model, that means template definitions must live in a module interface file (where they are visible to the compiler when processing imports), not in a module implementation file (which is compiled separately and whose definitions are not visible to consumers).

Wrong — template definition in implementation file
// geometry.cppm
export module geometry;
export template <typename T>
struct point { T x, y; };

export template <typename T>
double distance(point<T> p1, point<T> p2);
//      ↑ declaration only — body in geometry.cpp

// geometry.cpp
module geometry;
template <typename T>
double distance(point<T> p1, point<T> p2)
{
    /* ... */    // linker error at the call site:
}                // instantiation not visible to users
Correct — template definition in interface file
// geometry.cppm
export module geometry;

export template <typename T>
struct point { T x, y; };

// Full definition in the interface file
export template <typename T>
double distance(point<T> p1, point<T> p2)
{
    return std::sqrt(
        std::pow(p2.x - p1.x, 2) +
        std::pow(p2.y - p1.y, 2)
    );
}
// Users can instantiate at any T

Non-template exported functions, by contrast, can and should have their bodies in a separate module implementation file. Changing the body of a non-template function in geometry.cpp does not require recompilation of any code that imports geometry — a genuine build-time win that templates cannot get.

Splitting a growing module with partitions

Once the geometry library grows to include vectors, matrices, shapes, and spatial algorithms, keeping everything in one .cppm becomes unwieldy. Module partitions are the mechanism for splitting a single module across multiple files without making that internal structure visible to consumers. Each partition file handles one topic; the primary module interface file pulls them all together with export import :partition_name;.

// geometry.core.cppm — interface partition for primitives
export module geometry:core;
import <cmath>;
import <string>;

export namespace Geometry
{
    template <typename T>
    struct point { T x, y; };

    using int_point = point<int>;
    constexpr int_point int_point_zero { 0, 0 };

    template <typename T>
    double distance(point<T> p1, point<T> p2)
    {
        return std::sqrt(std::pow(p2.x-p1.x,2) + std::pow(p2.y-p1.y,2));
    }
}

export namespace Geometry::geometry_literals
{
    Geometry::int_point operator""_ip(const char* str, std::size_t len);
    // implementation in geometry.literals.cpp
}
// geometry.shapes.cppm — interface partition for shapes
export module geometry:shapes;
import :core;   // import the core partition (within the same module)

export namespace Geometry
{
    struct Circle
    {
        point<double> center;
        double radius;
        double area() const;
    };

    struct Rectangle
    {
        point<double> top_left, bottom_right;
        double area() const;
    };
}
// geometry.cppm — primary module interface file
export module geometry;    // no colon — this is the PRIMARY interface

export import :core;       // import AND re-export the core partition
export import :shapes;     // import AND re-export the shapes partition
// main.cpp — consumer sees one flat module interface
import geometry;           // gets core + shapes — the partition split is invisible

Geometry::Circle c { { 0.0, 0.0 }, 5.0 };
auto p = "3,4"_ip;        // UDL from :core partition, re-exported by primary

Key rule: partitions are invisible to consumers

import geometry:core; compiles from within the module itself. External code writing import geometry:core; is a compile error — the colon form is reserved for intra-module imports. Users always import the whole module; the partition structure is purely an author-facing organisation tool.

Implementation partitions for shared helpers

Interface partitions (declared with export module name:part;) carry exported names. There is a second kind: implementation partitions, declared with module name:part; — no export. An implementation partition cannot export anything; it exists to let multiple module implementation files share a body of private helper code that belongs to the module but should not appear in the interface.

// geometry.math_utils.cpp — implementation partition (no export keyword)
module geometry:math_utils;    // note: module, not export module

namespace Geometry
{
    double clamp_angle(double radians)
    {
        // private math helper — never exported
        while (radians < 0)    radians += 2 * 3.14159265;
        while (radians > 2*3.14159265) radians -= 2 * 3.14159265;
        return radians;
    }
}
// geometry.shapes.cpp — module implementation file for shapes
module geometry;        // implements the geometry module
import :math_utils;     // import the implementation partition
import :core;           // also needs core types

double Geometry::Circle::area() const { return 3.14159265 * radius * radius; }
double Geometry::Rectangle::area() const
{
    auto w = std::abs(bottom_right.x - top_left.x);
    auto h = std::abs(bottom_right.y - top_left.y);
    return w * h;
}

When to use partitions vs. separate modules

The partition decision usually comes down to one question: should external code ever be able to import only a part of your library? If yes, use separate named modules. If the entire library is logically one unit and the decomposition is purely for maintainability, use partitions.

CriterionUse partitionsUse separate modules
Consumer import granularityAll consumers import the whole library — decomposition is internalConsumers benefit from importing only what they need (e.g., just shapes)
Public ABI surfaceLibrary is one logical unit; structure is an implementation detailSub-libraries are stable, versioned units that users may depend on separately
Build timeAll partitions rebuild when any partition interface changesChanging geometry.shapes only recompiles importers of that specific module
Cross-partition visibilityOne partition can import another with import :part; — still internalSeparate modules import each other with full module names; any code can depend on either
Typical scenarioOne coherent library with logically distinct subsystems (e.g., std is one module with many partitions)A larger framework where each component is independently useful (e.g., boost.filesystem, boost.regex)

Common migration pitfalls

Putting template bodies in implementation files

Templates (both class templates and function templates) must be fully defined in the module interface file, or in an interface partition. The compiler must see the full definition at the point of instantiation.

Assuming module-internal imports are inherited by consumers

If the geometry module imports <cmath>, consumers do not automatically get cmath names. They must import what they need explicitly. Only module implementation files inherit interface imports — consumers do not.

Using macros from headers inside the module purview

Move any #include that defines needed macros into the global module fragment (before the export module line). Only preprocessor directives are valid there — no declarations.

Forgetting export import :part; in the primary interface file

Interface partitions declare their own exports, but those exports are not visible to consumers until the primary module interface file re-exports them with export import :part;. A missing re-export means users see an incomplete API.

Trying to export from an implementation file

Files that open with module name; (no export keyword) are implementation files. They cannot export. Only interface files (export module name; or export module name:part;) can export.

Migration checklist

  1. 1.Replace #pragma once and file-level #include directives with the module declaration + import declarations.
  2. 2.Move any macro-producing #include directives into a global module fragment (module; … export module name;).
  3. 3.Add export to all types, functions, aliases, and constants that consumers need to name.
  4. 4.Move non-template function bodies to module implementation files (.cpp with module name;).
  5. 5.Keep template definitions in the interface file.
  6. 6.If splitting with partitions, add export import :part; to the primary interface file for every interface partition.
  7. 7.Update your build system to compile .cppm files as module interface units (see modules-build-systems).
  8. 8.Update consumer code to use import geometry; instead of #include "geometry.h".
  9. 9.Explicitly import anything a consumer file needs to name by type — do not rely on transitive imports from the module.
Sign in to track progress