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
.cppminterface unit: providesexport-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.