I spent three years in high-frequency trading where a single misplaced header could turn a millisecond of latency into a catastrophic outage. Most tutorials treat CMake like a magic wand, telling you to just “link the library” and move on, but they completely gloss over the carnage caused by a poorly defined public private interface in cmake. If you treat your build system like a black box, you aren’t actually building a project; you’re just building a house of cards that will collapse the moment a downstream consumer tries to include a header that shouldn’t have been visible in the first place.
I’m not here to teach you the syntax of a command you can find in the documentation in five seconds. Instead, I’m going to show you how to actually architect your targets so that your dependencies don’t leak like a broken sieve. We’re going to look at the specific ways `INTERFACE`, `PUBLIC`, and `PRIVATE` keywords dictate how your compiler sees the world, and why getting this wrong is the fastest way to ship a build that works on your machine but breaks everyone else’s.
Table of Contents
The Transitive Trap Why Cmake Target Link Libraries Scope Matters

The real headache begins when you stop thinking about a single target and start thinking about the graph. This is where the cmake target_link_libraries scope stops being a theoretical concept and starts becoming a source of build failures. When you link a library to a target using `PUBLIC`, you aren’t just telling the linker what that target needs; you are telling every downstream consumer that they, too, must inherit those dependencies.
If you accidentally mark a heavy, internal dependency as `PUBLIC` instead of `PRIVATE`, you’ve just triggered a massive leak in your dependency graph. This is the essence of cmake build requirements vs interface requirements. A build requirement is what you need to compile your own source; an interface requirement is what the user needs to consume your header. If your header doesn’t include “, then Boost is a private requirement. If you leak it, you’re forcing every developer on your team to pull in Boost just to compile a simple unit test. It’s messy, it slows down the build, and it makes refactoring a nightmare.
Leaking Implementation Details Through Cmake Include Directories Visibility

The real headache starts when you realize that `target_include_directories` behaves exactly like your dependency graph: it propagates. If you use `PUBLIC` for an include path that only your implementation files need to see, you aren’t just setting a path; you are polluting the include search space of every downstream consumer. This is a classic case of failing to distinguish between cmake build requirements vs interface requirements. You might think you’re being helpful by making headers “easy to find,” but you’re actually just increasing the surface area for name collisions and accidental includes.
When you mismanage cmake include directories visibility, you create a fragile build environment where a consumer can compile code that should be illegal. I’ve seen entire teams ship code that relies on a header being present simply because a dependency leaked it through a transitive path. The moment that dependency is updated or swapped, the build breaks in ways that feel like magic, but are actually just the predictable result of poor scoping. If a header isn’t part of your library’s public API, it shouldn’t be in your `PUBLIC` or `INTERFACE` scope. Period.
Rules of Engagement: How to Stop Your Build System from Bleeding
- Use `PRIVATE` for everything by default. If a header you’re using doesn’t appear in your own library’s public `.h` files, it has no business being `PUBLIC`. Treat your dependency list like a controlled substance.
- Reserve `INTERFACE` for header-only libraries or template-heavy logic where you aren’t actually compiling a binary. If you use it for a compiled library, you’re just creating a ghost dependency that confuses the linker.
- Audit your `target_include_directories` with the same scrutiny you’d give a memory leak. If a downstream consumer can `#include` your internal implementation headers because you used `PUBLIC`, you’ve just broken the encapsulation of your entire project.
- Stop using `link_libraries()` at the directory level. It’s a blunt instrument that pollutes every target in the scope. Use `target_link_libraries()` to attach dependencies to specific targets, or you’ll spend your weekends debugging why a utility library is pulling in half of Boost.
- Remember that `PUBLIC` is a promise to your users. When you mark a dependency as `PUBLIC`, you are telling every downstream developer: “You will need this dependency to compile against me.” If that promise is a lie, you’re just shipping a broken build.
The Cost of Sloppy Scoping
Treat `PUBLIC` as a heavy-duty tool, not a default setting; if a header doesn’t need to be seen by every downstream consumer, keep it `PRIVATE` to prevent header pollution and massive rebuild times.
Remember that `INTERFACE` is for the consumers only; using it incorrectly for internal implementation details is a fast track to link-time errors that are a nightmare to debug.
Your build system is part of your API contract; if you leak an internal dependency via `PUBLIC` linking, you’ve just forced every user of your library to inherit your technical debt.
The Cost of Laziness
At the end of the day, managing CMake visibility isn’t about following some arbitrary style guide; it’s about controlling the blast radius of your changes. If you treat `target_link_libraries` and `target_include_directories` as dumping grounds for every dependency you happen to need, you aren’t building a library—you’re building a monolith that forces every consumer to inherit your technical debt. By strictly separating what is publicly required for a consumer to use your API from what is merely privately necessary to build your implementation, you stop the silent propagation of header pollution and broken dependency chains.
C++ is already a language of strict rules and subtle side effects; your build system shouldn’t add to the chaos. Treat your interface boundaries with the same rigor you apply to your class invariants. When you get the visibility right, your build times drop, your compilation errors become meaningful rather than cryptic, and your library becomes a predictable tool rather than a minefield. Stop letting your implementation details leak into the global namespace of your users. Build with intent, or don’t bother building at all.