- Published on
Modern Concurrency and Legacy code
- Authors

- Name
- Omar Elsayed
Introduction
We can’t touch that code — it’s all callback-based!’
Sound familiar? As a developer, you’ve probably heard this countless times, or even said it yourself.
Modern Concurrency promises cleaner more maintainable code with its elegant async/await syntax, yet many teams find themselves stuck with legacy codebase, feeling like they’re forced to choose between modern development practices and stable working systems.
But what if you didn’t have to choose?
At work, we faced this exact challenge, our network layer was deeply rooted in completion handlers — a common scenario in many iOS applications. However, through careful planning and strategic implementation, we successfully modernized our entire codebase to use Swift Concurrency without breaking existing functionality or requiring extensive QA resources.
In this article, I’ll walk you through our journey, sharing practical strategies and real-world examples of how we bridged the gap between old and new, You’ll learn how to:
- Gradually adopt modern concurrency patterns
- Safely refactor callback-based code
- Maintain backward compatibility throughout the transition
- Implement these changes with minimal risk and testing overhead
Before diving in, I recommend reading my previous article “Future of concurrent code in Swift” for helpful context on Swift’s concurrency model. Now Let’s explore how you can bring your legacy codebase into the modern era — without the headaches. 🛠️
Legacy Callback
Let’s examine our network call implementation using completion handlers:
class Worker: BaseWorker, WorkerProtocol {
func Info(
for id: String,
completion: @escaping (NetworkResponse<Response, BaseErrorResponse>
) -> Void) {
let request = Action.getInfo(id: id)
guard var url = pathProvider.createURL(type: request) else {
completion(.failure(.urlNotFound))
return
}
let endPoint = RequestEndpoint(
url: url,
parameters: request.parameters
)
networkClient.request(endpoint: endPoint, responseHandler: completion)
}
}
At first glance, you might think:
Just refactor the network layer to use async/await!
While this seems like a straightforward solution, it presents several real-world challenges:
Scope of Change: The network layer is a core module used throughout the entire application. Modifying it would trigger a cascade of changes across multiple features.
Resource Constraints a complete refactor would require:
- Extensive QA testing across all features
- Temporary feature development slowdown
- Risk of introducing bugs in stable functionality
- Team Impact any solution needs to consider the team, We need an approach that:
- Doesn't burden developers with complex migration patterns
- Allows continuous feature development during the transition
- Provides clear benefits that outweigh the implementation effort
So the key Requirements for migration to async/await:
- Enable async/await while preserving existing callback-based code
- Provide a developer-friendly migration path
- Support gradual, incremental adoption
- Minimize risk and testing overhead
The question becomes: How can we introduce modern concurrency patterns while respecting these constraints? Let's explore a practical approach that balances technical debt reduction with business realities 🤔.
Continuation
The first challenge - modernizing our network layer without breaking existing code - has an elegant solution...continuations.
To understand continuations, let's first grasp how async/await works under the hood:
func Test() async -> Void {
/*The pointer (BookMark) was created right here*/
let result = try await UseCase.getInfo(for: "")
}
// at the point getInfo was called the continuation was created to
// point where the compiler stoped excuting the Test function
When you use await, Swift creates an invisible marker (a continuation) at that point in your code where you called await. Think of it as a bookmark that lets Swift remember exactly where to resume execution later, the system can then freely switch to other tasks returning to this bookmark when the async operation completes.
Let's see how we can leverage this mechanism to modernize callback-based code:
class Worker: BaseWorker, WorkerProtocol {
// Old Code
func Info(
for id: String,
completion: @escaping (NetworkResponse<Response, BaseErrorResponse>
) -> Void) {
let request = Action.getInfo(id: id)
guard var url = pathProvider.createURL(type: request) else {
completion(.failure(.urlNotFound))
return
}
let endPoint = RequestEndpoint(
url: url,
parameters: request.parameters
)
networkClient.request(endpoint: endPoint, responseHandler: completion)
}
// Modern concurrency approach
func Info(
for id: String
) async -> NetworkResponse<Response, BaseErrorResponse> {
return await withCheckedContinuation { continuation in
Info(for: id) { result in
continuation.resume(returning: result)
}
}
}
}
As you see in the above code we created a new function that uses the old callback function. So how we can use continuations 🤔?
- Creating the Bridge:
withCheckedContinuationwe create a bridge between callback-based code and the modern async world. - Managing the Continuation:
- The continuation parameter is your link back to the async context.
- You must call
continuation.resume()exactly once to provide the result. - You can return values with
resume(returning:)or errors withresume(throwing:)orresume(with result: sending Result<T,E>)if you are using the swiftResult type. - The return type of
withCheckedContinuationdetermines what you can pass to resume function.
- Safety Considerations, swift offers two types of continuations:
CheckedContinuation: Includes runtime checks to catch mistakes, if you resumed continuations more than once or didn't resume it at all (preferred during development)UnsafeContinuation: Removes safety checks for better performance (suitable for production)- Always ensure continuations are resumed exactly once in all code paths to avoid crashes 💥.
As a rule of thumb use CheckedContinuation during development then ship UnsafeContinuation.
By using continuation we have converted all the functions to modern concurrency without breaking old code.
But still this pattern requires careful implementation by every team member, how can we make this transition smoother and less error-prone 🤔 ?
Swift Macros
In WWDC 23, Apple introduced Swift Macros, the idea of macros is to stop you from writing repetitive code by generating it for you - interesting right ?
For more about macros I recommend you to read this article to get you started "Mastering Swift Macros"
so with the above definition in mind, let's first look at the repetitive code we want to generate.
func Info(
for id: String
) async -> NetworkResponse<Response, BaseErrorResponse> {
return await withCheckedContinuation { continuation in
Info(for: id) { result in
continuation.resume(returning: result)
}
}
}
As you see above converting a callback to async function have a kind of consistent steps to be achieved - let's go through them one by one:
- we take the callback input parameter as output of the async function.
- Any extra parameters in the function will be added to the async function as parameters to be passed for the callback function when we call it in the
withCheckedContinuationclosure. - we will always resume the continuation in the callback of the old function ( Info in this example) with the callback parameter.
- to enhance the performance since we know that the continuation will resume we will use
UnsafeContinuation.
So the final result should be something like that when we use macro:
func Info(
for id: String
) async -> NetworkResponse<Response, BaseErrorResponse> {
return await withUnsafeContinuation { continuation in
Info(for: id) { result in
continuation.resume(returning: result)
}
}
}
I will not go in details how this macro was created but if you want to look at an example visit this link, and you can message me on iMessage if you have any questions I will be very glad to help ❤️.
Now how it looks like to convert old callbacks to new Concurrency approach:
@ConvertToAsync
class Worker: BaseWorker, WorkerProtocol {
// Old Code
func Info(
for id: String,
completion: @escaping (NetworkResponse<Response, BaseErrorResponse>
) -> Void) {
let request = Action.getInfo(id: id)
guard var url = pathProvider.createURL(type: request) else {
completion(.failure(.urlNotFound))
return
}
let endPoint = RequestEndpoint(
url: url,
parameters: request.parameters
)
networkClient.request(endpoint: endPoint, responseHandler: completion)
}
// Modern concurrency approach, this is genrated by the macro.
// it's only visible when you expand the macro.
func Info(
for id: String
) async -> NetworkResponse<Response, BaseErrorResponse> {
return await withCheckedContinuation { continuation in
Info(for: id) { result in
continuation.resume(returning: result)
}
}
}
}
Now what seemed impossible and difficult task is a piece of cake 🍰 and easy for everyone in the team no matter how good you are with new async/await approach.
Conclusion
In conclusion, I want to advice you to always think about the team when you suggest new approach and how to make it easy for them. Also think about the company because you don't want to slowdown feature development during new approaches.
The above approach is the most simple easy way to convert to Modern Concurrency without needing a single QA test or even time to adapt it, it's ready to go once you make it.
Enjoy the solution 😉 and subscribe for more like this