How Modules Change the Way We Split C++ Programs

Modules in C++20 changed how we can structure C++ code.

They are often discussed as a build-time improvement, because modules can reduce repeated parsing of headers. But there is another important part: modules also change how we expose interfaces and hide implementation details.

With headers, everything placed in a header becomes visible to every translation unit that includes it. With modules, the interface can contain both exported and non-exported declarations.

That gives us a finer level of control.

But does it automatically enforce architecture boundaries?

Not completely.

Modules give us better tools for defining boundaries, but we still need to design those boundaries carefully.

This article explores how modules change the way we split C++ code, what they improve compared to headers, and where implementation details can still leak into the module interface.

Old C/C++ Model: Header and Implementation

Before modules, C++ code was usually split into two parts:

  • a header file for the public API,
  • a .cpp file for implementation details.

Declarations that had to be visible to users went into the header file: class declarations, function declarations, constants, templates, and inline functions.

Implementation details usually stayed in the .cpp file.

If a helper function or a global variable was needed only for implementation, it could be placed into the .cpp file and given internal linkage with static or an unnamed namespace.

For example, suppose we have a class Foo.

Its public API is declared in foo.hpp:

#pragma once

#include <string>
#include <string_view>

class Foo {
public:
    std::string_view name() const;
    void name(std::string_view value);

private:
    std::string m_name;
};

The implementation goes into foo.cpp.

A helper function such as trim() can stay in the implementation file:

#include "foo.hpp"

#include <string_view>

namespace {

constexpr std::string_view trim(std::string_view text)
{
    constexpr std::string_view whitespace = " \t\n\r\f\v";

    auto start = text.find_first_not_of(whitespace);

    if (start == std::string_view::npos) {
        return {};
    }

    auto end = text.find_last_not_of(whitespace);

    return text.substr(start, end - start + 1);
}

} // namespace

std::string_view Foo::name() const
{
    return m_name;
}

void Foo::name(std::string_view value)
{
    m_name = std::string{trim(value)};
}

Now a user of Foo includes only the header:

#include "foo.hpp"

#include <iostream>

int main()
{
    Foo foo;
    foo.name(" some name ");

    std::cout << foo.name() << '\n';

    return 0;
}

The user can construct Foo and call its public member functions. The implementation detail trim() is not visible because it lives in the .cpp file.

That model works well for ordinary functions and non-template code.

But templates make things more complicated.

When Headers Expose Too Much

Templates usually need their definitions to be visible at the point of instantiation.

That means template implementation details often end up in headers.

A common workaround is to place helper functions into a detail namespace. The name says: “this is not part of the public API.”

But this is only a convention.

The user can still call those helpers if they include the header.

For example, consider a helper function used to print a std::tuple:

#pragma once

#include <cstddef>
#include <iostream>
#include <tuple>
#include <utility>

namespace detail {

template <typename Tuple, std::size_t... Is>
void print_tuple_impl(const Tuple& tuple, std::index_sequence<Is...>)
{
    ((std::cout << std::get<Is>(tuple) << ' '), ...);
    std::cout << '\n';
}

} // namespace detail

template <typename... Args>
void print_tuple(const std::tuple<Args...>& tuple)
{
    detail::print_tuple_impl(
        tuple,
        std::index_sequence_for<Args...>{}
    );
}

The public function is print_tuple().

The helper detail::print_tuple_impl() is supposed to be an implementation detail. But it still lives in the header. Anyone who includes the header can name it and call it:

std::tuple<int, char> tuple{100, 'a'};

detail::print_tuple_impl(
    tuple,
    std::index_sequence_for<int, char>{}
);

The detail namespace communicates intent, but it does not enforce encapsulation.

This is one of the problems modules can improve.

Modules Add a New Granularity Level

Modules also have an interface and an implementation side.

But unlike headers, a module interface does not automatically expose every declaration to importers.

Only declarations marked with export become visible to code that imports the module.

Everything else can be used inside the module interface but cannot be named directly by importers.

For example, the print_tuple code can be written as a module interface.

The following example uses import std for brevity. In real projects, standard library module support depends on the compiler and standard library implementation. If your toolchain does not support import std, use ordinary includes in the global module fragment instead.

export module print_tuple;

import std;

template <typename Tuple, std::size_t... Is>
void print_tuple_impl(const Tuple& tuple, std::index_sequence<Is...>)
{
    ((std::cout << std::get<Is>(tuple) << ' '), ...);
    std::cout << '\n';
}

export template <typename... Args>
void print_tuple(const std::tuple<Args...>& tuple)
{
    print_tuple_impl(
        tuple,
        std::index_sequence_for<Args...>{}
    );
}

Here, print_tuple() is exported.

The helper function print_tuple_impl() is not exported.

A consumer can import the module and call print_tuple():

import std;
import print_tuple;

int main()
{
    std::tuple<int, char> tuple{100, 'a'};

    print_tuple(tuple);

    return 0;
}

But the consumer cannot directly call print_tuple_impl() because that name is not exported by the module.

This is already a major improvement compared to the header-based version.

With headers, implementation helpers are often visible but marked as “internal” only by convention.

With modules, non-exported names in the module interface are not nameable by importers.

Nameable vs Reachable

The export keyword makes a declaration nameable by code that imports the module.

In other words, the importer can write:

print_tuple(tuple);

because print_tuple is exported.

But it cannot write:

print_tuple_impl(tuple, std::index_sequence_for<int, char>{});

because print_tuple_impl is not exported.

However, “not nameable” does not always mean “irrelevant to the importer.”

For templates, the compiler may still need semantic information about non-exported declarations used by exported templates.

In the previous example, print_tuple() is an exported template. When it is instantiated, the compiler needs to know how its body works. That body calls print_tuple_impl().

So print_tuple_impl() is not nameable by the importer, but it is still reachable through the exported template.

This distinction is important:

  • nameable means user code can directly refer to the declaration;
  • reachable means the compiler may need the declaration while compiling or instantiating exported code.

Modules improve encapsulation because non-exported names are not directly available to consumers.

But module interfaces can still contain implementation details that become part of the compiled interface of the module.

That is why module interface design still matters.

Interface Dependencies Still Matter

Modules help hide names, but they do not automatically make every implementation detail harmless.

If implementation details are placed into the module interface, they may still increase the amount of information stored in the compiled module interface. They may also make the interface harder to reason about and more expensive to build.

Consider this module interface:

module;

#include <nlohmann/json.hpp>

export module books;

import std;

class Book {
public:
    void set_author(std::string_view author)
    {
        m_data["author"] = author;
    }

    void set_name(std::string_view name)
    {
        m_data["name"] = name;
    }

    std::string to_json() const
    {
        return m_data.dump();
    }

private:
    nlohmann::json m_data;
};

export std::string create_book_json(
    std::string_view name,
    std::string_view author
)
{
    Book book;

    book.set_author(author);
    book.set_name(name);

    return book.to_json();
}

The class Book is not exported.

That means an importer cannot write:

Book book;

The name Book is not part of the public API.

But Book still lives in the module interface unit. The module interface also includes nlohmann/json.hpp and defines the body of create_book_json() inline.

This does not automatically mean that users of the module must directly depend on nlohmann_json in their own source files.

But it does mean that the module interface itself depends on nlohmann_json.

That dependency becomes part of the module interface build. It can increase compile time, enlarge the compiled module interface, and make implementation details harder to isolate.

The better question is not only:

“Can users name this type?”

The better question is:

“Does this type need to be in the module interface at all?”

In this example, Book is only an implementation detail of create_book_json().

So it should not live in the module interface.

Move Implementation Details into Module Implementation Units

The fix is similar to the old header / .cpp split:

put only what consumers need into the interface, and move implementation details into the implementation unit.

The module interface can contain only the exported declaration:

export module books;

import std;

export std::string create_book_json(
    std::string_view name,
    std::string_view author
);

The implementation details move into books.cpp:

module;

#include <nlohmann/json.hpp>

module books;

import std;

class Book {
public:
    void set_author(std::string_view author)
    {
        m_data["author"] = author;
    }

    void set_name(std::string_view name)
    {
        m_data["name"] = name;
    }

    std::string to_json() const
    {
        return m_data.dump();
    }

private:
    nlohmann::json m_data;
};

std::string create_book_json(
    std::string_view name,
    std::string_view author
)
{
    Book book;

    book.set_author(author);
    book.set_name(name);

    return book.to_json();
}

Now Book is neither nameable nor reachable through the exported module interface.

It belongs to the module implementation unit.

The public interface of the module contains only one function declaration.

This makes the boundary cleaner:

  • users can call create_book_json(),
  • users cannot name Book,
  • implementation details stay in the implementation unit,
  • nlohmann::json is used only by the implementation.

In CMake, that means nlohmann_json can remain a private dependency of the books target.

A modern CMake setup may look like this:

find_package(nlohmann_json REQUIRED)

add_library(books STATIC)

target_sources(books
    PUBLIC
        FILE_SET CXX_MODULES
        FILES
            books.ixx
    PRIVATE
        books.cpp
)

target_link_libraries(books
    PRIVATE
        nlohmann_json::nlohmann_json
)

A consumer target only links to books:

add_executable(main main.cpp)

target_link_libraries(main
    PRIVATE
        books
)

And the consuming code stays clean:

import std;
import books;

int main()
{
    std::string json = create_book_json(
        "The C++ Programming Language",
        "Bjarne Stroustrup"
    );

    std::cout << json << '\n';

    return 0;
}

The consumer imports only the books module and uses the exported function.

It does not need to know that the implementation uses nlohmann::json.

When a Dependency Becomes Public

A dependency becomes part of the public interface when exported declarations expose it.

For example, if the module exports a function returning nlohmann::json, then the dependency is no longer an implementation detail:

export nlohmann::json create_book(
    std::string_view name,
    std::string_view author
);

Now the public API mentions nlohmann::json.

Consumers need to understand that type to use the function.

In this case, the dependency should be treated as public:

target_link_libraries(books
    PUBLIC
        nlohmann_json::nlohmann_json
)

The rule is the same as with headers and target-based CMake:

  • if a dependency is needed only to build the target, keep it PRIVATE;
  • if a dependency is needed by consumers of the target, make it PUBLIC.

Modules do not remove this rule.

They just give us better tools to keep public and private parts separated.

Practical Rule for Module Interfaces

Modules make it possible to hide more implementation details than headers.

But that does not mean every helper type or function should be placed into the module interface.

A good module interface should be small and intentional.

Put into the module interface:

  • exported declarations that consumers need,
  • exported templates that must be available for instantiation,
  • small non-exported helpers only when they are required by exported templates.

Move into module implementation units:

  • private classes,
  • implementation-only functions,
  • heavy dependencies,
  • function bodies that do not need to be visible,
  • types that are not part of the public API.

The practical rule is simple:

export only what consumers need; move everything else into implementation units.

Conclusion

C++ modules do not simply replace headers with a new syntax.

They change the way we define boundaries.

With headers, everything placed in a header becomes visible to every translation unit that includes it. The best we can usually do is hide implementation helpers behind conventions such as detail namespaces.

Modules give us a stronger tool: explicit export.

Only exported declarations become nameable by importers. Everything else can remain internal to the module.

But modules do not remove the need for good interface design.

If implementation details are placed into the module interface, they may still become reachable for template instantiation or semantic analysis. They can increase coupling, enlarge the compiled module interface, and make dependencies harder to reason about.

The practical rule is simple:

export only what consumers need; move everything else into module implementation units.

Modules help enforce architecture boundaries.

But they do not design those boundaries for us.

That part is still our job.