DR-003-Arch: Public Header Location and Directory Structure for C++ Components#
Date: 2026-08-19
Public Header Location and Directory Structure for C++ Components
|
status: proposed
|
||||
Context / Problem#
As we modularize our C++ components with Bazel, we need a standardized directory
layout that defines where public (API) and private (implementation) headers live.
This decision directly impacts API encapsulation, developer workflow, packaging for
non-Bazel consumers, and integration with Bazel’s strict header visibility model
(hdrs vs. srcs).
Bazel is authoritative here: a cc_library distinguishes between headers that form
the compilable public surface of a target (hdrs) and headers that are purely
internal to the compilation of that target (srcs). A downstream target may only
#include headers that appear in the hdrs of a library it directly depends on.
The physical directory layout does not by itself enforce this boundary — the BUILD
file does. The question is therefore how to structure directories so that the
physical layout reinforces the logical API boundary that Bazel enforces, rather
than working against it.
Current state in S-CORE#
Within S-CORE there is mostly no separate include/ directory in use today.
Components rely completely on Bazel’s hdrs attribute to designate public headers,
with headers and sources living together in a flat directory. This works for Bazel,
but it makes the public contract of a component invisible at the file-system level:
a developer has to open BUILD.bazel to learn which headers are public, and
extracting a clean public SDK for non-Bazel consumers requires filtering.
Goals#
Make the public API surface of a component obvious without reading
BUILD.bazel.Map cleanly onto Bazel’s
hdrs/srcssplit with minimal boilerplate.Produce clean, collision-free
#includepaths for consumers.Enable simple, folder-level packaging of the public API for non-Bazel consumers.
Keep the developer’s day-to-day workflow (edit
.hnext to.cc) reasonable.
External references#
The Pitchfork Layout (PFL) — in particular the separated header placement convention, where public headers live under
include/and private headers/sources live undersrc/.The Canonical Project Structure (P1204R0) — WG21 paper describing a canonical layout with public headers under a project-named subdirectory.
Both references converge on placing public headers under a dedicated, project-named include root.
Options Considered#
Option A: Flat Directory Structure (headers and sources mixed)#
All public headers, private headers, and source files live together in the same
directory (e.g. libs/my_lib/). Public vs. private is expressed only through the
Bazel hdrs/srcs split. This is the current de-facto S-CORE convention.
libs/my_lib/
├── BUILD.bazel
├── my_lib.h # public -> hdrs
├── my_lib.cc # source -> srcs
├── internal_helper.h # private -> srcs
└── internal_helper.cc
Advantages#
Zero Bazel path overhead: No
strip_include_prefix/includesjuggling. The header’s on-disk path is its include path, so there is one obvious way to write an#includeand no divergence between what the file tree shows and what the compiler sees.Easier local navigation: Declarations sit next to their implementations; no jumping between distant folders in the IDE. Refactoring that splits or renames a unit touches a single directory, and “go to file” / fuzzy-open lands on the pair immediately.
Less nesting: Avoids redundant paths like
.../my_lib/include/my_lib/, which keeps both the file tree and relative#includestatements short.Lowest friction for small or leaf components: For a component with one or two public headers, a private helper, and its sources, the flat layout has the best effort-to-value ratio — the
include/+src/split adds structure that such a component does not need. This matches how most of S-CORE is organized today.Single source of truth for the API boundary: The public surface is defined in exactly one place — the
hdrslist inBUILD.bazel. There is no second, implicit contract encoded in the directory layout that could drift out of sync with the build file (e.g. a header physically underinclude/but not actually inhdrs, or vice versa).Bazel already enforces the boundary: A consumer can only
#includeheaders a dependency lists inhdrs; reaching for a private header insrcsfails the build regardless of where the file physically sits. The encapsulation guarantee therefore does not depend on the directory structure at all — the flat layout loses no enforcement, only visibility.Cheapest migrations and moves: Moving a component, extracting a sub-library, or promoting a private header to public is a
BUILD.bazeledit only — no files move on disk, so diffs stay small and reviews stay focused on the API change itself.Fewer glob edge cases: With everything in one directory,
globpatterns are simple and there is no risk of a misplaced file silently falling outside aninclude/**orsrc/**pattern.
Disadvantages#
Public API not visible from the file tree: Developers must read
BUILD.bazelto learn which headers are public.Accidental API exposure: A private header may be listed in
hdrsby mistake, or a consumer may reach for an internal header.Poor scalability: As the library grows the folder becomes cluttered.
Packaging difficulty: Extracting only public headers for external (non-Bazel) delivery requires filtering scripts rather than a folder copy.
Weaker as a published module: Without a prefix to strip, a consumer sees the producer’s repo-relative path, which leaks the internal layout and is not namespaced by component:
// consuming @my_lib as a module, flat layout #include "libs/my_lib/my_lib.h"
Getting the clean
#include "my_lib/my_lib.h"requires addinginclude_prefix(or naming the flat dirmy_lib/), i.e. reintroducing the Bazel path machinery Option A set out to avoid.
Option B: Separate include/ Directory (industry standard)#
Public headers are placed in a dedicated include/ directory, nested under the
component name (include/my_lib/*.h) to prevent include-path collisions. Private
headers and sources live under src/.
libs/my_lib/
├── BUILD.bazel
├── include/
│ └── my_lib/
│ └── my_lib.h # public -> hdrs
└── src/
├── my_lib.cc # source -> srcs
├── internal_helper.h # private -> srcs
└── internal_helper.cc
Advantages#
High encapsulation: The public contract is physically isolated. It is immediately clear to a consumer what may be used.
Clean BUILD files:
hdrs = glob(["include/**/*.h"])andsrcs = glob(["src/**"])without risk of accidentally exposing private files.Standardized packaging: Exporting the public API is a single folder copy.
Clear architectural intent: Naturally enforces good API-design discipline and aligns with the Pitchfork Layout and the Canonical Project Structure.
Collision-free includes: The
my_lib/nesting guarantees globally unique include paths, e.g.#include "my_lib/my_lib.h".
Disadvantages#
Bazel path handling: Requires
strip_include_prefix = "include"so consumers can write#include "my_lib/my_lib.h"rather thaninclude/my_lib/my_lib.h.Folder redundancy: The
include/my_lib/nesting feels redundant to some.Navigation overhead: Minor cost from switching between
include/andsrc/during active development.
Option C: Hybrid Directory Structure#
Only strictly public API headers are placed in include/my_lib/*.h. All private
headers (*.h) and implementation sources (*.cc) sit directly in the main
directory (or a local src/ next to it).
libs/my_lib/
├── BUILD.bazel
├── include/
│ └── my_lib/
│ └── my_lib.h # public -> hdrs
├── my_lib.cc # source -> srcs
├── internal_helper.h # private -> srcs
└── internal_helper.cc
Advantages#
Reduced navigation overhead: Developers open
include/only when changing the public contract; internal headers stay next to their.cc.Clear Bazel separation:
hdrsmatchesinclude/**,srcsmatches the rest.Clean API packaging: Copying
include/still yields a pristine public SDK with no leakage of private helpers.
Disadvantages#
Inconsistent private-header location: Some headers live in
include/, some in the root — developers may be unsure where to look.Strict include conventions required: Private headers use relative paths (
#include "internal_helper.h") while public headers use the nested path (#include "my_lib/my_lib.h"), which must be applied consistently.
Evaluation Criteria#
Criterion |
A: Flat |
B: Separate |
C: Hybrid |
|---|---|---|---|
Public API visible in file tree |
– |
++ |
+ |
API encapsulation / leak safety |
- |
++ |
+ |
Clean |
+/- |
++ |
+ |
Bazel |
- |
++ |
+ |
Non-Bazel packaging (folder copy) |
– |
++ |
+ |
Bazel boilerplate (low is better) |
++ |
- |
+/- |
Local navigation (edit .h next .cc) |
++ |
- |
+ |
Scalability of large components |
- |
++ |
+ |
Consistency / low cognitive load |
+ |
++ |
- |
Alignment with PFL / P1204 |
- |
++ |
+ |
Decision Proposal#
Option B: Separate include/ Directory — public API headers live under
include/<component>/, private headers and sources live under src/.
For components that are small, header-heavy, or where the public/private split is
trivial, Option C (Hybrid) is an accepted variation: keep the include/<component>/
public root, but allow private headers to sit next to their .cc in src/ (or the
component root). The invariant that must always hold is: everything a consumer is
allowed to #include lives under include/<component>/, and nothing else does.
Accepted alternative — flat layout with an impl/ subpackage. Because the
dominant pattern in communication and baselibs today is a flat layout that draws
the public/private boundary with an impl/ subpackage plus Bazel visibility (see
Prior Art & Existing S-CORE Practice), that pattern is explicitly accepted as an
equivalent alternative to Option B/C. It satisfies the same invariant by a different
mechanism: public headers live at the component root, everything non-public lives
under <component>/impl/** whose visibility is restricted to the component’s own
subpackages. Components choosing this variant must keep implementation headers out
of any hdrs reachable by external consumers and restrict impl/ visibility
accordingly. This avoids a large-scale migration of existing components while still
guaranteeing an enforced, discoverable API boundary. The trade-off versus Option B is
that the public surface is discoverable via package/visibility rules rather than a
single directory, and packaging for non-Bazel consumers is less trivial.
Rationale#
The public contract becomes a first-class, visible artifact. A reviewer or consumer can see the entire API surface by looking at
include/<component>/without openingBUILD.bazel. This is the single biggest weakness of the current flat approach.It maps directly onto Bazel’s
hdrs/srcsmodel. The directory boundary and the Bazel visibility boundary coincide, so the file layout reinforces the guarantee Bazel already enforces, instead of leaving it implicit.Collision-free include paths. Nesting under the component name (
include/my_lib/) guarantees that#include "my_lib/foo.h"is globally unique across the whole build graph, which matters at S-CORE scale with many modules.Trivial packaging for non-Bazel consumers. Delivering the public SDK is a folder-level copy of
include/, not a filtering script over a mixed directory.Ecosystem alignment. The layout matches the Pitchfork Layout (separated header placement) and the WG21 Canonical Project Structure, lowering the barrier for external contributors and tooling.
The main cost — a small amount of Bazel boilerplate (strip_include_prefix) and the
include/<component>/ nesting — is one-time, mechanical, and easily templated.
Bazel Usage#
Defining a component#
Public headers go into hdrs and are exposed through strip_include_prefix so that
the on-disk include/ prefix is removed from the include path consumers see:
# libs/my_lib/BUILD.bazel
cc_library(
name = "my_lib",
srcs = glob([
"src/**/*.cc",
"src/**/*.h", # private headers are NOT part of the public API
]),
hdrs = glob(["include/**/*.h"]),
strip_include_prefix = "include",
visibility = ["//visibility:public"],
)
With strip_include_prefix = "include", a header at
libs/my_lib/include/my_lib/my_lib.h is included by consumers as:
#include "my_lib/my_lib.h"
Within the component’s own sources, private headers are included with a path
relative to src/, e.g. #include "internal_helper.h", and never leak through
hdrs.
Note on alternatives:
include_prefixcan add a prefix, andincludesadds a directory to the compiler search path (with the well-known-I/-isystemcaveats).strip_include_prefixis preferred because it is hermetic, does not pollute the include path of downstream targets, and yields the clean<component>/<header>.hform. Avoidincludesfor public APIs unless a third-party layout forces it.
Consuming the component within the repository#
cc_library(
name = "app",
srcs = ["app.cc"],
deps = ["//libs/my_lib"],
)
// app.cc
#include "my_lib/my_lib.h" // only headers under include/my_lib/ are reachable
Bazel will reject an #include of a private header from src/, because it is not in
the hdrs of //libs/my_lib. The directory layout and the build system thus enforce
the same boundary.
Consuming the component as a Bazel module (bzlmod)#
When a component is published as a Bazel module, the include/<component>/ layout
carries over unchanged. A downstream repository adds the dependency in its
MODULE.bazel:
# MODULE.bazel (downstream)
bazel_dep(name = "my_lib", version = "1.0.0")
and consumes it exactly as an in-repo target:
cc_library(
name = "downstream",
srcs = ["downstream.cc"],
deps = ["@my_lib//:my_lib"],
)
#include "my_lib/my_lib.h"
Because the public include root is stable and prefix-stripped, the include paths a
consumer writes are identical whether the component is consumed in-repo, via a
local_path_override, or as a released module from a registry. This decouples the
consumer’s source from the producer’s on-disk layout and is the key property that
makes the module boundary clean.
Consumer view across the three layouts#
How consuming @my_lib as a module looks from the outside, for each option:
Option |
Producer |
Consumer |
|---|---|---|
A: Flat |
|
|
A: Flat + prefix |
|
|
B: Separate |
|
|
C: Hybrid |
|
|
In all cases the deps and MODULE.bazel entry are identical
(deps = ["@my_lib//:my_lib"], bazel_dep(name = "my_lib", version = "1.0.0"));
only the resulting #include path differs. Options B and C give the same clean,
layout-independent path out of the box; Option A only matches it by reintroducing a
prefix.
Prior Art & Existing S-CORE Practice#
There is no single mandated industry layout for public C++ headers with Bazel; two established schools of thought exist, and S-CORE currently mixes both.
External guidance#
Bazel-native / Google style (no
include/): The Google C++ Style Guide requires headers to be included by their repo-root-relative path (#include "score/mw/foo/bar.h"), with no dedicatedinclude/directory. The Bazelcc_libraryreference encodes the same public/private model viahdrs/srcs. This is essentially Option A, but with repo-root paths rather than bare filenames.General open-source convention (dedicated
include/): The Pitchfork Layout (PFL) and the WG21 Canonical Project Structure (P1204R0) both place public headers under a project-namedinclude/<lib>/root — matching Options B/C.
Existing practice inside S-CORE#
The following reflects the state of the eclipse-score GitHub organization on the
main branch as of 2026-08-19. It is a snapshot and may change; re-check the
current main before relying on it. As of that date there is no unified
convention — the following patterns coexist:
Pattern |
Example repo |
Mechanism |
|---|---|---|
Flat, headers next to sources; public/private via an |
|
plain |
|
|
headers under |
|
|
|
Flat + |
|
prefix reset so header is included as |
Flat on disk + synthesized logical path via |
|
|
Two observations worth calling out:
The dominant real-world pattern in both
communicationandbaselibsis not a dedicated top-levelinclude/(Option B). It is a flat layout where the public/private boundary is drawn by animpl/subpackage plus Bazelvisibility(e.g.communicationkeeps its public API inscore/mw/com/*.hand hides everything underscore/mw/com/impl/**, whose visibility is restricted to//score/mw/com:__subpackages__). This is closer to Option A at repo scale and shows the recommendation of this DR is not yet the prevailing practice.Where a clean external include path is wanted, S-CORE reaches for different Bazel levers —
includes(futurecpp),strip_include_prefix/include_prefix(static_reflection, lifecycle) — rather than a single agreed mechanism. Thelifecyclepattern even keeps a flat on-disk layout but synthesizes a logicalscore/mw/...path viainclude_prefix.
The absence of a unified convention — and the divergence in both directory layout and Bazel mechanism — across these repositories is a primary motivation for this design decision.
Header Name Collisions Across Modules#
A common worry is what happens when several modules expose an identically named
header — for example error.h. The important point is that collisions are decided by
the include-path string, not the file name. Two error.h files coexist without
issue as long as their include paths differ:
#include "score/filesystem/error.h" // baselibs
#include "score/concurrency/future/error.h" // baselibs
Both exist side by side in baselibs today with no conflict, because the package
prefix makes them unique. A problem only arises when the path is shortened to the bare
file name and two dependencies provide it:
#include "error.h" // provided by module A AND module B → ambiguous
If a target depends on both libraries, -I/-isystem ordering decides which file
wins — the wrong header may be included, silently violating the One Definition Rule.
The Bazel module name does not protect against this: @module_a / @module_b do
not appear in the C++ include path by default; the path is determined solely by the
package location and by strip_include_prefix / include_prefix / includes.
Consequences per option:
Option A (flat, repo-root includes): collision-safe as long as headers are included by their full repo-root path. The danger is resetting the prefix (
strip_include_prefix = "."as inutils/base64, orincludes = ["."]), which collapses the path to#include "error.h"and re-introduces the ambiguity globally.Option B (
include/<component>/): collision-safe, but the protection comes from the component-name nesting, not from theinclude/directory itself. A flatinclude/error.h(without the<component>subdirectory) still collides.Option C (Hybrid): same as B — public headers stay unique via
include/<component>/.Flat +
impl/+ visibility: same as A — the repo-root path is preserved, and the restrictedimpl/visibility additionally shrinks the set of externally reachable headers.
Rule: Uniqueness must be guaranteed by the include-path prefix (the project or
component name) — via the repo package path under Option A, or via the
include/<component>/ nesting under Options B/C. A bare include/ without a
component-named subdirectory does not solve the problem.
Consequences#
Positive#
The public API of every C++ component is visible, isolated, and self-documenting.
Bazel
hdrs/srcsboundaries align with the physical layout, reducing accidental API leakage.Consumers get stable, collision-free include paths regardless of how the dependency is resolved (in-repo, override, or registry module).
Public SDK packaging for non-Bazel consumers is a folder copy.
The layout is consistent with widely used community conventions (PFL, P1204).
Negative / Costs#
A modest, one-time increase in Bazel boilerplate (
strip_include_prefix) and theinclude/<component>/nesting.Minor day-to-day navigation overhead from the
include/↔src/split.Existing flat components must be migrated to gain the benefits (can be incremental).
Follow-Up Actions#
Provide a component template / scaffolding (directory skeleton +
BUILD.bazel) that encodes theinclude/<component>/+src/layout andstrip_include_prefix.Document the convention in the S-CORE contribution guidelines and C++ coding guidelines, including the private-vs-public include-path rules.
Define a migration path for existing flat components (opportunistic, per module).
Consider a lightweight CI/lint check that flags private headers appearing in
hdrsor public headers being included via non-canonical paths.