You don't need protocols
February 14, 2023After discussing with developers I realized some of theme use what I would call "protocol first" approach: make a protocol then think about the need.
While it might works sometimes it comes with downsides: protocols are not to be used everywhere! They are good tools but not the only one you should use. Just like you don't use a hammer 🔨 for everything... (except maybe to to make Xcode 🛠 working, but that's another topic!).
Today I'd like to show you a few alternatives you can use instead of protocols: closures, structs and enums.
Closures
Let's say you need to a method to open iOS settings ⚙️ from your app. This is something that would be mostly done using your AppDelegate. But because you don't want to tie your classes to it you decide to... define a protocol!
protocol SettingsProvider {
func openSettings() -> Void
}
extension AppDelegate: SettingsProvider {
func openSettings() -> Void {
...
}
}
class FooViewModel {
init(SettingsProvider: SettingsProvider) {
}
}
The code is working and technically there is nothing wrong. However see how we created a protocol with only one method? Beside this protocol will never grow: it will always have only one method requirement.
If we look to testing a we also notice we need to provide a concrete instance type:
class SettingsProviderMock: SettingsProvider {
...
}
So much code for yet such a little feature (opening iOS settings). Maybe we could go simpler? Yes we can! 💪
By using closures we can effectively get the same result with less code:
class FooViewModel {
init(openSettings: @escaping () -> Void) {
...
}
}
That's all we need. See how we even got more clarity? Instead of referring to a (little bit) obscure SettingsProvider
we now clearly state we intend to open the settings.
What about testing? Here again we gain in simplicity and flexibility. We now just need to provide our closure when writing our test. Which can do nothing or trigger some XCTAssert*
.
class FooTests: XCTestCase {
func test_XX() {
let viewModel = FooViewModel(openSettings: { ... })
}
}
In summary if you have a small isolated requirement then consider using closures before going for the mighty 🪄 protocol.
Structs
Structs are under-used by some Swift developers. Let's say we have an app working with a User. This is a very typical problem that you'll encounter in 99,9% apps.
This user might come from multiple sources like Apollo (GraphQL) or CoreData database. So in order to abstract its usage you decide to use a protocol:
protocol UserModel {
let id: UUID { get }
let firstName: String { get }
let lastName: String { get }
}
first glance there’s nothing wrong with this approach, just like for our previous example. However as soon as you start using it you may facing some issues:
- 🐣 You cannot create a UserModel instance. You always have to rely on someone else to do so. Very cumbersome!
- 🗝 All your implementations need to conform to this specific contract. You could argue this is the objective...
- 😓 ...But you soon realize you have have hard-time having both GraphQL (Apollo) and CoreData models implementing the same protocol. Indeed most CoreData attributes are optional!
One way to fix it would by declaring both attribute storage and protocol conformance:
class UserCoreData: NSManagedObject {
@NSManaged private var _firstName: String?
var firstName: String { _firstName ?? "" }
}
However it becomes unclear what's the difference _firstName
and firstName
.
Finally we also need to create a specific mock implementation for our testing:
struct UserModelMock: UserModel {
let id: UUID
let firstName: String
let lastName: String
}
Just... a plain data struct 😳. Isn't it a little bit weird to have such a type only for testing?
If we look to both our struct and protocols we can see they are identical. That is because our protocols is composed only of attributes. More importantly: there is no logic to implement.
It may now becomes obvious that the best approach to represent such requirement is by using a struct:
struct UserModel {
let id: UUID
let firstName: String
let lastName: String
}
Doing this we already fix two of our issues:
- 🐣 We can manually create a UserModel instance
- 🧪 We can write tests using this implementation
As for handling data from multiple sources the best approach is by converting source data to our model:
extension UserCoreData {
func toModel() -> UserModel {
UserModel(id: id, firstName: firstName ?? "", lastName: lastName ?? "")
}
}
Doing so you gain more flexibility:
- You can handle each data source specificity by having fallback values... or throwing errors if you want to
- You effectively hide your data source implementation
- You make it more explicit when working with the data source and when using your model
Enums
Let's take an example where you need to handle multiple user types inside your app:
- a regular user, like the one we just saw
- an admin users, who have additional permission
- an anonymous users, who has no identity and more restricted access
Trying to modify our previous struct we may end up with something like this:
struct UserModel {
var id: String? // optional because anonymous user has no id
var firstName: String
var lastName: String
var isAdmin: Bool
}
However we now have to handle optional id everywhere in our code. We can now also create weird states like UserModel(id: nil, firstName: "", lastName: "", isAdmin: true)
where user is... anonymous but admin at the same time 🤯.
One might argue that if we stood using protocols this issue would not have happend for sure!
protocol AnonymousUser {
}
protocol User: AnonymousUser {
var id: String
var firstName: String
var lastName: String
}
protocol AdminUser: User { }
Indeed it solves our issues. But it comes with its own downsides:
- We have a naming issue:
User
is anAnonymousUser
... that's kind of strange 🤔 - This time we need not 1 but 3 struct mocks for writing tests!
Beside what if we wanted to have an attribute specific to anonymous users?
Let think about we need. Our user can either be anonyous or a regular user or an admin. Most of the time when using "or" it means we're looking for an enum:
enum UserGrant {
case anonynmous
case regular(User)
case admin(User)
}
Now we can easily check if our user is anonymous, admin or just a plain regular one. One interesting thing to note is that this information is not linked to User
itself: these are two different objects.
As a result any method dealing with User
but don't requiring its grant can continue using User
. This makes our code less tighted to unrelated concept.
Conclusion
Protocols are great tools but not the only one available in Swift. As counter-intuitive as it may sounds to some readers other solutions can result in simpler and more maintainable code.
As such don't fall into "protocol first" paradigm and look to some of Swift most powerful features: struct, enums and closures.