Erez Hod

Designing Swift APIs for Scale

It’s no secret that the best way to win a developer’s heart is by enhancing their developer experience (DevEx). I believe that introducing clear, consistent, and robust API design, in addition to implementing a well-fitting architecture, is something we cannot afford to overlook.

Creating a smooth and intuitive experience for developers when writing Swift code comes down to the names, idioms and patterns you use in your APIs. These design guidelines help ensure that your code fits seamlessly into the larger Swift ecosystem, making it feel natural and familiar to other developers.

You might think that API design is more fitting for packaged code like libraries, packages, frameworks, and SDKs. However, what if I told you that every time you write a non-private property or function, you are, in fact, writing an API?

Wake up Samurai. We have an API to design

Real World Example

Let’s take a look at a real-world example that I actually encountered while maintaining a chat app for a company I worked for in the past:

struct ChatManager {
    func sendMessageToUser(message: Message,
                           user: User,
                           otherUser: User,
                           messageOptions: MessageOptions) {
        // ...
    }
}

While this code does have a somewhat clear intent—to send a message to a user—the call site can be extremely confusing. Who is user: User? Is it the current user or the one I’m messaging? And who is otherUser: User? Does it refer to an additional user I’m sending the message to in some strange way?

You can see where this is going and how confusing it can get.

So… how do we make this API better?

We’ll get to that in a later section. First, let’s dive into a few basic guidelines with some code examples to better understand our intentions.

Naming, Call Sites & Context

Clear naming in context is a fundamental rule for writing clean code, helping make our intentions obvious to ourselves and other developers.

Let’s create a new VideoLibrary and, within it, add an API method that adds a new Video to the library:

struct VideoLibrary {
    mutating func add(video: Video) throws {
        // ...
    }
}

At first glance, this looks pretty good and clear. However, we still need more context. Also, the video: argument name might be redundant, like this:

videoLibrary.add(video: video) // Redundant: No reason to read `video` twice

One way to improve our code is by using argument labels. Swift allows us to add argument labels to method and function arguments, which helps provide clearer context and intent:

mutating func add(_ video: Video) throws { // Adde an empty label using an underscore
    // ...
}

// Usage
videoLibrary.add(video)

As you can see at the call site, an empty label removes the need to provide a argument name before the value. However, the call site is still unclear. What if our add API method actually ignores the video we provide if it already exists and throws an error?

    // We've added an empty label using underscore
    mutating func add(_ video: Video) throws {
        if videos.contains(video) {
            throw VideoLibraryError.videoAlreadyExists
        }
        // ...
    }

We could fix this by adding a label that provides a clearer intent:

    mutating func add(new video: Video) throws {
        if videos.contains(video) {
            throw VideoLibraryError.videoAlreadyExists
        }
        // ...
    }

We’ve added the label new to our API method. This way, we have provided a clearer intent to the fact that the input Video must be new to the data source.

Another example could be introducing a new API method in our VideoLibrary to calculate the user’s remaining disk space in their cloud service, in case they want to sync their videos

extension VideoLibrary {
    func calculateDiskSpace(_ service: CloudService) -> DiskSpace {
        // ...
    }
}

As you can guess, the call site can become very unclear:

let diskSpace = videoLibrary.calculateDiskSpace(user.cloudService) // Unclear: what is the intention of the returned `DiskSpace`?

We can definitely better this using our newly learned method to provide a label to the call side like so:

func calculateDiskSpace(leftIn service: CloudService) -> DiskSpace

let diskSpace = videoLibrary.calculateDiskSpace(leftIn: user.cloudService)

This simple leftIn label, provides clearer context and intent on what we are going to receive in return.

Nested Types

Swift lets us do something really cool: the ability to nest types within each other, which helps clarify relationships between types.

Let’s create a new type called Album in addition to our existing Video type. The Album will be a collection of videos with a few additional metadata arguments:

struct Video {
    let name: String
    let author: Author
}

struct Album {
    let name: String
    let videos: [Video]
}

Now, you might argue that it’s obvious in the global context that an Album relates to Video. But what if your Product Manager now wants to introduce an Audio album with different arguments? Are you going to rename your type to VideoAlbum to accommodate this change? Maybe. But there’s another option—use nested types.

extension Video {
    struct Album {
        let name: String
        let videos: [Video]
    }
}

extension Audio {
    struct Album {
        let name: String
        let artist: String
        let genre: String
        let audios: [Audio]
    }
}

By moving our struct Album { ... } type to be nested within the Video type declaration, we can now declare and initialize it in this context:

let videoAlbum = Video.Album(name: "Family vacation 2024", videos: [...])
let audioAlbum = Audio.Album(name: "Meteora", artist: "Linkin Park", genre: "Alternative Rock", audios: [...])

This option provides an easier way to keep namings short, clear and have direct relationships to their counterpart types.

💡 Of course, there’s also the option of introducing generics to our Album type, but that approach is beyond the scope of this article.

Functions Overloading

Function (or method) overloading is a practice that can be found in many programming languages, but Swift makes it easy by allowing us to use the same function name with different parameters, thus treating it as a totally different method signature.

Let’s introduce a new API to our VideoLibrary that enables us to add a new Album type:

struct VideoLibrary {
    mutating func add(new video: Video) throws {
        // ...
    }

    mutating func add(new album: Video.Album) throws {
        // ...
    }
}

As you can see, we have reused the add method naming convention but used a totally different argument. So now, when we call the .add, we can have two types of adding methodologies:

videoLibrary.add(new: video)
videoLibrary.add(new: album)

Strong Typing

Some API method argument types can be clear enough just by using some built-in strong types (such as String, Int, and more). For example if we just ask for an age in update(age: Int, for...), but how can we improve it for more complex types and still maintain a readable flow?

Consider the following API method we have added to our VideoLibrary in order to update an author for a specific Video:

extension VideoLibrary {
    mutating func update(author: String, for video: Video) {
        // ...
    }
}

videoLibrary.update(author: "George Lucas")

Let’s create a new type named Author and have it contain a name of type String as a member and update our API method:

struct Author {
    let name: String
}

extension VideoLibrary {
    mutating func update(author: Author, for video: Video) {
        // ...
    }
}

videoLibrary.update(author: Author(name: "George Lucas"), for: video)

Now it makes it much more readable as a sentence, but something is still off. Let’s try to omit the author label like so:

mutating func update(_ author: Author, for video: Video) {
    // ...
}

// Usage
videoLibrary.update(Author(name: "George Lucas"), for: video)

Now, you can see that within the call site, it feels like reading a complete English sentence:

Update author name George Lucas for video.

Although it’s broken English, I believe you can understand the point 🤭

Default & Optional Method Arguments

Optionals in Swift are a great way to omit or declare that some values can be missing. These can also be used for method arguments in Swift.

Optional: A type that represents either a wrapped value or the absence of a value. — Apple Documentation

Additionally, default value arguments are also available in Swift, providing an option to pass a default value in case a value is missing, but we don’t actually want it to be nil.

Consider the following Network manager that can be used to send requests to a URL:

struct Network {
    func request(
        url: URL,
        method: String,
        headers: [HTTPHeader],
        queryItems: [URLQueryItem],
        body: [String : any]
    ) {
        // ...
    }
}

We can improve this request API method using Optionals and default values:

struct Network {
    func request(
        _ url: URL, // Omit the `url` label
        method: HTTPMethod = .get, // defaulted to `.get` if not provided
        headers: [HTTPHeader]? = nil, // optional
        queryItems: [URLQueryItem]? = nil, // optional
        body: [String : any]? = nil // optional
    ) {
        // ...
    }
}

// Default GET request
networking.request(url)

// GET request with headers
networking.request(url, headers: headers)

// POST request with all arguments
networking.request(url,
                   method: .post,
                   headers: headers,
                   body: body)

Additionally, it is mostly considered a good practice to place optional arguments as close as possible to the end of the method/function.

Note that because we have defaulted some Optional arguments to nil, we don’t have to provide them inside the call site at all. Very convenient.

Making our Chat App API method better

Remember this API method from the “Real World Example” section?

struct ChatManager {
    func sendMessageToUser(message: Message,
                           user: User,
                           otherUser: User,
                           messageOptions: MessageOptions) {
        // ...
    }
}

Now, after we have learned and understooed some cool practices we can start treating this abomination and make it much simpler to implement.

Let’s introduce a few changes from what we have learend thus far:

  1. Omit the need to provide a message argument name
  2. Add clearer context as to from who to who this message is going to be
  3. The ability to provide options should be totally optional. Also note that we nested the type Options within our Message type for even clearer context
struct ChatManager {
    func send(_ message: Message, // 1
              from currentUser: User, // 2
              to destinationUser: User,
              with options: Message.Options? = nil /* 3 */) {
        // ...
    }
}

And now, let’s inspect its call site in a before & after manner:

// Before
chatManager.sendMessageToUser(message: message,
                              user: currentUser,
                              otherUser: destinationUser,
                              messageOptions: options)

// After
chatManager.send(message,
                 from: currentUser,
                 to: destinationUser,
                 with: options)

// We can also omit the ‘with options' argument if we don't need to pass it
chatManager.send(message,
                 from: currentUser,
                 to: destinationUser)

Where to go from here?

While designing APIs isn’t an exact science, understanding the fundamentals and recognizing the need to make our code clearer and more intentional—for ourselves and other developers—can go a long way. By mastering the tools at your disposal, you’ll set yourself on the path to becoming a much better engineer.

If you have any more interest in the official Swift conventions and guideliness, you can read more about it in the official Swift API Design Guidelines page.

👨🏻‍💻 Happy coding!

Get Weekly Insights

Receive the latest articles, tips, and updates on development for Apple platforms right in your inbox.