[RFC] Separate Public and Private Header Roots for Framework Libraries
Summary
Give each framework library a public include root that contains only its consumer-visible header tree, plus a separate target-private source root.
This RFC uses AzCore because it is the clearest, lowest-level example, but it is not an AzCore-only proposal. The intended scope covers all major framework libraries under Code/Framework.
AzCore currently publishes Code/Framework/AzCore itself as an include directory. That makes normal includes work:
#include <AzCore/Component/Component.h>It also makes every other matching path beneath the module directory searchable by dependent targets, including physical directories such as Platform and headers intended only for AzCore's implementation.
The proposed layout is:
Code/Framework/AzCore/
Include/
AzCore/
... public headers ...
Source/
AzCore/
... private headers ...
... implementation files ...with target include directories equivalent to:
INCLUDE_DIRECTORIES
PUBLIC
Include
PRIVATE
SourceThe repeated AzCore is intentional. Include is the physical include root, while AzCore remains the logical header namespace.
Supported library-qualified includes such as <AzCore/...> and <AzFramework/...> remain unchanged. No generated C++ interface, public namespace, ABI, or runtime behavior is intended to change.
Motivation
A framework target should expose that library's public header surface, not the library's entire working directory. AZ::AzCore is the clearest example, but the same boundary should exist for the other major framework targets.
When a framework publishes its module root, it cannot enforce a distinction between public and private headers. A directory named Internal or Detail can tell developers that a header is unsupported. Still, it does not stop another target from including that header while it remains beneath a public include root.
A broad module root also leaks physical repository structure into the compiler's include namespace. <Platform/...> is the clearest example, but the problem applies to any current or future implementation directory placed beside a library's logical header tree.
CMake source lists do not restrict header lookup. The compiler searches the include directories it receives, regardless of whether a header is listed as a public or private source. Symbol export macros are also unrelated. They control linkage visibility, not whether the preprocessor can find a header.
This change turns an informal convention into a target boundary. Accidental implementation dependencies fail when they are introduced, rather than becoming a de facto API that is hard to remove later.
Scope
The required migration covers the major framework libraries under Code/Framework: AtomCore, AzCore, AzFramework, AzGameFramework, AzNetworking, AzQtComponents, and AzToolsFramework.
Specialized platform and test frameworks should use the same layout where they expose a reusable public header surface, but they do not need to block the initial migration. AzCore is the first implementation and validation target, not the point where this RFC is considered complete.
Design
Framework libraries should follow this target-level convention:
<Library>/
Include/
<Library>/
... consumer-visible headers ...
Source/
<Library>/
... target-private headers ...
... implementation files ...The required rules are:
- Only the public root is propagated through the target's interface.
- The module root and private source roots are not propagated to consumers.
- Public headers remain beneath the library-qualified include namespace.
- The same logical header path must not exist in both roots.
- Platform-specific roots remain private unless their contents are required to compile the public interface.
The owning library receives both roots, so its source can still use qualified includes. For example:
#include <AzCore/Memory/SystemAllocator.h> // Public
#include <AzCore/Memory/AllocatorManagerImpl.h> // PrivateA target depending on AZ::AzCore receives only Include, so the public header resolves and the private header does not. The same rule applies independently to every migrated framework target.
Templates and inline implementation
Not every header named Internal or Detail can become private.
Public templates, inline functions, and some platform abstractions require the consumer's compiler to see implementation text. Those headers must remain under the public root even if they aren't supported for direct use.
The distinction becomes:
Include/<Library>/.../Internal/
Required to compile the public interface, but not supported for direct use.
Source/<Library>/...
True target-private implementation.Platform code
Public platform declarations should be exposed through the normal library namespace under Include/<Library>/....
Platform implementation details should remain under Source or another private PAL root. The proposal does not remove or redesign PAL machinery. It only prevents physical implementation roots such as Platform/... from being inherited by every consumer of the owning framework. Each library can settle its exact PAL file arrangement during migration without changing the target-interface rule.
Compatibility impact
Correctly layered code should need only file moves and build-metadata updates. A public header can move from:
Code/Framework/AzCore/AzCore/Memory/SystemAllocator.hto:
Code/Framework/AzCore/Include/AzCore/Memory/SystemAllocator.hwhile callers continue to use:
#include <AzCore/Memory/SystemAllocator.h>The same physical move preserves the existing <Library/...> include spelling for each migrated framework.
The migration may reveal existing public-to-private dependencies. A header needed by a public template or inline function must remain in the public tree; otherwise, refactor the dependency.
Code that directly includes implementation headers only reachable because the module root is public may stop compiling. That includes paths such as <Platform/...> and genuinely private Internal or Detail headers. This intentionally tightens an accidentally broad interface, not a claim that no downstream code will notice the change.
Advantages
- Major framework libraries gain an enforceable private-header boundary.
- Existing supported
<Library/...>include spellings are preserved. - Physical repository directories no longer become public include namespaces by accident.
- Layering violations are found when introduced rather than when an implementation is later moved.
- Framework libraries converge on one predictable source layout and target-interface convention.
Disadvantages
- Migrating all major framework libraries will require a large number of mechanical file moves and CMake updates.
- Individual migrations may conflict with concurrent changes touching the same files.
- Existing accidental consumers of private headers may break.
- Classifying template, inline, generated, and PAL headers will require care in each framework.
- The full migration will likely span several reviewable changes rather than one patch.
- There is no runtime performance benefit. The benefit is a cleaner and enforceable build interface.
Implementation
Treat this as a codebase-wide framework migration, split into reviewable changes.
AzCore should be migrated first because it sits near the bottom of the dependency graph and contains many of the difficult template, generated, and PAL cases. It establishes the convention, but it is not the endpoint.
For each scoped library:
- Create
Include/<Library>andSource/<Library>. - Move supported public headers and consumer-required implementation beneath
Include. - Move target-only headers and implementation files beneath
Source. - Update CMake file lists, generated-input paths, and platform-specific paths.
- Export only
Includepublicly and addSourceprivately. - Build the library and representative dependent targets to find incorrectly classified headers.
- Verify that the module root and private roots are absent from
INTERFACE_INCLUDE_DIRECTORIES.
After AzCore, migrate the remaining scoped frameworks in dependency-aware order. Each framework can be handled independently enough to keep reviews and merge conflicts manageable, but a tracking issue should cover the full set so the work is not considered complete when the AzCore change lands.
A small consumer test should compile representative public headers for each migrated framework using only that target's interface requirements. It should also verify that representative private headers and paths are not inherited.
Alternatives
Keep the current layout and rely on Internal or Detail.
This documents intent but does not enforce it. It remains appropriate only for implementation that must be visible to compile a public API.
Restrict headers through CMake source lists
Source lists affect build inputs and IDE organization. They do not limit what the compiler can find beneath an exported include root.
Rely on symbol visibility
Export annotations control binary symbols. They don't make a header private and don't cover header-only or inline implementation.
Migrate only AzCore
AzCore is the clearest example, but limiting the change to AzCore would leave the same boundary problem and inconsistent layout in the other major framework libraries. AzCore should establish the pattern, not define the scope.
Migrate all framework libraries in one change
The final convention should be shared, but a single tree-wide patch would be difficult to review and likely to conflict with unrelated work. An incremental migration keeps each change manageable while still committing to the codebase-wide end state.
Prior art
CMake's usage-requirement model already separates public include directories inherited by consumers from private include directories used only by the owning target. Its standard examples use an exported include root containing a library-qualified subtree such as include/mylib.
Unreal Engine applies the same rule at engine scale with module-local Public and Private directories. A module dependency exposes the module's public headers, not its complete source tree.
The directory names differ, but the architectural rule is the same.
Source: o3de/o3de