This article was originally published in Swift For Good. Please consider purchasing the book to support Black Girls Code.

Introduction

Well-designed Application Programmer Interfaces, or APIs, enhance our ability to create amazing apps. APIs that interact well with others are a delight to use: they provide a breath of fresh air that invigorates us, provides new ideas, and encourages new approaches.

Programming is translating concepts between different representations. Whether it’s moving from “on/off” electrical signals to logic gates or from assembly code to human-readable code, each translation boundary is defined by an API: the rules by which ideas can be communicated. Every API changes how a concept is represented, and that change usually adds new capabilities. A simple “on/off” switch isn’t very useful, but if you combine enough of them in the right way, you end up with computers that produce incredible things.

We use APIs to translate concepts from one “layer” to another. When an API does not provide a clean set of rules, we end up with an “impedance mismatch” (for more information, see https://en.wikipedia.org/wiki/Impedance%5fmatching) and encounter a very real form of resistance as we move between layers. We lose time, energy, motivation, and focus, and are left feeling frustrated and wondering if we couldn’t build something better ourselves.

In this chapter, we’ll examine the single underlying mentality that defines all great APIs so that ones we build in the future will maximize productivity and be a delight to use.


The right mindset

When we initially start designing an API, we tend to have several goals about how we want it to look. In addition to solving a core problem, we typically want APIs to:

  • Be “accessible” to as many people as possible
  • Require little domain-specific knowledge
  • Hide complexity
  • Fit naturally in with other APIs being used in an app
  • Allow for future improvements and enhancements

These are worthwhile goals, but they can all be summarized down to a single fundamental principle:

A great API is kind to all developers who work with it.

There are two general categories of developers who use an API, and making a kind API has different implications for both groups.


Being kind to yourself

The first group of developers who work with an API are its authors. A kind API is therefore kind to its authors, and that kindness stems from asking the question:

What can I do to make maintaining this API as simple as possible?

Minimize the public API

APIs live longer than we expect. It sometimes doesn’t feel like it, but the iPhone is over 12 years old. Most of the things we use every day in UIKit have been there since the beginning. Every modification of a view hierarchy or application delegate callback uses APIs that have been in place since day one and have not changed substantially since.

A kind API anticipates a lengthy existence by minimizing the public API. APIs with a small surface area have a limited number of ways in which future changes might affect external usage, thus simplifying maintenance.

When I worked on the UIKit framework at Apple, I was responsible for creating the first “pull to refresh” functionality in iOS 6. Early on in the design process, I explored ideas of “views that live outside the scrollable bounds” which I called “margin views”. These views would trigger callbacks as they were progressively revealed or occluded and would form the basis of creating the “pull/reveal to refresh” feature. I eventually realized the maintenance cost would be quite high and I could minimize the exposed functionality (and the long-term costs) by focusing on exactly what was being requested.

The key decision was to make the feature be a subclass of UIControl. I realized the API needed to trigger a callback to the app, and the simplest way to do that is the target/action pattern established via UIControl. This meant the final API could be tiny, because the mechanism for notifying the app of invocation was already built in and used platform conventions.

Since iOS 6, UIRefreshControl has changed its implementation at least once but the API has remained the same. By minimizing the public API, the current maintainers have very little work to do. There are only a few things that need to keep working: one class, four properties, and two methods.

The decision to make UIRefreshControl have a minimal API has meant that on-going maintenance has been “kind” and straight-forward.

Be smart, not clever

When developing UIRefreshControl, I chose to separate its behavior from its look. Inside UIRefreshControl are two core pieces of functionality. One is the underlying state machine (see https://en.wikipedia.org/wiki/Finite%2dstate%5fmachine) that defines how the control should react as its enclosing UIScrollView scrolls. The other part updates the UI to match the current state.

By separating these two pieces, I intentionally chose to allow UIRefreshControl to alter its appearance without changing its behavior. This was a prescient decision: UIRefreshControl was introduced in iOS 6 with the “stretchy blob” idiom, but was updated the very next year for the new system look-and-feel in iOS 7.

What could have been an arduous process of decoupling behavior from visualization was instead a simple task to write a different UI visualization class that followed the same pattern: update the UI to match the current state. Compared to all of the other changes happening to UIKit at the time, it was one of the easiest to make.

This design was a smart design; it followed SOLID principles (see https://en.wikipedia.org/wiki/SOLID).

It’s not always obvious what a smart and minimal API should be. In these cases, we should avoid prematurely reducing duplication with “clever” solutions. In this context, “clever” is almost always a pejorative. Clever code can be efficient and have a certain degree of elegance, but it almost always ends up being difficult to maintain.

There is a balance to strike here between “minimizing the API” and “avoiding cleverness”. Sometimes we’re faced with the choice of “should I be clever in order to minimize the API, or should I keep the implementation sane and expose more methods?” In this situation, it’s good to remember what Sandi Metz, a prominent Ruby developer, said:

Duplication is far cheaper than the wrong abstraction.

In her blog post (https://www.sandimetz.com/blog/2016/1/20/the-wrong-abstraction) she describes how being clever tends to force us down design paths where we “paint ourselves into a corner” and can no longer make changes without breaking existing functionality. On the other hand, duplicated interfaces can always be unified later through thoughtful deprecations and proper abstractions.

Being smart and avoiding clever code leaves your options open for the future.

Embrace your fallibility

I forget things all the time. You will too. Writing a kind API means admitting to yourself that you will make mistakes and building in safeguards so you can catch mistakes quickly before they cause problems or introduce regressions. The two biggest safeguards I use are assertions and unit tests.

Assertions help ensure that the expectations I have while writing the code will be met in the future. These can be asserting that some internal state is consistent, that I’m on a particular queue, and so on.

For example, the action method for a Timer might look like this:

private func startPerformingTimerAction(_ timer: Timer) {
    assert(timer == _timer)
    dispatchPrecondition(condition: .onQueue(.main))

    timer.invalidate()
    queue.async { self.performTimerAction() }
}

Here, the assertions serve multiple purposes:

  1. They verify that this action is only being used for the Timer I’m expecting. If I accidentally end up with multiple timers, the assert() will catch that.

  2. They verify the Timer is scheduled on the proper thread by performing a queue assertion.

If either of these fail, I will know about it very quickly during development and testing, and the resulting crash log will point directly to the file and line where my expectations were not being met.

This principle also underscores the importance of unit tests. Assertions are great for finding mistakes at runtime, but unit tests can make sure my changes are consistent across a broader scope than just the one area where I’m writing code. Tests help me be confident that my changes in one part of the codebase didn’t break behavior in another.

func test_CalendarManager_DoesNotRetainQuery() {
    let manager = CalendarManager()
    
    var query: CalendarQuery? = CalendarQuery()
    weak var weakQuery = query
    
    manager.executeQuery(query, completion: { })
    
    // By setting this to nil, we should be destroying the last strong reference; this means the weak variable should become nil. If it's not, then something else is retaining the query
    query = nil 
    XCTAssertNil(weakQuery, "CalendarManager has retained the query")
}

A Russian proverb succinctly expresses this idea:

Trust, but verify (Доверяй, но проверяй)

We can trust that future changes to the code will get things “right”, but we verify that this actually happens. Unit tests and assertions are some of tools we can use to verify that future maintenance happens correctly.

You Deserve Nice Things

A couple of years ago, Soroush Khanlou gave a presentation called You Deserve Nice Things (watch it here: https://www.youtube.com/watch?v=bsPR0LqetYU). In it he talks about how we make our job more difficult by denying ourselves tools (“nice things”) to make it easier. He outlines a number of principles about when and why we should extend built-in types. The one that particularly resonated with me is increasing expressiveness.

Let’s consider some logic where we need to add up a bunch of numeric values. We have all the tools built-in to do this, so we naïvely write code that looks like this:

var totalCost = 0
for number in values {
    totalCost += number
}

Maybe we’ll then realize that looping over a sequence is what the reduce() method is for, so we’ll change it to this:

let totalCost = values.reduce(0, { $0 + $1 })

If we’re really clever, we’ll notice that the second parameter is a function that takes two numbers and returns one number, which matches the + operator, so we write:

let totalCost = values.reduce(0, +)

All of these work and are fine, but they’re not very expressive. Why is that 0 in there? What is the “reduction” going on? There’s a “nice thing” we can do to more clearly describe why we’re executing this code and what we’re wanting to accomplish, and that’s to write a simple extension on Sequence:

extension Sequence where Element: Numeric {
    internal var sum: Element {
         reduce(0, +)
    }
}

...

let totalCost = values.sum

For any future maintainer, we have increased the expressivity of the code which makes it easier to maintain. We’re not requiring them to understand what reduce() means or to explicitly know that the + operator can be passed around as a function. Most importantly, we’re directly stating our intention: we want a sum of the values.

As you write code, write it in a way to maximize readability. One of the lesser-known truths of programming is that we don’t write code for computers; we write code for other programmers. Writing a kind API means making the experience of reading the code straight-forward for them.


Being kind to users

The other group of developers who use an API are the ones adopting it in their apps.

To make an API that’s kind for them, we ask ourselves:

What can I do to make using this API as simple as possible?

All APIs are a means to an end; they are never the end themselves. As we’re writing code, we use APIs to help us move towards our goal of writing an app. API authors take pleasure in designing and publishing powerful APIs, but we accept the reality that no matter how cool or powerful or “galaxy brain”-inducing our API is, every single client is using it as a stepping stone along a path to something greater.

This simple truth guides us to some underlying principles about how users want to use our APIs.

Remove more work than you add

App developers want to spend as little time as possible working with our API. They’d much rather be focusing on the logic, functionality, and features of their app. Therefore, any API we build must get out of the way.

We’ve all used APIs where we’ve been forced to spend excessive amounts of time trying to figure out how to make it work. We desperately search through documentation, hoping to find that one sentence buried in a paragraph somewhere that’s going to make it all make sense. Or we’ve had to use APIs in order to meet business requirements, but the framework provided by the vendor is poorly documented, has unusual conventions, and doesn’t “feel right”. These are unkind APIs, because they lead us to consider the possibility of “might it be faster if I did this myself instead of using this framework?” Their “cost” is high.

A kind API pays as much of the cost for app authors as possible.

Integrating

A kind API makes integration simple. Our framework should support all commonly-used dependency management systems. We’ll have a Package.swift for the Swift Package Manager; a podspec for Cocoapods; and an xcodeproj for Carthage and Git Submodules. This allows app developers to easily integrate it into the system they’re already using.

Sometimes a simple “clone and go” setup isn’t feasible. When that’s the case, we’ll make any subsequent setup steps in a run-once setup script and clearly communicate this. Perhaps the xcodeproj has a “Run Script” build phase that performs the setup if it has not been completed already. If the framework requires static configuration, then we could detect that during compilation and produce build errors that clearly describe what the app developer still needs to do. If that’s not practical (perhaps a particular dependency management system doesn’t support executing scripts during compilation), then we should quickly detect this situation at runtime and surface the error to the developer.

A framework should be a “one-click install”. This reduces the amount of work required by app developers to its bare minimum. When we can’t do that, we make sure that we’re being as helpful as we can, while still requiring the least amount of work from the developer.

Using

Kind APIs minimize the cost of using them. They follow platform conventions and match the structure and style of the code with which it will be used. Following conventions and using standard patterns (see https://swift.org/documentation/api-design-guidelines), reduces the amount of cognitive work developers must perform. When they see “an object that has a delegate” or “a method that takes a completion handler”, they can relate this to other concepts that they’ve already seen and used.

Esoteric names and patterns force work upon app developers that increases the cost of adoption. Lesser-known patterns might be unfamiliar enough that developers decide it would be simpler to use something else. Every design decision is about finding a balance between “a good way to express a solution” and “what are users willing to deal with”. Sometimes the “correct” tool for the job can be harder to use than a “good enough” tool. In a very real way, API design is UI design for developers.

Easing adoption also takes advantage of the work we did earlier to expose a minimal API. The fewer methods and types we expose, the less there is to understand in order to “know everything”. Explaining how to use a “margin view” as a pull-to-refresh implementation would take quite a bit of work. But telling someone to “create a UIRefreshControl, set the target/action, and set it on a UIScrollView” is all anyone needs to know to use it.

A kind API uses familiar patterns and types to simplify adoption.

Debugging

Usually no matter how minimal we make an API or how plain we make the patterns, there will be errors. When errors occur, debugging them should be simple and fast. An error should include information (perhaps in the userInfo dictionary of an NSError or as properties on an Error-conforming type) on where it’s from, why the framework believes it’s an error, some thoughts on what might have gone wrong, and a message to perhaps show the user if the app considers this a fatal problem.

We want to avoid forcing developers to open up our source code and trace the logic themselves in an unfamiliar codebase. Very few app developers enjoy this process.

Kind APIs pay down the cost of debugging by making errors clear and helpful.

Upgrading

Producing an app update typically means updating dependencies as well. There are opportunities here to pre-pay the costs of upgrading.

An app developer incurs costs during upgrading if our API:

  • Changes (adds to or removes from) the public interfaces
  • Changes how existing API behaves
  • Changes how the framework builds

Each of these changes adds to the work of app development. That’s the opposite of what a framework should be doing: it should be removing work, not adding it.

We should not create more work for app developers when we evolve our API. As much as is technically possible, we always leave existing API in place. Removing API (deleting methods and types) means that what might otherwise be a simple process to create an app update becomes a much more arduous process of “figure out what broke” and “determine the new way to do this now”.

Leaving existing API in place does mean that our public-facing API will grow. We can mitigate this by deprecating older code paths, but only removing them if absolutely necessary in major version updates (ie, a 2.x to 3.0 release). Deprecation messages indicate to an app developer that there’s probably a better way of doing a task, but the old way will continue to work.

In this example, the renamed parameter in the @availability annotation points to the new functionality and the message explains why this one has been deprecated. Xcode uses this to surface build warnings to developers and intelligently offer fix-its and hints.

public extension Expression {
    @available(*, deprecated, renamed: "init(string:configuration:)", message: "Manually passing in all the required arguments has been replaced with a single Configuration struct")
    convenience init(string: String, operatorSet: OperatorSet, options: TokenResolverOptions, locale: Locale?) throws { 
        ...   
    }
}

When we deprecate a method, we also update its implementation to use a new version. In the example above, the previous implementation created and executed some parsers, passing all of the parameters in to the proper objects. After deprecation, it becomes a proxy for a “better” version:

convenience init(string: String, operatorSet: OperatorSet, options: TokenResolverOptions, locale: Locale?) throws {
    var c = Configuration()
    c.operatorSet = operatorSet
    c.allowArgumentlessFunctions = options.contains(.allowArgumentlessFunctions)
    c.allowImplicitMultiplication = options.contains(.allowImplicitMultiplication)
    c.useHighPrecedenceImplicitMultiplication = options.contains(.useHighPrecedenceImplicitMultiplication)
    c.locale = locale
    try self.init(string: string, configuration: c)
}

Carefully deprecating functionality and updating old methods lowers the cost of adopting new versions. We want to make sure that app developers have the choice of adopting new functionality when they’re ready to do so.

This also means that all new features in our API should be idle unless an app developer explicitly initiates them. This way, when an existing user updates to a new version of our framework, we’re not forcing them to accept run-time performance costs that they haven’t explicitly decided to pay.

Removing more work than we add often means we end up doing quite a bit of “grunt work” ourselves. We have more work to update older code. We have more work to make features opt-in. We have more work to make sure we avoid breaking their builds. This is as it should be. The point of using an API is so that app authors don’t have to pay that cost themselves. Our framework exists to remove work for them, which means it adds work for us.

Build for the common case

The APIs we build should meet most needs, but we don’t have to meet every need of every use-case. In fact, trying to accommodate everything is an indication we’re building the wrong thing. APIs are abstractions, and all abstractions add or remove capabilities.

Building for the common case means we need to understand why app developers would want to use our framework and exactly what benefits we’re providing. This allows us to focus our efforts and provide the best API for that use-case.

This also means providing flexibility and allowing app developers to use as much or as little of the functionality we’re providing.

public class DataStore {
    /// Retrieve all values that match a provided `Filter`
    ///
    /// - Parameters:
    ///   - filter: The `Filter<T>` describing the constraints to be used when fetching matching values. If a filter is specified for a type that does not support filtering, then the completion handler will be immediately invoked with an `.invalidRequest` error code. If this value is `nil`, all values of the type will be retrieved.
    ///   - completion: A completion handler that will be invoked with either the array of retrieved values, or a `RequestError` describing the first failure encountered when retrieving the values.
    /// - Returns: A `Cancellable` via which the entire operation may be prematurely stopped. This value may be safely discarded without halting the request.
    @discardableResult
    public func requestAll<T: ModelType>(matching filter: Filter<T>?, completion: @escaping (Result<[T], RequestError>) -> Void) -> Cancellable {
        ...
    }
}

In this example method used to fetch some model objects from a datastore, we’re making the “common case” easy:

  1. The Filter<T> parameter is optional. An app developer using this is allowed to pass in nil, which the framework can interpret itself. We don’t require the developer to unwrap an optional value, which simplifies their code.
  2. Familiar patterns (completion handlers and the Result type) mean there’s minimal work to understand the intended usage pattern.
  3. The return value of the method is “discardable”; app developers have the choice of keeping a reference to the Cancellable value or simply ignoring it.
  4. Clear and descriptive documentation means help is an option-click away.

Be opinionated…

Kind APIs have clear opinions about how to accomplish a particular task. An app developer that has decided to use our framework has explicitly expressed a degree of trust in our ability to solve their problem. Embrace that trust, and be confident in the opinions we’ve published.

Being opinionated means saving developers from themselves. If there are things that our framework could theoretically do but would cause undesirable behavior, then we should craft an API to make that code flow impossible. Whether it’s carefully crafted generic types or selectively exposing methods on progressively-returned values, we have many tools to guide app developers to “the right way” of doing things. We can often stop developers from doing things we don’t want them to do.

Sometimes we need to stop developers from doing things they don’t want to do. I develop a framework for performing calendrical calculations. One of the features of this framework is to “adjust” calendar values by a delta value. A value representing “January 14th, 2020” can be adjusting by “adding 3 months” to it. However, the underlying calendaring code technically allows you to add nonsensical values to “January 14th”, like “3 seconds”. What would it mean to add 3 seconds to January 14th, 2020? In my framework, I included code that recognizes this scenario and stops developers from doing this, because it’s something they don’t want to do.

The operator to perform valid adjustments looks like this:

public func +<S>(lhs: Value<S, Era>, rhs: Delta<S, Era>) -> Value<S, Era> {
    lhs.applying(delta: rhs)
}

I also define all of the invalid things that can be done:

@available(*, unavailable, message: "Adding months to a year is invalid")
public func +(lhs: Value<Year, Era>, rhs: Delta<Month, Era>) -> Never { invalid() }

@available(*, unavailable, message: "Adding days to a year is invalid")
public func +(lhs: Value<Year, Era>, rhs: Delta<Day, Era>) -> Never { invalid() }

@available(*, unavailable, message: "Adding hours to a year is invalid")
public func +(lhs: Value<Year, Era>, rhs: Delta<Hour, Era>) -> Never { invalid() }

...

Defining these operator overloads does three things:

  1. The compiler will prefer these versions, since they’re more explicitly typed than the generic version.
  2. By marking them as unavailable, the compiler will produce an error if developers type them into their code (ex: today + .seconds(3)).
  3. The message supplied in the annotation shows up as the compiler error, which gives me an opportunity to teach app developers about why they shouldn’t be doing what they’re trying to do.

Having a firm opinion about correctly performing date and time calculations has drastically impacted how I’ve designed the API for my framework. It means I’m only going to allow very specific things, because (in my experience) those are the correct things to want, and the things I’ve restricted are things that lead to incorrect results.

Since I’ve built this API to be kind, it recognizes incorrect usage and teaches developers about a better way.

…but not too opinionated

App developers using our framework are trusting that our opinions and solutions are correct. We don’t want to abuse that trust by forcing opinions on them that are out of scope with the problems we’re solving.

One example of this I’ve seen is when I come across a framework that has an example project, but (for whatever reason) the example project requires a specific dependency manager. What about people who want to try out the example, but don’t use that dependency manager? Examples like these are unkind to exploring developers.

I also see frameworks forcing users to adopt very specific architectural patterns. While it’s possible that using a functional-reactive architecture (for example) might be the best way to express the solution to a particular problem, it also forces very explicit code choices onto users; choices they may not be prepared to make.

Building a kind API means that we don’t force choices onto our users that are unrelated to the problems we’re solving. A framework that performs some logic around PDFs should only express opinions about PDFs. Extensions on standard library types used in our framework should be internal (unless the extension itself is explicitly part of our API). We shouldn’t publish any types that are “generally useful”, as these tend to end up conflicting with new types from the standard library or other frameworks. Before we had Result in the standard library, it was pretty common to end up with multiple dependencies that each declared their own Result type. The ensuing compiler errors were never fun to debug.

Similarly, we are conscientious of the dependencies we rely on, as those also become part of our framework. Including dependencies means we’re forcing their decisions onto clients as well. There are times when this is appropriate, but it’s a decision that we only make after careful thought and consideration.

A kind API is opinionated on exactly the problem it solves, but holds no opinions on anything else.


Designing kind APIs

These are the rules I follow when designing an API. Whether it’s something small like UIRefreshControl or something large like an entire calendaring framework, one question guides my choices:

How do I make this as kind as possible?

At its heart, API design is an art. It’s an alchemical mixture of logic and intuition that form new ideas. Like all art forms, it takes time to develop and hone. With diligent practice and consideration for others, you’ll develop the skill to design beautiful, practical, and kind APIs.