API Design and Semantic Versioning
APIs as Promises
An API is a contract — a public, documented promise that, given input , the system will return output . Every caller that integrates with your API builds on that promise. Changing the promise after the fact breaks every caller, potentially causing cascading failures across an ecosystem of dependent systems.
This is why API design requires far more care than internal implementation design. An internal function can be refactored with impunity because you control all its callers. A public API function cannot — once it is published and used by others, changing it is an ecosystem-breaking event.
Semantic Versioning (SemVer): MAJOR.MINOR.PATCH
SemVer is a specification for signalling the nature of changes through version numbers, so that consumers can assess the risk of upgrading:
| Bump | When | Example |
|---|---|---|
| MAJOR | You made an incompatible, breaking API change. Existing callers will need to modify their code to upgrade. | Renaming a public function; removing a field; changing a parameter type; adding a required parameter. 2.4.1 → 3.0.0 |
| MINOR | You added new, backwards-compatible functionality. Existing callers can upgrade without changing their code. | Adding a new optional parameter with a default value; adding a new endpoint. 2.4.1 → 2.5.0 |
| PATCH | You made backwards-compatible bug fixes. No new functionality; no breaking changes. | Fixing a calculation error; patching a security vulnerability without changing the API surface. 2.4.1 → 2.4.2 |
Rule of thumb: when in doubt, bump MAJOR. Under-versioning (labelling a breaking change as MINOR) is far more damaging than over-versioning (labelling a compatible change as MAJOR). The former breaks consumers unexpectedly; the latter makes consumers unnecessarily cautious but does no harm.
What Counts as Breaking?
Anything that would cause an existing, correctly-written caller to fail:
- Structural: removing or renaming a function, class, field, or endpoint. Changing a parameter’s type. Adding a required parameter (because existing callers do not provide it).
- Behavioural: changing a default value. Changing the order of returned results. Throwing a new checked exception. Changing the format of a response field.
- Semantic: changing the meaning of a return value. If
getCount()previously returned the number of active users and now returns total users, this is a breaking change even though the function signature is unchanged. SemVer cannot encode semantic changes; they must be communicated through documentation.
Dependency Constraints
Package managers support different levels of version flexibility:
| Constraint | Notation (npm ecosystem) | Meaning |
|---|---|---|
| Exact | 1.2.3 | Only version 1.2.3. No bug fixes. Safest if you distrust the upstream author’s SemVer discipline. |
| Tilde | ~1.2.3 | Allows PATCH-level updates: >=1.2.3 <1.3.0. You get bug fixes but no new features. |
| Caret | ^1.2.3 | Allows MINOR and PATCH updates: >=1.2.3 <2.0.0. The industry default, assuming the author follows SemVer correctly and won’t ship breaking changes in a MINOR bump. |
Lockfiles (package-lock.json, Cargo.lock, yarn.lock) record the exact version of every dependency installed in the last successful build. This ensures reproducibility across the team: Alice and Bob both get identical dependency trees, even if a new version was published between Alice’s install and Bob’s.
Postel’s Law (The Robustness Principle)
Jon Postel, one of the architects of the early Internet, stated in RFC 760 (1980):
Be conservative in what you send, be liberal in what you accept.
- Conservative (Writer / Sender): stick strictly to the specification. Do not send extra fields, non-standard extensions, or “convenient” deviations from the contract. If the spec says a field is optional, do not assume it will always be present just because your current implementation always sends it.
- Liberal (Reader / Receiver): gracefully handle input that does not perfectly match expectations. Ignore unrecognised extra fields instead of crashing — this is forward compatibility, letting newer systems add fields without breaking older ones.
This principle is why the Internet has survived 40+ years of evolution. TCP implementations vary across operating systems, hardware, and decades — but Postel’s Law ensures they interoperate. A TCP stack that crashes on an unrecognised option byte would cause failures across every network path that passes through a newer router.
Backward and Forward Compatibility
Backward compatibility: newer code can handle requests or data from older code. This is essential during rolling deployments, where old and new versions of a service run simultaneously. A new version of user-service must still understand the message format that the old version sends.
Forward compatibility: older code can gracefully handle data from newer code. This is achieved by ignoring unknown fields (Postel’s liberal reader) rather than crashing on them. In a distributed system, you cannot upgrade every node at the exact same instant — a mix of versions always coexists.
Expected Learning
- Explain why an API is a contract and why breaking that contract has ecosystem-wide consequences.
- State the SemVer rules for MAJOR, MINOR, and PATCH, and determine the correct version bump for a given change.
- Identify whether a described change is breaking (MAJOR) or compatible (MINOR or PATCH).
- Distinguish exact, tilde, and caret constraints.
- State Postel’s Law and explain how it enables forward compatibility.
- Distinguish backward from forward compatibility and explain why both matter in distributed systems.
Past Paper Questions
Example Sheet 2, Question 8(b) tests SemVer: given a specific API change (adding a required parameter), determine the correct version bump and explain your reasoning. Example Sheet 2, Question 9(b-c) tests the deprecation path (covered in the next note) and why breaking a dependency contract causes cascading CI/CD pipeline failures.