JavaRush/Courses/Kotlin SELF/Interfaces as API Boundaries

Interfaces as API Boundaries

Open

1. Interfaces as boundaries between layers

You can write an application without interfaces, and it will even work. Just like you can ride a bicycle without hands—sometimes it works, until you hit a pothole. Interfaces are not for “beauty”; they are there to protect your code from the chaos of change: when you change storage or the report format, you don’t want your command logic, service, printing, and half the project to start falling apart because of one edit.

Imagine we have an “expense tracking” project (a hypothetical mini-tracker). At the domain level we want to be able to:

  • add an expense with validations,
  • get the list of expenses,
  • build a text report.

But how exactly expenses are stored (in a list, in a map, in a cache, in a file—not today) and how exactly the report looks (plain text, table, CSV—also not today) are details you will constantly want to change.

An interface in this story is like an “outlet”: you don’t have to know how a power plant is built to plug in a laptop.

Interface as a contract

It’s very important not to turn an interface into a “god object” that can do everything in the world. A good interface is a contract: minimum methods, maximum clarity. It answers the question: “What does the calling code need?”—and ignores the question: “What would be cool to add for the future?”

There is a classic beginner trap: you wrote InMemoryExpenseRepository, and then made the ExpenseRepository interface exactly as wide as your implementation, including all helper methods. As a result, the interface became not a contract but a “photograph of the implementation.” Then it stops protecting the system: any new wish in the implementation drags interface changes with it, which means changes in all layers.

Keep this rule in mind:

An interface is not a list of your capabilities. An interface is a list of other people’s needs.

Domain model

Before writing repositories and exporters, we need a data model. The model in the domain should be as simple as possible: data and nothing extra.

// FILE: domain/model/Expense.kt
package domain.model

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

There is no println here, no readln, no formatting, no storage. Only meaning: “an expense is a title and an amount.”

2. Repository: an access contract for domain data

A repository (Repository) is the “entry point” for working with data. At our level (v1) it’s just a contract: how to add an expense and how to get the list of expenses. Nothing fancy.

Important: a repository is not necessarily a database. Today it can be a regular in-memory list. The point is not where the data lives, but that domain code must not depend on a specific storage mechanism.

Interface ExpenseRepository: narrow and boring means good

Let’s make the contract minimal: add() and all().

// FILE: domain/service/ExpenseRepository.kt
package domain.service

import domain.model.Expense

interface ExpenseRepository {
    fun add(expense: Expense)
    fun all(): List<Expense>
}

Why does all() return List instead of MutableList? Because we want to expose a “read-only view” so external code can’t “accidentally” delete data directly.

In Kotlin there’s an important practical idea: MutableList is a list with mutation operations, and List is “read-only.” Even if under the hood you have a mutable list, it’s better to expose a read-only interface. This matches Kotlin’s overall collections approach: there are read-only types and mutable types, and mutables have mutation operations.

Implementation InMemoryExpenseRepository: keep MutableList inside

Now we write an in-memory store. This is the storage layer, but it implements the domain contract.

// FILE: storage/InMemoryExpenseRepository.kt
package storage

import domain.model.Expense
import domain.service.ExpenseRepository

class InMemoryExpenseRepository : ExpenseRepository {
    private val items = mutableListOf<Expense>()

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

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

Notice a detail: items is private. This is not a “small thing”; it’s direct protection against leaking responsibilities. If items were public, the CLI could do repo.items.clear() and “break everything honestly, without warning.”

Nuance: a “read-only List” does not mean “immutability of the object”

There’s a subtle Kotlin point here: if you returned items as a List, it restricts the caller at the type level (they can’t call add()), but it’s still the same collection, just a different “view” of it.

Sometimes that’s fine (and in a v1 learning project—it’s fine). But sometimes you want to return a “snapshot” (toList()) so external code can’t influence your state even indirectly.

You can do this:

override fun all(): List<Expense> = items.toList()

This is safer from an encapsulation perspective, but more expensive in memory/time. For v1 we’ll more often keep the simple option, but it’s important to understand the choice exists.

3. Domain service

Now let’s gather the logic of “what counts as a valid expense” in one place. The service doesn’t print, doesn’t read input, and doesn’t store data directly—it applies rules and talks to the repository.

// FILE: domain/service/ExpenseService.kt
package domain.service

import domain.model.Expense

class ExpenseService(private val repo: ExpenseRepository) {

    fun add(title: String, amount: Int) {
        val normalizedTitle = title.trim()
        require(normalizedTitle.isNotEmpty()) { "Title must not be empty" }
        require(amount > 0) { "Amount must be > 0" }

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

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

It’s important to see the architectural “geometry” of dependencies here:

  • ExpenseService depends on the interface ExpenseRepository,
  • the concrete implementation (InMemoryExpenseRepository) will be wired from the outside, in the CLI,
  • the service doesn’t know and must not know where and how it’s stored.

That is dependency injection via the constructor (constructor injection) in its most basic, “manual” form.

4. Exporter: a separate contract for report formatting

Now the second boundary—reports. Beginners usually do this: the service builds a string and then also prints it. Or the CLI builds the report by hand. Both options create unnecessary coupling.

Formatting logic is a separate responsibility: “turn data into text.” We deliberately do it so the exporter returns a String and does not call println. Why? Because printing is the CLI layer, and formatting is a separate role that can be reused.

Interface ReportExporter

// FILE: domain/service/ReportExporter.kt
package domain.service

import domain.model.Expense

interface ReportExporter {
    fun export(expenses: List<Expense>): String
}

The interface is narrow again: one method, one job. It doesn’t know about the console, doesn’t know about commands, doesn’t know about the repository. It was given data—it returned text.

Implementation PlainTextExporter: a simple text report

// FILE: domain/service/PlainTextExporter.kt
package domain.service

import domain.model.Expense

class PlainTextExporter : ReportExporter {
    override fun export(expenses: List<Expense>): String {
        return expenses.joinToString(separator = "\n") { e ->
            "${e.title}: ${e.amount}"
        }
    }
}

This exporter uses the most straightforward format possible. And that’s good: when you’re designing architecture, it’s better to start with a simple format to see the boundaries.

Another implementation: a “table” with alignment

Sometimes you want the report to look neater. We can make a second exporter without changing the service and without changing the repository.

// FILE: domain/service/TableTextExporter.kt
package domain.service

import domain.model.Expense

class TableTextExporter : ReportExporter {
    override fun export(expenses: List<Expense>): String {
        return expenses.joinToString(separator = "\n") { e ->
            val title = e.title.padEnd(20, ' ')
            val amount = e.amount.toString().padStart(6, ' ')
            "$title | $amount"
        }
    }
}

A key effect: you can switch the report format by replacing the implementation in the CLI (we’ll do that below) without rewriting the rest of the system.

5. How everything is wired: the CLI assembles dependencies

The most common mistake at this stage is “dragging everything back into main.” So we keep main short: it creates the repository, the service, the exporter, and calls operations.

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

import domain.service.ExpenseService
import domain.service.TableTextExporter
import storage.InMemoryExpenseRepository

fun main() {
    val repo = InMemoryExpenseRepository()
    val service = ExpenseService(repo)
    val exporter = TableTextExporter()

    service.add("Coffee", 200)
    service.add("Taxi", 500)

    println(exporter.export(service.all()))
    // Coffee               |    200
    // Taxi                 |    500
}

Notice: the CLI did println, because that’s its job. The exporter returned a string. The service applied rules. The repository stored. Everyone has their own role, and nobody steps into someone else’s territory.

6. Why a “narrow API” is not pedantry, but protection from future problems

Right now you want to add everything to ExpenseRepository: remove, update, findByTitle, clear, count, totalAmount, and “this other thing too, it might come in handy.” But that turns the interface into the “center of the universe,” and every layer starts depending on too many details.

Instead, keep interfaces minimal, and express complex operations either as separate service methods (domain rules) or as reports (exporters).

Below is a small table to reinforce the intuition.

Question Bad answer Good answer
“Where should println go?” “In the exporter; it’s about reports” “In the CLI: the exporter returns a String”
“Who should trim the title?” “The CLI; it’s input” “The Service: it’s a domain rule”
“Can we return MutableList outward?” “Yes, it’s faster” “No, outward is List; mutation is hidden”
“Should all() do toList()?” “Always yes, because it looks nice” “Sometimes yes: it’s a snapshot, but it has a cost”

7. Dependency diagram: who is allowed to know about whom

So your head doesn’t turn into a Kotlin compiler (even though that sounds like a dream for some people), it helps to keep a picture in mind:

flowchart TD
    CLI[app.cli<br/>readln/println/commands]
    SERVICE[domain.service<br/>rules and scenarios]
    MODEL[domain.model<br/>data types]
    REPO[domain.service.ExpenseRepository<br/>contract]
    EXPORT[domain.service.ReportExporter<br/>contract]
    STORAGE[storage<br/>InMemoryExpenseRepository]

    CLI --> SERVICE
    CLI --> EXPORT
    SERVICE --> MODEL
    SERVICE --> REPO
    STORAGE --> REPO
    EXPORT --> MODEL

The idea is: the CLI can assemble the system from parts and run use cases. The domain holds rules and contracts. Storage implements the contract. The exporter implements the contract. But the domain must not “import the CLI,” otherwise the layers get mixed up.

8. Common mistakes

Mistake #1: an interface “just in case” becomes huge.
Usually it looks like this: you wrote InMemoryExpenseRepository, noticed that “if you want” you can delete, clear, sort, and sum—so you added all of that to ExpenseRepository. Then the CLI uses half the methods, the service uses the other half, and you can no longer change the repository implementation without rewriting the entire project. The cure is very boring but effective: keep only those methods in the interface that the calling code truly cannot live without.

Mistake #2: the repository “leaks out” via MutableList.
When all() returns MutableList, external code gets the right to change the repository’s internal state directly. This breaks service invariants: it might validate amount > 0, and then someone in the CLI will add an expense with -100 directly into the list. So we expose List and keep MutableList inside. In Kotlin this aligns with the general model of read-only and mutable collections.

Mistake #3: the exporter prints by itself (println inside export).
If the exporter prints, it becomes “half CLI,” and it’s also impossible to reuse properly: you can’t get the report string and, for example, show it as part of another message. Keep the rule: the exporter returns a String, and printing is in the CLI.

Mistake #4: the service creates the repository inside itself (val repo = InMemoryExpenseRepository()).
This makes the service tightly coupled to a specific storage implementation. It seems minor, but it instantly kills the whole point of the interface. The service should depend on the contract (ExpenseRepository) and receive an implementation via the constructor. Then the CLI can “swap in” a different implementation without changing domain code.

Mistake #5: all(): List<Expense> = items is perceived as “full protection from changes.”
A read-only List does restrict access to mutation operations, but it can still be a “view” of the same collection. Sometimes that’s enough; sometimes you want to return a copy (toList()), i.e., a snapshot of the data. Kotlin explicitly emphasizes the difference between copying a collection and creating a second reference to the same collection.

1
Task
Kotlin SELF,  39 level,  2 lecture
Locked
Number repository
Number repository
1
Task
Kotlin SELF,  39 level,  2 lecture
Locked
Export lines
Export lines
1
Task
Kotlin SELF,  39 level,  2 lecture
Locked
Catalog service
Catalog service
1
Task
Kotlin SELF,  39 level,  2 lecture
Locked
Two reports
Two reports
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet