JavaRush/Courses/Kotlin SELF/Client and scenario architecture: no “network in the CLI”...

Client and scenario architecture: no “network in the CLI”

Open

1. Why “network in the CLI” turns into a problem

When you write your first network program, it’s very tempting to do everything right inside main: read a command, immediately do client.get(...), immediately parse JSON, immediately println. In a small example it even looks “fine”, like instant noodles: add boiling water — and it’s ready. The problem is that after a couple of new commands, that noodle mess starts living its own life and taking revenge for the shortcuts.

Imagine we already have a console app from previous days — a hypothetical expense tracker. Let it store expenses locally, and today we want to add a tip command that fetches a “financial tip of the day” from a remote server. Sounds harmless — right up until you try to do it “right in the CLI”.

Network in the CLI usually leads to three problems at once.

First, responsibilities get mixed: in one place you have command parsing, HTTP headers, JSON models, and error handling. Second, it’s unclear what to test: the “command” or the “network”? Third, any change (for example, a different URL or a new response format) forces you to poke the “sacred main”, which is already scary to touch because, like an ancient artifact, it’s held together by sheer luck.

To get out of this, we’ll split the program into two new layers:

  1. Client layer — “how we go to the network”.
  2. Scenario layer — “why we go to the network and what we do with the result”.

And the CLI will remain what it should be: “read a command → call a scenario → print the result”.

2. Layer diagram: who is responsible for what

When people say “architecture”, beginners often hear “now there will be 43 folders, 12 abbreviations, and one shamanic tambourine”. We won’t do that. We need the simplest learning setup that actually helps: the CLI doesn’t know about Ktor and HTTP details, the scenario doesn’t print to the console, and the client doesn’t decide what to tell the user.

Here’s the visual model (as a flowchart) that we’ll keep coming back to:

flowchart TD
    CLI["CLI (main): reads commands, prints text"]
    SC["Scenario: decides what to do with the result"]
    API["Client API: interface of the remote service"]
    KTOR["Ktor implementation: HttpClient + requests"]
    NET["Internet/Server"]

    CLI --> SC --> API --> KTOR --> NET
    KTOR --> API --> SC --> CLI

The idea is simple: the lower the layer, the less it “knows” about your program and the more it “knows” about the technology. So the client knows about HTTP and JSON, but it doesn’t know you have a tip command. The scenario knows what “tip of the day” is, but it doesn’t know what headers the request has. The CLI knows the user typed tip, but it doesn’t know what HttpResponse is.

It helps to keep a small table in mind:

Layer What it does What it does NOT do
CLI reads commands, prints messages doesn’t build HTTP requests, doesn’t parse JSON
Scenario decides “what to return to the user” doesn’t print, doesn’t manage HttpClient
Network Client goes to the network, returns data/errors doesn’t know about commands and UI text

This setup works well with Kotlin code and familiar rules for organizing code: keep related things together, don’t scatter methods alphabetically, but go “top-down by meaning” — this matches general recommendations for formatting conventions.

3. Network-layer contract: an interface and an explicit result

The first practical step is to agree on what exactly the network layer returns. This is a key point: if the client returns a “raw HttpResponse”, you’re dragging HTTP details upward. If the client does println, you lose control over the output. So the most beginner-friendly option is to return an explicit result where success and errors are separated by types.

Explicit request result: ApiResult

Let’s start with a simple sealed class that can distinguish three cases:

  1. success (data received),
  2. HTTP error (a response exists, but it’s non-2xx),
  3. network error (exception, timeout, unavailability, etc.).
sealed class ApiResult<out T> {
    data class Ok<T>(val value: T) : ApiResult<T>()
    data class HttpError(val status: Int, val body: String) : ApiResult<Nothing>()
    data class NetworkError(val message: String) : ApiResult<Nothing>()
}

Response model and remote-service interface

Now let’s define the response model for our tip command. Let the server return JSON like:

{ "text": "Save 10% of your income immediately after payday" }

Then the Kotlin model:

import kotlinx.serialization.Serializable

@Serializable
data class TipResponse(val text: String)

And here is the main architecture element — the interface describing the remote service capabilities:

interface TipsApi {
    suspend fun fetchDailyTip(): ApiResult<TipResponse>
}

Notice two things.

First, it’s suspend — our network is asynchronous, and we’re already used to this from coroutines.

Second, the CLI is no longer required to know what Ktor, HttpResponse are, or how headers work. The CLI only knows: “there is a TipsApi, and you can ask it for the tip of the day”.

This approach matches the idea of a “narrow contract”: you describe what the program needs, not how you will obtain it (Ktor today, a different client tomorrow). Ktor fits perfectly into this model: it can be an implementation, not the meaning of the program.

4. Client implementation in Ktor: technical details live here

Now let’s implement TipsApi using Ktor. This is exactly where all the “technical stuff” belongs: HttpClient, URL, status checks, reading the body, JSON deserialization, exception handling.

HttpClient factory

First — a client factory (so we create it in one place and configure it consistently):

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json

fun buildHttpClient(): HttpClient =
    HttpClient(CIO) {
        install(HttpTimeout) { requestTimeoutMillis = 3_000 }
        install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    }

TipsApi implementation via Ktor

Now the API implementation. We’ll intentionally stick to the style “explicitly got a HttpResponse → explicitly read the body”, so the code stays readable:

import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText

class KtorTipsApi(
    private val client: HttpClient,
    private val baseUrl: String
) : TipsApi {
    override suspend fun fetchDailyTip(): ApiResult<TipResponse> {
        return try {
            val response: HttpResponse = client.get("$baseUrl/tip")
            if (response.status.value in 200..299) ApiResult.Ok(response.body())
            else ApiResult.HttpError(response.status.value, response.bodyAsText())
        } catch (e: Exception) {
            ApiResult.NetworkError(e.message ?: "Network error")
        }
    }
}

This is where it’s important to feel the responsibility boundary. This class:

  • builds the URL,
  • makes the request,
  • checks the status,
  • returns a clear ApiResult.

But it does not print and does not decide what to say to the user. It simply returns the result honestly.

5. Scenarios and CLI: everyone does their own job

If the client returns ApiResult, we need a place where that result turns into a “clear human answer”. That’s the scenario. It lives “above” the network, but “below” the CLI.

Scenario: translating ApiResult into meaning

A scenario is great because it’s convenient to keep rules like: “if HTTP 404 — say one thing, if it’s a timeout — say another, if it’s success — show the tip text”.

Let’s make a scenario that returns a ready-to-print string for the CLI:

class GetDailyTipScenario(private val api: TipsApi) {
    suspend fun execute(): String {
        return when (val r = api.fetchDailyTip()) {
            is ApiResult.Ok -> "Tip of the day: ${r.value.text}"
            is ApiResult.HttpError -> "Tips service returned HTTP ${r.status}"
            is ApiResult.NetworkError -> "Failed to fetch a tip: ${r.message}"
        }
    }
}

This code looks “too simple”, and that’s the point. The scenario is where the program becomes a program, not a pile of protocols.

Notice one more architectural detail: the scenario depends on the interface TipsApi, not on KtorTipsApi. This lets us swap the implementation (for example, for a “stub” during development) without changing the scenario and CLI.

CLI: command → scenario → output

Now the nicest part: main() doesn’t have to understand HTTP, JSON, and status codes. It just manages input/output and the resource lifecycle.

Let’s make a mini CLI with tip and exit commands. For simplicity, we’ll keep the command-reading loop the same way you’ve done many times before:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val client = buildHttpClient()
    try {
        val api: TipsApi = KtorTipsApi(client, baseUrl = "https://example.com")
        val scenario = GetDailyTipScenario(api)

        while (true) {
            print("> ")
            when (readln().trim()) {
                "tip" -> println(scenario.execute())
                "exit" -> return@runBlocking
                else -> println("Commands: tip, exit") // Commands: tip, exit
            }
        }
    } finally {
        client.close()
    }
}

Notice: main() now reads like an instruction, not like an article titled “How the Internet works”.

And yes, try/finally around client.close() is not paranoia — it’s an adult habit. Networks like resources, and resources like to be closed.

6. Dependency composition: assemble the app in one place

As an app grows, there’s a temptation to “create everything wherever”: HttpClient inside the API, API inside the scenario, scenario inside the command handler… and suddenly you have 17 clients and 17 timeouts because “it creates itself”.

To avoid that, we use one simple trick: compose dependencies in one place (sometimes called the “composition root”). In a learning project, this can be a separate function or a small class.

For example, let’s make an AppContext that creates and stores everything we need:

import io.ktor.client.HttpClient

class AppContext(baseUrl: String) : AutoCloseable {
    private val client: HttpClient = buildHttpClient()

    val tipsApi: TipsApi = KtorTipsApi(client, baseUrl)
    val getDailyTipScenario = GetDailyTipScenario(tipsApi)

    override fun close() = client.close()
}

And then main() becomes even cleaner:

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    AppContext(baseUrl = "https://example.com").use { ctx ->
        println(ctx.getDailyTipScenario.execute())
        // Tip of the day: ...
    }
}

Here we use the same principle you’ve already seen in I/O topics: the resource is created in one place and closed there too. HttpClient has close(), and use conveniently wraps the lifecycle.

7. Extending without pain: a new endpoint and minimal changes

Architecture is considered successful not when “everything is neatly arranged into folders”, but when a new feature is added locally. Let’s verify that.

Imagine tomorrow you want a ping command to quickly check whether the service is alive (or just to practice). In a bad architecture, you dive into main() and start adding requests there. In ours, you add a method to the API, an implementation in the Ktor client, and a scenario. You touch the CLI minimally.

Update the contract

Let’s add to the contract:

interface TipsApi {
    suspend fun fetchDailyTip(): ApiResult<TipResponse>
    suspend fun ping(): ApiResult<String>
}

Add an implementation to the Ktor client

Implementation (briefly, as text):

import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText

override suspend fun ping(): ApiResult<String> {
    return try {
        val response = client.get("$baseUrl/ping")
        if (response.status.value in 200..299) ApiResult.Ok(response.bodyAsText())
        else ApiResult.HttpError(response.status.value, response.bodyAsText())
    } catch (e: Exception) {
        ApiResult.NetworkError(e.message ?: "Network error")
    }
}

Add a scenario

Scenario:

class PingScenario(private val api: TipsApi) {
    suspend fun execute(): String =
        when (val r = api.ping()) {
            is ApiResult.Ok -> "ping: ${r.value}"
            is ApiResult.HttpError -> "ping: HTTP ${r.status}"
            is ApiResult.NetworkError -> "ping: ${r.message}"
        }
}

And only at the very end do you add one branch in the CLI. That’s the main sign the layers work: changes don’t “spill” everywhere.

8. Common mistakes

Mistake #1: creating HttpClient inside each request function.
People often do this “to make it simpler”: inside fetchDailyTip() they create an HttpClient, do get, and close it. In practice, you get extra overhead, it’s harder to control timeouts/plugins, and at some point you start forgetting to close the client in one of the branches. It’s better to create the client once (in main or AppContext) and pass it into the API implementation.

Mistake #2: returning HttpResponse outward “let them deal with it”.
It looks like saving effort, but in reality you pull HTTP details upward, and then status/bodyAsText() show up in scenarios and the CLI. That’s exactly what we were trying to avoid. It’s better to return ApiResult<T> or another explicit result, so the upper layers work with clear types instead of the transport.

Mistake #3: doing println inside the network client.
It’s very tempting to “help yourself while debugging”: print the URL, status, body. But then those prints end up in user output and interfere. If you need logging, that’s a separate topic and a separate responsibility. At the current level, keep the rule: the client returns data/errors, and only the CLI prints.

Mistake #4: trying to parse body<T>() for any status code.
If the server returns 400/500, the body may be not the JSON you expect (or not JSON at all). Then you’ll get a deserialization exception and lose the meaning of the error. Keep discipline: first check the status, and only in the success branch read body<T>(), and in the error branch read bodyAsText().

Mistake #5: “catching everything” and turning it into one text like “something went wrong”.
When you do catch (Exception) and return the same phrase, you deprive yourself (and the user) of understanding: did the internet go down, was it a timeout, an invalid URL, or did the server honestly say “your data is invalid”. Even if you’re not doing fine-grained classification yet, separate at least HTTP errors from network exceptions — that alone greatly improves predictability.

Mistake #6: mixing scenario and CLI: the scenario starts calling readln() or printing.
Sometimes it seems convenient: “the scenario knows what to ask the user”. But then the scenario stops being the scenario layer and turns into a second main(). Keep the boundary: input/output lives in the CLI, the scenario lives in the logic, the client lives in the network. It’s boring, but boring means reliable (in a good way).

1
Task
Kotlin SELF,  58 level,  4 lecture
Locked
Call result
Call result
1
Task
Kotlin SELF,  58 level,  4 lecture
Locked
Fake service
Fake service
1
Task
Kotlin SELF,  58 level,  4 lecture
Locked
Tip scenario
Tip scenario
1
Task
Kotlin SELF,  58 level,  4 lecture
Locked
Ktor client
Ktor client
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet