Skip to content

Designing APIs With Combine (Swift)

A SwiftUI guide for coding agents. Also covers future publisher wrap callback, deferred lazy publisher, passthroughsubject stream, combine healthkit, handleevents lifecycle, callback to publisher.

Wrapping callback-based APIs as publishers unlocks Combine's operator chain: retry, replaceError, flatMap, and friends. Two building blocks cover it: Future for a single async result and PassthroughSubject for a stream of values over time.

Future, Wrapped in Deferred

Future turns a completion handler into a one-shot publisher. Its trap: it is eager, so the work runs the moment you create it, not when someone subscribes. Wrap it in Deferred to make it run on subscription.

func authorize() -> AnyPublisher<Bool, Error> {
  Deferred {
    Future { handler in
      self.store.requestAuthorization(toShare: [.workout], read: [.hr]) { success, error in
        if let error = error {
          handler(.failure(error))
        } else {
          handler(.success(success))
        }
      }
    }
  }.eraseToAnyPublisher()
}

The payoff is at the call site: health.authorize().retry(3).replaceError(with: false) composes retry and fallback logic that the raw callback API cannot express.

PassthroughSubject for Streams

Future completes after its first value. For values arriving over time (locations, heart rate samples), create a PassthroughSubject and push into it with send.

func heartRate() -> AnyPublisher<[Double], Error> {
  let subject = PassthroughSubject<[Double], Error>()

  let query = HKAnchoredObjectQuery(
    type: HKQuantityType.heartRate,
    predicate: nil, anchor: nil, limit: HKObjectQueryNoLimit
  ) { _, newSamples, _, _, error in
    if let error = error {
      subject.send(completion: .failure(error))
    } else {
      let samples = newSamples as? [HKQuantitySample] ?? []
      subject.send(samples.compactMap { $0.quantity.doubleValue(for: .bpm()) })
    }
  }

  return subject.handleEvents(
    receiveSubscription: { _ in self.store.execute(query) },
    receiveCancel: { self.store.stop(query) }
  ).eraseToAnyPublisher()
}

The handleEvents operator ties the underlying long-running query to the subscription lifecycle: start it on subscribe, stop it on cancel. Without that, the query runs forever even after everyone stopped listening.

Composing the Two

flatMap chains the one-shot into the stream, so authorization gates the data feed.

health.authorize()
  .retry(3)
  .flatMap { authorized in
    authorized ? health.heartRate().eraseToAnyPublisher()
               : Empty().eraseToAnyPublisher()
  }
  .replaceError(with: [])
  .sink { print($0) }
  .store(in: &cancellables)

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-swiftui-guide({ topic: "swiftui-designing-api-using-combine-framework" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems