Chain Of Responsibility Pattern In Swift
Greetings after a long time. In real life, we always encounter problems and somehow we look for a solution. We usually find a solution, but do we consider improving this solution? I must admit that we don’t do this much in real life.
But we should do this in our work. Then let’s take a look at the Chain of Responsibility that reins in complex sequential processes.
Let’s understand our problem first. I have a social media project called Socia. And it has a sign-up flow like every social media app. There are several responsibilities here.
The code containing part of the relevant flow is available below. And it’s messed up. There are a lot of controls in a single function, and it is very difficult to both read the code and make new developments at the moment.
func autoLoginAfterRegister(name: String, surname: String, bio: String, image: UIImage, username: String, on system: AuthSystem) {
system.isAuthProgress = true
DispatchQueue.main.async {
self.sgServices.uploadPhoto(image: image, imagePath: "profile/profilePic.jpg", username: username) { saveResult in
switch saveResult {
case .failure(let saveError):
system.failedTransaction(message: saveError.localizedDescription)
return
case .success(let imageURL):
let userData = UserModel(username: username, name: name, surname: surname, bio: bio, imagePath: imageURL, bannerPath: nil, followers: nil, follows: nil, posts: nil).toMap()
self.fsSaveService.saveUser(saveData: userData) { saveResult in
switch saveResult {
case .failure(let saveError):
system.failedTransaction(message: saveError.localizedDescription)
case .success:
if system.lastLoginID.isEmpty {
self.fsSaveService.saveBondedAcc(provider: system.lastLoginProvider, id: system.lastLoginID, username: username) { result in
switch result {
case.failure(let err):
system.failedTransaction(message: err.localizedDescription)
case .success:
system.successfulTransaction {
system.username = username
system.defaults.set(username, forKey: "username")
system.setCurrentUser()
system.isEdittingRequired()
}
}
}
} else {
system.failedTransaction(message: "LOCAL_AN_ERROR_TAKED")
return
}
}
}
}
}
}
}
Now let’s redesign this flow with the chain of responsibility and talk about the “+ — ” points.
First, let’s create a handler protocol. We do this to create each element in the chain.
protocol SociaHandler {
associatedtype Request: FirebaseRequestStrategy
var nextHandler: (any SociaHandler)? { get set }
var request: Request? { get set }
init(with handler: (any SociaHandler)?)
func handle(_ onSuccess: (() -> Void)?) async -> LocalizedError?
}
My application works with Firebase and I have a manager managed with Strategy Pattern. The strategy I have adopted here is a base protocol for all my Firebase transactions.
If you don’t have an idea about strategy pattern, you can look here or here.
Let’s blend Pattern and my needs;
- Each chain can have a chain after it.
- Each chain must contain a handler method.
- Depending on the need, a request can be given to the relevant chain.
So, let’s create the localizable error enum I need and the requests we will use in our chains.
enum AuthError: LocalizedError {
// Common Errors
case unexpectedError
case requestError
case decodeError
case uploadError
// Required Fields
case emptyEmail
case emptyPassword
case emptyRepassword
case emptyUsername
case emptyName
case emptySurname
// Password Related Errors
case passwordTooShort
case passwordsNotMatched
// Related Errors
case usernameAlreadyUsed
case emailAlreadyUsed
// Bio and Profile Photo Errors
case bioTooLong
var errorDescription: String? {
switch self {
case .unexpectedError:
return "An unexpected error occurred."
case .requestError:
return "Request is nil or wrong."
case .emptyEmail:
return "Email is empty."
case .emptyPassword:
return "Password is empty."
case .emptyRepassword:
return "Re-entered password is empty."
case .emptyUsername:
return "Username is empty."
case .emptyName:
return "Name is empty."
case .emptySurname:
return "Surname is empty."
case .passwordTooShort:
return "Password is too short."
case .passwordsNotMatched:
return "Passwords do not match."
case .usernameAlreadyUsed:
return "Username is already in use."
case .emailAlreadyUsed:
return "Email is already in use."
case .bioTooLong:
return "Bio is too long."
case .decodeError:
return "Data decoding error."
case .uploadError:
return "Upload error."
}
}
}
These are the error situations we may encounter. There’s more. But I chose to keep it short for the sake of examples. When you see this is an example request.
final class CheckUsernameRequest: FirestoreRequestStrategy {
var model: RegisterModel
var type: FirestoreRequestType {
.get(model.username)
}
var referance: String = "accDatas"
var data: [String: Any] {
model.dictionary ?? [:]
}
init(_ model: RegisterModel) {
self.model = model
}
}
Now it’s time to go into some detail. Let’s start writing our chains.
When we look at our flow, we see that the first chain is the part that controls the username. Let’s create a Check Username Handler for this.
final class CheckUsernameHandler<T: FirestoreRequestStrategy>: SociaHandler {
typealias Request = T
var nextHandler: (any SociaHandler)?
var request: T?
init(with handler: (any SociaHandler)?) {
nextHandler = handler
}
func handle(_ onSuccess: (() -> Void)?) async -> LocalizedError? {
guard let request else { return AuthError.requestError }
guard let model: RegisterModel = request.data.decode() else { return AuthError.decodeError }
guard model.username.isEmpty == false else { return AuthError.emptyUsername }
onSuccess?()
let result: FirebaseResponse<UsernameCheckModel> = await FirestoreManager.shared.request(request)
if result.status == .error(5) && result.content == nil {
return await nextHandler?.handle(nil)
}
return AuthError.usernameAlreadyUsed
}
}
Let’s look at what this code does.
- Even if the Handler method does not expect a request, this chain needs a request. So our first check is whether the request exists.
- Afterward, we check whether the model is correct in our request. This way, we know that the content of the request we send will be correct, and if we need it (which we do), we can perform validations on the request body.
- We want to check the username. So, isn’t it necessary to get a username? Let’s check this.
- Then we see a closure called onSuccess. So what is this? I needed to be able to say “The previous transaction was successful, do this before you do your own thing” when triggering the previous chain or the chain itself. Therefore, such a closure was born entirely out of my need.
- Afterward, we go to a service and wait for a response from Firebase. The logic behind the code here is as follows. If no document is found, error number 5 is returned. This is exactly what we want!
- If there is no data, we can write our usage in the next step. That’s why we trigger nextHandler’s handle method.
- Of course, if we did not come to such a conclusion, the username unfortunately does not exist and we return the relevant error to inform our user of this.
Then we can move on to our next step. The next handler of our first chain: AuthHandler.
final class AuthHandler<T: FirebaseAuthRequestStrategy>: SociaHandler {
typealias Request = T
var nextHandler: (any SociaHandler)?
var request: T?
init(with handler: (any SociaHandler)?) {
nextHandler = handler
}
func handle(_ onSuccess: (() -> Void)?) async -> LocalizedError? {
guard let request else { return AuthError.requestError }
guard let model: RegisterModel = request.data.decode() else { return AuthError.decodeError }
guard model.email.isEmpty == false else { return AuthError.emptyEmail }
guard model.password.isEmpty == false else { return AuthError.emptyPassword }
guard model.rePassword.isEmpty == false else { return AuthError.emptyRepassword }
guard (model.password.count > 8) == false else { return AuthError.passwordTooShort }
guard model.password == model.rePassword else { return AuthError.passwordsNotMatched }
onSuccess?()
let result = await FirebaseAuthManager.shared.request(request)
if result.status == .success {
return await nextHandler?.handle(nil)
}
return AuthError.emailAlreadyUsed
}
}
Let’s examine the differences from our first chain.
- Everything is the same, right down to model checking. We need more validation. We can increase or decrease the number here according to our needs.
- Then we make a different service call and register the user. If the registration is successful, the image upload chain begins for us.
final class ProfilePhotoHandler<T: FirestorageRequestStrategy>: SociaHandler {
typealias Request = T
var nextHandler: (any SociaHandler)?
var request: T?
init(with handler: (any SociaHandler)?) {
nextHandler = handler
}
func handle(_ onSuccess: (() -> Void)?) async -> LocalizedError? {
guard let request else { return AuthError.requestError }
guard request.storageData.isEmpty == false else { return await nextHandler?.handle(nil) }
onSuccess?()
let result: FirebaseResponse<String> = await FirestorageManagerManager.shared.request(request)
if result.status == .success, let downloadLink = result.content {
return await nextHandler?.handle { [weak self] in
guard let handler = self?.nextHandler as? RegisterHandler<RegisterRequest> else { return }
guard var requestModel = handler.request?.model else { return }
requestModel.imagePath = downloadLink
handler.request?.model = requestModel
}
}
return AuthError.uploadError
}
}
This part is quite different. Because we have a different bond with the next chain.
- The main task of this chain is to upload the profile photo and bring us the download link.
- But we will register our user in the next chain and we also need the link to the user’s profile picture. Therefore, when this chain is completed successfully, we attach the link we have to the request of the other chain.
final class RegisterHandler<T: FirestoreRequestStrategy>: SociaHandler {
typealias Request = T
var nextHandler: (any SociaHandler)?
var request: T?
init(with handler: (any SociaHandler)?) {
nextHandler = handler
}
func handle(_ onSuccess: (() -> Void)?) async -> LocalizedError? {
guard let request else { return AuthError.requestError }
guard let model: UserModel = request.data.decode() else { return AuthError.decodeError }
guard model.name?.isEmpty == false else { return AuthError.emptyName }
guard model.surname?.isEmpty == false else { return AuthError.emptySurname }
guard (model.bio?.count ?? 0 < 250) == true else { return AuthError.bioTooLong }
onSuccess?()
let result: FirebaseResponse<UserModel> = await FirestoreManager.shared.request(request)
if result.status == .success {
return await nextHandler?.handle(nil)
}
return AuthError.uploadError
}
}
- onSuccess in our new chain now makes sense. The URL of the uploaded image is now included in the model we have. And with this step, the chain is completed.
Now that we’ve written down all the relevant parts, let’s see how to connect them. I created all the models we needed. The basic thing when creating the chain is to start from the end.
struct ChainTest: View {
@State private var isAlertOn = false
@State private var chainResult: LocalizedError?
@State private var isLoadingOn = false
@State private var isSheetOn = false
var body: some View {
ZStack {
VStack {
Button("Run Chain", action: runChain)
}.confirmationDialog("Error!", isPresented: $isAlertOn, titleVisibility: .visible) {
Button("Try Again!", action: runChain)
} message: {
Text(chainResult?.localizedDescription ?? "")
}.tint(.accentColor)
if isLoadingOn {
VStack {
ProgressView()
}.frame(width: 100.0.responsiveW, height: 100.0.responsiveH)
.background(.ultraThinMaterial)
}
}.sheet(isPresented: $isSheetOn) {
VStack {
Image(systemName: "checkmark.rectangle.stack.fill")
.font(.largeTitle)
Text("Successful!")
}
}
}
private func runChain() {
let registerModel = RegisterModel(username: "ates", email: "test1@gmail.com", password: "12345678", rePassword: "12345678")
let userModel = UserModel(username: "ates", name: "Mehmet", surname: "Ateş")
let imageData = UIImage(systemName: "applelogo")?.jpegData(compressionQuality: 0.8) ?? Data()
let registerHandler = RegisterHandler<RegisterRequest>(with: nil)
registerHandler.request = RegisterRequest(userModel)
let profilePhotoHandler = ProfilePhotoHandler<UploadProfilePhotoRequest>(with: registerHandler)
profilePhotoHandler.request = UploadProfilePhotoRequest(username: "ates", storageData: imageData)
let authHandler = AuthHandler<AuthRequest>(with: profilePhotoHandler)
authHandler.request = AuthRequest(registerModel)
let checkUsernameHandler = CheckUsernameHandler<CheckUsernameRequest>(with: authHandler)
checkUsernameHandler.request = CheckUsernameRequest(registerModel)
Task {
isLoadingOn = true
chainResult = await checkUsernameHandler.handle(nil)
guard chainResult != nil else { return isLoadingOn = false }
isAlertOn.toggle()
isLoadingOn = false
}
}
}
On the page I created above, our first attempt was successful. Because all validations are smooth and our user registration is open.
But on our second try, exactly what we expected happened. We’ve just created a registration! You cannot use the same username.
To do this, I updated the username, run the project again, and try again. But I forgot one thing. My email is the same and I just created a user with it.
I change this and try again. This time, I see a screen like this.
We have comprehensively examined the Chain of Responsibility and seen what difference it makes in real projects.
- Our code is now definitely fragmented and open to development.
- It’s easy to add a flow in between, at the beginning, or the end.
- It is very easy to update the relevant chain and catch which chain an error occurs in.
- Testability is high.
Summary
We examined the Chain Of Responsibility and managed to perform a string operation in a chain. Now, when I want to add a new piece to Socia’s recording flow, I can create a handler and quickly add it to any position I want in the chain.
Thank you for reading. Happy Swifting.
My Resources
- HolySwift — Chain Of Responsibility
- Refactoring.Guru — Chain Of Responsibility Article — Example Code