API Design in C++
TL;DR
- An API is a contract with the user, not just an implementation.
- A good API is invisible; a bad API causes continuous pain.
- Top-down (user-centric) design is almost always the right answer.
- The more successful an API is, the exponentially higher the cost of change becomes.
- Once implementation details leak into an API, future options disappear.
Overview
This talk addresses the question “How should we design APIs?”
from the perspective of practical maintainability, rather than syntax or style.
There is only one core message:
The starting point of API design is
‘how users will actually use it’, not ‘a cool implementation’.
The talk answers the following questions:
- What is the difference between a good API and a bad API?
- Why is the top-down (user-centric) perspective important?
- Why does it become harder to change an API as it grows?
- How does API/ABI compatibility constrain design?
- What should you look for when evaluating an API in practice?
Detailed Breakdown
1. Good APIs and Bad APIs
Characteristics of a Good API
- Users do not consciously notice the API’s existence.
- It can be used intuitively with only domain knowledge.
- It remains usable for years to decades.
- Changes are rare, and when they happen, the impact on users is minimal.
- It positively contributes to testing, performance, and scalability.
A good API doesn’t even feel “good”; it just works.
Characteristics of a Bad API
- The platform or implementation details are directly exposed.
- You have to check the documentation every time you use it.
- It is easy to misuse.
- Testing is difficult and bugs occur frequently.
- A cycle of deprecate → new API → deprecate again.
A bad API is not a one-time pain; it causes continuous suffering.
2. API Design Philosophy: Top-down vs Bottom-up
Top-down Design (Recommended)
- First, define the user scenarios.
- Write example code and documentation first.
- Define the API.
- Implement it last.
→ The API speaks the user’s language, not the implementation’s language.
Bottom-up Design (Risky)
- Starts with implementation (algorithms, external libraries).
- The API is driven by the implementation.
Problems:
- Implementation details leak directly into the API.
- Users must understand the internal implementation to use it.
- Testing, extending, and replacing all become difficult.
3. API User Base and Cost of Change
| User Base | Difficulty of API Change |
|---|---|
| Individual / Small Scale | Low |
| Multiple Teams Internally | High (Requires mass refactoring and rebuilds) |
| Public API | Nearly Impossible |
The more successful an API is, the harder it is to fix.
API / ABI Compatibility
Terminology
- Library API + Compiler ABI = Library ABI
ABI Compatibility
- A break in ABI compatibility results in:
- Worst case: Subtle malfunctions.
- Best case: Immediate crash.
- Especially important when using dynamically linked libraries (e.g.,
libstdc++). - Can be ignored if all users always recompile everything from scratch.
API Compatibility
- API incompatibility usually means it simply won’t compile.
- Important when distributing libraries (both binary and source).
- Important even when recompiling everything.
ABI-Compatible Changes
- Adding a new free function.
- Adding/removing friend declarations in a class.
- Adding a value to the end of an enum (provided the enum type size doesn’t change).
- Adding static member variables/functions.
- Adding non-virtual methods (excluding overloads).
- Adding constructors.
- Changing default argument values.
- Removing private non-virtual methods.
- Adding typedefs and new types.
- Changing an inline function to a non-inline function.
- Previously compiled code will continue using the old implementation.
ABI-Incompatible Changes
- Changing a method
(const/constexpr, return type, argument position, cv-qualifier, template parameters, final/override, noexcept, inline/template implementation details, etc.). - Changing the class inheritance hierarchy.
- Adding/removing/reordering/changing types of class members.
- Adding or removing overloads.
- Changing how member functions are inlined.
- Changing access modifiers.
- Adding/removing/reordering virtual functions
(including changing between non-virtual and virtual). - Changing thrown exceptions and exception hierarchies.
- Changing the type or cv-qualifier of global data.
API-Compatible Changes
- Adding parameters with default values.
- Adding methods to a class.
- Adding members to a class.
- Adding a class.
- Changing the order of members or methods.
API-Incompatible Changes
- Removing arguments, public/protected methods, members, or classes.
- Changing argument or member variable types.
- Renaming public/protected members or methods.
- Moving a class to a different header.
Guiding Principles of API Design
1. Make it Easy to Use
- Reduces onboarding time.
- Decreases bugs and misuse.
- Increases adoption rate.
- Follows the Principle of Least Surprise.
Methods:
- Name classes, methods, parameters, and types well.
- Should be clear and meaningful.
- Should be concise.
- Should reflect domain semantics.
- Should use commonly accepted terminology.
- Maintain consistency inside and outside the component.
- Maintain an appropriate level of abstraction.
- Example: Pass an iterator instead of the container itself.
2. Make it Hard to Misuse
- The contract should be clear and narrow in scope.
- Follow generally accepted coding conventions.
- It should behave exactly as the user expects.
3. It Must Be Documented
If it’s not documented, it doesn’t exist.
- Document all public elements.
- Contract definitions must be clear, concise, and unambiguous.
- Provide tutorials (documentation is code too).
- Maintain a documentation style guide.
4. Be Minimally Complete
- Include only what is necessary.
- Exclude features that can be easily built by composing existing APIs.
- Maintain abstractions and representation invariants.
- Abstraction: Behavior should be indistinguishable regardless of implementation.
- Representation invariant: Conditions that the object must always satisfy.
Example:
std::mutex+std::lock_guardmutexprovides the primitive.lock_guardprovides an exception-safe RAII utility.
5. Be Loosely Coupled
- Minimize mutual dependencies between components.
- Structure components cohesively.
Recommendations:
- Avoid circular dependencies.
- Include/import only what is necessary.
- Utilize callback and observer patterns.
- Utilize the Mediator pattern (single point of access for resources).
6. Do Not Expose Implementation Details
Hyrum’s Law:
“With a sufficient number of users of an API,
it does not matter what you promise in the contract:
all observable behaviors of your system
will be depended on by somebody.”
Effects:
- Users can design from a domain perspective.
- API providers gain more freedom to make changes.
- Upgrades and testing become easier.
Guidelines:
- Use domain types in public interfaces.
- Prohibit exposing internal types.
- Prohibit exposing types/functions from dependent libraries.
7. Consider Long-Term Use
- Requirements change.
- Platform constraints also change.
- You don’t need to include every feature from the start.
- Features should be incrementally addable.
Example:
- Define in/out parameters as structs/classes
→ Fields can be expanded later.
Principle of Least Surprise:
- The user’s predictions should be correct.
- If domain-based assumptions are wrong, the API has failed.
8. Avoid Boilerplate Code
- The amount of code required to perform a specific task
is a good indicator of an API’s suitability. - Maintain an appropriate level of abstraction.
- A web server API should handle HTTP requests/headers, not raw sockets.
- Provide utilities for common, repetitive tasks.
- Makes writing tests easier.
9. Design to be Platform Independent
- Prohibit designs that only work on a specific OS.
- Also prohibit designs that fail to work on a specific OS.
- Windows vs POSIX.
- Endianness issues.
- Compiler-dependent issues.
- Use identical types for overloaded functions.
10. Design with Test Drivers in Mind
The Golden Rule of API Design:
Write tests that use your API yourself.
- Must be usable in application/integration tests.
- Avoid concrete class APIs that cannot be mocked.
- Interface-based design is recommended.
- Minimize the mock scope for complex APIs.
- Interface Segregation Principle (ISP).
- Should be executable standalone by breaking dependencies.
11. Design for Testability
- Design the API so that internal features can be tested.
- Must be able to substitute system/library dependencies.
Recommended Techniques:
- PIMPL (Pointer to Implementation) pattern.
Summary
- Writing a good API is extremely important.
- Actual usage patterns must be considered.
- Applying API design principles significantly improves quality.
Takeaways
- An API is a promise, not just code.
- A good API cannot emerge without the user’s perspective.
- Implementation details must never leak into the API.
- API/ABI compatibility must be considered early in the design phase.
- “We’ll fix it later” is almost always the wrong choice in API design.
Reference
C++Now 2018: Titus Winters “Modern C++ API Design: From Rvalue-References to Type Design”
API Design Principles - John Pavan - CppNorth 2023
Testability and C++ API Design - John Pavan, Lukas Zhao & Aram Chung - C++Now 2024
Leave a comment