Protocols and Possibility

I recently dove into looking at Bitwarden’s iOS codebase for fun, and to find resources for other engineers that I work with to learn native the native mobile languages. Since Bitwarden is quite large, they actually have to take architecture seriously from a code readability, testability, and maintainability standpoint, and not leave it off as a nice to have.

Regardless, I saw a lot of massive protocols like this.


protocol AuthService {
    var callbackUrlScheme: String { get }

    func answerLoginRequest(_ request: LoginRequest, approve: Bool) async throws

    func checkPendingLoginRequest(withId id: String) async throws -> LoginRequest

    func denyAllLoginRequests(_ requests: [LoginRequest]) async throws

    func generateSingleSignOnUrl(from organizationIdentifier: String) async throws -> (url: URL, state: String)

    func getPendingAdminLoginRequest(userId: String?) async throws -> PendingAdminLoginRequest?

    func getPendingLoginRequest(withId id: String?) async throws -> [LoginRequest]

    func hashPassword(password: String, purpose: HashPurpose) async throws -> String

    func initiateLoginWithDevice(
        email: String,
        type: AuthRequestType
    ) async throws -> (authRequestResponse: AuthRequestResponse, requestId: String)

    func loginWithDevice(
        _ loginRequest: LoginRequest,
        email: String,
        isAuthenticated: Bool,
        captchaToken: String?
    ) async throws -> (String, String)

    func loginWithMasterPassword(
        _ password: String,
        username: String,
        captchaToken: String?,
        isNewAccount: Bool
    ) async throws

    func loginWithSingleSignOn(code: String, email: String) async throws -> LoginUnlockMethod

    func loginWithTwoFactorCode(
        email: String,
        code: String,
        method: TwoFactorAuthMethod,
        remember: Bool,
        captchaToken: String?
    ) async throws -> LoginUnlockMethod

    func requirePasswordChange(
        email: String,
        isPreAuth: Bool,
        masterPassword: String,
        policy: MasterPasswordPolicyOptions?
    ) async throws -> Bool

    func resendVerificationCodeEmail() async throws

    func resendNewDeviceOtp() async throws

    func setPendingAdminLoginRequest(_ adminLoginRequest: PendingAdminLoginRequest?, userId: String?) async throws

    func webAuthenticationSession(
        url: URL,
        completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler
    ) -> ASWebAuthenticationSession
}
        

This protocol has 18 requirements, which is quite a lot.

However, I’ve also seen such large protocols parroted around as general advice, though not explicitly. No one is literally saying, “You should make protocols as large as possible!”, but you probably have heard of something called the Repository pattern. An average Repository protocol looks like this.


protocol NotesRepository {
  func findNote(by id: UUID) async throws -> Note?
  func allNotes(for notebookId: UUID?) async throws -> [Note]
  func findNotes(by text: String) async throws -> [Note]

  func save(note: Note) async throws

  func moveNote(from srcNotebookId: UUID, to dstNotebookId: UUID) async throws
  func deleteNote(with ids: Set<UUID>) async throws
}
        

Even still, 6 protocol requirements is quite a lot. Most protocols that you conform to from most libraries and frameworks, including Apple’s, do not have nearly as many requirements. Or, if those protocols do have lots of requirements, it’s usually an some kind of “delegate” protocol where most of the requirements are optional, and you only end up implementing a small subset of the requirements most of the time.

I would rather have something like this instead of NotesRepository. Where each protocol defines a small class of cohesive functionality.


protocol NoteSaver {
  func save(note: Note) async throws
}

protocol NoteMover {
  func moveNote(from srcNotebookId: UUID, to dstNotebookId: UUID) async throws
}

protocol NoteDeleter {
  func deleteNote(with ids: Set<UUID>) async throws
}

protocol NoteFinder {
  func findNote(by id: UUID) async throws -> Note?
  func allNotes(for notebookId: UUID?) async throws -> [Note]
  func findNotes(by text: String) async throws -> [Note]
}
        

Inevitably, this will turn into a piece about what an optimal protocol size is. Naturally, my answer is of course that “it depends”, but it’s usually not the kind of large protocols that you more commonly see in the wild. Rather, it’s about leaving open as many different conformance possibilities as possible, and large protocols with many requirements tend to not provide many conformance possibilities.


The Case Against Large Protocols

Before jumping in, I don’t want to give the impression that such protocols are always bad. Large protocols are fine if the context they are used in has an answer for the generalized case that I will bring against them.

There are 2 primary pain points that I have with large protocols. First, is the fact that often no parts of your app need to depend on the entire protocol, but rather only a few requirements of it at a time. Second, larger protocols are harder to conform to in general, and are harder to compose (ie. Creating large pieces of functionality by composing smaller conformance’s together).

The main point that you should take away from this is that smaller and easier it is to conform to a protocol, you obtain more possibilities for implementing that protocol. This in turn gives you more options when you need to add or modify a feature in your code.

Unnecessary Dependence

The functionality that often depends on large protocols often only needs a small subset of the requirements (typically only 1-3 in 90% of cases).

For instance, this could be a pure SwiftUI view, view model, or TCA reducer for a UI component only needs to depend on functionality that performs the data actions taken by the user. This could be something like adding a new note an HTTP API when a new note is created through a form. In this case, such a pure view, view model, or reducer only needs to depend on this protocol.


protocol NoteSaver {
  func save(note: Note) async throws
}
        

Yet instead, we often end up depending on this.


protocol NotesRepository {
  func findNote(by id: Int) async throws -> Note?
  func allNotes(for notebookId: Int?) async throws -> [Note]
  func findNotes(by text: String) async throws -> [Note]

  func save(note: Note) async throws

  func moveNote(from srcNotebookId: Int, to dstNotebookId: Int) async throws
  func deleteNote(with ids: Set<Int>) async throws
}
        

If we’re using Xcode previews, it would be wise to be able to preview what our UI is like when the save operation invoked by a note creation form fails. This allows us to experience the user’s pain as they try-try-try again to use our broken app. We’ll likely rely on a mock to achieve this, because this makes it easy to put the UI in a failing state.

Since our NoteSaver protocol only has a single requirement, creating a mock is quite easy. In fact, we can just create it directly in the preview block because it’s so easy. We can even throw in some fake delay, just to simulate the mindless wait that our frustrated user will experience for real.


struct AddNoteForm: View {
  init(saver: any NoteSaver) {
    // ...
  }
  // ...
}

#Preview {
  struct FailingSaver: NoteSaver {
    func save(note: Note) async throws {
      struct SomeError: Error {}

      try await Task.sleep(for: .seconds(3))
      throw SomeError()
    }
  }

  AddNoteForm(saver: FailingSaver())
}
        

Ok… Now let’s decrease the delay to see what happens to a user with a higher speed connection.


#Preview {
  struct FailingSaver: NoteSaver {
    func save(note: Note) async throws {
      struct SomeError: Error {}

-      try await Task.sleep(for: .seconds(3))
+      try await Task.sleep(for: .seconds(1))
      throw SomeError()
    }
  }

  AddNoteForm(saver: FailingSaver())
}
        

I can easily do this because our FailingSaver is quite small and is easy to change in isolation without affecting other views.

Now instead of depending on small protocol, let’s try previewing AddNoteForm this while depending on our larger NoteRepository protocol.


struct AddNoteForm: View {
  init(saver: any NoteRepository) {
    // ...
  }
  // ...
}

#Preview {
  struct FailingSaver: NoteRepository {
    func findNote(by id: Int) async throws -> Note? { nil }
    func allNotes(for notebookId: Int?) async throws -> [Note] { [] }
    func findNotes(by text: String) async throws -> [Note] { [] }

    func save(note: Note) async throws {
      struct SomeError: Error {}

      try await Task.sleep(for: .seconds(3))
      throw SomeError()
    }

    func moveNote(from srcNotebookId: Int, to dstNotebookId: Int) async throws {}
    func deleteNote(with ids: Set<Int>) async throws {}
  }

  AddNoteForm(saver: FailingSaver())
}
        

There’s just so many extra requirements to implement here. Having a bunch of adhoc conformances to NoteRepository would be quite annoying. Often, most apps will just create a single mock, and reuse it everywhere because creating many conformance’s is quite annoying. This mock will need to get large to accommodate artificial failure modes.


// In some other file...

// This is the only mock conformance to NoteRepository in the app.
final class MockNotesRepository: NoteRepository {
  var notes = [Int?: [Note]]()
  var savedNotes = [Note]()
  var saveError: (any Error)?
  var moveError: (any Error)?
  var deleteError: (any Error)?

  func findNote(by id: Int) async throws -> Note? {
    // ...
  }

  func allNotes(for notebookId: Int?) async throws -> [Note] {
    // ...
  }

  func findNotes(by text: String) async throws -> [Note] {
    // ...
  }

  func save(note: Note) async throws {
    // ...
  }

  func moveNote(from srcNotebookId: Int, to dstNotebookId: Int) async throws {
    // ...
  }

  func deleteNote(with ids: Set<Int>) async throws {
    // ...
  }
}
        

Now, if we want to add our artificial delay to saving a note, we have to update the global mock that’s used by all previews. Others may want to do the same, and this leads to contention, and bloating the mock with all sorts of weird functionality.

If we’re like Bitwarden, or we don’t want to end up with code that regresses constantly, we’ll also likely have a test suite (though this may be considered optional for some code bases). It turns out that being able to spin up adhoc mocks is also quite useful in those cases. For instance, if we drive AddNoteForm with an Observable model, we may end up with this.


@Observable
class AddNoteFormModel {
  init(saver: any NoteSaver) {
    // ...
  }
  // ...
}

@Test("Shows Alert When Failing To Add Note")
func showsAlertWhenFailingToAddNote() async throws {
  struct FailingSaver: NoteSaver {
    func save(note: Note) async throws {
      struct SomeError: Error {}
      throw SomeError()
    }
  }

  let model = AddNoteFormModel(saver: FailingSaver())
  model.title = "My note"
  model.contents = "This is fun"

  #expect(model.alert == nil)
  await model.submitted()
  #expect(model.alert == .failedToSaveNote)
}
        

If we had made AddNoteFormModel depend on NotesRepository, this process would again be cumbersome, and we would likely have to resort to using our one and only MockNotesRepository.

Now, I don’t think reusing a mock conformance is a bad idea. In fact, it’s a great idea! However, a mock that’s reused everywhere also cannot always have its behavior adjusted for a one-off experiments, previews, or test cases. Continuously adjusting a shared mock in these ways would lead to the shared mock becoming large, complicated, and harder to change over time.

Difficulty of Conformance

Think of protocols as a set of possibilities. That is, a protocol defines a subset of types that conform to it. The Equatable protocol defines the subset of types that have an equality mechanism, and likewise SwiftUI’s View protocol defines the subset of types that can be rendered in a user interface. The harder it is to conform to the protocol, the smaller the subset is. Each type in the subset represents a different possibility of how its requirements are carried out.

In other words, protocols that are easy to conform to have many possibilities which means that you have many options for conforming to them. On the flip side, a protocol that is harder to conform to limits the number of options you have for making a conformance.

It goes without saying that smaller protocols are often easier to conform to, and therefore provide more possibilities.

With smaller protocols, you’re more likely to add new behavior to your app by creating a new conformance. With larger protocols, you’re more likely to make changes to the fewer existing conforming types you have directly in order to add new behavior.

Feed Filters DSL

Here’s a small protocol that we’ll call FeedFilter. A FeedFilter is a mechanism to filter out a specific content type from a typical social media feed. Note that in this example, there are multiple different content types, hence the Content associated type.


protocol FeedFilter<Content> {
  associatedtype Content

  func shouldIncludeInFeed(content: Content) async -> Bool
}
        

FeedFilter is an incredibly easy protocol to conform to, so let’s make our first conformance!


struct ThreadsExcludeIfControversialFilter: FeedFilter {
  let preferences: Preferences

  func shouldIncludeInFeed(content: Thread) -> Bool {
    self.preferences.threadFilterControvesyRate < content.downvotePercentage
  }
}
        

That was easy! Let’s make another!


struct ThreadsExcludeIfDownvotedFilter: FeedFilter {
  func shouldIncludeInFeed(content: Thread) -> Bool {
    content.userVote != .downvote
  }
}
        

Now, what if we want to create a filter that excludes thread if either the user downvoted them, or if the thread if controversial? We can certainly make a ThreadsExcludeIfDownvotedOrControversialFilter, but now we’re getting into AbstractSingletonProxyFactoryBean territory. All we’re trying to do either is run 1 filter before the other, so let’s make a simple conformance to do just that.


extension FeedFilter {
  func run(
    before other: some FeedFilter<Content>
  ) -> some FeedFilter<Content> {
    RunBeforeFilter(a: self, b: other)
  }
}

private struct RunBeforeFilter<
  A: FeedFilter,
  B: FeedFilter
>: FeedFilter where A.Content == B.Content {
  let a: A
  let b: B

  func shouldIncludeInFeed(content: A.Content) async -> Bool {
    guard await self.a.shouldIncludeInFeed(content: content) else {
      return false
    }
    return await self.b.shouldIncludeInFeed(content: content)
  }
}
        

Now we can easily create our downvotedOrControvesialFilter like so.


let downvotedOrControversial = ThreadsExcludeIfControversialFilter()
  .run(before: ThreadsExcludeIfDownvotedFilter())
        

In fact RunBeforeFilter works for any content type, and any 2 filters with the same content type. Now let’s try making another conformance that also doesn’t care too much about the content type. For instance, lots of scrollable content on social media contains some very forbidden words. Let’s make a filter to filter those words.


protocol HiddenPhrasesFilterable {
  var language: Language { get }
  var textBlocks: [(String, HiddenPhraseOptions)] { get }
}

struct HiddenPhrasesFilter<Content: HiddenPhrasesFilterable>: FeedFilter {
  let phrases: HiddenPhrases

  func shouldIncludeInFeed(content: Content) -> Bool {
    // Tokenize the content by word with NLTokenizer and use a trie
    // to efficiently match phrases in HiddenPhrases...
  }
}
        

I’ve omitted the implementation of HiddenPhrasesFilter for brevity, but suffice to say it used the algorithm described by the comment above. However, the main point was that we now have a filter that works on any kind of content that conforms to HiddenPhrasesFilterable.

Finally, let’s make a simple conformance that works with Comments instead of just Threads.


struct CommentExcludeIfNoRepliesFilter: FeedFilter {
  func shouldIncludeInFeed(content: Comment) -> Bool {
    content.replyCount > 0
  }
}
        

We can keep going, but the gist of this is that we can reuse filtering logic to create separate content filtering pipelines. I ended up with something like this.


extension FeedFilter where Content == Thread {
  static func defaultThreads(
    preferences: Preferences,
    accountsActor: some SavedAccountsActor
  ) -> some FeedFilter<Content> {
    NSFWFilter(preferences: preferences)
      .run(
        before: ThreadsExcludeIfUserDownvotedFilter()
          .onlyRunIf { preferences.shouldFilterDownvotedThreads }
      )
      .run(
        before: ThreadsExcludeIfControversialFilter(preferences: preferences)
          .onlyRunIf { preferences.shouldFilterControversialThreads }
      )
      .run(
        before: ContentMarkerFilter(
          accountsActor: accountsActor,
          contentMarker: .markedAsReadThreads
        )
        .onlyRunIf { preferences.shouldFilterThreadsMarkedAsRead }
      )
      .run(
        before: ContentMarkerFilter(
          accountsActor: accountsActor,
          contentMarker: .hiddenThreads
        )
      )
      .run(before: HiddenPhrasesFilter(phrases: preferences.hiddenPhrases))
      .keepingCurrentUserContent(using: accountsActor)
  }
}

extension FeedFilter where Content == Comment {
  static func defaultPosts(
    preferences: Preferences,
    accountsActor: some SavedAccountsActor
  ) -> some FeedFilter<Content> {
    NSFWFilter(preferences: preferences)
      .run(
        before: ContentMarkerFilter(
          accountsActor: accountsActor,
          contentMarker: .hiddenComments
        )
      )
      .run(
        before: CommentExcludeIfNoRepliesFilter()
          .onlyRunIf { preferences.shouldFilterUnrepliedComments }
      )
      .run(before: HiddenPhrasesFilter(phrases: preferences.hiddenPhrases))
      .keepingCurrentUserContent(using: accountsActor)
  }
}

// More content types...
        

Adding new filtering logic was incredibly easy, because FeedFilter was easy to conform to. The filters were also relatively isolated from each other, and only handled a small concern, which made them easy to compose together into a larger and more meaningful filter.

Composing Persistence Based Protocols

Now for a point of consideration, would you do what we did above with NotesRepository?

Likely not because in many cases you would find it cumbersome, but you’re probably also questioning why we would need to in the first place. NotesRepository is an interface that implements IO for notes, and that IO has a very specific data store (HTTP API, Local Database, etc.). Therefore, if the implementation is just simple IO calls, then why is there a need for the kind of composition that you see with FeedFilter?

This point is true in most cases with persistence, but that doesn’t mean that you want to leave out options arbitrarily. As long as the overall operation can be split into different parts cleanly, you can still rely on this composition. In fact, persistence has many mechanisms which are composable.

This composability is literally why I decided to author Swift Operation, and that library actually provides a similar DSL as FeedFilter for any async operation.


extension Post {
  static func query(for id: ID) -> some QueryRequest<Post, Query.State> {
    Self.$query(id: id).retry(limit: 3)
      .refetchOnChange(
        of: .connected(to: NWPathMonitorObserver.startingShared())
      )
      .deduplicated()
  }

  @QueryRequest
  private static func query(id: ID) async throws -> Post {
    // ...
  }
}
        

Just like that, we’ve configured retries, deduplication, and the ability to refetch the post when the user’s network connection flips from offline to online. This works for more than just network requests by the way, it also works for calls to Apple Frameworks, and much more.

You can create new building blocks of functionality by conforming to the OperationModifier protocol. For instance, here’s a simple one that adds artificial delay to an operation. You might wonder why you want to do that. It turns out that in Xcode previews, it’s very helpful to add such delay to see what the user experiences when their connection is slow.


struct DelayModifier<Operation: OperationRequest>: OperationModifier {
  let delay: TimeInterval

  func fetch(
    isolation: isolated (any Actor)?,
    in context: OperationContext,
    using operation: Operation,
    with continuation: OperationContinuation<Operation.Value>
  ) async throws -> Operation.Value {
    try await context.delayer.delay(for: delay)
    return try await operation.run(isolation: isolation, in: context, with: continuation)
  }
}

extension OperationRequest {
  func delay(for duration: TimeInterval) -> ModifiedOperation<Self, DelayModifier<Self>> {
    self.modifier(DelayModifier(delay: duration))
  }
}

@QueryRequest
func myQuery() async throws -> SomeData {
  // ...
}

let myQuery = $myQuery.delay(for: 0.3)
        

In the demo app for Swift Operation, I’ve even implemented such a modifier that persists the results of each query to a database. This allowed me to create dev tools that provide the opportunity to inspect any query made by the app. Thanks to SQLiteData, the results are even synced across iCloud, and can be viewed from any device.


extension StatefulOperationRequest where Self: Sendable {
  public func analyzed() -> ModifiedOperation<Self, _AnalysisModifier<Self>> {
    self.modifier(_AnalysisModifier())
  }
}

public struct _AnalysisModifier<
  Operation: StatefulOperationRequest & Sendable
>: OperationModifier, Sendable {
  public func run(
    isolation: isolated (any Actor)?,
    in context: OperationContext,
    using query: Operation,
    with continuation: OperationContinuation<Operation.Value, Operation.Failure>
  ) async throws(Operation.Failure) -> Operation.Value {
    @Dependency(\.defaultDatabase) var database
    @Dependency(\.continuousClock) var clock
    @Dependency(ApplicationLaunch.ID.self) var launchId
    @Dependency(\.uuidv7) var uuidv7

    let yields = Mutex([Result<Operation.Value, Operation.Failure>]())
    let continuation = OperationContinuation<Operation.Value, Operation.Failure> {
      result,
      context in
      yields.withLock { $0.append(result) }
      continuation.yield(with: result, using: context)
    }

    var result: Result<Operation.Value, Operation.Failure>!
    let time = await clock.measure {
      result = await Result { @Sendable () async throws(Operation.Failure) -> Operation.Value in
        try await query.run(isolation: isolation, in: context, with: continuation)
      }
    }

    let analysis = OperationAnalysis(
      id: OperationAnalysis.ID(uuidv7()),
      launchId: launchId,
      operation: query,
      operationRetryAttempt: context.operationRetryIndex,
      operationRuntimeDuration: time,
      yieldedResults: yields.withLock { $0 },
      finalResult: result
    )

    await withErrorReporting {
      try await database.write {
        try OperationAnalysisRecord.insert { OperationAnalysisRecord.Draft(analysis) }.execute($0)
      }
    }
    return try result.get()
  }
}
        

A screen showing a list of all operations performed by an app for a particular app launch. A screen showing the result of a specific operation with statistics on its total runtime, number of retry attempts, and run date.

Suffice to say, doing this would be incredibly difficult with larger protocols, as you would have to add the above behavior to each requirement. Since OperationModifier is a small protocol with a requirement, it was quite easy to add this Dev Tools interface, as well as artificial delay, and much more that you can find in the library.


Addressing Alternatives and Concerns

Now that I’ve laid out the case against large protocols, I want to spend some time going through many strategies that I’ve seen in the community to address some of the problems of large protocols.

Struct Based Interfaces

For the point on mocking, one may try to turn to a mutable struct of closures instead of a protocol. PointFree has been the primary advocate for this style as far as I can tell, and the docs of Swift Dependencies even have an entire article on this.

In other words, we would make NotesRepository a struct like this.


import Dependencies

@DependencyClient
struct NotesRepository {
  var findNote: @Sendable (_ id: Int) async throws -> Note?
  var allNotes: @Sendable (_ notebookId: Int?) async throws -> [Note]
  var findNotes: @Sendable (_ text: String) async throws -> [Note]
  var save: @Sendable (note: Note) async throws -> Void
  var moveNote: @Sendable (from srcNotebookId: Int, to dstNotebookId: Int) async throws -> Void
  var deleteNote: @Sendable (with ids: Set<Int>) async throws -> Void
}
        

Now in tests, we can override specific properties to provide a mock value.


@Test("Shows Alert When Failing To Add Note")
func showsAlertWhenFailingToAddNote() async throws {
  try await withDependencies {
    struct SomeError: Error {}
    $0[NotesRepository.self].save = { _ in throw SomeError() }
  } operation: {
    // AddNoteFormModel would access NotesRepository through the
    // @Dependency property wrapper.
    let model = AddNoteFormModel()
    model.title = "My note"
    model.contents = "This is fun"

    #expect(model.alert == nil)
    await model.submitted()
    #expect(model.alert == .failedToSaveNote)
  }
}
        

For the record, I used to use this style extensively. However, I often found myself repeating the same patterns of code over and over again for each test.


@Test("Something")
func something() async throws {
  try await withDependencies {
    struct SomeError: Error {}
    $0[NotesRepository.self].save = { _ in throw SomeError() }
  } operation: {
    // ...
  }
}

@Test("Other Thing")
func otherThing() async throws {
  try await withDependencies {
    struct SomeError: Error {}
    $0[NotesRepository.self].save = { _ in throw SomeError() }
  } operation: {
    // ...
  }
}

// So on...
        

Another annoying thing about this pattern has to do with spies explicitly. In many cases, you need to use something like Mutex to capture the value passed to the dependency closure, and repeating this is not very fun either.


import Synchronization

@Test("Spy Save")
func spySave() async throws {
  let savedNotes = Mutex([Note]())
  try await withDependencies {
    $0[NotesRepository.self].save = { note in
      savedNotes.withLock { $0 = note }
    }
  } operation: {
    // ...
  }
}
        

If we want to do domain specific asserts on the spied values, we’re also out of luck because we cannot just add an additional API to a struct of closures that returns a property captured by those closures.

In the long run, I’ve often reduced duplication by creating simple and reusable mocks that conform to a small protocol.


struct FailingSaver: NoteSaver {
  func save(note: Note) async throws {
    struct SomeError: Error {}
    throw SomeError()
  }
}

@MainActor
final class SpySaver: NoteSaver {
  private(set) var savedNotes = [Note]()

  // We also get domain-specific asserts with this approach.
  func expectSaved(_ note: Note) {
    #expect(self.savedNotes.contains(note))
  }

  func save(note: Note) async throws {
    self.savedNotes.append(note)
  }
}
        

Despite these flaws, I actually think structs with closures are great for certain kinds of problems. For instance, I often find myself relying on bags of closures over creating a Delegate protocol because delegates are often created in an adhoc fashion.


extension HapticaExtension {
  public struct Delegate: Sendable {
    public var onJavaScriptExceptionThrown:
      (@Sendable (HapticaExtension, JavaScriptException) -> Void)?
    public var onMetadataUpdated: (@Sendable (HapticaExtension, HapticaExtensionMetadata) -> Void)?

    public init(
      onJavaScriptExceptionThrown: (
        @Sendable (HapticaExtension, HapticaExtension.JavaScriptException) -> Void
      )? = nil,
      onMetadataUpdated: (@Sendable (HapticaExtension, HapticaExtensionMetadata) -> Void)? = nil
    ) {
      self.onJavaScriptExceptionThrown = onJavaScriptExceptionThrown
      self.onMetadataUpdated = onMetadataUpdated
    }
  }
}
        

In other words, if the operation is very adhoc and is tightly coupled to what the caller is doing (eg. The operation would change the caller’s state somehow.), then I think closures suit that use case better. Delegates are a perfect example of this, but many well-known APIs also accept simple closures such as initializer of Task.

Small Protocols are a lot of Boilerplate

I won’t deny this, but a small increase in typing speed is not worth time lost in friction. That is to say, I would rather deal with a bit of boilerplate over not being able to rapidly iterate.

Perhaps you may also be thinking that I would advocate for small types to conform to the small protocols. Naturally, this would result in an explosition of small classes.


protocol NoteSaver {
  func save(note: Note) async throws
}

final class APINoteSaver: NoteSaver {
  init(api: NotesAPI) {
    // ...
  }
}

protocol NoteDeleter {
  func deleteNote(with id: Int) async throws
}

final class APINoteDeleter: NoteSaver {
  init(api: NotesAPI) {
    // ...
  }
}
        

Obviously, this would get quite annoying. Thankfully, Swift lets you extend existing types to conform to protocols you own after the fact. In most cases, I usually have a larger class that implements many of these small protocols.


final class NotesAPI {
  // ...
}

extension NotesAPI: NoteSaver {
  // ...
}

extension NotesAPI: NoteDeleter {
  // ...
}
        

Protocols represent possibilities, and so I would rather leave the option for either a small class or large class to conform to them. For instance, maybe we want to save the note to a local file as well as the backend when it’s saved. We can address that with a simple conformance without having to modify NotesAPI.


struct FileSaver<Base: NoteSaver>: NoteSaver {
  let base: Base
  let baseURL: URL

  func save(note: Note) async throws {
    try await self.base.save(note: note)
    try JSONEncoder().encode(note)
      .write(to: self.baseURL.appending(path: "\(note.id).json")
  }
}

let fileAPISaving = FileSaver(base: NotesAPI.shared)
        

With the old NotesRepository approach, our only reasonable option would’ve added this file saving functionality on to our 1 potentially massive conforming type.


final class DefaultNotesRepository: NotesRepository {
  private let api: NotesAPI
  private let baseURL: URL

  // ...

  init(api: NotesAPI, let baseURL: URL) {
    // ...
  }

  // ...

  func save(note: Note) async throws {
    try await self.api.save(note: note)
    try JSONEncoder().encode(note)
      .write(to: self.baseURL.appending(path: "\(note.id).json")
  }

  // ...
}
        

I want to make clear that I’m not necessarily advocating for the small-conformance style as the best way to implement local persistence on top of an HTTP API. In most cases, I would directly implement it alongside the API call in a larger conformance because that would be easier to understand.

However, small protocols leaves open small conformances as an option, and that option may fit your context better than integrating it directly inside DefaultNotesRepository. If we had more requirements to implement behind the NotesRepository protocol, then it’s likely that we would have to keep bloating DefaultNotesRepository. If the implementation starts to become a bit bloated, it may be easier to add new behavior to the overall operation of saving a note with a smaller conformance.

Sometimes, an explosion of small conforming types is what we want (eg. SwiftUI Views), but other times we want larger and more cohesive conforming types. Small protocols give you both options, whereas larger ones don’t.

You can just Autogenerate Mocks with a Package (Mockingbird, SwiftyMocky, etc.)

Outside of the obvious dependency problem this has, the other pain point is that these kinds of packages tend to own the entire generation pipeline for the mocks, and they often cannot be modified after the fact. I actually think that this is worse than spending the time to create your own larger and global mock. At least the “one large mock” can be edited by its creators to accommodate different scenarios.

Mocks are Bad Lmao

In many cases this is true, and I certainly don’t advocate for mocking everything. Without making this an entire piece on my opinion on mocks, sometimes it’s just ok to use them as a weapon in your arsenal. In fact, when I do mock a protocol for 1 suite of tests, I’ll often have another suite of tests that test the live implementation of the mocked protocol.

Furthermore, I don’t want “mocking” to be the main idea that comes out of this piece. Smaller protocols certainly make mocking easier, but the larger idea is possibility. Mocking itself is a possibility.

What if I need a Large Protocol?

Then make one.

Alternatively, you can create larger protocols by composing smaller ones. This is how Codable is done, and you can do it with your own protocols too.


typealias NotesRepository = NoteSaver & NoteDeleter & NotesFinder

// OR Alternatively (this requires conforming types to explicitly
// conform to NotesRepository)
protocol NotesRepository: NoteSaver, NoteDeleter, NotesFinder {}
        

When should I use a Large Protocol?

The answer to this largely depends on how its is used.

Take this scenario.


protocol Large {
  func r1()
  func r2()
  func r3()
  func r4()
  // All the way to r8...
}

func foo(using large: some Large) {
  large.r1()
  large.r2()
  large.r3()
  large.r4()
  // All the way to r8...
}
        

Since foo uses all the requirements from Large, then Large is a properly sized protocol because there is no unnecessary dependence. Additionally, we may not need a ton of possibility towards how Large is implemented, so in this case a large protocol is a decent trade-off.

Another thing to consider on the unnecessary dependence point is that the protocol has a cohesive set of requirements. For instance.


extension ScheduleableAlarm {
  public protocol Authorizer: AnyObject, Sendable {
    associatedtype Statuses: AsyncSequence<ScheduleableAlarm.AuthorizationStatus, Never>

    func requestAuthorization() async throws -> AuthorizationStatus
    func statuses() -> Statuses
  }

  public enum AuthorizerKey: DependencyKey {
    public static var liveValue: any Authorizer {
      // ...
    }
  }
}

extension ScheduleableAlarm.AuthorizationStatus {
  public struct UpdatesKey: SharedReaderKey {
    private let authorizer: any ScheduleableAlarm.Authorizer

    // ...

    public func subscribe(
      context: LoadContext<Value>,
      subscriber: SharedSubscriber<Value>
    ) -> SharedSubscription {
      let task = Task.immediate {
        for await status in self.authorizer.statuses() {
          withAnimation { subscriber.yield(status) }
        }
      }
      return SharedSubscription { task.cancel() }
    }
  }
}
        

In this case UpdatesKey only uses the statuses requirement from ScheduleableAlarm.Authorizer. Yet, I still think ScheduleableAlarm.Authorizer is properly sized for because its requirements are coherent (ie. Requesting authorization would be expected to have an impact on the status updates.), and because it is still easy to conform to (ie. It only has 2 requirements.).

Another thing could be that the protocol has default implementations for many of its requirements.


public protocol QueryRequest<Value, State>: QueryPathable, Sendable
where State.QueryValue == Value {
  associatedtype Value: Sendable
  associatedtype State: QueryStateProtocol = QueryState<Value?, Value>

  var _debugTypeName: String { get }

  var path: QueryPath { get }

  func setup(context: inout QueryContext)

  func fetch(
    in context: QueryContext,
    with continuation: QueryContinuation<Value>
  ) async throws -> Value
}

extension QueryRequest {
  public var _debugTypeName: String { typeName(Self.self) }

  public func setup(context: inout QueryContext) {
  }
}

extension QueryRequest where Self: Hashable {
  public var path: QueryPath {
    QueryPath(self)
  }
}
        

In this case, the only real requirement that a conformance often needs to implement is fetch.


extension Post {
  struct Query: QueryRequest, Hashable {
    let id: Int

    func fetch(
      in context: QueryContext,
      with continuation: QueryContinuation<Post>
    ) async throws -> Post {
      let url = URL(string: "https://jsonplaceholder.typicode.com/posts/\(id)")!
      let (data, _) = try await URLSession.shared.data(from: url)
      return try JSONDecoder().decode(Post.self, from: data)
    }
  }
}
        

Making Large Protocols Composable

You may be surprised to hear that it’s still possible to achieve much of the compositional benefits of smaller protocols with large protocols as well if you’re willing to write an entire modifier system.

Swift Operation takes this approach because the OperationRequest and StatefulOperationRequest protocols have a bunch of annoying requirements that don’t need to be considered when implementing composable behavior.


public protocol OperationRequest<Value, Failure> {
  associatedtype Value
  associatedtype Failure: Error

  var _debugTypeName: String { get }
  func setup(context: inout OperationContext)

  func run(
    isolation: isolated (any Actor)?,
    in context: OperationContext,
    with continuation: OperationContinuation<Value, Failure>
  ) async throws(Failure) -> Value
}

public protocol StatefulOperationRequest<State>: OperationRequest
where Value: Sendable, State.OperationValue == Value, State.Failure == Failure {
  associatedtype State: OperationState
  var path: OperationPath { get }
}
        

If you were to implement composable behavior with OperationRequest, chances are that you would forget to forward _debugTypeName, _path, or setup. This is why the library defines a simpler OperationModifier protocol.


public protocol OperationModifier<Value, Failure> {
  associatedtype Operation: OperationRequest
  associatedtype Value
  associatedtype Failure: Error

  func setup(context: inout OperationContext, using operation: Operation)

  func run(
    isolation: isolated (any Actor)?,
    in context: OperationContext,
    using operation: Operation,
    with continuation: OperationContinuation<Value, Failure>
  )
}
        

The library then provides a query called ModifiedOperation, which does the hard work of adapting your OperationModifier to work with an OperationRequest.


public struct ModifiedOperation<
  Operation: OperationRequest,
  Modifier: OperationModifier
>: OperationRequest where Modifier.Operation == Operation {
  public typealias Value = Modifier.Value

  public let operation: Operation
  public let modifier: Modifier

  @inlinable
  public var _debugTypeName: String {
    self.operation._debugTypeName
  }

  @inlinable
  public func setup(context: inout OperationContext) {
    self.modifier.setup(context: &context, using: self.operation)
  }

  @inlinable
  public func run(
    isolation: isolated (any Actor)?,
    in context: OperationContext,
    with continuation: OperationContinuation<Modifier.Value, Modifier.Failure>
  ) async throws(Modifier.Failure) -> Modifier.Value {
    try await self.modifier.run(
      isolation: isolation,
      in: context,
      using: self.operation,
      with: continuation
    )
  }
}

extension ModifiedOperation: StatefulOperationRequest
where
  Modifier.Operation: StatefulOperationRequest,
  Operation.Value == Modifier.Value,
  Operation.Failure == Modifier.Failure
{
  public typealias State = Operation.State

  @inlinable
  public var path: OperationPath {
    self.operation.path
  }
}
        

SwiftUI also takes this approach for adapting composability for views. For instance, the View protocol from SwiftUI actually looks something like this.


public protocol View {
  nonisolated static func _makeView(view: _GraphValue<Self>, inputs: _ViewInputs) -> _ViewOutputs

  nonisolated static func _makeViewList(view: _GraphValue<Self>, inputs: _ViewListInputs) -> _ViewListOutputs

  nonisolated static func _viewListCount(inputs: _ViewListCountInputs) -> Int?

  associatedtype Body: View

  @ViewBuilder
  @MainActor
  @preconcurrency
  var body: Self.Body { get }
}
        

However, you don’t ever implement those top 3 requirements. In fact, you may not even have known about their existence because Xcode doesn’t show them! Instead, a few select primitive views from SwiftUI (eg. Text) implement those requirements whilst a default implementation is used for the typical views that you create in your app. Even if you tried to implement those requirements, chances are is that it would be impossible because they use types that are largely internal to SwiftUI.

This is why SwiftUI provides a ViewModifier and the ModifiedContent view if you want to add composable behavior to your views. ModifiedContent is a special view that adapts your ViewModifier into a legitimate SwiftUI view by providing proper implementations of _viewListCount, _makeViewList, and _makeView based on the child content in the view.


public struct ModifiedContent<
  Content: View,
  Modifier: ViewModifier
>: View {
  public let content: Content
  public let modifier: Modifier

  // See: https://github.com/OpenSwiftUIProject/OpenSwiftUI/blob/main/Sources/OpenSwiftUICore/Modifier/ViewModifier.swift

  public static func _makeView(
    view: _GraphValue<Self>,
    inputs: _ViewInputs
  ) -> _ViewOutputs {
    Modifier.makeDebuggableView(
      modifier: view[offset: { .of(&$0.modifier) }],
      inputs: inputs
    ) {
      Content.makeDebuggableView(
        view: view[offset: { .of(&$0.content) }],
        inputs: $1
      )
    }
  }

  // ...
}

extension View {
  public func modifier<Modifier>(
    _ modifier: Modifier
  ) -> ModifiedContent<Self, Modifier> {
    // ...
  }
}
        

Conclusion

The more possibilities that your protocol provides, the easier it is to add new features, perform one-off experiments, and make changes to your code. Possibility comes by making your protocols easy to conform to, which often means making them small with simpler requirements.

When you have possibility, it doesn’t matter if a “god” class, a tiny one-off handwritten mock, or an entire DSL implements the protocol. The important part is that you have the possibility to do any of those things in the first place.

Unnecessary dependence on large protocols hinders the amount of possibility for no good reason because it requires a larger than necessary conformance. Furthermore, a large protocols with a bunch of requirements, such as a “Repository” protocol, also hinders possibility because there is more friction when conforming to larger protocols.

However, if a large protocol can counter those 2 points in your context, then feel free to create one. Additionally, you may find that you can create a large protocol by composing it from smaller protocols.

Lastly, you should check out Swift Operation, it handles a lot of pain around asynchronous state management and data fetching for you.

— 11/13/25