21 May, 2024

@_spi or How to develop better APIs in Swift

When developing a public API, we often reach the point where we would like different clients of our interface to consume either experimental features under development, or to tailor specific methods for them that we would not like other clients to use.

Swift's @_spi (System Programming Interface) attribute offers a solution by allowing developers to define subsets of an API targeted at specific clients, effectively hiding them from unintended users.

Let's illustrate this with an example. Suppose we're developing a public library called CheckoutSDK, which facilitates payment processing. Within this library, we have various methods, some of which are intended for specific client use:

public class Checkout {
     
     public func createTransaction() { }

    /// Only for Transfers’ team use:
     public func createTransfer() { } 

    /// Only for experimental feature with Crypto team:
     public func createExperimentalCryptoTransaction() { }
}

We do not really have a way of enforcing that both the createTransfer() and createExperimentalCryptoTransaction() methods will be used only by the SDK clients we want (comments are not binding 😅) and will not be used by other users of the CheckoutSDK, potentially creating side effects. These would all be fair uses of our SDK, even if we did not intend them that way:

import CheckoutSDK

let checkout = Checkout()
checkout.createTransaction()
checkout.createTransfer()
checkout.createExperimentalCryptoTransaction()

Regular access control alone would not really help us here. One approach could be creating sub-specs our API for each specific client (e.g., creating a specific module PaymentsSDKforTransfers) but that would not really scale with many different consumers.

So what other alternative do we have?

SPIs to the rescue!

System Programming Interfaces (SPIs) lets us define a subset of our API targeted at specific clients and, most importantly, hidden by default from the rest of the clients.

In Swift, we currently have the experimental @_spi(spiName) attribute, which lets us mark any declaration as an SPI. Let's see how we can enhance our CheckoutSDK with this new attribute:

public class Checkout {
     
     public func createTransaction() { }

     @_spi(Transfer) public func createTransfer() { } 

     @_spi(Experimental) public func createExperimentalCryptoTransaction() { }
}

Now, to access either the createTransfer() or createExperimentalCryptoTransaction() method, we have to be intentional with it when importing the CheckoutSDK module. Let’s dive into each possibility:

  • 1. No @_spi when importing:
import CheckoutSDK

let checkout = Checkout()
checkout.createTransaction()
checkout.createTransfer() // ❌ Compilation error: ‘createTransfer’ is inaccessible due to ‘@_spi’ protection level.
checkout.createExperimentalCryptoTransaction() // ❌ Compilation error: ‘createExperimentalCryptoTransaction’ is inaccessible due to ‘@_spi’ protection level.
  • 2. Add @_spi(Transfer) when importing:
@_spi(Transfer) 
import CheckoutSDK

let checkout = Checkout()
checkout.createTransaction()
checkout.createTransfer() // ✅ Now this works! 
checkout.createExperimentalCryptoTransaction() // ❌ Compilation error: ‘createExperimentalCryptoTransaction’ is inaccessible due to ‘@_spi’ protection level.

  • 3. Add both @_spi(Transfer) and @_spi(Experimental) when importing:
@_spi(Transfer)
@_spi(Experimental)
import CheckoutSDK

let checkout = Checkout()
checkout.createTransaction()
checkout.createTransfer() // ✅ Now this works! 
checkout.createExperimentalCryptoTransaction() // ✅ Now this also works! 

Awesome! We can now tailor our API for specific use cases without cluttering the general public API. This brings several benefits:

  • - Encapsulation: maintain encapsulation by keeping internal APIs hidden from the general public API.
  • - Selective Exposure: only expose certain APIs to specific modules, not globally.
  • - API Stability: experiment and iterate on internal APIs without affecting the public API, providing more flexibility in development.

The dreaded Underscore ( _ ) annotation

If you are familiar with the underscore (@_foo) annotation for Swift attributes, you will quickly realize that this feature is experimental, and that comes with a couple of caveats. The Swift documentation for underscore attributes comes with the following warning:

Usage of these attributes outside of the Swift monorepo is STRONGLY DISCOURAGED, and they reserve the right to freely change its behavior.

In spite of this, the behavior and syntax of @_spi has remained unchanged for over 4 years and it is extensively used by stable products like Stripe in their public SDKs. So, in the realm of experimental attributes, I personally think the risks of using it are very low, making it the de facto way of creating SPIs in Swift.

I hope you found this article helpful. For any questions, comments, or feedback, don't hesitate to reach out: connect with me any of the social links below, or drop me an e-mail at marcos@varanios.com.

Thank you for reading! 🥰