yurii.
back to work
CASE STUDY

Building FlowBus - An event backbone for a large Android system

SHIPPED
Started: Sep 2023Shipped: April 2024Role: Principal Engineer

FlowBus: When an Event Bus Stops Being Just an Event Bus

There is a peculiar moment in the life of a software system when a solution that once felt almost embarrassingly simple begins to reveal how many assumptions had been hiding inside it.

An event bus is a good example.

At first, the problem seems trivial. One component knows something that another component needs to know, so the first component publishes an event and the second subscribes to it. Kotlin gives us Flow, SharedFlow, and Channel; the language gives us sealed hierarchies and coroutines; and, for a while, the whole problem appears to fit comfortably into a few generic functions.

Then the system grows.

One event represents a command and should disappear as soon as it has been delivered. Another represents state, which means that a component starting thirty seconds later must still be able to obtain its latest value. A third represents not one state but a collection of independently addressable states. Some values belong to an isolated logical scope and must never cross its boundary. Some must survive a process restart. Some events are harmless enough to be visible everywhere, whereas others represent internal state that only a restricted group of components should be allowed to observe or modify.

At that point, the event bus is no longer a pipe.

It has become a border.

And borders are where architecture begins.

The system behind FlowBus was heavily event-driven, which meant that the event layer eventually found itself underneath a considerable part of the application. What initially looked like a communication problem gradually became a question of state, identity, persistence, isolation, ownership, validation, and recovery.

The original question had been:

How do we deliver an event from one component to another?

The question that eventually mattered was much larger:

How do we make event communication predictable when the system becomes too large for its rules to live in developers' memory?

That distinction shaped FlowBus.

FlowBus became a central communication mechanism, although calling it merely an "event bus" eventually stopped describing the problem particularly well. It understands whether an event is transient or represents retained state, whether there may be one instance or many, whether the state belongs to a particular logical scope, whether it must survive a restart, whether a missing value may be synthesized, and whether the component requesting an operation has permission to perform it.

None of those capabilities were introduced because they made the architecture look sophisticated.

They appeared because, one by one, the assumptions of the simpler system stopped being true.

Good architecture often grows exactly this way.

We usually encounter architecture backwards. We see the finished diagram, with its carefully named interfaces and arrows, and it is tempting to imagine that somebody understood the complete problem from the beginning and simply designed the right boxes.

Real systems are less polite.

They grow more like cities.

A road exists because somebody once needed to reach a building. A second road appears when the first becomes crowded. Intersections eventually require traffic rules; districts acquire boundaries; certain roads stop accepting certain kinds of traffic. Much later, somebody looks at the whole thing and calls it infrastructure.

FlowBus developed in much the same way.

It Started With Events

The smallest useful event bus is almost offensively simple:

publish(event)

subscribe<Event> {
    // react
}

And there is nothing wrong with that.

Abstractions should begin small. Premature architecture has the unfortunate ability to solve imaginary problems with remarkable elegance.

The first difficulty appears when two objects that travel through the same mechanism have entirely different semantics.

Consider an event representing a request:

@Serializable
data object RefreshRequested : SystemEvents

Its meaning is temporal.

Something requested a refresh, interested components may react, and after that moment has passed the event should disappear with it. A component that starts several minutes later should certainly not discover the "latest refresh request" and execute it again.

Now consider another event:

@Serializable
data class ConnectionState(
    val connected: Boolean,
    val endpoint: String?
) : NetworkEvents

Syntactically, both are simply objects.

Semantically, they have almost nothing in common.

A component may appear long after ConnectionState was originally published and still need to know its latest value. Waiting for another transition merely to discover the current state would make the lifecycle of one component depend unnecessarily on the timing of another.

RefreshRequested describes something that happened.

ConnectionState describes something that is true.

Treating them identically makes the API look simpler, but it does not remove the distinction. It merely forces every consumer to rediscover that distinction on its own.

That led to one of the fundamental ideas behind FlowBus:

the semantics of an event should be encoded in the event model rather than remembered by every component that uses it.

The event hierarchy therefore distinguishes between transient events and several forms of retained state. Sticky events retain one latest value; sticky collections retain several independently keyed instances; scoped variants associate state with an additional logical boundary; and selected sticky events may also be persisted automatically.

Each category exists because it answers a different question.

An ordinary event asks:

Who needs to know about this now?

A sticky event asks:

What is the latest known value?

A sticky collection asks:

What is the latest known value for this identity?

A scoped event asks:

Within which logical context is this information valid?

A persistable event asks:

Should this value still exist after the current process no longer does?

Once those questions became explicit, a large part of the architecture stopped being accidental.

The conceptual evolution of the system can be seen as a sequence of pressures rather than a sequence of features:

The arrows are important because every step exists as a consequence of the previous one. Persistence did not appear because persistence is fashionable; it appeared because retained state stopped being sufficient once process lifetime became relevant. Security did not appear because event buses ought to have permission systems; it appeared because a central communication mechanism eventually became an architectural boundary.

State Hiding Inside an Event Stream

There is a subtle trap in event-driven design.

Once everything is expressed as events, it becomes tempting to think that everything is therefore a stream.

It is not.

A stream can describe the sequence through which something changed, but most components eventually need to ask a less historical and far more practical question: what is the state now?

One possible architecture would reconstruct that answer from the complete event history. Event sourcing deliberately does this, and in systems where the history itself is the source of truth, that can be exactly the right design.

That was not the problem FlowBus needed to solve.

Here, the historical sequence and the latest materialized state had different purposes.

The stream represented change.

The sticky value represented the present.

Whenever a sticky event is published, FlowBus conceptually performs both operations: the event is distributed to active subscribers, while its latest instance is retained for components that need to query it later.

FlowBus.publish(
    ConnectionState(
        connected = true,
        endpoint = "primary"
    )
)

The same publication therefore has two consequences.

Current subscribers learn that something changed.

Future readers can learn what the value is.

These ideas are closely related, but they are not identical, and the internal architecture deliberately preserves that distinction.

The runtime communication layer manages both event distribution through Kotlin Flows and runtime sticky storage, while FlowBus remains above that mechanism and coordinates the broader behavior surrounding each event.

This distinction between stream and state turned out to be fundamental. A SharedFlow can distribute a value beautifully, but distribution does not answer the question of what a late consumer should observe. Conversely, a state store can tell us what is currently true, but it does not naturally express that something has just changed.

FlowBus had to provide both without confusing one for the other.

At this point, the problem appears largely solved.

Until the process disappears.

Memory Is Not Persistence

Runtime state is useful precisely because it is cheap.

It is also temporary precisely because it is memory.

Once the process restarts, a beautifully designed in-memory map becomes an empty map.

This creates a question that every stateful architecture must eventually answer: which pieces of current state are merely convenient to cache, and which ones actually belong to the durable state of the system?

Not every sticky event needs persistence.

Some values are meaningful only during the current execution and can be reconstructed cheaply. Others represent state whose disappearance would materially change the behavior of the system.

Those events need to survive.

The naïve solution is straightforward: whenever such an event is created, the feature that owns it also implements persistence.

An entity is written.

Then a DAO.

Then a mapper.

Then restoration logic.

Then another feature does the same thing.

Then another.

Nothing is fundamentally wrong with this design until the number of events grows sufficiently large. At that point, persistence stops being domain logic and becomes repeated ceremony.

And repeated ceremony tends to decay.

One implementation forgets to restore a field.

Another uses a slightly different default.

A third updates runtime state but fails to update persistent state.

The fourth reads persistence through an entirely different path.

The difficulty is not the number of lines. The difficulty is that a single architectural rule has acquired several implementations.

FlowBus instead treats persistence as a capability of the event itself.

A persistable event remains an ordinary strongly typed object from the caller's perspective, while the infrastructure recognizes its semantics and delegates its storage to a persistence registry. When the same value is requested later and no runtime copy exists, FlowBus may consult that registry before concluding that the state is absent.

The resulting read operation follows a deterministic recovery path:

The order matters.

The freshest runtime representation wins.

If runtime state is unavailable, durable state may restore continuity.

If nothing has ever been stored, some events allow the system to construct a well-defined initial value.

Other events deliberately do not.

That last distinction deserves more attention than it usually receives.

Absence Is a State Too

Default values are wonderfully convenient.

They are also surprisingly good at lying.

Imagine requesting a Boolean value that has never been produced and receiving false.

Does false mean that the system has explicitly determined that the condition is false?

Or does it mean that nobody has provided the answer yet, so the infrastructure invented one?

Those are radically different statements.

For that reason, FlowBus does not assume that every sticky event may be created from thin air. Default creation is an explicit capability. An event may opt into it, and its properties may define the defaults that should be used when no runtime or persisted instance exists.

Other events intentionally refuse such behavior.

When one of those events has never existed, reading it fails instead of manufacturing state.

That design reflects a principle that turned out to be useful well beyond FlowBus:

unknown and default are not synonyms.

A system becomes easier to reason about when it refuses to pretend otherwise.

The important detail here is not the annotation itself. The annotation is merely syntax. The architectural point is that the decision is owned by the event definition rather than by whichever component happens to read the event first.

One reader cannot decide that absence means false while another decides that it means true, and a third throws an exception. The rule exists once.

That is what infrastructure is supposed to do.

Let the Compiler Do the Boring Work

Persistence solved one problem and immediately created another.

Suppose an event is declared as persistable.

The runtime now needs some way to represent it in a database, map it in both directions, locate the correct persistence implementation, and, when permitted, construct a default instance.

One approach would have been reflection.

Another would have been registration by hand.

Both would work.

Neither was particularly attractive.

The event declaration already contained most of the information needed by the infrastructure. Kotlin's type system described its payload; annotations described its semantics; serialization metadata described its serialized representation; default annotations described how an initial value might be created.

The compiler could see all of it.

So the compiler became part of the solution.

Kotlin Symbol Processing inspects event declarations during compilation and generates the repetitive infrastructure required by the runtime: persistence-related classes, mapping logic, registry implementations, and sticky factories. FlowBus itself depends on the resulting abstractions rather than on the machinery that generated them.

The compile-time and runtime worlds therefore meet through interfaces:

That distinction is architectural, not cosmetic.

At runtime, FlowBus knows that it needs to persist an event and asks StorageRegistry.

It does not know which DAO must be called, how a particular event maps to a particular database representation, or which generated class performs the conversion.

Likewise, when a default instance is required, FlowBus asks StickyFactory.

The runtime does not perform constructor discovery or reflection.

The compiler already did the complicated work.

This moved complexity rather than eliminating it.

That is often what good architecture does.

The important question is not whether complexity exists; in a sufficiently capable system, it always will.

The useful question is where that complexity is allowed to live.

Here, deterministic and repetitive complexity moved into compilation, where errors could become build failures instead of runtime surprises.

The runtime became more boring as a result.

Boring runtime code is usually a compliment.

When One Identity Is Not Enough

Retaining a single latest value solves only one category of state.

Sometimes a system does not have one current instance of a concept.

It has many.

A collection of independent entities may all share the same event type while representing different identities. Treating the complete set as one enormous sticky object would technically work, but it would couple unrelated updates and force consumers to manipulate more state than necessary.

Sticky collections solve that problem by allowing multiple retained instances of the same event type, each identified by a key. The original event model also permits validation rules to be associated with those keys.

Conceptually, event identity begins with a type and may acquire additional dimensions:

The key is not merely an argument passed into some map.

It is part of what distinguishes one retained state from another.

Once identity is treated as an architectural concept rather than an implementation detail, validation becomes a natural consequence. If a key has a constrained domain, the event system itself can enforce that constraint rather than asking every caller to remember it.

Then another dimension appears.

Many sufficiently large systems contain some notion of isolated context. Depending on the application, that context might represent a tenant, a session, a workspace, a device, a domain, an account, or some entirely different logical boundary.

The name does not matter.

The requirement does.

A value belonging to one context must not accidentally be observed or manipulated as though it belonged to another.

The simplest implementation is also the most tempting:

if (event.scope == myScope) {
    handle(event)
}

It is wonderfully easy to write.

Unfortunately, it is equally easy not to write.

Whenever an invariant depends on every consumer remembering the same if, the invariant is not really part of the architecture yet.

The original implementation models this notion through partition-specific event types, including retained and collection variants. Publicly, however, the broader idea is more useful: some events carry an additional scope dimension, and that dimension belongs to the communication contract itself.

The type model can be thought of approximately like this:

This diagram is intentionally conceptual rather than a literal copy of every Kotlin declaration, because what matters publicly is the set of semantics the type hierarchy enforces: transience, retention, multiplicity, scope, and durability.

The recurring principle remains the same.

If a rule is important enough that every consumer must obey it, it probably should not remain a consumer responsibility.

The Bus Eventually Became a Security Boundary

Once a communication system becomes central enough, another assumption becomes difficult to defend:

Any component that can access the bus may do anything with any event.

In a small application that may be acceptable.

In a system composed of many independent components, it becomes an architectural liability.

Reading an event and modifying one are not equivalent capabilities.

Publishing a command and observing its result are not equivalent capabilities.

Deleting retained state is certainly not equivalent to reading it.

FlowBus consequently evolved into a secured communication layer in which participants are represented as actors and event access is governed by explicit permissions. The underlying model distinguishes operations such as publishing, reading, subscribing, and deleting.

An event may be public:

@Public
data class ServiceStatus(
    val available: Boolean
) : Event

or it may define restricted access:

@Protected(
    publish = [StateOwner::class],
    read = [StateOwner::class, StateObserver::class]
)
data class InternalState(
    val value: String
) : Event.Sticky()

The exact actors are application-specific.

The important design choice is not.

The permissions live with the event definition, while enforcement happens at the communication boundary.

The interaction looks roughly like this:

This provides a form of locality that becomes increasingly valuable as a codebase grows.

To understand an event's ownership model, a developer should not have to search for every publisher and every subscriber and infer security from usage.

The declaration should answer the question.

This turns the bus into something larger than a dispatcher.

A dispatcher asks:

Where should this message go?

A secured communication boundary must ask another question first:

Is this message allowed to go there at all?

That is a substantially different responsibility.

Validation Belongs at the Boundary

Security is not the only property that benefits from central enforcement.

Events may carry validation rules, while keyed events may impose constraints on their identifiers. The original FlowBus publication path validates the event before passing it into runtime communication and performs key validation where applicable.

The important question is not whether validation exists, but when it happens.

Consider the alternative.

An invalid event is published.

Three subscribers receive it.

One rejects it.

Another silently adjusts one property into an acceptable range.

The third stores it.

Now the system contains several interpretations of the same invalid state.

The original error occurred at publication time, but the visible failure may happen several layers later.

FlowBus instead validates the event before it enters normal communication.

The same principle applies to identity constraints.

The publication pipeline therefore behaves like an activity with several gates:

The order is deliberate.

An unauthorized value should never briefly enter runtime state before being rejected.

An invalid value should never be observed by subscribers.

A value rejected by the in-memory model should not somehow become acceptable merely because it can be serialized.

The rule is straightforward:

invalid state should fail as close as possible to the boundary through which it enters the system.

That reduces the number of impossible states every component downstream must be prepared to handle.

A Small API Can Hide a Large Contract

After all these capabilities are added, the public API still looks deceptively simple.

FlowBus.publish(event)

FlowBus.subscribe(EventType::class, scope) { event ->
    // react
}

val state = FlowBus.getSticky(StateEvent::class)

Collections and scoped state add more specific operations, and deletion follows the same conceptual model.

There is a temptation in API design to replace specialized operations with one universal function:

get(
    type,
    key = null,
    scope = null
)

The surface becomes smaller.

Unfortunately, the number of meaningless combinations becomes larger.

What does a key mean for a non-collection event?

What should happen when a scoped event is requested without a scope?

Should a caller be allowed to provide a scope for an event whose identity does not include one?

A smaller API is not necessarily a simpler API.

Sometimes several precise operations produce a more understandable system than one excessively flexible operation.

This is one reason the event hierarchy uses different retained-state abstractions and why the API exposes operations that correspond to those semantics instead of flattening every possibility into a bag of nullable parameters.

The goal is not merely to make valid operations possible.

It is to reduce the number of invalid operations the caller can conveniently express.

Reading State Is Also a Policy

The same principle applies to reads.

A call to retrieve retained state looks innocent, but by the time FlowBus matured, the call represented a sequence of policy decisions.

First, the caller must be authorized to read the event.

Then runtime state is preferred because it represents the most recent in-process value.

If the runtime copy does not exist and the event is persistable, durable storage is consulted.

If durable state is absent, default construction may be attempted, but only when the event explicitly permits it.

Otherwise, the absence remains visible as an error.

The API call hides this sequence because the caller should not need to reimplement it.

That is exactly the kind of complexity an architectural boundary should absorb.

Deletion Is a State Transition

Deletion introduced another small but revealing architectural decision.

When retained state disappears, should that operation remain invisible to the rest of an event-driven system?

Suppose a keyed entry is removed.

Or an entire retained collection is cleared.

The runtime store changes. Persistent storage may change as well.

Yet, unless something is emitted, subscribers that care about the state transition may know nothing about it.

FlowBus therefore treats deletion itself as observable behavior. When retained state is removed, a corresponding real-time event describes the deletion.

The interaction can be understood as a state transition rather than a storage command:

The larger principle is more interesting than the particular event type:

in an event-driven architecture, meaningful changes to shared state should not happen silently.

Creation is a state transition.

Update is a state transition.

Deletion is also a state transition.

The last one is simply easier to forget because what remains afterward is nothing.

Serialization Became Part of Observability

Once asynchronous communication becomes a primary mechanism of coordination, logs stop being merely diagnostic output.

They become one of the few ways to reconstruct how the system arrived at a particular state.

The event definitions therefore participate in serialization. Events use explicit serial names, and payload fields may also define concise serialized names, allowing event logs to remain stable and readable rather than exposing long implementation-specific Kotlin names.

This may appear secondary to communication and persistence, but in asynchronous systems observability is part of correctness.

When component A reacts to an event from component B, produces another event consumed by component C, and the final visible failure occurs several seconds later, a stack trace frequently describes only the end of the story.

An event trail describes the story itself.

The quality of that trail depends directly on the event model.

Good event names make behavior legible.

Clear payloads make transitions explainable.

Stable serialization makes historical logs useful even after implementation details evolve.

In that sense, the event model becomes not only a communication model but also a vocabulary for describing the runtime behavior of the system.

The Architecture Is Not SharedFlow

It would be easy to summarize FlowBus by saying that it is built using Kotlin Flows.

That statement is true and not particularly useful.

SharedFlow is a mechanism.

The architecture lies in the rules surrounding it.

A publication involves identity, authorization, validation, runtime delivery, optional retention, and optional persistence.

A read involves authorization, runtime lookup, persistent recovery, and controlled default construction.

A deletion may affect runtime and durable state and then itself become observable.

The coroutine primitives underneath these operations are implementation choices.

This distinction matters because sophisticated primitives are sometimes mistaken for sophisticated design.

Using SharedFlow does not decide ownership.

A Channel does not define state semantics.

A coroutine does not tell us which values should survive a restart.

Those decisions still need somewhere to live.

FlowBus is the place where those decisions meet.

What FlowBus Deliberately Does Not Know

Infrastructure becomes dangerous when success makes it hungry.

Once every component uses a central mechanism, almost every new behavior can be rationalized as something that mechanism could conveniently handle.

Workflow orchestration could go into the bus.

Retry policies could go into the bus.

Domain transitions could go into the bus.

UI behavior could go into the bus.

Eventually the communication layer would understand the entire application, at which point the application would effectively have one enormous hidden dependency.

FlowBus deliberately avoids that direction.

It understands the mechanics and policy of communication:

  • whether state is transient or retained;
  • how retained instances are identified;
  • whether state belongs to a logical scope;
  • whether the value is durable;
  • whether it may have a synthetic default;
  • whether it is valid;
  • whether the caller is authorized.

It does not understand the domain meaning of the event.

It does not decide what should happen because a particular state changed.

It does not own workflows merely because their messages happen to travel through it.

That boundary is critical.

The bus controls how information may move.

The rest of the system decides what that information means.

This is also what prevents FlowBus from becoming a god object. Centralizing policy is useful; centralizing business behavior is not.

Those ideas can sound similar in an architecture diagram, but they lead to very different systems.

The Architecture That Emerged

Once the implementation details are stripped away, FlowBus can be viewed as several boundaries layered around runtime communication.

There are substantially more pieces here than in the original:

publish(event)
subscribe<Event>()

Yet the caller still sees an intentionally constrained API.

That is the trade.

Complexity did not disappear.

It moved behind boundaries where it could be implemented, tested, generated, and enforced once instead of rediscovered throughout the application.

This is perhaps the most important difference between a utility and infrastructure.

A utility saves typing.

Infrastructure preserves behavior.

The Difficult Part Was Never Any Individual Feature

Sticky values are not a new idea.

Keyed state is not a new idea.

Persistence is not a new idea.

Code generation is not a new idea.

Permissions are not a new idea.

Neither are validation rules, logical scopes, structured event logs, nor Kotlin Flows.

The difficult part begins when all of those concerns must coexist without contradicting one another.

A keyed event has both state semantics and identity.

A durable keyed event has state semantics, identity, and lifecycle beyond the process.

A scoped durable keyed event adds another dimension of isolation.

A protected scoped durable keyed event must preserve every previous guarantee while adding ownership and authorization.

Default creation must respect the same identity model.

Deletion must remove the correct runtime and durable representations.

Serialization must preserve enough information for diagnostics.

And after all of that, the public API should still make ordinary operations unsurprising.

Architecture tends to become difficult at intersections.

Individual mechanisms are often simple.

The hard work is preventing the solution to one problem from quietly breaking the assumptions of another.

That is also why the final architecture cannot be judged merely by counting its interfaces or looking at the size of its implementation.

The value is in the constraints that continue to hold when the features are combined.

The Cost of Making Rules Explicit

There is, of course, a price.

FlowBus introduces more concepts than a generic event dispatcher.

There are event categories.

There are annotations.

There are generated implementations.

There is a permission model.

There are different APIs for different retained-state semantics.

There are explicit failure modes where a simpler implementation might simply return null.

For a small application, much of this would be unnecessary.

That point matters.

Architecture is not automatically better because it is more sophisticated. A solution that is correct for a system with hundreds of interacting event types may be absurd for an application with six screens and three flows.

The justification lies in repetition and risk.

When a rule is applied in one place, a convention may be enough.

When a rule is applied in a hundred places, convention becomes an unreliable distribution mechanism.

At that scale, explicitness starts paying for itself.

A type that prevents misuse is cheaper than a wiki page explaining how not to misuse something.

A compile-time failure is cheaper than a field failure.

A generated mapper is cheaper than fifty almost-identical handwritten mappers.

A central permission check is cheaper than discovering that one forgotten code path bypassed the ownership model.

The additional architecture earns its existence only when those costs become real.

What I Took Away From Building It

The most important lesson from FlowBus was not about event buses.

It was about where rules belong.

A rule may begin as a comment.

Then it becomes a convention.

Then the convention is copied into several components.

Eventually somebody forgets it.

If violating that rule can make the system inconsistent, insecure, or difficult to recover, then the problem is no longer that the developer forgot the convention.

The problem is that the architecture allowed the convention to remain optional.

That principle repeatedly shaped FlowBus.

State semantics moved into event types.

Identity moved into event types.

Scope became part of the communication contract.

Persistence became declarative.

Default creation became explicit.

Security became centralized.

Validation moved to the boundary.

Repetitive infrastructure moved to compile time.

Each change removed one more rule from human memory and placed it somewhere the system could enforce.

That, in the end, is what I consider the real purpose of architecture.

Architecture is not primarily about drawing boxes.

It is not about maximizing the number of abstractions.

And it is certainly not about turning simple code into a framework before the problem demands one.

Architecture exists to preserve important decisions as the number of people, components, features, and years increases.

FlowBus began as a way to move events.

It became valuable when moving events stopped being the difficult part.

The difficult part was protecting all the rules around them.

Stack

KotlinFlowsKSPThreadsAndroid SDK