CodeGym /행동 /Kotlin SELF /Event-driven thinking — loose coupling and the event cont...

Event-driven thinking — loose coupling and the event contract

Kotlin SELF
레벨 57, 레슨 0
사용 가능

1. Introduction

Today we start 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 code with event publication, how the roles of event source and listeners appear, 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 write a small educational 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 the second or third “side effect” (logging, statistics, saving to a file, notifications), the code starts behaving like chewing gum under a desk: it kind of holds, but touching it is unpleasant.

Let’s imagine our practical project — a conditional console expense tracker BudgetBuddy. We have an add command that adds an expense. The naive option: “if we added it — then we log immediately, recalculate the report immediately, save immediately, print a message immediately”.

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. It even looks kind of 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 which 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 it 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 phrasings:

  • “Send an email to the user” — this is a command; you have already chosen who and what must be done.
  • “The user is registered” — this 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-save. It reports a fact: “expense added”. And then — whoever heard it, good for them.

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 this fact. Moreover, there can be one listener, two, or twenty — and the source should not start nervously hiccuping because of it.

Minimal implementation with 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. Collections in Kotlin exist exactly for this: to store a set of elements and allow adding/removing them. MutableCollection has operations that change contents (for example, add/remove), and that is the foundation for a “subscriber list”.

A minimal “one → many” model may 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, unsubscribe, handler errors yet). But as a learning picture — it is great: we added an expense, iterated through listeners, each performed its reaction.

Important: we deliberately use mutableListOf, because the subscriber list changes: someone subscribed, someone disconnected. 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 say out loud 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 recalculating statistics. With the direct approach, addExpense must know about all three. With the event approach, addExpense knows only about the fact of addition, while 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 pleasant thing appears: to add a new reaction, we do not need to touch addExpense. We add a new listener function and subscribe it. The program grows, but the “centre” 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 by which everyone plays.

A contract is not necessarily a formal 20-page document. In an educational project, it can be just a clear agreement in your head and in the code. But you need to decide the questions in advance, because otherwise different parts of the program will start expecting different behaviour.

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 “magic strings”, it is easy to make a mistake. 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 about ordering and lifecycle appear.
Listener errors If one listener crashes, should the others still run? Without an agreement, you get random crashes: “sometimes it works, sometimes it doesn’t”.
Call order Is there a guaranteed order of listeners? If the order matters, you are effectively building a conveyor, and that is a different style.
Subscription management Can you subscribe/unsubscribe during runtime? Otherwise you risk “eternal listeners” that get called when they should no longer be called.
Behaviour when there are no listeners What does the event do if no one is subscribed? It is fine if it does “nothing”. This makes the code predictable.

Notice: we are not yet talking about “how exactly” to implement unsubscribe or safe broadcasting. We only fix the point: 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 an event name: it affects the team’s thinking more than it seems. If you name an 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 the form “what happened”:

  • expenseAdded / ExpenseAdded
  • expenseRemoved
  • reportGenerated

And it is useful 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 the state change, listeners may see the “world before 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 change state, then publish the fact.

fun addExpense(expenses: MutableList<Expense>, e: Expense) {
    expenses += e
    for (listener in expenseAddedListeners) listener(e)
}

Mark the “fact point” in code

Now we will take a very important step that looks almost ridiculously simple: we separate “adding an expense” from “all reactions”. Not via a full-fledged Event<T> (that will be later), but via an explicit function — an “event point”. This is a useful intermediate style: you already discipline the code even if you do not have subscription infrastructure yet.

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 “no one is listening” yet? Because you fix the architectural meaning: a fact happened here. Later, when listeners appear, you will replace the body of emitExpenseAdded with broadcasting to subscribers, and the rest of the code will not need to be ploughed up.

And that is exactly the moment when the system starts to “unclench”: addExpense stops being the place where people add “one more reaction”. It becomes the place where a domain action happens (an expense was 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 take away some simplicity. And if you do not say it out loud, at some point you may 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 did not discipline the usage.

The main cost is that execution becomes less linear. With a direct call, you read the code top-down 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 habit: now the behaviour is defined not by a single 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 broadcasting, 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 somehow do it later”.

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 like “calculate and return”, events will only get in the way.

5. Common mistakes

Mistake 1: confusing an event with a command.
When an event is called sendEmail, the architecture starts to “ask” the event source to know what exactly should happen. This breaks loose coupling: the source again starts to depend on specific actions. If you want events — name them as facts (userRegistered, expenseAdded) and keep in mind the thought “an event must be true”.

Mistake 2: making a “central function” that still knows about all reactions.
Sometimes a developer says: “Ok, we’ll have an event”, but then writes emitExpenseAdded, inside which they manually call audit(), recalcStats(), saveToFile(). This is just moving the same coupling to another place. The event model is useful exactly when reactions are plugged in from the outside, and the source does not know who they are.

Mistake 3: not agreeing on the error behaviour 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 an educational project, it is worth deciding in advance: “one failed — we still call the rest” or “the error is critical — we stop everything”. In the next lectures we will make broadcasting resilient, but the idea must appear already now.

Mistake 4: building logic where listener order is critical, but it is not fixed anywhere.
If you silently expect that “statistics first, then saving”, and someone changed the subscription order — you get a bug that is impossible to understand by looking at the code of addExpense. If the 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 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 “calculate and return”, events will only get in the way. Good architecture is not “events everywhere”, but “events where they really reduce coupling and make extension cheaper”.

1
과제
Kotlin SELF, 레벨 57, 레슨 0
잠금
등록 사실
등록 사실
1
과제
Kotlin SELF, 레벨 57, 레슨 0
잠금
레벨 변경
레벨 변경
1
과제
Kotlin SELF, 레벨 57, 레슨 0
잠금
주문 생성
주문 생성
1
과제
Kotlin SELF, 레벨 57, 레슨 0
잠금
장바구니 이벤트
장바구니 이벤트
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION