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): 1

Step-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.