One of the things we tend to do in OOP is to create types to define behaviours. However, types are best used to describe data, i.e. objects. We've had to use types in the past because of language limitations.
This is a small description of how we can use the power of functions to define code in a way that is easier to read, maintain and test.
We can define components that consume data using methods. They are often defined something like this:
interface Tracker {
fun trackScreenView()
fun trackItemAddedToBasket(item: BasketItem)
fun trackPurchase(items: List<BasketItem>)
}
However, this can also be defined using a sealed hierarchy (aka a sum type):
sealed interface TrackerEvent {
data object ScreenView : TrackerEvent
data class ItemAddedToBasket(val item: BasketItem)
data class Purchase(val items: List<BasketItem>) : TrackerEvent
}
Also, for convenience, we can define a typealias
to define Tracker
:
typealias Tracker = (TrackerEvent) -> Unit
This means a few things...
- Implementation of
Tracker
is a lot more flexible, without loosing any of the original use.
fun printTracker(): Tracker = { event: TrackerEvent ->
when (event) {
is ScreenView -> print("Screen tracked")
is ItemAddedToBasket -> print("Item added to basket: ${event.item}")
is Purchase -> print("Purchase of ${event.items.size} items complete")
}
}
- It's a lot easier to use composition to mix multiple trackers into one.
fun compositeTracker(vararg trackers: Tracker): Tracker = { event ->
trackers.forEach { it(event) }
}
- We can still use classes internally for dependencies:
internal class FirebaseTracker @Inject constructor(
private val analytics: FirebaseAnalytics,
) : Tracker {
override fun invoke(event: TrackerEvent) {
when (event) {
is ScreenView -> analytics.track("screen viewed")
is ItemAddedToBasket -> analytics.track(
eventName = "item added",
properties = mapOf("itemName" to event.item.id)
)
is Purchase -> analytics.track(
eventName = "item added",
properties = mapOf("basketSize" to event.items.size)
)
}
}
}
- However, we can also define dependencies using a Scope, that we can easily 'inject' when needed:
interface AnalyticsScope {
fun track(eventName: String, properties: Map<String, Any> = emptyMap())
}
fun AnalyticsScope.track(event: TrackerEvent) {
when (event) {
is ScreenView -> track("screen viewed")
is ItemAddedToBasket -> track(
eventName = "item added",
properties = mapOf("itemName" to event.item.id)
)
is Purchase -> track(
eventName = "item added",
properties = mapOf("basketSize" to event.items.size)
)
}
}
This will be even more useful with context receiver (although, it's already possible to combine multiple scopes defined as interfaces)
- When testing this component, if we want to verify the calls, creating a mock is very easy.
@Test
fun `some fun test`() {
val eventsTracked = mutableListOf<TrackerEvent>()
val mockTracker: Tracker = { eventsTracked += it }
doSomethingWithTracker(mockTracker)
check(eventsTracked == listOf(ScreenView))
}
- We can also use data composition here to create derivative behaviour. For instance, before we send it to analytics, we may want to add some extra information to the events we are sending. We can crate a wrapper for that
data class AnalyticsEvent(
val event: TrackerEvent,
val extraProperties: Map<String, Any>,
)
And then use a HOF (High order function) to enhance the data:
fun analyticsTracker(
track: (AnalyticsEvent) -> Unit,
userExtras: UserExtras,
): Tracker = { event ->
track(AnalyticsEvent(event, userExtras.extraProperties))
}
interface UserExtras {
val extraProperties: Map<String, Any>
}
Or, if you prefer using DI's, you can still define this as a type:
class AnalyticsTracker @Inject constructor(
private val track: (AnalyticsEvent) -> Unit,
private val userExtras: UserExtras,
) : Tracker {
override fun invoke(event: TrackerEvent) {
track(AnalyticsEvent(event, userExtras.extraProperties))
}
}
As opposed to a consumer component, with Query Component, we can have behaviour that also return something other than Unit
.
interface UserService {
fun me() : User
fun myConnections(): List<User>
fun setName(name: String) : Boolean
fun ping()
}
Because of the heterogeneous nature of the results, if we want to use a similar pattern (I like to call it a Query service) we need to define a return hierarchy, as well as an input.
sealed interface UserQuery<T : UserResult> {
data object Me : UserQuery<UserResult.Single>
data object MyConnections : UserQuery<UserResult.Multiple>
data class SetName(val name: String) : UserQuery<UserResult.Confirmation>
data object Ping : UserQuery<UserResult.NoReturn>
}
sealed interface UserResult {
data class Single(val user: User) : UserResult
data class Multiple(val users: List<User>) : UserResult
data class Confirmation(val done: Boolean) : UserResult
data object NoReturn : UserResult
}
We can the create the same service in this fashion:
@Suppress("UNCHECKED_CAST")
fun <T : UserResult> forUsers(query: UserQuery<T>): T =
when (query) {
is UserQuery.Me -> UserResult.Single(Self("I"))
is UserQuery.MyConnections -> UserResult.Multiple(emptyList()) // lonely
is UserQuery.Ping -> UserResult.NoReturn
is UserQuery.SetName -> UserResult.Confirmation(true)
} as T
The only caveat here is that the compiler is not able to realise that all the returns are
covariant with T
. So we need to do a little unsafe cast at the end.
Although we know it's safe ^_^'
We also get the same benefits that we had with the consumer component.