API and Library Design
Learning Objectives
Design public APIs with stable contracts, clear ownership and error models.
Apply semantic versioning, deprecation and backward compatibility.
Use Pimpl for ABI stability; evolve interfaces safely.
Prerequisites: Modules 14, 55, 62.
Intuition and Definition
A public API is a promise. Everything visible to users — names, signatures, behaviour, error cases, performance characteristics, even undocumented behaviour people rely on ("Hyrum's law") — becomes hard to change.
Minimal; hide implementation; one obvious way to do things Document preconditions, postconditions, thread safety, complexity Ownership & lifetime
Who owns returned objects? How long are references valid?
One consistent model (exceptions, expected, error codes) SemVer: MAJOR for breaking changes, MINOR for compatible features, PATCH for fixes
Add, don't change; deprecate before removing; extension points designed in Binary stability (C++)
Pimpl, no inline access to private members, stable exported symbols
Why It Matters
Internal code can be refactored freely; a published API cannot. Mistakes in public APIs are paid for by every user, for years.
Syntax and Language Notes
[[deprecated("use connect(Endpoint) instead")]] void connect(const char* host, int port);/** @deprecated use connect(endpoint) */
export function connectHost(host: string, port: number): void;[Obsolete("Use Connect(Endpoint)")] public void Connect(string host, int port) { }Complete Example
A Pimpl-based library class: the header exposes only a pointer, so private members can change without breaking binary compatibility; deprecated overloads forward to the new API.
#include <expected>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
// ---------- public header (what users compile against) ----------
class TelemetryClient {
public:
struct Endpoint { std::string host; int port; };
enum class Error { InvalidEndpoint, NotConnected };
explicit TelemetryClient(Endpoint e);
~TelemetryClient(); // defined where Impl is complete
TelemetryClient(TelemetryClient&&) noexcept;
TelemetryClient& operator=(TelemetryClient&&) noexcept;
std::expected<void, Error> connect();
std::expected<int, Error> send(const std::string& metric, double value); // returns sequence number
[[deprecated("use send(metric, value)")]] bool push(const std::string& metric, double value);
private:
struct Impl; // layout hidden: free to change
std::unique_ptr<Impl> impl_;
};
// ---------- library implementation file ----------
struct TelemetryClient::Impl {
Endpoint endpoint;
bool connected = false;
int sequence = 0;
std::vector<std::string> buffer; // added in v1.3 — no ABI break for users
};
Telemet
…send before connect: NotConnected error
sent, sequence 1
bad endpoint: InvalidEndpoint error
sizeof(TelemetryClient) = sizeof(pointer): 1Step-by-Step Walkthrough
Users see only Endpoint, Error, the public functions and an opaque Impl pointer; sizeof(TelemetryClient) is one pointer and never changes.
The buffer member was "added in v1.3" inside Impl — users don't need to recompile (ABI stable).
Errors are returned as std::expected consistently; the deprecated push forwards to send, and calling it produces a compiler warning that guides migration.
The destructor is declared in the header but defined in the implementation file, where Impl is complete (required for unique_ptr of an incomplete type).
Common Mistakes
Exposing internal types (vendor SDK types, containers you might change) in public signatures.
Boolean and positional parameter soup (open(path, true, false, 3)).
Inconsistent error models (some functions throw, others return codes, others return null).
Breaking changes in minor versions — including behaviour changes users relied on.
Unclear ownership — returning raw pointers without saying who deletes them.
No API tests — the public behaviour isn't pinned, so refactors silently change it.
Trade-offs and Alternatives
Pimpl costs a heap allocation and indirection per call; header-only/inline APIs are faster but freeze layout. Versioned namespaces (inline namespace v2) let two versions coexist. For web APIs, version in the URL or header and keep old versions alive during migration.
Real-World Use
Qt (d-pointers), Windows COM interfaces (never changed once published; new versions get new interfaces: IFoo2), POSIX C APIs, npm packages (SemVer + type declarations .d.ts), REST APIs with deprecation headers.
Practice
Q1. (Easy) Under SemVer, which version part changes for a breaking change?
Answer: MAJOR.
Q2. (Medium) Why must the Pimpl class's destructor be defined in the .cpp file?
Answer: std::unique_ptr<Impl>'s deleter needs the complete Impl type; defining the destructor where Impl is complete avoids an incomplete-type error.
Q3. (Advanced) What is Hyrum's law and how does it affect API evolution?
Answer: "With enough users, every observable behaviour of your system will be depended on by somebody." Even undocumented details (ordering, timing, error messages) become de facto contracts, so changes must be tested against real usage, rolled out gradually, and communicated — and APIs should minimise observable surface from the start.
Interview Question
Q1. "How do you add a parameter to a widely used public function without breaking users?"
Answer: Don't change the existing signature. Add an overload (or a new function) with the extra parameter; have the old one forward with a sensible default; for C++ binary compatibility, keep the old symbol exported; consider an options/parameter object so future additions don't need new overloads; mark the old version deprecated with a migration message once the new one is proven; remove it only in the next MAJOR version.
Quick Quiz
Q1. What does Pimpl stand for?
Answer: Pointer to implementation.
Q2. Name three things a public API should document besides signatures.
Answer: Error behaviour, ownership/lifetime, thread safety (also complexity and preconditions).
Q3. What does a MINOR version bump promise?
Answer: New backward-compatible functionality.
Summary
Public APIs are promises: minimal surface, explicit contracts, consistent errors, clear ownership.
Evolve by adding and deprecating; protect ABI with Pimpl; follow SemVer.
Related: Modules 14, 55, 62.