How do you evolve a public API without breaking callers: deprecation, renames, versioning and source versus binary compatibility?
Predict first
A deprecated function is called. Does it still compile?
@available(*, deprecated, message: "use fetchUser(id:)")
func getUser(_ id: Int) -> String { fetchUser(id: id) }
func fetchUser(id: Int) -> String { "user\(id)" }
print(getUser(1))
- Yes, with a warning, and it prints user1
- No, it is an error
Mental model. Renaming a street: you put up the new sign, keep a “formerly Old Road” sign for a year, mail everyone the change, and only then take the old sign down. You do not remove it overnight and hope the postman knows.
A published API is used by code you cannot see, so change is a process, not an edit. First know what “breaking” means. Source compatibility: callers recompile without changes. Binary compatibility: callers built against the old version keep working against the new binary (relevant for frameworks distributed as binaries and for library evolution mode). Adding a new method, a new type or a defaulted parameter (as a new overload) is usually source-compatible; removing or renaming a symbol, changing a signature, adding a case to a public non-frozen enum (which breaks exhaustive switches; handle with @unknown default on the caller’s side) or tightening a type are breaking. Semantic versioning communicates it: patch for fixes, minor for compatible additions, major for breaking changes, and Swift Package Manager resolves versions by that promise.
The standard tool is deprecation with a migration path. Mark the old symbol @available(*, deprecated, message: "use fetchUser(id:)", renamed: "fetchUser(id:)"): callers get a warning (the code still compiles and runs) with a fix-it, and you keep it working, usually forwarding to the new API, for at least one major release or a published window. Only then promote it to @available(*, unavailable, renamed:), which is a compile error pointing at the replacement, and finally remove it in a major version. Platform-specific introductions use @available(iOS 17, *). Keep the deprecated shim thin so it does not preserve old bugs forever, test the old and new paths, and announce changes in a changelog with before/after code. To reduce the need for breakage: expose less (keep types internal, package or behind protocols), use @frozen only for types that will never change layout, prefer additive designs (options structs, defaulted parameters), version wire formats and data schemas separately from code, and use feature flags for behaviour changes. For internal APIs inside a monorepo you can often change callers in one commit and skip deprecation; for a public SDK, or an API used by several apps on different release cycles, deprecate first. Measure usage (telemetry, a search across known consumers) before removing, and be explicit about your support policy.
Deprecation is a warning with a message. The old function keeps working, forwarding to the new one, so callers can migrate at their own pace.
Common traps
- Removing or renaming a public symbol in a minor release. It breaks every caller; deprecate first, remove in a major version.
- Adding a case to a public enum without thinking about callers’ switches. Exhaustive switches break; callers need
@unknown default.
They might follow up with
- What is the difference between deprecated and unavailable? Deprecated warns and still works; unavailable is a compile error.
- When can you skip deprecation? For internal APIs where you can update all callers at once.
Related questions
- 15.5.1 What makes a good Swift API: naming, argument labels, defaults, ownership, thread documentation and misuse…
- 15.6.2 How do you run a shared mobile platform for many apps and teams: layering, ownership, versioning and…
- 15.3.1 What is a feature module, how do you draw module boundaries, and how does access control enforce them?
← What makes a good Swift API: naming, argument labels, defaults…