Experimenting with a C++ modules compatibility pattern for Beman libraries

Thanks to @ednolan and @ClausKlein for the discussions that informed this experiment.

I’ve been experimenting with a pattern for adding C++20 module support to libraries without breaking existing header-based consumers. The code is at GitHub - camio/modules-triangle: C++23 modules triangle dependency demo with compatibility header pattern · GitHub. I want to focus the discussion on the source-level patterns, not the CMake details (those are incidental).

The setup is a triangle dependency: library A (module) depends on both library B (headers-only) and library C (module-capable), and B also depends on C. This exercises the tricky case where a module library needs to be consumed by both module-aware and traditional consumers simultaneously.

Here are the key ideas:

1. One module enablement option per library, not a global toggle. Each library independently decides whether it builds with module support (e.g., LIBRARY_C_MODULES=ON). We don’t want to treat Beman as a monolith — libraries are independent and should be adoptable independently.

2. Consumers don’t need to know or care whether a dependency uses modules. Library C provides a compatibility header that transparently does the right thing: if C was built as a module, #include <library_c/library_c.hpp> resolves to import library_c;. If not, it provides the declarations directly. This means library B (a traditional header library) gets the benefits of C’s module build without any changes to its own code. Optimizing for consumption is important.

3. One target per library — no decision fatigue. You link to library_c, period. There’s no library_c_modules vs library_c_headers split. The build option controls how it’s built, but consumers always see the same target name.

4. The compatibility header serves three roles from one file:

  • When included from the .cppm interface unit: provides export-annotated declarations
  • When included by a non-module consumer (modules enabled): emits import library_c;
  • When modules are disabled: provides plain declarations

For libraries that produce a .a (i.e., aren’t header-only), the pattern does require two source files for the implementation (module impl unit vs traditional), but a shared _impl.hpp avoids duplicating function bodies. Header-only libraries don’t need this.

5. Imports in the global module fragment are standard-conforming. A subtlety of this pattern: when library A’s .cppm does #include <library_c/library_c.hpp> in its global module fragment, the compatibility header triggers an import. This is allowed — a #include of an importable header can be rewritten to an import during translation phase 4, and the [cpp.import] p2 prohibition on imports produced by source file inclusion applies to “the group of a module-file,” which does not encompass the global module fragment’s own group. The README has a detailed analysis of the relevant standard wording.

Compiler support note: This currently only works with Clang 18+. GCC has a long-standing conformance bug (Bug 99000) that prevents the include-after-import deduplication the compatibility header relies on — still unfixed in GCC 16 trunk as of March 2026.

Would love to hear thoughts, especially on whether this pattern makes sense as a direction for
Beman.

2 Likes

I have been made some improvements, see Add support for import std;

P.S.: it works with g++15 on my iMAC too, but only w/o import std; :wink:

I switched to Boost:

and

Thanks @ClausKlein, I merged in all your changes.

Boost config, core, type_threats, throw_exception, charconv optional, …
works with modules and import std;