Почему 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 —