JavaRush /Courses /Kotlin SELF /Minimal implementation of Event<T>: listeners, subscrib...

Minimal implementation of Event<T>: listeners, subscribe, and emit

Kotlin SELF
Level 57, Lesson 1
Available

1. Introduction

If you are just getting started, the idea of “let’s make an event instead of a direct call” can feel like overengineering. And sometimes that’s true: if you have one handler and it’s always the same one, it’s simpler to call it directly. But as soon as you get several independent reactions to a single action, direct calls turn your code into a combine harvester: you add a println, then “one more println”, then “one more function”, and suddenly you have a 200-line method that both “does the work” and “reacts to the work”.

An event helps separate roles. The source says: “I added an expense.” And the listeners decide what to do: log it, update the UI, compute stats, send a notification. The source does not have to know who those listeners are. Our goal today is to implement the simplest mechanism: “the source stores a list of functions and calls them”.

Who is a listener in Kotlin: function type (T) -> Unit

Before writing Event<T>, you need a very down-to-earth understanding of what exactly we will store in the list. We will store functions. Yes—just like numbers, strings, and lists, except functions.

Kotlin has function types for this, for example (Int) -> String or (ExpenseAdded) -> Unit. This type reads almost like a regular function signature: “takes T, returns Unit”. Kotlin uses this notation directly for variables, parameters, and collections of functions.

Here is the smallest example of a “listener” without events—just a function stored in a variable:

fun main() {
    val listener: (String) -> Unit = { s ->
        println("Got message: $s")
    }

    listener("Hello!") // Got message: Hello!
}

Notice two things. First, the lambda has a parameter s, and it’s a normal variable. Second, the return value is Unit, meaning “we don’t return anything useful; we just perform an action”.

This is the perfect shape for an event handler: the event “passes data”, the handler “does something”.

2. Minimal contract for Event<T>

When we say “let’s make a class Event<T>”, it’s important not to fall into the state of “now we’ll build our own Spring inside a console project”. Today we do the minimum—but in a way that the minimum is clean and extendable (when the time comes).

Let’s describe the contract in plain language. Our Event<T> should:

  1. store a list of listeners (functions of type (T) -> Unit);
  2. allow subscription: subscribe(listener);
  3. allow publishing an event: emit(value)—and call all listeners one by one;
  4. be type-safe: if the event is Event<ExpenseAdded>, then the listener must accept ExpenseAdded, not “anything”.

And one more important point: the listener list must be hidden inside. From the outside, there should be no way to do “oops, I accidentally cleared listeners”. So we make the list private.

4. Implementing Event<T>: a MutableList of listeners

Now let’s write the class itself. We will create a private field listeners and store lambdas there.

This clearly shows why we needed collections and function types earlier: listeners is literally a MutableList<(T) -> Unit>.

class Event<T> {
    private val listeners = mutableListOf<(T) -> Unit>()

    fun subscribe(listener: (T) -> Unit) {
        listeners += listener
    }

    fun emit(value: T) {
        for (l in listeners) l(value)
    }
}

A lot of meaning is packed into these three lines:

In listeners += listener, the += operator is used; for a MutableList it adds an element in place (this is plusAssign). It’s simply shorter than listeners.add(listener).

In emit we iterate over the list and call each handler: l(value). It looks like a “function call” because l is a variable of a function type, and you can invoke it like a regular function.

If there are no subscribers, the loop simply won’t run. That’s a normal contract: the event “silently passes”.

5. Event data: why the type T matters

The type parameter T is what makes our Event truly convenient. It pins down what data the event carries.

You could make a “universal” event over Any and pass whatever you want, but then you would immediately lose half of Kotlin’s value: the compiler would stop protecting you from mistakes.

In Kotlin, the idea that “a function type includes the parameter type” is fundamental. If you have val f: (Int) -> Unit, you can’t accidentally pass a string to it. Same here: if the event is Event<ExpenseAdded>, the listener must accept ExpenseAdded. That is “type safety”.

By the way, in the Kotlin documentation this is described as “function types”: the notation (A, B) -> C corresponds to functions that take arguments of types A, B and return C.

6. Example: the “expense added” event

So the example isn’t in a vacuum, let’s continue our educational console app (let’s call it BudgetBook): we add expenses and want to react to the fact that an expense was added.

Let’s create a simple payload type. In a real project this could be your data class Expense(...), but to keep the example compact we’ll take a separate “event wrapper”.

data class ExpenseAdded(val title: String, val amount: Int)

Now let’s make a service that adds an expense (for now just prints) and emits the event:

class ExpenseService {
    val expenseAdded = Event<ExpenseAdded>()

    fun addExpense(title: String, amount: Int) {
        println("Added expense: $title ($amount)")
        expenseAdded.emit(ExpenseAdded(title, amount))
    }
}

Notice a subtle architecture point. ExpenseService does not do “logging”, “UI”, or “statistics”. It does its job and reports the fact: “expense added”. Everything else is the subscribers’ responsibility.

7. Subscribers: many reactions to one event

Now let’s show that an event is a “1 → many” model. One source, many listeners, and they don’t have to know about each other.

fun main() {
    val service = ExpenseService()

    service.expenseAdded.subscribe { e -> println("Audit: ${e.title}") }
    service.expenseAdded.subscribe { e -> println("UI: +${e.amount}") }

    service.addExpense("Coffee", 5)
    // Added expense: Coffee (5)
    // Audit: Coffee
    // UI: +5
}

The nice thing here is that addExpense doesn’t change when we add new reactions. Want a third reaction? Just add one more subscribe.

Another useful trick is to move the lambda into a variable so it gets a name. This makes the code feel less “magical”:

fun main() {
    val service = ExpenseService()

    val statsListener: (ExpenseAdded) -> Unit = { e ->
        println("Stats: amount=${e.amount}")
    }

    service.expenseAdded.subscribe(statsListener)
    service.addExpense("Sandwich", 7)
    // Added expense: Sandwich (7)
    // Stats: amount=7
}

8. Limitations of the minimal version and the interaction diagram

It’s important to be honest: this version of Event<T> is minimal. It solves the main task: “store a list of handlers and call them”. But it includes a few deliberate simplifications.

First, we don’t address “how to unsubscribe”. For now, the subscription is “forever” (for the lifetime of the Event object). This is not a bug; it’s simply an unclosed topic: we intentionally don’t complicate the interface today so you can feel the basic mechanics.

Second, we don’t implement any protection against a handler throwing an exception. Right now, if one listener crashes, execution of emit will stop there, and the remaining listeners may not run. In some scenarios this is even desired (the error is critical), but more often you want one failing handler not to break the entire dispatch. However, that’s already a “dispatch reliability” topic, and we won’t implement it today so we don’t mix two topics into one mess.

Third, we don’t discuss “dangerous” cases where a handler modifies the listener list during dispatch. That’s a separate engineering story: it’s important, but it’s too early for a minimal event.

Our goal today is for you to clearly understand: Event<T> is just a tiny object with a MutableList of functions and two methods.

A small diagram: who talks to whom

Sometimes it’s more useful to see the architecture than to read another paragraph. In minimal form it looks like this:

flowchart LR
    S["ExpenseService
(source of the fact)"] -->|"emit(ExpenseAdded)"| E["Event⟨ExpenseAdded⟩"] E --> L1["Audit listener
(log/audit)"] E --> L2["UI listener
(message to the user)"] E --> L3["Stats listener
(statistics)"]

And note: there are no “listener → service” arrows here. The service doesn’t have to know that subscribers exist at all. That’s exactly what gives you loose coupling: the source reports the fact, and reactions are plugged in from the outside.

9. Common mistakes

Mistake #1: making the listener list public “for convenience”.
Almost always this ends with someone outside doing event.listeners.clear() or adding something in a way you didn’t expect. As a result, the event starts behaving unpredictably, and you catch bugs like “why do my handlers sometimes not get called”. The list should be private, and only the subscribe and emit methods should be exposed.

Mistake #2: storing listeners as Any (or () -> Unit for all cases).
It seems like “more universal” means “better”. In practice you lose type safety and start dragging in casts, type checks, and other pain from the world of dynamic languages. Kotlin’s strength is exactly that (T) -> Unit locks down the handler contract, and Event<T> locks down the event contract. Function types are a normal and supported language mechanism.

Mistake #3: confusing an “event” with a “command”.
If you name an event sendEmailEvent, you subconsciously turn it into a command “send an email”. That makes the architecture worse: the source starts thinking about a specific reaction. It’s better to name an event as a fact: userSaved, expenseAdded, reportGenerated. Then any handler can decide what to do with that fact: email, log, statistics—anything.

Mistake #4: expecting emit to “return something” you can rely on.
In our model, listeners return Unit, and emit also returns nothing. This is intentional design: an event is broadcasting a fact, not computing a result. If you need to collect results from multiple handlers and aggregate them, that’s a different contract and a different task. If you try to “stuff” that into Event<T> immediately, you’ll end up with an awkward monster.

Mistake #5: adding extra business logic into Event.
Sometimes you want Event.emit itself to do println("Event happened!"). It feels convenient, but it quickly turns Event into a place that “knows too much”. Event should be infrastructure: store subscribers and invoke them. If you need logging—subscribe a separate listener (and that’s actually a great demonstration that events work).

1
Task
Kotlin SELF, level 57, lesson 1
Locked
First listener
First listener
1
Task
Kotlin SELF, level 57, lesson 1
Locked
Message event
Message event
1
Task
Kotlin SELF, level 57, lesson 1
Locked
Loading progress
Loading progress
1
Task
Kotlin SELF, level 57, lesson 1
Locked
Adding an expense
Adding an expense
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION