1. Интерфейсы как границы между слоями
Можно написать приложение без интерфейсов, и оно даже будет работать. Как и можно ехать на велосипеде без рук — иногда получается, пока не встретится яма. Интерфейсы нужны не для «красоты», а чтобы защитить код от хаоса изменений: когда вы меняете хранение или формат отчёта, вы не хотите, чтобы у вас посыпалась логика команд, сервис, печать и половина проекта из-за одной правки.
Представим, что у нас есть проект «учёт расходов» (условный мини‑трекер). На уровне domain мы хотим уметь:
- добавлять расход с проверками,
- получать список расходов,
- строить текстовый отчёт.
Но как именно расходы хранятся (в списке, в карте, в кеше, в файле — не сегодня) и как именно выглядит отчёт (plain text, таблица, CSV — тоже не сегодня) — это детали, которые постоянно хочется менять.
Интерфейс в этой истории — это «розетка»: вы не обязаны знать, как устроена электростанция, чтобы включить ноутбук.
Интерфейс как договор
Очень важно не превращать интерфейс в «бог‑объект», который умеет всё на свете. Хороший интерфейс — это договор: минимум методов, максимум ясности. Он отвечает на вопрос: «Что нужно вызывающему коду?» — и игнорирует вопрос: «А что было бы прикольно добавить на будущее?».
Есть классическая ловушка новичка: вы написали InMemoryExpenseRepository, а потом сделали интерфейс ExpenseRepository ровно такой же ширины, как ваша реализация, включая все вспомогательные методы. В итоге интерфейс стал не договором, а «фотографией реализации». Тогда он перестаёт защищать систему: любая новая хотелка в реализации тянет за собой изменения интерфейса, а значит — изменения во всех слоях.
Держим в голове правило:
Интерфейс — это не список ваших возможностей. Интерфейс — это список чужих потребностей.
Доменная модель
Прежде чем писать репозитории и экспортёры, нам нужна модель данных. Модель в domain должна быть максимально простой: данные и ничего лишнего.
// FILE: domain/model/Expense.kt
package domain.model
data class Expense(
val title: String,
val amount: Int,
)
Здесь нет println, нет readln, нет форматирования, нет хранилища. Только смысл: «расход — это название и сумма».
2. Repository: контракт доступа к доменным данным
Репозиторий (Repository) — это «точка входа» для работы с данными. На нашем уровне (v1) это просто контракт: как добавить расход и как получить список расходов. Ничего сверхъестественного.
Важно: repository — это не обязательно база данных. Сегодня это может быть обычный список в памяти. Смысл не в том, где лежат данные, а в том, что domain‑код не должен зависеть от конкретного способа хранения.
Интерфейс ExpenseRepository: узкий и скучный — значит, хороший
Сделаем контракт минимальным: add() и all().
// FILE: domain/service/ExpenseRepository.kt
package domain.service
import domain.model.Expense
interface ExpenseRepository {
fun add(expense: Expense)
fun all(): List<Expense>
}
Почему all() возвращает List, а не MutableList? Потому что мы хотим отдавать наружу «read-only вид», чтобы внешний код не мог «случайно» удалить данные напрямую.
В Kotlin есть важная практическая идея: MutableList — это список с операциями изменения, а List — «только чтение». Даже если под капотом лежит изменяемый список, наружу лучше отдавать read-only интерфейс. Это соответствует общему подходу коллекций Kotlin: есть read-only типы и mutable-типы, и у mutable есть операции изменения.
Реализация InMemoryExpenseRepository: прячем MutableList внутрь
Теперь пишем хранилище в памяти. Это storage‑слой, но он реализует domain‑контракт.
// 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
}
Обратите внимание на деталь: items — private. Это не «мелочь», а прям защита от утечек ответственности. Если бы items был публичным, CLI мог бы сделать repo.items.clear() и «всё сломать честно, без предупреждения».
Нюанс: «read-only List» не означает «неизменяемость объекта»
Здесь есть тонкий момент Kotlin: если вы вернули items как List, это ограничивает вызывающего на уровне типов (он не может вызвать add()), но это всё ещё та же коллекция, просто «вид» на неё другой.
Иногда это нормально (и в учебном проекте v1 — нормально). Но иногда хочется возвращать «снимок» (toList()), чтобы внешнему коду вообще нельзя было повлиять на ваше состояние даже косвенно.
Можно сделать так:
override fun all(): List<Expense> = items.toList()
Это безопаснее с точки зрения инкапсуляции, но дороже по памяти/времени. Для v1 мы чаще оставим простой вариант, но важно понимать, что выбор существует.
3. Domain‑сервис
Теперь соберём логику «что считается корректным расходом» в одном месте. Сервис не печатает, не читает ввод, не хранит данные напрямую — он применяет правила и обращается к репозиторию.
// 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()
}
Тут важно увидеть архитектурную «геометрию» зависимостей:
- ExpenseService зависит от интерфейса ExpenseRepository,
- конкретная реализация (InMemoryExpenseRepository) будет подключена снаружи, в CLI,
- сервис не знает и не должен знать, где и как это хранится.
Это и есть внедрение зависимости через конструктор (constructor injection) в самом базовом, «ручном» виде.
4. Exporter: отдельный контракт для форматирования отчёта
Теперь вторая граница — отчёты. Обычно новички делают так: сервис формирует строку, а потом ещё и печатает её. Или CLI строит отчёт вручную. Оба варианта создают лишнюю связность.
Логика форматирования — это отдельная ответственность: «превратить данные в текст». Мы специально делаем так, чтобы exporter возвращал String, а не делал println. Почему? Потому что печать — это CLI‑слой, а форматирование — это отдельная роль, которую можно переиспользовать.
Интерфейс ReportExporter
// FILE: domain/service/ReportExporter.kt
package domain.service
import domain.model.Expense
interface ReportExporter {
fun export(expenses: List<Expense>): String
}
Интерфейс снова узкий: один метод, одна задача. Он не знает про консоль, не знает про команды, не знает про репозиторий. Ему дали данные — он вернул текст.
Реализация PlainTextExporter: простой текстовый отчёт
// 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}"
}
}
}
Этот exporter делает максимально прямолинейный формат. И это хорошо: когда вы делаете архитектуру, лучше начинать с простого формата, чтобы увидеть границы.
Ещё одна реализация: «табличка» с выравниванием
Иногда хочется, чтобы отчёт выглядел аккуратнее. Мы можем сделать второй exporter, не меняя сервис и не меняя репозиторий.
// 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"
}
}
}
Важный эффект: вы можете переключить формат отчёта одной заменой реализации в CLI (мы это сделаем ниже), не переписывая остальную систему.
5. Как всё связывается: CLI собирает зависимости
Самая частая ошибка на этом этапе — «притащить всё обратно в main». Поэтому мы держим main коротким: он создаёт репозиторий, сервис, экспортёр и вызывает операции.
// 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
}
Обратите внимание: CLI сделал println, потому что это его работа. Exporter вернул строку. Сервис применил правила. Репозиторий сохранил. У каждого — своя роль, и никто не лезет на чужую территорию.
6. Почему «узкий API» — это не занудство, а защита от будущих проблем
Сейчас хочется добавить в ExpenseRepository всё подряд: remove, update, findByTitle, clear, count, totalAmount и «ещё вот эту штуку, пригодится». Но это превращает интерфейс в «центр вселенной», и любой слой начинает зависеть от слишком большого количества деталей.
Вместо этого держим интерфейсы минимальными, а сложные операции выражаем либо как отдельные методы сервиса (domain‑правила), либо как отчёты (exporters).
Ниже маленькая таблица, чтобы закрепить интуицию.
| Вопрос | Плохой ответ | Хороший ответ |
|---|---|---|
| «Куда положить println?» | «В exporter, он же про отчёты» | «В CLI: exporter возвращает String» |
| «Кто должен тримить название?» | «CLI, там же ввод» | «Service: это правило предметной области» |
| «Можно ли вернуть MutableList наружу?» | «Да, быстрее же» | «Нет, наружу — List, мутация спрятана» |
| «Надо ли all() делать toList()?» | «Всегда да, потому что красиво» | «Иногда да: это snapshot, но это цена» |
7. Схема зависимостей: кто о ком имеет право знать
Чтобы голова не превращалась в компилятор Kotlin (хоть это и звучит как мечта некоторых людей), полезно держать картинку:
flowchart TD
CLI[app.cli<br/>readln/println/команды]
SERVICE[domain.service<br/>правила и сценарии]
MODEL[domain.model<br/>типы данных]
REPO[domain.service.ExpenseRepository<br/>контракт]
EXPORT[domain.service.ReportExporter<br/>контракт]
STORAGE[storage<br/>InMemoryExpenseRepository]
CLI --> SERVICE
CLI --> EXPORT
SERVICE --> MODEL
SERVICE --> REPO
STORAGE --> REPO
EXPORT --> MODEL
Смысл такой: CLI может собирать систему из деталей и вызывать сценарии. Domain держит правила и контракты. Storage реализует контракт. Exporter реализует контракт. Но domain не должен «импортировать CLI», иначе слои перепутаются.
8. Типичные ошибки
Ошибка №1: интерфейс «на всякий случай» становится огромным.
Обычно это выглядит так: вы написали InMemoryExpenseRepository, увидели, что там можно «при желании» и удалять, и очищать, и сортировать, и считать суммы — и вы добавили всё это в ExpenseRepository. Потом CLI начинает использовать половину методов, сервис — другую половину, и вы уже не можете поменять реализацию репозитория без переписывания всего проекта. Лечится очень скучно, но эффективно: в интерфейсе оставляются только те методы, без которых вызывающий код реально не может жить.
Ошибка №2: репозиторий «протекает наружу» через MutableList.
Когда all() возвращает MutableList, внешний код получает право менять внутреннее состояние репозитория напрямую. Это рушит инварианты сервиса: он может проверять amount > 0, а потом кто-то в CLI добавит расход с -100 напрямую в список. Поэтому наружу отдаём List, а внутри храним MutableList. В Kotlin это согласуется с общей моделью read-only и mutable коллекций.
Ошибка №3: exporter печатает сам (println внутри export).
Если exporter печатает, он становится «наполовину CLI», а ещё его невозможно нормально переиспользовать: вы не можете получить строку отчёта и, например, показать её как часть другого сообщения. Держим правило: exporter возвращает String, а печать — в CLI.
Ошибка №4: сервис создаёт репозиторий внутри себя (val repo = InMemoryExpenseRepository()).
Так сервис становится жёстко привязан к конкретной реализации хранения. Вроде мелочь, но это мгновенно убивает смысл интерфейса. Сервис должен зависеть от контракта (ExpenseRepository) и получать реализацию через конструктор. Тогда CLI может «подставить» другую реализацию без изменения domain‑кода.
Ошибка №5: all(): List<Expense> = items воспринимают как «полную защиту от изменений».
Read-only List действительно ограничивает доступ к операциям изменения, но это всё ещё может быть «вид» на ту же коллекцию. Иногда этого достаточно, иногда хочется возвращать копию (toList()), то есть снимок данных. Kotlin отдельно подчёркивает разницу между копированием коллекции и созданием второй ссылки на ту же коллекцию.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ