JavaRush/Courses/Kotlin SELF/Fixation v1 — a unified command contract

Fixation v1 — a unified command contract

Open

1. Introduction

As a project grows, what breaks things most often is not new code, but “new code that accidentally touched old code.” In a small educational app this shows up quickly too: you added a new command, and suddenly old commands started printing “kind of differently,” and an error is output either as a string, or as null, or as println(repo.all()) (yes, that very style of “let Kotlin explain to the user what happened”).

Fixation v1 is the moment when you say: “we have stable rules of the game.” Not “everything is perfect,” not “NASA-level architecture,” but something simple: commands return a result of a single type, the response format is built in one place, and main becomes a predictable script for wiring and calling. This sharply reduces chaos: adding commands is easier, reading code is easier, and you’re less afraid to touch a file, because it doesn’t drag half the project along with it.

To make it as down-to-earth as possible: v1 is when your project no longer resembles a “box of wires,” and starts to resemble a “box of connectors.” There may be many wires, but they plug in only at strictly defined points.

The problem: “commands return whatever”

Before introducing a unified contract, it’s useful to see the pain live. Typically beginners (and sometimes very grown-up developers after a hard day) do this: one command returns a string, another returns a Boolean, the third prints by itself, the fourth throws an exception, the fifth returns null because “well, it didn’t work.”

As a result, the CLI starts to look like a zoo of checks: here if (result != null), there try/catch, here println inside a service, there—inside a repository (that’s already a rare species, but it happens). And the worst part: you can’t tell at a glance what a command actually does. You have to remember “does this command print by itself or return a string?”

We need one common “envelope” for a command result. You can put either success or an error into it. Then the CLI always does the same thing: calls the handler, gets the result, prints the result. No special branches for each command except routing itself.

2. A unified command result: sealed interface CommandResult

When you first see sealed, it can feel “advanced,” but today we’re using it very practically: as a list of allowed result variants. The idea is simple: a command is either successful (Ok) or not (Error). That’s it. No “almost worked,” “returned 0,” “well it’s sort of true, but…”.

Let’s create a result type. It makes sense to keep it closer to the CLI boundary (because it’s the result of handling a command), for example in the app.cli.contract package. Important: this is not a domain model; it’s specifically “a command result for the interface.”

// FILE: app/cli/contract/CommandResult.kt
package app.cli.contract

sealed interface CommandResult {
    data class Ok(val message: String) : CommandResult
    data class Error(val message: String) : CommandResult
}

Notice a detail: both Ok and Error contain message. This is a very useful v1 discipline: every result has a human-readable explanation. In future versions you can make it more complex (for example, add data), but in v1 it’s important to achieve stability and simplicity.

3. Centralized formatting

If you’ve ever changed message text across a project, you know this quest: “find all places where ERROR is printed.” Search usually works, but then you find yet another place that printed println("Err: ..."). And another. And another…

In v1 we do it like this: formatting the result lives in one place. The CLI calls render() and prints the string. You can change the format centrally, and all commands automatically start looking the same.

// FILE: app/cli/contract/CommandResultRender.kt
package app.cli.contract

fun CommandResult.render(): String =
    when (this) {
        is CommandResult.Ok -> "OK: $message"
        is CommandResult.Error -> "ERROR: $message"
    }

Exhaustiveness of when matters here. We do not write else. Why? Because sealed interface promises: the number of variants is finite. If you later add a new variant (for example, Help or Exit), the compiler will say: “hey, you forgot to handle one more case.” And that’s an ideal “guard” at the entry point for changes: it won’t let you make the system incomplete.

4. A unified command handling contract: handle(...) -> CommandResult

Now we need an object (or a function) that accepts a parsed command and returns CommandResult. This is the v1 “public point” for the CLI: you feed a string into the parser, the parser gives a structure to the handler, and the handler returns the result.

First, let’s fix the model of a parsed command. We’re not building a complex parser; we only need the command name and one argument string.

// FILE: app/cli/parse/ParsedCommand.kt
package app.cli.parse

data class ParsedCommand(
    val name: String,
    val arg: String?
)

Now a simple parsing function. Here we’re not trying to make an “ideal CLI”; we’re just making a stable agreement: a command is a word, and an argument is everything else.

// FILE: app/cli/parse/CommandLineParser.kt
package app.cli.parse

fun parseCommandLine(line: String): ParsedCommand {
    val s = line.trim()
    val space = s.indexOf(' ')
    return if (space == -1) ParsedCommand(s.lowercase(), null)
    else ParsedCommand(s.substring(0, space).lowercase(), s.substring(space + 1).trim().ifEmpty { null })
}

Yes, the line is a bit long, but the logic is simple: normalize the command name (lowercase()), cut the argument and turn an empty string into null. This is very convenient later: arg == null means “there is no argument.”

And now—the command handler. It uses the domain service and exporter (which we already built in previous lectures), but it exposes only CommandResult to the outside.

// FILE: app/cli/CommandHandler.kt
package app.cli

import app.cli.contract.CommandResult
import app.cli.parse.ParsedCommand
import domain.service.ExpenseService
import domain.service.ReportExporter

class CommandHandler(
    private val service: ExpenseService,
    private val exporter: ReportExporter
) {
    fun handle(cmd: ParsedCommand): CommandResult =
        when (cmd.name) {
            "list" -> CommandResult.Ok(service.report(exporter))
            else -> CommandResult.Error("Unknown command: ${cmd.name}")
        }
}

For now we implemented only "list" to keep the example small. But the most important thing here is the shape: every path returns CommandResult. Even an unknown command is not an exception, not null, not printing right here, but a normal Error(...).

5. How the layers look together: dependency “wiring” in main

In main we don’t want business logic. We want a script: build dependencies → read a command → parse → handle → render → print. This is the case where a “simple program” becomes a sign of order rather than primitiveness.

Let’s show a minimal main that handles one command. In a real project you probably already made a loop, but right now it’s important to see the entire v1 structure end to end.

// FILE: app/cli/Main.kt
package app.cli

import app.cli.contract.render
import app.cli.parse.parseCommandLine
import storage.InMemoryExpenseRepository
import domain.service.ExpenseService
import domain.service.PlainTextExporter

fun main() {
    val handler = CommandHandler(ExpenseService(InMemoryExpenseRepository()), PlainTextExporter())
    val result = handler.handle(parseCommandLine(readln()))
    println(result.render()) // OK: ... or ERROR: ...
}

Yes, the handler wiring line looks dense. That’s normal for v1. We intentionally do not introduce dependency injection containers and “magic wiring.” What matters is something else: main knows nothing about expense rules and report formats; it simply connects the parts, like a LEGO constructor (sometimes painfully stepping on the bricks).

If you want to add a loop, it will remain “thin” too:

// FILE: app/cli/MainLoop.kt
package app.cli

import app.cli.contract.render
import app.cli.parse.parseCommandLine

fun runLoop(handler: CommandHandler) {
    while (true) {
        val result = handler.handle(parseCommandLine(readln()))
        println(result.render()) // unified response format
    }
}

There is no "exit" command yet—that’s fine. We are fixing the result contract, not building a perfect terminal.

6. Why Ok/Error is a stable API

At this stage it’s useful to take a small “architecture pause.” It feels like we wrote only two tiny data classes and one extension function. But in terms of effect it’s like when you finally label the breakers in your electrical panel: electricity existed before too, but now you don’t accidentally turn off the fridge when you wanted to turn off the hallway light.

Here’s what stabilizes Ok/Error:

From the perspective of the calling code (the CLI), there is no longer a variety of results. You don’t need to remember “what the command returned.” You always get one type and you always render it the same way.

From the perspective of project growth, new commands are added predictably. You don’t smear messages across main and you don’t breed println calls in unexpected places. If you add an "add" command, it will also return Ok/Error.

From the compiler’s perspective, you get “completeness insurance.” sealed + when without else is your free static audit: the compiler forces you to handle all variants.

To reinforce this idea, it’s handy to see the pipeline diagram once:

flowchart LR
    A[readln] --> B[parseCommandLine]
    B --> C[CommandHandler.handle]
    C --> D[CommandResult]
    D --> E[render]
    E --> F[println]

This is a good sign that “v1 is assembled”: the steps are clear, and each step is responsible for its own part.

And now a very important point about “v1 quality criteria.” This isn’t a list for the sake of a list; it’s a way to quickly check that the structure holds up when you’re tired and want to “just finish the feature.” If your domain layer does not use readln() and println(), storage is hidden behind an interface, the exporter returns a string, and main looks like a script, then you truly fixed the boundaries rather than just spreading files across folders.

7. A small practical nuance

Sometimes there’s a temptation to make it “smarter”: the "list" command returns the report string, the "add" command returns a Boolean, the "help" command returns a list of strings… and so on. It seems “more type-safe” (each command is “unique,” right). But at the CLI level this turns into having to distinguish commands one more time: “if this is list—print it like this, if add—differently.”

A unified result is a compromise for resilience. We say: at the CLI level we care about one thing—did it work or not, and what message should we show. Inside the domain you can return any structures you want, but at the command boundary we reduce everything to a single shape.

In v1 this is especially useful because you’re not building complex interfaces yet. You have a console, and a console loves simple rules: one command—one response line, preferably without surprises.

For a visual comparison, you can keep a table like this in mind:

Approach What commands return What happens in the CLI
“Each command does what it wants” String, Boolean, null, printing inside many special cases
Unified v1 contract CommandResult one handling script

And yes, if it seems like this is “too simple,” congratulations: you’ve started valuing simplicity as an engineering tool. It’s a dangerous path—next you’ll start naming variables properly and stop writing x1, x2, x3. There’s almost no way back.

8. Typical mistakes when fixing v1 and using Ok/Error

Mistake #1: commands still print inside themselves, and CommandResult exists “for show.”
This happens often: you introduced CommandResult, but ExpenseService still contains println("Added!"). As a result, the message is printed twice, or printed in an unexpected place, or the “OK/ERROR” format isn’t followed. The fix is simple and a bit painful: remove println from domain and storage, and force yourself to return data/result outward. The CLI prints; other layers do not.

Mistake #2: writing when over sealed with else “so it always compiles.”
else looks like a safety cushion, but in practice it’s a switch that disables the alarm. While everything is fine, you won’t notice. But when you add a new result variant, the compiler will no longer help you find the places where you forgot to handle it. In v1 it’s better to discipline yourself: if it’s sealed, handle variants explicitly, without else.

Mistake #3: starting to use CommandResult as a “bag for everything,” and half the domain model ends up inside.
Sometimes you want to put into Ok not a string, but a list of expenses, a total, statistics… and suddenly CommandResult turns into a new domain-model layer, just with a different name. In v1 keep the contract narrow: message: String. If you really want data, it’s better to let the domain return data, the exporter build a string, and the command return the string in Ok.

Mistake #4: the command handler starts directly poking the repository or holds state that should live in the domain.
If CommandHandler suddenly starts doing repo.items.add(...) or validates amounts itself, the boundary has drifted: the CLI command became “half-domain.” In v1 it’s better to patiently keep roles: the handler routes and calls the service, the service applies rules, the repository stores, the exporter formats.

Mistake #5: different commands use different message styles because they format strings “somewhere along the way.”
Today you wrote "OK: ..." in one place, tomorrow—"Success: ..." in another. The user sees inconsistency, and you spend time on cosmetics instead of logic. In v1 this is solved by centralized render() and the habit that everything printed to the user goes through one formatting point.

Mistake #6: main stops being a script and grows logic again—only now “in nice packages.”
This is a sneaky form of regression: you split files up, introduced interfaces, and then main gained 60 lines of conditions and branching. A sign of a healthy v1: main reads top-to-bottom like a recipe. If the recipe turns into a novel, it’s time to move responsibility back into handler/service/exporter.

1
Task
Kotlin SELF,  39 level,  4 lecture
Locked
Command Result
Command Result
1
Task
Kotlin SELF,  39 level,  4 lecture
Locked
Unified rendering
Unified rendering
1
Task
Kotlin SELF,  39 level,  4 lecture
Locked
Command parsing
Command parsing
1
Task
Kotlin SELF,  39 level,  4 lecture
Locked
Command Handler
Command Handler
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet