Ballast Repository
Overview
MVI has been known for a while as a great option for managing UI state, but most applications will also need to manage some state that lives longer than a single screen. This would be things like account management, or caching of expensive computations or API calls, and MVI can actually be a great fit for this Repository Layer, too. The Repository Layer has a lifetime that is longer than any single screen, and acts as a liaison between your UI code (the typical MVI area) and the domain objects that make the UI work.
On Android, it's recommended to have a Data Layer, but exactly how to build it is not well known, and there really aren't any recommendations from Google, either. Dropbox Store attempted to step in and create a library to implement this Data or Repository layer, but in practice it works more like a persistent cache than a true solution for app-wide State management.
Ballast Repository aims to fill that gap, and provide an opinionated way to manage the data in your application layer, using the same MVI model you're used to with your UI code. One huge benefit of using Ballast as your repository layer vs other solutions, is that you can approach both UI and non-UI development with the same mindset; you don't have to "context switch" when moving between layers!
Ballast Repository is built around 3 core concepts: the MVI model as implemented with a special BallastRepository
ViewModel, the Cached<T>
interface to hold and update data within the Repository, and the EventBus
to facilitate
communication between Repository instances throughout the entire layer.
Example Use-Case
Before diving into the usage of the Repository module, it may be helpful to get a basic intuition for when you might need it, and how this layer of your application is intended to work. Consider the following situation:
You have an app where users can log on and view how much they've used your service, and how much it costs them. The users may have multiple linked accounts and switch between the accounts freely. Viewing their usage is tied to the individual account, but billing is aggregated among all accounts to simplify paying the bill.
We want to minimize the number of API calls for a snappy user-experience, so we cache every API response. Whenever the user changes the current account, we want to refresh their usage data, but not the billing info, since we want to show the new usage data for the new account, but the billing data does not need to be changed.
In this model, using a BallastRepository, we would hold the user account info in an
AccountRepository
, the usage data in UsageRepository
, and billing info in BillingRepository
. All the cached data
is held within a Cached<T>
property of each Repository's State. Changing accounts involves sending an Input
to AccountRepository
, which then makes its own changes and then sends the relevant Input through the
EventBus
to the BillingRepository
. The UI layer does not need to know any specifics of what's going on
in the Repository layer, as it just passively observes the Cached
properties. Furthermore, it also does not need to
know anything about the specific organization of data in it, when changing one property needs to clear the cache of
another, etc. You can easily wire up any screen to change the account or fetch the usage/billing info, trust that it
will be fetched only once if needed or else returned from the cache, and know that the relevant UI will be updated
automatically whenever the repository finished updating its cached without having to do any specific UI handling for
that.
Usage
BallastRepository
BallastRepository
is a special BallastViewModel
implementation that is intended to be used as the "ViewModel" of
your Repository layer. Unlike UI ViewModels, the Repositories do not have EventHandlers
, as Events sent from the
Repository InputHandler are sent to the EventBus instead (which is simply a SharedFlow). It also uses the
FifoInputStrategy
to ensure that all Inputs are handled, rather than being dropped or cancelled, though they're still
processed one-at-a-time.
Repositories need a CoroutineScope
to control their lifetime (commonly a single, glogal Application CoroutineScope),
and the EventBus
instance, which should be shared among all Repositories. There also exists a
AndroidBallastRepository
which implements the same semantics, but is an instance of androidx.lifecycle.ViewModel
and
so can be scoped to a Navigation sub-graph.
class ExampleRepositoryImpl(
coroutineScope: CoroutineScope,
eventBus: EventBus,
) : BallastRepository<
ExampleRepositoryContract.Inputs,
ExampleRepositoryContract.State>(
coroutineScope = coroutineScope,
eventBus = eventBus,
config = BallastViewModelConfiguration.Builder()
.apply {
initialState = ExampleRepositoryContract.State()
inputHandler = ExampleRepositoryInputHandler()
name = "Example Repository"
}.build()
)
The Contract
for a Repository can be anything you need it to be, but a common implementation based around Ballast's
own Cached<T>
interface looks like the example below. You can add as many cached properties to the same Repository as
needed, but they should typically be related by domain.
object ExampleRepositoryContract {
data class State(
val initialized: Boolean = false,
val examplePropertyInitialized: Boolean = false,
val exampleProperty: Cached<ExampleValue> = Cached.NotLoaded(),
)
sealed interface Inputs {
data object ClearCaches : Inputs
data object Initialize : Inputs
data object RefreshAllCaches : Inputs
data class RefreshExampleProperty(val forceRefresh: Boolean) : Inputs
data class ExamplePropertyUpdated(val value: Cached<ExampleValue>) : Inputs
}
}
The corresponding InputHandler is also very much templated, using the fetchWithCache()
function to determine when to
update the cached value:
class ExampleRepositoryInputHandler(
private val exampleApi: ExampleApi,
) : InputHandler<
ExampleRepositoryContract.Inputs,
Any,
ExampleRepositoryContract.State> {
override suspend fun InputHandlerScope<
ExampleRepositoryContract.Inputs,
Any,
ExampleRepositoryContract.State>.handleInput(
input: ExampleRepositoryContract.Inputs
) = when (input) {
is ExampleRepositoryContract.Inputs.ClearCaches -> {
updateState { ExampleRepositoryContract.State() }
}
is ExampleRepositoryContract.Inputs.Initialize -> {
val previousState = getCurrentState()
if (!previousState.initialized) {
updateState { it.copy(initialized = true) }
// start observing flows here
logger.debug("initializing")
observeFlows(
key = "Observe account changes",
params.eventBus
.observeInputsFromBus<ExampleRepositoryContract.Inputs>(),
)
} else {
logger.debug("already initialized")
noOp()
}
}
is ExampleRepositoryContract.Inputs.RefreshAllCaches -> {
// refresh all the caches in this repository
val currentState = getCurrentState()
if (currentState.examplePropertyInitialized) {
postInput(ExampleRepositoryContract.Inputs.RefreshExampleProperty(true))
}
Unit
}
is ExampleRepositoryContract.Inputs.RefreshExampleProperty -> {
updateState { it.copy(examplePropertyInitialized = true) }
fetchWithCache(
input = input,
forceRefresh = input.forceRefresh,
getValue = { it.exampleProperty },
updateState = { ExampleRepositoryContract.Inputs.ExamplePropertyUpdated(it) },
doFetch = {
exampleApi.fetchValue()
},
)
}
is ExampleRepositoryContract.Inputs.ExamplePropertyUpdated -> {
updateState { it.copy(value = input.value) }
}
}
}
The final piece of the puzzle is where things start to look a bit different from normal UI MVI usage. A Ballast Repository typically shouldn't be directly exposed to the UI, but instead hidden behind an interface so the UI layers don't need to worry about sending the right Inputs and the right time to clear the caches, etc. Instead the UI just requests data from the Repository interface as normal and receives the data it needs as a flow, while the Ballast Repository does all the work in the background to fetch or return cached data.
public interface ExampleRepository {
fun getExampleValue(refreshCache: Boolean): Flow<Cached<ExampleValue>>
}
The class that extends BallastRepository
should then also implement the interface, and send the correct Inputs as the
UI requests data. This makes the actual fetches of data lazy.
class ExampleRepositoryImpl(
coroutineScope: CoroutineScope,
eventBus: EventBus,
) : BallastRepository<
ExampleRepositoryContract.Inputs,
ExampleRepositoryContract.State>(
coroutineScope = coroutineScope,
eventBus = eventBus,
config = BallastViewModelConfiguration.Builder()
.apply {
initialState = ExampleRepositoryContract.State()
inputHandler = ExampleRepositoryInputHandler()
name = "Example Repository"
}.build()
), ExampleRepository {
override fun getExampleValue(refreshCache: Boolean): Flow<Cached<ExampleValue>> {
trySend(ExampleRepositoryContract.Inputs.Initialize)
trySend(ExampleRepositoryContract.Inputs.RefreshExampleProperty(refreshCache))
return observeStates()
.map { it.exampleProperty }
}
}
There is a lot of boilerplate to this method, and eventually there may be a generic Caching Repository to do all this for you. But for now, it's best to just be explicit, so you can easily track what data is being changed and at what time within each Repository.
EventBus
The EventBus
class is basically just a wrapper around a SharedFlow
. It should share the same instance among all
Repositories, so that one Repository can post an event to the bus, and it will be delivered to another Repository.
Each Repository should typically observe values of its own type from the EventBus, using
eventBus.observeInputsFromBus<ExampleRepositoryContract.Inputs>()
, but you're free to observe values of any type. An
example is using a generic "ClearCache" token sent to the bus, and all repositories can watch for that token and clear
themselves.
Values can be sent from one Repository to another with the normal InputHandlerScope.postEvent()
. You can post any
non-null value, as the Events
type is Any
.
Cached
Cached
is a sealed class which holds the data in your Repository and notifies observers of all changes to that value
as it is loaded. It can be one of 4 states: NotLoaded
, Fetching
, Value
, or FetchingFailed
.
For values that need to be loaded once from some remote source or expensive computation, use fetchWithCache()
within
your InputHandler in response to a Refresh*
Inputs. That function takes care of determining when to fetch new values
and capturing errors from the fetcher. But one particular feature of it is that when a hard refresh is requested, the
state will change the previously-cached value will be carried through those states until a new value finally returns,
which can be used to show a progress indicator in the UI with the old values, rather than clearing the entire screen
while loading. The Cached<T>
value has a number of extension functions to help in displaying the right things in the
UI according to the status of that cached value.
When a UI ViewModel is observing a Cached<T>
property from a Repository, you should think of it as if the UI ViewModel
simply observes a "view" of the repository. Technically, the cached values will be copied into the UI ViewModel, but
there shouldn't be any reason to change the value directly in the UI ViewModel. Instead, send those changes back to the
Repository and wait for it to get changed there, at which point the updated value will flow back into the UI ViewModel.
Also, do not unwrap the Cached value in the UI ViewModel, continue to hold onto it as the wrapped Cached<T>
value so
that the UI can use the Cached DSL to optimize its display of the inner value.
Installation
repositories {
mavenCentral()
}
// for plain JVM or Android projects
dependencies {
implementation("io.github.copper-leaf:ballast-repository:4.2.1")
}
// for multiplatform projects
kotlin {
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.github.copper-leaf:ballast-repository:4.2.1")
}
}
}
}