Domain-Driven Design в Kotlin: как не утонуть в сложности бизнес-логики

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему DDD и Kotlin — идеальная пара

Когда проект разрастается, а бизнес-требования начинают напоминать запутанный клубок, обычный CRUD-подход перестаёт работать. Вы ловите себя на мысли, что половина кода — это бесконечные проверки if-else, а любое изменение в логике ведёт к правке десятка файлов. Знакомо?

Тут на сцену выходит Domain-Driven Design (DDD) — подход, который ставит бизнес-логику в центр вселенной. А Kotlin с его лаконичным синтаксисом, мощной системой типов и поддержкой функциональных концепций становится идеальным инструментом для реализации DDD. В этой статье мы разберём, как объединить эти две силы, чтобы ваш код стал не просто рабочим, а выразительным и гибким.

Если вы пишете на Kotlin и хотите, чтобы ваша архитектура дышала, а не скрипела — вы по адресу. Сегодня мы не просто поговорим о теории, а напишем реальные примеры кода.

Domain-Driven Design: краткий ликбез для занятых

Прежде чем нырнуть в код, давайте договоримся о терминах. DDD — это не фреймворк и не библиотека. Это набор принципов, которые помогают разработчикам и бизнесу говорить на одном языке.

Ключевые понятия:

  • Ubiquitous Language (Единый язык) — термины из кода должны совпадать с терминами, которые используют бизнес-аналитики и заказчики.
  • Bounded Context (Ограниченный контекст) — чёткая граница, внутри которой модель имеет смысл. Например, «Клиент» в контексте продаж и «Клиент» в контексте поддержки — это разные сущности.
  • Entity (Сущность) — объект с уникальным идентификатором, который меняется во времени.
  • Value Object (Объект-значение) — неизменяемый объект, который определяется своими атрибутами, а не идентификатором.
  • Aggregate (Агрегат) — группа связанных объектов, которая рассматривается как единое целое.
  • Repository (Репозиторий) — абстракция для доступа к данным, скрывающая детали хранения.

Звучит абстрактно? Сейчас переведём это на Kotlin.

Value Objects на Kotlin: грация и безопасность

Начнём с самого приятного — Value Objects (VO). В Kotlin их реализовывать одно удовольствие благодаря data class.

Представьте, что мы работаем в системе бронирования отелей. У нас есть понятие «Период проживания». Это не сущность — это просто набор дат. Идеальный кандидат для VO.

data class StayPeriod(
    val checkIn: LocalDate,
    val checkOut: LocalDate
) {
    init {
        require(checkIn.isBefore(checkOut)) {
            "Дата заезда должна быть раньше даты выезда"
        }
    }

    val totalNights: Int
        get() = ChronoUnit.DAYS.between(checkIn, checkOut).toInt()

    fun overlapsWith(other: StayPeriod): Boolean {
        return !(this.checkOut.isBefore(other.checkIn) || this.checkIn.isAfter(other.checkOut))
    }
}

Почему это круто?

  • Неизменяемость — все поля val. Никто случайно не изменит дату заезда.
  • Встроенная валидация — блок init гарантирует, что объект создастся только в корректном состоянии.
  • Поведение — метод overlapsWith инкапсулирует бизнес-правило. Вы не ищете пересечение дат в сервисах, а спрашиваете у самого объекта.

В DDD мы называем это богатой моделью — объект не просто хранит данные, а умеет выполнять бизнес-операции.

Entity: когда личность важна

Теперь перейдём к Entity. В отличие от VO, сущность меняется и имеет идентичность. В Kotlin мы используем обычный класс, но с акцентом на идентификатор.

Пример — бронь в отеле (Booking):

class Booking(
    val id: BookingId,
    val roomNumber: RoomNumber,
    val guestName: String,
    period: StayPeriod
) {
    var period: StayPeriod = period
        private set

    var status: BookingStatus = BookingStatus.CONFIRMED
        private set

    fun reschedule(newPeriod: StayPeriod) {
        require(status == BookingStatus.CONFIRMED) {
            "Нельзя перенести отменённую бронь"
        }
        require(newPeriod.checkIn.isAfter(LocalDate.now())) {
            "Новая дата заезда должна быть в будущем"
        }
        this.period = newPeriod
    }

    fun cancel() {
        require(status == BookingStatus.CONFIRMED) {
            "Бронь уже отменена или завершена"
        }
        this.status = BookingStatus.CANCELLED
    }
}

// Value Objects для идентификаторов
@JvmInline
value class BookingId(val value: UUID)

@JvmInline
value class RoomNumber(val value: String)

Обратите внимание на несколько деталей:

  • Мы используем value class для идентификаторов — они не занимают лишней памяти, но дают типобезопасность (вы не перепутаете BookingId с RoomNumber).
  • Сеттеры приватные — изменить состояние сущности можно только через её методы (reschedule, cancel). Это защищает инварианты.
  • Внутри методов — бизнес-правила, а не просто «присвоить новое значение».

Агрегаты: управляем транзакциями

Когда у вас несколько сущностей, которые должны изменяться вместе, приходит время Aggregate. Агрегат — это корень, через который вы работаете со всеми вложенными объектами.

В нашем отеле может быть агрегат Hotel, который содержит комнаты и брони:

class Hotel(
    val id: HotelId,
    val name: String,
    private val rooms: MutableSet<Room> = mutableSetOf()
) {
    fun addRoom(room: Room) {
        require(rooms.none { it.number == room.number }) {
            "Комната с номером ${room.number.value} уже существует"
        }
        rooms.add(room)
    }

    fun bookRoom(roomNumber: RoomNumber, guestName: String, period: StayPeriod): Booking {
        val room = rooms.find { it.number == roomNumber }
            ?: throw IllegalArgumentException("Комната ${roomNumber.value} не найдена")

        require(room.isAvailableFor(period)) {
            "Комната недоступна на выбранные даты"
        }

        val booking = Booking(
            id = BookingId(UUID.randomUUID()),
            roomNumber = roomNumber,
            guestName = guestName,
            period = period
        )
        room.addBooking(booking)
        return booking
    }
}

Ключевой принцип: все изменения внутри агрегата атомарны. Если вы бронируете комнату, обновляется и список броней в комнате, и статус. Никаких сайд-эффектов снаружи.

Repository: скрываем базу данных

Repository —

Блоги

Рекомендаций по книгам