Map Models Made Easy
In this article, we’ll look at a simplified approach to model mapping in Swift, making it easier to map data across architecture layers without unnecessary complexity.
Introduction
In software development, it’s common to encounter situations where you need to map one model to another, especially when transferring data between different layers of your application architecture. Often, this process involves additional parameters that must be filled in the target model or long lines of code to ensure proper transformation. Managing these mappings efficiently is essential for clean and maintainable code.
struct Employee {
let id: Int
let name: String
let department: String
}
struct FullTimeEmployeeViewModel {
let someService: Service
let employee: Employee
}
Imagine Presentation layer need FullTimeEmployeeViewModel and on usecase layer we have fetch to Employee. Now we need to Map UseCase Model to presentation view-model.
Normally we can do this mapping inline inside a presentation component like a parent view-model like this:
list.map { employee in
FullTimeEmployeeViewModel(
someService: di.someService,
employee: employee
)
}
However, the process isn’t always straightforward. Sometimes, models require additional parameters or static values for transformation. You might find yourself writing long, repetitive lines of code to accommodate this. Writing code for these transformations repeatedly can make your codebase harder to maintain and cluttered.
Solution
Leveraging protocol is a good solution to craft a stable solution, so Here is the mapper protocol:
protocol Mapper {
associatedtype From
associatedtype To
func map(from entity: From) -> To
}
This protocol enables us to encapsulate the mapping procedure into new structure:
struct FullTimeEmployeeMapper: Mapper {
func map(from entity: Employee) -> FullTimeEmployeeViewModel {
FullTimeEmployeeViewModel(someService: ??, employee: entity)
}
}
When a ViewModel requires an additional service, what’s the best approach to handle it? Should we pass the service using a generic tuple parameter, or is it better to create a dedicated Data Transfer Object (DTO) for this purpose? Ideally, we aim to minimize boilerplate code, but finding the right balance is key to maintaining clean architecture and flexibility.
I present you the third generic parameter in protocol Context which is default to Void to avoid declare it when it is not required.
protocol Mapper {
associatedtype From
associatedtype To
associatedtype Context = Void
func map(from entity: From, context: Context) -> To
}
Using context, you can create a container within the mapper that passes necessary parameters alongside the source entity. To maintain a functional programming approach, this container is used as a parameter within the map
method rather than being injected directly into the structure. This keeps the mapping process clean, modular, and aligned with functional programming principles.
The downside of passing Context to the map method is that it limits flexibility. If you treat mappers as protocols (which isn’t always ideal since mappers are typically used in place rather than as injected services), you lose the ability to provide different contexts for different use cases. But You can adapt the mapper protocol to better align with your requirements.
struct FullTimeEmployeeMapper: Mapper {
struct Context {
let someService: SomeService
}
func map(from entity: Employee, context: Context) -> FullTimeEmployeeViewModel {
FullTimeEmployeeViewModel(
someService: context.someService,
employee: entity
)
}
}
Now that we have the complete idea laid out, let’s refine it further. Here are some improvements:
- Define all associated types as primary associated types. This enables easier passing mapper as protocol and not depending on implementation.
- Add a default implementation for the
map
method where theContext
isVoid
, so there’s no need to implementVoid
each time. - Make the
map
method throwable to handle cases where something could go wrong during the mapping process.
These adjustments will enhance both the usability and robustness of the mapping process.
protocol Mapper<From, To, Context> {
associatedtype From
associatedtype To
associatedtype Context = Void
func map(from entity: From, context: Context) throws -> To
}
extension Mapper where Context == Void {
func map(from entity: From) throws -> To {
try map(from: entity, context: ())
}
}
Conclusion
Using a mapping protocol like Mapper provides a clean, modular approach to transforming data models in Swift, especially within layered architectures like MVVM or Clean Architecture. This structure brings several advantages:
1. Separation of Concerns: The Mapper protocol abstracts the transformation logic, isolating it from the core business logic. This keeps each layer of your architecture focused on its specific role, improving code readability and maintainability.
2. Reusability and Scalability: With a generic Mapper protocol, you can easily create additional mappers to handle various model transformations without duplicating code. This setup is flexible enough to support diverse model transformations as the app scales, especially if multiple layers or modules require similar transformations.
3. Improved Readability: By organizing mapping logic within dedicated mappers, the overall readability of your code improves. Rather than embedding transformation details within different parts of the codebase, they’re centralized, making it easy for future developers to understand and maintain.
In summary, implementing a mapping protocol structure enhances the modularity, flexibility, and maintainability of your codebase, allowing you to handle complex data transformations between architecture layers more effectively.