1. Introduction
Today we begin looking at programs as a system where “something happened” matters more than “someone called someone”. We will figure out how to replace direct calls between parts of the code with event publication, how the roles of event source and listeners emerge, and why a pre-agreed event contract saves a project from chaos. Then, in the next lectures, we will gradually make events convenient, safe, and suitable for the coroutine world.
When code starts to “stick”
If you are writing a learning console program in 50 lines, it may seem that architecture was invented for boring people in ties. But there is bad news: as soon as you add a second or third “side effect” (logging, statistics, saving to a file, notification), the code starts behaving like chewing gum under a desk: it sort of holds together, but it is unpleasant to touch.
Let’s imagine our practical project: a conditional console expense tracker BudgetBuddy. We have an add command that adds an expense. The naive approach: “if we added it, then we immediately log it, immediately recalculate the report, immediately save it, immediately print a message”.
data class Expense(val id: Int, val title: String, val amount: Int)
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
println("Added: ${e.title} (${e.amount})") // Added: Coffee (250)
println("Audit: expense added id=${e.id}") // Audit: expense added id=1
println("Stats: need to recalc totals") // Stats: need to recalc totals
}
The code works. And it even seems cute. And then you add “one more small reaction” — for example, auto-saving to a file — and the addExpense function turns into a combine harvester.
The problem is not that there are many lines. The problem is that addExpense starts knowing too much: what audit is, what statistics are, when to save, in what format to log. It is no longer “add an expense”; it is “add an expense and do a hundred more things because life is pain”.
An event is a fact, not a command
When the word event/“event” appears in code, beginners often perceive it as “some kind of magic flying through the air”. In reality everything is simpler and more down-to-earth: an event is a message about a fact, not an order to a specific module. The difference is subtle, but it changes the architecture radically.
Compare two wordings:
- “Send an email to the user” is a command: you have already chosen who and what they must do.
- “The user has registered” is a fact; then different parts of the system can react in their own way.
In our BudgetBuddy, the “command” would look like this: sendEmailToAccountant(expense) (yes, the accountant is a person too). And the “fact” will sound like: expenseAdded(expense).
That is loose coupling: the part of the code that adds an expense does not have to know that somewhere there is audit, reports, or auto-saving. It reports the fact: “expense added”. And then whoever heard it is the winner.
2. The “one → many” model: source and listeners
In an event model there is almost always the same picture: there is an event source (publisher/emitter) that publishes a fact, and there are listeners (listeners/observers) that react to that fact. And there can be one, two, or twenty listeners — and the source should not start nervously hiccupping because of that.
Minimal implementation using a collection of handlers
To feel the idea with your hands, you do not need any libraries or frameworks. A collection of handlers is enough. Kotlin collections exist for exactly this: store a set of elements and allow adding/removing them. MutableCollection has operations for changing the contents (for example, add/remove), and that is the foundation for a “subscriber list”.
The minimal “one → many” model can look like this:
data class Expense(val id: Int, val title: String, val amount: Int)
val expenseAddedListeners = mutableListOf<(Expense) -> Unit>()
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
for (listener in expenseAddedListeners) listener(e)
}
This is not yet an “ideal” event implementation (we are not discussing safety, unsubscription, or handler errors yet). But as a learning picture it is great: we add an expense, iterate over listeners, and each one performs its own reaction.
Important: we intentionally use mutableListOf because the subscriber list changes: someone subscribes, someone disconnects. Kotlin is fine with storing a mutable collection in a val: the reference does not change, but the contents do. It is like “the box is always the same, but the candies inside appear and disappear”.
Attach reactions separately
Let’s spell out what exactly becomes better when we stop “tugging neighbours by the sleeve” and start publishing events.
Imagine we have three reactions to adding an expense: printing to the console, audit, and statistics recalculation. In the direct approach, addExpense must know about all three. In the event-driven approach, addExpense only knows about the fact of addition, and the reactions live separately.
fun printOnAdded(e: Expense) {
println("Added: ${e.title} (${e.amount})") // Added: Coffee (250)
}
fun auditOnAdded(e: Expense) {
println("Audit: expense id=${e.id}") // Audit: expense id=1
}
fun statsOnAdded(e: Expense) {
println("Stats: recalc after ${e.title}") // Stats: recalc after Coffee
}
Connecting listeners looks like this:
fun main() {
expenseAddedListeners += ::printOnAdded
expenseAddedListeners += ::auditOnAdded
expenseAddedListeners += ::statsOnAdded
}
And here a nice thing appears: to add a new reaction, we do not need to get into addExpense. We add a new listener function and subscribe it. The program grows, and the “center” of the system does not turn into a monster that knows everything about everything.
3. The event contract and the rules of the game
When people first hear about events, they often get the feeling: “Oh, cool, now we can just emit anything, anywhere, and everything will work by itself”. This is a dangerous illusion. Events work well only when they have a contract — that is, a set of pre-agreed rules everyone plays by.
A contract is not necessarily a formal 20-page document. In a learning project it can simply be a clear agreement in your head and in the code. But the questions must be decided in advance, because otherwise different parts of the program will start expecting different behavior.
What is included in the contract
Below is a table of “what to agree on”, without diving into implementation (we will implement it in the next lectures).
| Contract part | Question | Why it matters |
|---|---|---|
| Event data (payload) | What do we pass to listeners: a string, a number, an object? | If you pass around “magic strings”, it is easy to make mistakes. A separate data type makes the meaning explicit. |
| Synchrony | Are listeners called “right now” or “sometime later”? | If it is synchronous, a listener can slow down the main operation. If it is asynchronous, questions of ordering and lifecycle appear. |
| Listener errors | If one listener crashes, should the others still run? | Without an agreement you will get random failures: “sometimes it works, sometimes it doesn’t”. |
| Call order | Is there a guaranteed listener order? | If order matters, you are effectively building a pipeline, and that is a different style. |
| Subscription management | Can you subscribe/unsubscribe while the program is running? | Otherwise you risk “eternal listeners” that get called when they are no longer needed. |
| Behavior when there are no listeners | What does the event do if nobody is subscribed? | It is fine if it is “nothing”. That makes the code predictable. |
Notice: we are not yet saying “how exactly” to implement unsubscription or safe dispatch. We are only locking in that this is part of the contract, and without it an event system quickly turns into mysticism, where everything works only on Fridays.
How to name events
A funny thing happens with the name of an event: it affects the team’s thinking more than it seems. If you name the event sendEmail, you have already baked into the architecture that “event = email”. This breaks the idea of loose coupling, because the source seems to hint: “I expect you to send an email”.
It is better to name an event as a fact, usually in the past tense or in a “what happened” form:
- expenseAdded / ExpenseAdded
- expenseRemoved
- reportGenerated
And it helps to think like this: “An event must be true.” If an event is called expenseAdded, then at the moment of publication the expense has indeed already been added to the list/storage. If you publish the event before changing the state, listeners may see the “world before the changes” and start making decisions based on stale data. This is not a bug; it is a classic design trap.
A mini-example of the correct order: first we change the state, then we publish the fact.
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
for (listener in expenseAddedListeners) listener(e)
}
Extract the fact point in code
Now we will take a very important step that looks almost ridiculously simple: we will separate “adding an expense” from “all reactions”. Not via a full-fledged Event<T> (that will come later), but via an explicit “event point” function. This is a useful intermediate style: you discipline the code even if you do not yet have subscription infrastructure.
fun emitExpenseAdded(e: Expense) {
println("Event: expenseAdded(id=${e.id})") // Event: expenseAdded(id=1)
}
fun addExpense(expenses: MutableList<Expense>, e: Expense) {
expenses += e
emitExpenseAdded(e)
}
Why do this at all if “nobody is listening” yet? Because you fix the architectural meaning: the fact happened here. Later, when listeners appear, you will replace the body of emitExpenseAdded with dispatch to subscribers, and you will not have to plow through the rest of the code.
And that is exactly the moment when the system starts to “unclench”: addExpense stops being the place where you add “one more reaction”. It becomes the place where a domain action happens (expense added) and a fact is published.
4. Trade-offs: the price of flexibility and where events are not needed
Events are not free. They provide flexibility, but they take away some simplicity. And if you do not say this out loud, at some point you will start suspecting that “events are evil” because everything became non-obvious. In reality, you just did not pay the bill: you did not define the contract and you did not discipline usage.
The main cost is that execution becomes less linear. With a direct call, you read the code top to bottom and roughly understand: “first we added, then recalculated, then saved”. With events, you read: “we added, and then… somewhere, someone reacted”. This is normal, but it requires a habit: now behavior is determined not by one place, but by a set of subscriptions.
The second cost is managing the lifecycle of listeners. If subscriptions live longer than needed, you get unexpected repeated reactions. If a listener fails with an exception, you must understand what happens to the others. If a listener subscribes/unsubscribes during dispatch, you need to decide in advance how the system should behave. We are not solving these tasks yet, but it is important to see them as real engineering questions, not as “we’ll deal with it later somehow”.
And one more limitation that is useful to keep in mind: events are great for extensible “one → many” reactions, where listeners can appear and disappear. But if you need a simple result in the style of “compute and return”, events will only get in the way.
5. Typical mistakes
Mistake #1: confusing an event with a command.
When an event is called sendEmail, the architecture starts “asking” the event source to know what exactly must happen. This breaks loose coupling: the source starts depending on specific actions again. If you want events, name them as facts (userRegistered, expenseAdded) and keep in mind the idea “an event must be true”.
Mistake #2: creating a “central function” that still knows about all reactions.
Sometimes a developer says, “Ok, we’ll have an event,” but then writes emitExpenseAdded that manually calls audit(), recalcStats(), saveToFile() inside. This is just moving the same coupling to another place. The event model is useful precisely when reactions are connected from the outside and the source does not know who they are.
Mistake #3: not agreeing on the error-handling contract.
If one handler throws an exception and you did not account for it, the result will be “random”: sometimes everything ran, sometimes the program crashed, sometimes some reactions did not happen. Even in a learning project, it is worth deciding in advance: “one fails — we still call the others” or “the error is critical — we interrupt everything”. In the next lectures we will make dispatch resilient, but the idea should appear already now.
Mistake #4: building logic where listener order is critical, but it is not fixed anywhere.
If you silently expect “first statistics, then saving”, and someone changes the subscription order, you get a bug that is impossible to understand by looking at the addExpense code. If order is really important, it must become part of the contract (and then it is closer to a conveyor/pipeline than to “free reactions”).
Mistake #5: trying to replace absolutely all function calls with events.
Events are great for extensible “one → many” reactions, where listeners can appear and disappear. But if you need a simple result “compute and return”, events will only get in the way. Good architecture is not “events everywhere”; it is “events where they really reduce coupling and make extension cheaper”.
GO TO FULL VERSION