When designing an API, it's easy to use public types that denote resource identity to allow actions on a resource. As an example in the snippet below, we use Int to denote the identifier of the Storable type that the user of the interface wants to use to retrieve the value.
protocol StoredCollection {
associatedtype Storable: Codable
func fetch(id: Int) -> Storable
}
It allows the caller to ask for any random id: 5, 6, 99, 105. At first, this does not appear to be a problem, all we need to do is to make an edit to the interface to indicate that we will return an optional value in case the identifier does not exist.
protocol StoredCollection {
associatedtype Storable: Codable
func fetch(id: Int) -> Storable?
}
Now, let's add creation semantics into this collection, which could lead to the possibilities mentioned below.
//Option 1: Interface user specifies the id
protocol StoredCollection {
associatedtype Storable: Codable
func create(id: Int, value: Storable)
func fetch(id: Int) -> Storable?
}
//Option 2: Interface user just provides the value
protocol StoredCollection {
associatedtype Storable: Codable
func create(value: Storable) -> Int
func fetch(id: Int) -> Storable?
}
Option 2, in my opinion, is more preferable. It indicates that the responsibility of creating the identifier for the storable type belongs to the StoredCollection implementation, whereas Option 1 puts that responsibility on the user of StoredCollection.
And here is how Option 2 might look like when we update the interface to introduce update and remove semantics.
protocol StoredCollection {
associatedtype Storable: Codable
func create(value: Storable) -> Int
func update(id: Int, value: Storable)
func remove(id: Int)
func fetch(id: Int) -> Storable?
}
As was the case earlier with fetch, we are allowing Int to be passed in to update and remove and fetch. But the user may accidentally end up using a random variable of Int type, which has got nothing to do with the StoredCollection when invoking the method.
One way to circumvent this problem is by introducing an ID Type that is public to the outside world with respect to it's existence, but whose creation is private to the framework that's implementing StoredCollection.
protocol StoredCollection {
associatedtype Storable: Codable
func create(value: Storable) -> StoreIdentifier<Storable>
func update(id: StoreIdentifier<Storable>, value: Storable)
func remove(id: StoreIdentifier<Storable>)
func fetch(id: StoreIdentifier<Storable>) -> Storable?
}
public struct StoreIdentifier<Storable: Codable> {
private let value: Int
fileprivate init(value: Int) {
self.value = value
}
}
In the example above, we replaced Int with StoreIdentifier. StoreIdentifier could be a public type with init being internal to the system implementing StoredCollection. By making this change, we can safely send the type across system boundaries, but are also able to avoid usage confusion related to the user passing in an unrelated type as the identifier when using update, remove, and fetch.
As an example if we had retained identifier type as Int, the user could create a sum by adding two identifiers, which does not offer any meaning from the StoredCollection's perspective, but the user of the StoredCollection may try to assign a special meaning to the record identified as sum of identifiers of two other values in the collection. Having opaque identifiers like the ones mentioend above introduces additional types, but it offers the benefit that no meaning can be derived by the users of StoredCollection outside of using the type for invoking the stored collection API's
Another benefit is that the internal identifiers can be changed to String from Int, without having any impact on the users of the interface.