Recommend API Design for a service-oriented TCA application #1021
-
TCA is a fantastic framework and makes writing applications drastically easier but one component I've always found troublesome is writing services and clients. A good example is the two service-oriented packages provided, Take the extension MotionManager {
public static let live = MotionManager(
accelerometerData: { id in
requireMotionManager(id: id)?.accelerometerData.map(AccelerometerData.init)
},
attitudeReferenceFrame: { id in
requireMotionManager(id: id)?.attitudeReferenceFrame ?? .init()
},
availableAttitudeReferenceFrames: {
CMMotionManager.availableAttitudeReferenceFrames()
},
create: { id in
.fireAndForget {
if managers[id] != nil {
assertionFailure(
"""
You are attempting to create a motion manager with the id \(id), but there is already \
a running manager with that id. This is considered a programmer error since you may \
be accidentally overwriting an existing manager without knowing.
To fix you should either destroy the existing manager before creating a new one, or \
you should not try creating a new one before this one is destroyed.
""")
}
managers[id] = CMMotionManager()
}
},
destroy: { id in
.fireAndForget { managers[id] = nil }
},
deviceMotion: { id in
requireMotionManager(id: id)?.deviceMotion.map(DeviceMotion.init)
},
gyroData: { id in
requireMotionManager(id: id)?.gyroData.map(GyroData.init)
},
isAccelerometerActive: { id in
requireMotionManager(id: id)?.isAccelerometerActive ?? false
},
isAccelerometerAvailable: { id in
requireMotionManager(id: id)?.isAccelerometerAvailable ?? false
},
isDeviceMotionActive: { id in
requireMotionManager(id: id)?.isDeviceMotionActive ?? false
},
isDeviceMotionAvailable: { id in
requireMotionManager(id: id)?.isDeviceMotionAvailable ?? false
},
isGyroActive: { id in
requireMotionManager(id: id)?.isGyroActive ?? false
},
isGyroAvailable: { id in
requireMotionManager(id: id)?.isGyroAvailable ?? false
},
isMagnetometerActive: { id in
requireMotionManager(id: id)?.isDeviceMotionActive ?? false
},
isMagnetometerAvailable: { id in
requireMotionManager(id: id)?.isMagnetometerAvailable ?? false
},
magnetometerData: { id in
requireMotionManager(id: id)?.magnetometerData.map(MagnetometerData.init)
},
set: { id, properties in
.fireAndForget {
guard let manager = requireMotionManager(id: id)
else {
return
}
if let accelerometerUpdateInterval = properties.accelerometerUpdateInterval {
manager.accelerometerUpdateInterval = accelerometerUpdateInterval
}
if let deviceMotionUpdateInterval = properties.deviceMotionUpdateInterval {
manager.deviceMotionUpdateInterval = deviceMotionUpdateInterval
}
if let gyroUpdateInterval = properties.gyroUpdateInterval {
manager.gyroUpdateInterval = gyroUpdateInterval
}
if let magnetometerUpdateInterval = properties.magnetometerUpdateInterval {
manager.magnetometerUpdateInterval = magnetometerUpdateInterval
}
if let showsDeviceMovementDisplay = properties.showsDeviceMovementDisplay {
manager.showsDeviceMovementDisplay = showsDeviceMovementDisplay
}
}
},
startAccelerometerUpdates: { id, queue in
return Effect.run { subscriber in
guard let manager = requireMotionManager(id: id)
else {
return AnyCancellable {}
}
guard accelerometerUpdatesSubscribers[id] == nil
else { return AnyCancellable {} }
accelerometerUpdatesSubscribers[id] = subscriber
manager.startAccelerometerUpdates(to: queue) { data, error in
if let data = data {
subscriber.send(.init(data))
} else if let error = error {
subscriber.send(completion: .failure(error))
}
}
return AnyCancellable {
manager.stopAccelerometerUpdates()
}
}
},
startDeviceMotionUpdates: { id, frame, queue in
return Effect.run { subscriber in
guard let manager = requireMotionManager(id: id)
else {
return AnyCancellable {}
}
guard deviceMotionUpdatesSubscribers[id] == nil
else { return AnyCancellable {} }
deviceMotionUpdatesSubscribers[id] = subscriber
manager.startDeviceMotionUpdates(using: frame, to: queue) { data, error in
if let data = data {
subscriber.send(.init(data))
} else if let error = error {
subscriber.send(completion: .failure(error))
}
}
return AnyCancellable {
manager.stopDeviceMotionUpdates()
}
}
},
startGyroUpdates: { id, queue in
return Effect.run { subscriber in
guard let manager = requireMotionManager(id: id)
else {
return AnyCancellable {}
}
guard deviceGyroUpdatesSubscribers[id] == nil
else { return AnyCancellable {} }
deviceGyroUpdatesSubscribers[id] = subscriber
manager.startGyroUpdates(to: queue) { data, error in
if let data = data {
subscriber.send(.init(data))
} else if let error = error {
subscriber.send(completion: .failure(error))
}
}
return AnyCancellable {
manager.stopGyroUpdates()
}
}
},
startMagnetometerUpdates: { id, queue in
return Effect.run { subscriber in
guard let manager = managers[id]
else {
couldNotFindMotionManager(id: id)
return AnyCancellable {}
}
guard deviceMagnetometerUpdatesSubscribers[id] == nil
else { return AnyCancellable {} }
deviceMagnetometerUpdatesSubscribers[id] = subscriber
manager.startMagnetometerUpdates(to: queue) { data, error in
if let data = data {
subscriber.send(.init(data))
} else if let error = error {
subscriber.send(completion: .failure(error))
}
}
return AnyCancellable {
manager.stopMagnetometerUpdates()
}
}
},
stopAccelerometerUpdates: { id in
.fireAndForget {
guard let manager = managers[id]
else {
couldNotFindMotionManager(id: id)
return
}
manager.stopAccelerometerUpdates()
accelerometerUpdatesSubscribers[id]?.send(completion: .finished)
accelerometerUpdatesSubscribers[id] = nil
}
},
stopDeviceMotionUpdates: { id in
.fireAndForget {
guard let manager = managers[id]
else {
couldNotFindMotionManager(id: id)
return
}
manager.stopDeviceMotionUpdates()
deviceMotionUpdatesSubscribers[id]?.send(completion: .finished)
deviceMotionUpdatesSubscribers[id] = nil
}
},
stopGyroUpdates: { id in
.fireAndForget {
guard let manager = managers[id]
else {
couldNotFindMotionManager(id: id)
return
}
manager.stopGyroUpdates()
deviceGyroUpdatesSubscribers[id]?.send(completion: .finished)
deviceGyroUpdatesSubscribers[id] = nil
}
},
stopMagnetometerUpdates: { id in
.fireAndForget {
guard let manager = managers[id]
else {
couldNotFindMotionManager(id: id)
return
}
manager.stopMagnetometerUpdates()
deviceMagnetometerUpdatesSubscribers[id]?.send(completion: .finished)
deviceMagnetometerUpdatesSubscribers[id] = nil
}
})
private static var managers: [AnyHashable: CMMotionManager] = [:]
private static func requireMotionManager(id: AnyHashable) -> CMMotionManager? {
if managers[id] == nil {
couldNotFindMotionManager(id: id)
}
return managers[id]
}
}
private var accelerometerUpdatesSubscribers:
[AnyHashable: Effect<AccelerometerData, Error>.Subscriber] = [:]
private var deviceMotionUpdatesSubscribers: [AnyHashable: Effect<DeviceMotion, Error>.Subscriber] =
[:]
private var deviceGyroUpdatesSubscribers: [AnyHashable: Effect<GyroData, Error>.Subscriber] = [:]
private var deviceMagnetometerUpdatesSubscribers:
[AnyHashable: Effect<MagnetometerData, Error>.Subscriber] = [:]
private func couldNotFindMotionManager(id: Any) {
assertionFailure(
"""
A motion manager could not be found with the id \(id). This is considered a programmer error. \
You should not invoke methods on a motion manager before it has been created or after it \
has been destroyed. Refactor your code to make sure there is a motion manager created by the \
time you invoke this endpoint.
""")
} I find that this implementation is really confusing and hard to follow. The For example, let's say I'm building an application that has a couple of service needs. There's
Already, this is a complex application. CoreData for example, comes with a lot of boilerplate setup and putting that entire setup into the initializer call-site reveals its implementation details while also being very cluttered. Additionally, the notion of To this end, I've been trying to figure out what a good alternative looks like for application level services such as the aforementioned core data service or the StoreKit service. Going with the biometrics authentication service for illustration purposes, what I've come up with looks something like this: First I define a protocol, protocol AuthenticationServiceProviding {
var biometryType: LABiometryType { get }
var supportsBiometry: Bool { get }
var canAuthenticateWithBiometrics: Bool { get throws }
func authenticate() -> Effect<AuthenticationResponse, AuthenticationError>
} Then, I create concrete classes, one for the live implementation and one for tests: class AuthenticationService: AuthenticationServiceProviding {
/// Local authentication context.
private let context: LAContext = {
let context = LAContext()
context.localizedFallbackTitle = ""
return context
}()
var biometryType: LABiometryType {
context.biometryType
}
var supportsBiometry: Bool {
switch context.biometryType {
case .faceID, .touchID:
return true
case .none:
return false
@unknown default:
return false
}
}
var canAuthenticateWithBiometrics: Bool {
get throws {
var error: NSError?
let permissions = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
if let error = error {
throw error
}
return permissions
}
}
func authenticate() -> Effect<AuthenticationResponse, AuthenticationError> {
Effect.task {
guard try self.canAuthenticateWithBiometrics && self.supportsBiometry else {
throw AuthenticationError.notAvailable
}
let success = try await self.context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: self.authenticationReason
)
if !success {
throw AuthenticationError.failed
}
return .init()
}
.mapError { error -> AuthenticationError in
if let error = error as? LAError {
return AuthenticationError(error: error)
}
return .failed
}
.eraseToEffect()
}
} Well this design has several advantages, chiefly that its implementation details are hidden and it's easier to read, it also has drawbacks. Services like I would love to hear the core team's thoughts on what a potential happy-medium between the two designs could be, taking the ease of use and testable nature of Thanks for making such an excellent framework by the way! The fact that this is a discussion to be had speaks to the fact about how well-implemented and useful the library is, allowing more advanced discussion topics to arise. |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 1 reply
-
@stephencelis curious if you have any thoughts on this post 1.0.0 release (congrats btw!). TCA has evolved so much since I started to use it but I think this is the single biggest pain point I still face. I know since I originally wrote this post, Protocolization isn't the answer either; I've done that countless times where you have a protocol |
Beta Was this translation helpful? Give feedback.
-
I would not look to that example. As you can see from the git history, it has not been meaningfully updated in 2 years, and hence it is not exactly the best example of modern techniques in TCA. I would recommend looking at the demos in the TCA repo (voice memos, speech recognition, Standups, etc.) as well as our isowords game. There are many examples of dependencies that manage a reference-y object under the hood but do not call out to global constructs.
TCA has no opinion whatsoever on how you design your dependencies. We like to use structs to define the interface and values to define the implementations, but you can absolutely use protocols if you are more comfortable with that.
You can design dependencies with structs and still have the dependency use reference semantics. In fact, closures are references and their capturing capabilities make them a loose approximation for classes in Swift. That means our struct dependency interfaces are more similar to a class than a struct. Our desire to use structs for dependency interfaces doesn't have anything to do with reference vs value semantics. It has to do with ergonomics of constructing and transforming dependencies. I can't speak to what "Swifty" means since I do not think it is a well-defined concept, but I will say that even Apple has long recognized that protocols are sometimes not appropriate and has even eschewed protocols in some modern APIs in favor of "bags of closures". |
Beta Was this translation helpful? Give feedback.
I would not look to that example. As you can see from the git history, it has not been meaningfully updated in 2 years, and hence it is not exactly the best example of modern techniques in TCA.
I would recommend looking at the demos in the TCA repo (voice memos, speech recognition, Standups, etc.) as well as our isowords game. There are many examples of dependencies that manage a reference-y object under the hood but do not call out to global constructs.
TCA has no opinion whatsoever on how you design your dependencies. We l…