Skip to content

Instantly share code, notes, and snippets.

@pablisco
Last active February 1, 2024 15:49
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pablisco/bbc7cae38e9262fc2901556dfbf1b3c3 to your computer and use it in GitHub Desktop.
Save pablisco/bbc7cae38e9262fc2901556dfbf1b3c3 to your computer and use it in GitHub Desktop.
Query Behaviour

Behaviour

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.

Consumer Component

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))
    }
}

Query Components

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment