JavaRush/Courses/Kotlin SELF/Separation of responsibilities

Separation of responsibilities

Open

1. Introduction

Almost everyone starts the same way: they take main, put readln() inside it, a couple of ifs, a list of expenses, a couple of commands (add, list, remove)—and enjoy life. And that is a normal stage: you are learning, you want to see results quickly, and println() gives a nice dopamine hit. But then the typical story happens: there are more commands, more validations, output formatting gets “tweaked a bit”—and main turns into a “spaceship command centre” where you have steering, the engine, the kitchen, and the cat all at once.

Let’s sketch the typical “everything in one place” approach (this is not “a bad person wrote it”, it’s “this is how we grow”):

fun main() {
    val expenses = mutableListOf<String>()

    while (true) {
        print("> ")
        val line = readln().trim()
        if (line == "exit") break

        if (line.startsWith("add ")) {
            val title = line.removePrefix("add ").trim()
            expenses.add(title)
            println("Added: $title") // Added: Coffee
        } else if (line == "list") {
            println(expenses.joinToString())
        } else {
            println("Unknown command")
        }
    }
}

At first it seems like: “yeah, OK, readable”. And then you want, for example, an expense to have an amount, the amount to be positive, whitespace in the title to be cleaned consistently, output to look nicer, and to be able to calculate totals. And suddenly it turns out that:

  • input is mixed with rules (validation/normalization),
  • rules are mixed with storage (we directly poke into MutableList),
  • output is mixed with everything else,
  • and changing any small thing feels scary: what if we break the add command, and with it list, because we “reused a variable just a little bit”.

The main idea: when everything is in main, each new feature adds chaos not linearly, but exponentially.

2. Three roles: CLI, domain, storage

Now we will introduce three concepts. They sound “grown-up”, but they mean a very simple thing: who is responsible for what. Imagine a mini cafe:

  • one person takes the customer’s order,
  • another cooks,
  • a third keeps inventory/accounting for ingredients.

If one person does everything at once, then they are either a hero, or they will run out of health soon (more likely the latter). In software it is about the same: we need “distributed work”.

The roles in our console project are:

Role What it does What it must not do
CLI (Command Line Interface) Reads a line, understands the command, prints the response Does not store business rules, does not decide what is “right/wrong” in essence
Domain (business logic) Knows the rules: what an expense is, what validations are needed, how to calculate totals Does not call readln() and does not do println()
Storage (persistence) Stores and returns data (in v1—just in memory) Does not parse commands and does not decide business rules

It helps to keep a simple data-flow diagram in mind:

flowchart LR
    U[User] --> CLI[CLI: read/parsed]
    CLI --> D[Domain: applied rules]
    D --> S[Storage: saved/returned data]
    D --> CLI
    CLI --> U

And the key rule of the day: domain code must be callable without the console. That is, in theory, tomorrow we could make not a console app but, for example, a GUI—and the domain would barely change. (We will not build a GUI, but the idea is useful.)

3. Practical example: expense tracking

Domain model: “clean data”

Before we split responsibilities, we need to agree on what we store at all. Previously we could store an expense as a string, or as Pair<String, Int>, or as “well, a string with ; separators”. Today we will take a step toward a proper model: an expense is an object with clear fields.

Important: a domain model is not “logic”, it is “domain data”. It should be as simple and “clean” as possible: no input reading, no printing, no formatting.

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

There is a small but fundamental point here: when you see Expense(title, amount), you should not have to remember “what was in .first and what was in .second”. You read it like a human.

If you feel like adding category, createdAt, and all that—hold off. Today we are practicing separation of responsibilities, not building “perfect accounting”.

Storage: in-memory repository

When people hear “storage”, they often imagine a database, migrations, transactions, and mild panic. Today it is simpler: the storage layer in version v1 is a class that hides a list inside and provides methods to “add” and “get all”.

Why do we need a separate class if we could just have mutableListOf() in main? Because we want “how we store things” to be an isolated detail, not smeared across the whole program.

Notice an important detail: externally we return List<Expense>, not MutableList<Expense>. This habit saves your nerves: whoever receives a List cannot “quietly” delete an item and leave you with a mysterious bug. In Kotlin this directly matches the idea of read-only collection interfaces.

class ExpenseStorage {
    private val items = mutableListOf<Expense>()

    fun add(expense: Expense) {
        items.add(expense)
    }

    fun all(): List<Expense> = items
}

Notice: there is no readln() and no println() here. Storage is silent, like a good fridge: it does not comment on your decisions.

Domain: a service with rules, but without printing

The domain layer is where the “rules of the game” live. If CLI is “how to talk to the user”, then domain is “what counts as a valid expense”. This is exactly where require, validations, and string normalization belong.

Now we will create ExpenseService, which takes already-prepared values (title, amount), brings them into a normal form, and saves via storage.

class ExpenseService(
    private val storage: ExpenseStorage
) {
    fun addExpense(title: String, amount: Int) {
        val normalizedTitle = title.trim()
        require(normalizedTitle.isNotEmpty()) { "Title must not be empty" }
        require(amount > 0) { "Amount must be > 0" }

        storage.add(Expense(normalizedTitle, amount))
    }

    fun listExpenses(): List<Expense> = storage.all()
}

There are two important ideas here.

First: the domain accepts already prepared values, not a “raw command string”. That is, ExpenseService should not parse "add coffee 200". That is CLI’s job.

Second: the domain does not print “Added!” and does not ask the user “enter the amount”. It either performs the operation or reports an error via an exception (that is how we do it today; later we will make more “controlled” results, but that is the next part of the day).

CLI: read a line, parse a command, call the domain

The CLI layer is the “translator” between a human and the program. A human writes lines; the domain wants proper parameters. So the CLI must do three steps: read, parse, call.

readln() and println() live here—and that is normal and expected.

Let’s make a small command model and the simplest parser. Notice: the code is small and straightforward, without magic—we care about readability.

data class ParsedCommand(
    val name: String,
    val args: String
)

fun parseCommand(line: String): ParsedCommand {
    val trimmed = line.trim()
    val space = trimmed.indexOf(' ')
    return if (space == -1) {
        ParsedCommand(name = trimmed.lowercase(), args = "")
    } else {
        val name = trimmed.substring(0, space).lowercase()
        val args = trimmed.substring(space + 1).trim()
        ParsedCommand(name = name, args = args)
    }
}

Now the CLI logic: we read a line, parse it, do a when by command name, and then call the service.

For now we will implement two commands:

  • add <title>;<amount>
  • list
  • exit

Why use ;? Because the title can contain spaces, and we need a simple way to separate the amount. This is not an “ideal format”, it is just “convenient for a learning example”.

fun main() {
    val storage = ExpenseStorage()
    val service = ExpenseService(storage)

    while (true) {
        print("> ")
        val cmd = parseCommand(readln())

        if (cmd.name == "exit") break

        when (cmd.name) {
            "add" -> handleAdd(cmd.args, service)
            "list" -> handleList(service)
            else -> println("Unknown command: ${cmd.name}")
        }
    }
}

Here main already starts to look like a script: “read → parse → pass along”. And that is a very good sign.

All that remains is to implement handleAdd and handleList. Note: this is still the CLI layer, so printing here is fine.

fun handleAdd(args: String, service: ExpenseService) {
    val parts = args.split(';', limit = 2)
    val title = parts.getOrNull(0)?.trim().orEmpty()
    val amount = parts.getOrNull(1)?.trim()?.toIntOrNull() ?: 0

    try {
        service.addExpense(title, amount)
        println("OK: added") // OK: added
    } catch (e: IllegalArgumentException) {
        println("ERROR: ${e.message}") // ERROR: Amount must be > 0
    }
}

We intentionally catch IllegalArgumentException, because require(...) throws exactly that. This is “soft” handling: the program does not crash, and instead explains to the user what is wrong.

handleList:

fun handleList(service: ExpenseService) {
    val items = service.listExpenses()
    if (items.isEmpty()) {
        println("(empty)") // (empty)
        return
    }

    for ((index, e) in items.withIndex()) {
        println("${index + 1}. ${e.title} — ${e.amount}")
    }
}

Formatting the list is a CLI task. The domain should return data, and the CLI decides how to display it nicely.

Where the boundary between layers lies

When you start splitting code into roles, a subtle problem appears: “can I violate it just a little here—it’s only one println()?” You can. Right up until you have ten cases of “just one println”.

To keep the boundary from creeping, it helps to remember a few “anti-signals”.

  • If you see readln() inside a service, it is almost always a design mistake. readln() is about the console, and the console is CLI. The domain must be able to work without a person sitting next to it.
  • If you see println() inside storage, that is also a signal: storage must not “comment on life”. It should store and return.
  • If you expose MutableList, then any external code can call clear(), and then you will be looking for “who ate my data”. Kotlin collections clearly distinguish read-only and mutable interfaces, and this distinction is worth using as a protective fence.

Sometimes it feels like “there is no difference, I am writing the code alone anyway”. There is. The difference is you two weeks from now, who opens the project, and your life is different, your tasks are different, and you have zero desire to solve riddles made out of main.

4. Common mistakes when splitting into CLI / domain / storage

Before we finish, let’s go through the rakes almost everyone steps on (I stepped on them too, don’t worry—it is a tradition, like programmers’ tradition of creating yet another “final” file final_final2.kt).

Mistake #1: the domain starts printing to the console.
Usually it looks harmless: “I just want to print println("Added!") right inside addExpense”. But then the domain becomes dependent on the console, and later you will want different output (for example, a one-line report or no output at all), and you will have to dig println out of the logic. The correct idea: the domain either returns data or reports an error, and printing is the CLI’s responsibility.

Mistake #2: the domain reads input (readln()) “for convenience”.
It is tempting: “let the service ask for the amount itself”. But then the service cannot be used without an interactive console (for example, from another interface), and it becomes unclear where the scenario control even lives. readln() is strictly a CLI tool, and it belongs there.

Mistake #3: storage sticks out as a public val items: MutableList<Expense>.
People often do this “for simplicity”, and then some random code in CLI calls items.removeAt(0) and the logic breaks. Storage must hide mutability inside itself and return List externally. This supports the idea of read-only collection interfaces and protects against accidental mutation.

Mistake #4: the CLI starts applying business rules.
For example, in handleAdd you decide that “the amount must be > 0” and validate it in the CLI, and forget to validate it in the domain. Then another way of adding will appear (another command, import, anything), and some rules will exist only in one place. Business rules must live in the domain, and the CLI must be a “translator”, not a “judge”.

Mistake #5: mixing normalization and presentation.
A very common thing: the title string is trimmed (trim()) only when printing in println, because “it looks nicer”. But it is better to normalize data at the point it enters the domain (as we did), so that inside the program the data is always in a single format. Then output is just output, without trying to “patch up” meaning.

Mistake #6: trying too early to build “perfect architecture”.
There is an opposite extreme: someone reads the word “architecture” and urgently decides to make 25 classes, 12 interfaces, and a diagram they are afraid of themselves. Today the goal is more modest: separate input/output from rules and from storage. If you can explain where the CLI is, where the domain is, and where the storage is—you are already doing well, and the code will become noticeably calmer.

1
Task
Kotlin SELF,  39 level,  0 lecture
Locked
Pocket Notes
Pocket Notes
1
Task
Kotlin SELF,  39 level,  0 lecture
Locked
Terminal command
Terminal command
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet