As mentioned in the Series Introduction, Ballast is a foundational piece of the architecture of the Vesper app. Ballast is the architecture of the frontend application, and it is the Kotlin-language interface to the server-side schedules and background queue workloads. An understanding of Ballast and the problems it was designed to solve are crucial for understanding why I built Vesper in the way I did.
This post is part of a series on the Vesper Design Diaries, building a Production-Grade KMP App on a Bootstrap Budget. See the Series Introduction for context on the Vesper app and this blog series.
Full disclosure before anything else: I wrote Ballast. I've been building it for several years, and Vesper is the most complete application I've built with it. Take my recommendation with appropriate skepticism — I'm clearly not a neutral party. That said, Vesper is the real test of whether my own ideas hold up under production conditions, and I think they do.
What Ballast Is and Why I Built It
Ballast started out as a KMP MVI framework, essentially a take on the Redux library popular with React. But Ballast takes the general idea of Redux and expands it to leverage some really nice features of the Kotlin language (like sealed interfaces), while allowing more flexibility to the overall "unidirectional data flow" programming model though the use of Coroutines, all without sacrificing any of the safety that this pattern was created for. Ballast is a highly opinionated library that aims to give you a structure for your code that is easily repeatable, yet adaptable for each individual application.
So what does Ballast look like? The short version: every Screen defines a Contract — a sealed interface of Inputs (things that can happen), a State (the current snapshot), and Events (one-shot side effects like navigation or dialogs). In practice with a pure KMP Compose app, Events are not really used (they are much more necessary with working with legacy Android code). But here's what a Contract in Ballast typically looks like, describing the functionality of a Stopwatch:
object StopwatchContract {
data class State(
val elapsedSeconds: Int = 0,
val isRunning: Boolean = false,
)
sealed interface Inputs {
data object Start : Inputs
data object Stop : Inputs
data object Reset : Inputs
data object Tick : Inputs // posted internally when the stopwatch timer is running
}
sealed interface Events
}
The Contract is just a statement of the data that can be displayed to the screen (the State), and the Inputs are the actions the UI and background processes dispatch to change the State. An InputHandler processes each Input and returns an updated State or schedules additional work, while a ViewModel wires them together with a CoroutineScope to control its lifetime.
class StopwatchInputHandler : InputHandler<
StopwatchContract.Inputs,
StopwatchContract.Events,
StopwatchContract.State> {
override suspend fun InputHandlerScope<
StopwatchContract.Inputs,
StopwatchContract.Events,
StopwatchContract.State>.handleInput(
input: StopwatchContract.Inputs
) = when (input) {
is StopwatchContract.Inputs.Start -> {
updateState { it.copy(isRunning = true) }
sideJob("tick") {
while (true) {
delay(1.seconds)
postInput(StopwatchContract.Inputs.Tick)
}
}
}
is StopwatchContract.Inputs.Stop -> {
updateState { it.copy(isRunning = false) }
cancelSideJob("tick") /* cancels the running loop */
}
is StopwatchContract.Inputs.Reset -> {
updateState { it.copy(elapsedSeconds = 0, isRunning = false) }
}
is StopwatchContract.Inputs.Tick -> {
updateState { it.copy(elapsedSeconds = elapsedSeconds + 1) }
}
}
}
class StopwatchViewModel(
coroutineScope: CoroutineScope,
) : BasicViewModel<
StopwatchContract.Inputs,
StopwatchContract.Events,
StopwatchContract.State>(
coroutineScope = coroutineScope,
config = BallastViewModelConfiguration.Builder()
.withViewModel(
inputHandler = StopwatchInputHandler(),
initialState = StopwatchContract.State(),
name = "StopwatchViewModel",
)
.also { inputStrategy = FifoInputStrategy.typed() }
.build(),
eventHandler = eventHandler { },
)
With Compose, it's best to define each screen with two components: one which is fully Stateless that renders content purely based on the current State, and a Stateful version which gets the ViewModel instance via Dependency Injection and coordinates state and inputs with the Stateless version.
// Stateful — acquires the ViewModel and bridges to the stateless component
@Composable
fun StopwatchScreen() {
val vm = koinViewModel<StopwatchViewModel>()
val state by vm.observeStates().collectAsState()
StopwatchScreenContent(state, postInput = { vm.trySend(it) })
}
// Stateless — receives State and a postInput lambda; no ViewModel reference needed
@Composable
fun StopwatchScreenContent(
state: StopwatchContract.State,
postInput: (StopwatchContract.Inputs) -> Unit,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "${state.elapsedSeconds}s", style = MaterialTheme.typography.displayLarge)
Row {
if (state.isRunning) {
Button(onClick = { postInput(StopwatchContract.Inputs.Stop) }) { Text("Stop") }
} else {
Button(onClick = { postInput(StopwatchContract.Inputs.Start) }) { Text("Start") }
}
OutlinedButton(onClick = { postInput(StopwatchContract.Inputs.Reset) }) { Text("Reset") }
}
}
}
You'll notice that none of the above code requires annotation processors, code generation, or reflection to function. Ballast has none of that. Every contract, handler, and adapter is code I wrote and can navigate to directly in IntelliJ. When something breaks at 2am, I'm not guessing what a generated class is doing — I'm just reading the actual implementation (or nowadays, having Claude read it).
The "no magic" constraint isn't just aesthetics. On Kotlin Multiplatform, code generation support across all targets is still uneven. Reflection is either unavailable or slow on Native. KSP requires additional Gradle boilerplate and longer compile times, and makes it difficult to find, debug, and modify the generated code. Ballast sidesteps those problems entirely by requiring explicit code. You write more boilerplate upfront, but you get a consistent, debuggable, fully-navigable codebase in return, which can be freely modified or extended without requiring changes to the Ballast library itself.
Historically, the boilerplate problem was solved with scaffold tools, but these days, AI agents are so good at following patterns that you really don't even need that. I used Claude Code to build much of Vesper, and I was honestly shocked at how well it was able to follow the Ballast boilerplate without explicit prompting, given that Ballast is a bespoke library without much adoption. As a coworker told me when first getting introduced to Claude, "Claude loves structure". I completely agree, and Ballast's opinionated nature works with that very well.
The Contract Pattern in Practice
A typical screen in Vesper — such as the Submit Prayer screen — defines a contract like this:
object SubmitPrayerScreenContract {
data class State(
val prayerText: String = "",
val isSubmitting: Boolean = false,
val isError: Boolean = false,
) {
// many properties can be derived from the state, and it's best to
// co-locate that here to keep the logic out of Compose
val isPrayerValid = prayerText.isNotEmpty()
}
sealed interface Inputs {
data class UpdatePrayerText(val text: String) : Inputs
data object Submit : Inputs
}
sealed interface Events
}
The InputHandler is a pure function that handles Inputs one-at-a-time (though a Coroutine Channel) and provides APIs
to update the state, start "side jobs" (coroutines that run the "background" of the ViewModel), or send Events or
follow-up Inputs.
class SubmitPrayerScreenInputHandler(
private val submitPrayer: SubmitPrayerUseCase,
private val router: Router,
) : InputHandler<Inputs, Events, State> {
override suspend fun InputHandlerScope<Inputs, Events, State>.handleInput(input: Inputs) = when (input) {
is Inputs.UpdatePrayerText -> {
updateState { it.copy(prayerText = input.text) }
}
is Inputs.Submit -> {
val currentState = updateStateAndGet { it.copy(isSubmitting = true) }
try {
val prayerResult = submitPrayer(currentState.prayerText)
updateState { it.copy(isSubmitting = false) }
sideJob {
router.sendAndAwaitCompletion(GoToDestination("/prayers/${prayerResult.id}"))
}
} catch(e: Exception) {
updateState { it.copy(isSubmitting = false, isError = true) }
}
}
}
}
Note that unlike Redux, Ballast does not require a strict 1:1 transformation from the Input to a State. Instead, it just carefully guards the internal StateFlow so that a single Input may safely use Coroutines to make several calls in series, updating the state as it goes. It also ensures Inputs are queued up and processed one-at-a-time in the order they were received, making sure there are no race conditions with your InputHandler code.
Since the InputHandler may have dependencies injected via DI, the InputHandler itself must be injected into a ViewModel's constructor, along with a CoroutineScope to define the lifetime of the ViewModel:
class SubmitPrayerScreenViewModel(
coroutineScope: CoroutineScope,
inputHandler: SubmitPrayerScreenInputHandler,
configurationBuilder: BallastViewModelConfiguration.Builder,
) : BasicViewModel<Inputs, Events, State>(
coroutineScope = coroutineScope,
config = configurationBuilder
.withViewModel(inputHandler = inputHandler, initialState = SubmitPrayerScreenContract.State())
.build(),
)
The coroutine scope comes in from outside. That's the key design choice: whoever provides the scope owns the ViewModel's lifetime. On a screen, that's the navigation backstack entry or the Compose hierarchy (via rememberCoroutineScope()). For app-wide state, it's the Ktor application lifecycle on the server, or an application-scoped scope on mobile. I'll cover the backstack lifetime management in a dedicated post on navigation. I also have a Koin factory { } function which defines a base BallastViewModelConfiguration.Builder, which add cross-cutting Interceptors into all ViewModels, so I get things like automatic logging and a Debugger attached by default.
Application-Wide State
ViewModels don't need to be limited to just the UI layer, though. Vesper uses an additional AppStateViewModel scoped to an application-wide CoroutineScope which holds the state that needs to outlive any individual screen: auth tokens, theme preference, onboarding status, feature flags. This ViewModel uses a @Serializable State class with the saved-state module to persist all changes to multiplatform-settings. Cold launches restore the state from disk rather than starting blank, and the whole application is gated on this state restoration in the Splash Screen, so we can ensure it was correctly restored before attempting to use any of the AppState data.
For an online-first app like Vesper, this pattern acts as a lightweight data layer that sidesteps the need for a full SQLite database. The app's local storage needs are modest — a handful of preference values and session tokens — and a single serialized State covers all of it without a schema, migrations, a query layer, or DAO mapping code. Auth tokens and other sensitive fields are stored using encrypted storage helpers, making the settings store a safe credential store. Because Ballast processes Inputs sequentially, callers can use sendAndAwaitCompletion to post an Input and suspend until it has been fully handled — including until BallastSavedStateInterceptor has flushed the new state to disk. The ViewModel acts as a fast in-memory cache; sendAndAwaitCompletion gives you the guarantee that the cache and the store are in sync when you need it.
The Same Pattern on the Server
Beyond UI and application state, Ballast also powers the server-side background queues (via ballast-queue) and cron-style scheduled jobs (via ballast-scheduler) — the same Contract/InputHandler model, just with Inputs serialized to a Postgres table and retried on failure rather than processed in memory. Navigation is the same story: ballast-navigation drives routing with a URL-based backstack, using the same Contract pattern you saw above. I'll cover all of these topics in their own posts; the point for now is that learning the Contract/InputHandler shape once gives you a mental model that applies across the entire application stack and across various domains, not just UI state.
Why This Matters for Vesper
Vesper is a relatively simple app, and yet still has around 30 screens to manage, along with an application-wide state machine, non-trivial navigation logic, and a lot of complex platform-specific logic needed to bridge everything together. All of this runs within the Ballast system, making all user interactions and state changes predictable and reliable. New developers (or AI agents) know exactly what to look for in any feature: find the Contract, read the Inputs to know what can happen, read the InputHandler to know how those changes get applied.
The consistency also makes testing straightforward. InputHandler implementations have no framework dependencies — they take domain use cases as constructor arguments, which can be stubbed for tests. The State is a plain data class, which allows us to recreate any possible UI state for previews or snapshot tests. There's nothing to mock at the Ballast layer itself. I can verify the full behavioral logic of any screen by constructing its InputHandler, feeding it Inputs, and asserting on the resulting State — no Compose runtime, no Android emulator, no network.
That same legibility extends to AI-assisted development. Because each screen's logic is fully self-contained in a Contract + InputHandler pair, Claude could confidently generate or modify an InputHandler for a screen it had never seen before, just by pattern-matching against the rest of the codebase. The explicit structure that Ballast requires turns out to be exactly the kind of structure that makes a codebase easily understandable to both humans and machines.
I'll be referring back to Ballast throughout this series. The routing system, the server-side queue, and the saved-state adapter are all built on it. Understanding the basic Contract/InputHandler/ViewModel shape is the prerequisite for all of the other posts. You can find more documentation and examples, as well as additional features not used by Vesper such as Undo/Redo support or Firebase integrations, at github.com/copper-leaf/ballast.
Top comments (0)