DDD у Go. частина 1: Як FixDrive долає складність ride-hailing домену та уникає «великої купи бруду»

Привіт, DOU! Мене звати Юра, це моя перша стаття на DOU , я займався розробкою у FixDrive. Наш бізнес — це динамічний світ поїздок, де кожну секунду взаємодіють водії, пасажири, системи оплати та алгоритми пошуку авто.
Коли ride-hailing система росте, її бізнес-логіка стає неймовірно заплутаною.

Без чіткого підходу код швидко перетворюється на те, що в індустрії називають Big Ball of Mud («велика купа бруду») — стан, коли правка в одному місці непередбачувано ламає функціонал в іншому. У цій статті я розповім, як ми в FixDrive використовуємо Domain-Driven Design (DDD) та ітеративний аналіз за Крейгом Ларманом, щоб тримати складність під контролем.
Перший крок у DDD — перестати думати категоріями «таблиць у БД». У FixDrive ми виробили спільну термінологію з бізнес-експертами. Замість абстрактних CRUD-операцій ми використовуємо сценарії: «Замовлення поїздки», «Призначення водія», «Завершення маршруту».
Це відображається безпосередньо в коді. Якщо бізнес каже, що «номер телефону водія має бути валідним», ми створюємо окремий тип, який сам себе валідує.

Ми відмовилися від класичної трирівневої архітектури на користь Onion Architecture (цибулева архітектура). Її головна ідея — домен не повинний залежати від зовнішніх технологій (БД, HTTP-фреймворків).
У FixDrive ми виділяємо чотири шари:
  1. Domain Layer: Сутності (Entities), агрегати та інтерфейси.
  2. Application Layer: Координує виконання сценаріїв (наприклад, процес оплати поїздки).
  3. Infrastructure Layer: Реалізація репозиторіїв, клієнти для Google Maps API.
  4. Presentation Layer: Обробка вхідних запитів (gRPC або HTTP)

В Go ми суворо дотримуємося розподілу об’єктів:
  • Entities (Сутності): Мають унікальний ID (наприклад, конкретна Поїздка). Вони змінюються з часом, тому в Go ми завжди використовуємо вказівники для роботи з ними.
  • Value Objects (Об’єкти-значення): Не мають ID, порівнюються за значенням (наприклад, Координати GPS або Вартість). Важливо: для них ми не використовуємо вказівники, щоб забезпечити їхню незмінність (immutability)

Тепер поговоримо про Агрегати. Агрегати — Межі цілісності.
У ride-hailing поїздка — це Агрегат. Це кластер об’єктів (сама поїздка, маршрутні точки, деталі оплати), які сприймаються як єдине ціле для дотримання бізнес-правил. Зовнішній світ може взаємодіяти з поїздкою лише через Корінь агрегата (Aggregate Root). Це гарантує, що дані завжди залишаються узгодженими.

Ми не дозволяємо доменній логіці знати про SQL або MongoDB. Домен лише визначає інтерфейс репозиторію. Це дозволяє нам легко замінити базу даних або додати кешування, не змінюючи ядро системи. Крім того, це робить юніт-тестування максимально простим через використання моків

Абстракція сховища: інтерфейс лежить у теці domain/, а його імплементація живе виключно в infrastructure/. Це не просто CRUD-обгортка.
Ми не намагаємося спроектувати все одразу. За методикою Крейга Лармана, ми працюємо короткими ітераціями. На кожній ітерації ми проходимо повний цикл: аналіз прецедентів (use cases), створення моделі предметної області та реалізація в коді. Це дозволяє FixDrive швидко адаптуватися до вимог ринку.
package domain

import (
    "time"
)

// RideID — унікальний ідентифікатор поїздки
type RideID string

// RideStatus відображає життєвий цикл поїздки в ride-hailing сервісі
type RideStatus string

const (
    RideStatusPending   RideStatus = "pending"    // Пошук водія
    RideStatusAccepted  RideStatus = "accepted"   // Водій знайдений і прямує до клієнта
    RideStatusInProgress RideStatus = "in_progress" // Пасажир у машині, їдуть
    RideStatusCompleted  RideStatus = "completed"   // Поїздка успішно завершена
    RideStatusCancelled  RideStatus = "cancelled"   // Скасовано пасажиром або водієм
)

// Ride — основна сутність для FixDrive.tech
type Ride struct {
    ID           RideID
    PassengerID  string       // Хто замовляє
    DriverID     string       // Хто виконує (може бути порожнім на етапі Pending)
    
    // Маршрут
    PickupPoint  string       // Точка А (адреса або координати)
    Destination  string       // Точка Б
    
    // Фінанси та сервіс
    Price        float64      // Вартість поїздки
    Currency     string       // Наприклад, "UAH"
    ServiceType  string       // Економ, Стандарт, Комфорт, Бізнес
    
    Status       RideStatus
    
    CreatedAt    time.Time    // Час замовлення
    StartedAt    *time.Time   // Час, коли пасажир сів у авто
    FinishedAt   *time.Time   // Час завершення
}


Як це працює в архітектурі

Цей код належить до рівня Domain, тобто він описує правила бізнесу. Він не знає про базу даних чи API. Будь-яка інша частина програми (наприклад, сервіс розрахунку ціни або сервіс призначення водія) буде використовувати саме цю структуру як «єдине джерело істини»

package usecase
import (
    "context"
    "domain"
)
type RideUsecase struct {
    rideRepo      domain.RideRepository
    passengerRepo domain.PassengerRepository
    pricingService domain.PricingService // Специфічно для таксі
}
func (u *RideUsecase) CreateRideDraft(ctx context.Context, passengerID string, pickup, destination string) (*domain.Ride, error) {
    // 1. Перевіряємо, чи існує пасажир та чи не заблокований він
    passenger, err := u.passengerRepo.GetByID(ctx, passengerID)
    if err != nil {
        return nil, domain.ErrPassengerNotFound
    }
// 2. Розраховуємо ціну (аналог вибору категорії/ціни в оголошеннях)
    price, err := u.pricingService.Calculate(ctx, pickup, destination)
    if err != nil {
        return nil, err
    }
// 3. Створюємо доменну сутність через конструктор (Business Logic)
    ride, err := domain.NewRide(passenger.ID, pickup, destination, price)
    if err != nil {
        return nil, err
    }
// 4. Зберігаємо чернетку в базу через репозиторій
    if err := u.rideRepo.Save(ctx, ride); err != nil {
        return nil, err
    } return ride, nil
}

Репозиторій тепер — це просто «тупа» обгортка над базою даних. Він отримує готовий об’єкт Ride і кладе його в таблицю.

Валідація стану: Перед створенням поїздки ми можемо перевірити, чи немає у пасажира вже активної поїздки (бізнес-правило), що неможливо зробити всередині простого методу Save

  • Pricing Service: У таксі ціна — залежить від відстані, заторів та попиту. Винесення логіки в Usecase дозволяє легко вставити сервіс прорахунку.
  • Валідація стану: Перед створенням поїздки ми можемо перевірити, чи немає у пасажира вже активної поїздки (бізнес-правило), що неможливо зробити всередині простого методу

Інкрементальна розробка базується на ідеї поступового розширення функціональності системи. Продукт не створюється як монолітний блок — він «виростає» через окремі частини, кожна з яких додає нову цінність. Наприклад, у сервісі замовлення поїздок спочатку можна реалізувати базове створення заявки без складної логіки. Далі — додати розрахунок вартості, потім валідацію користувача, і вже після цього — статуси поїздки. Кожен такий крок є завершеним інкрементом: система стає функціональнішою, і її вже можна використовувати.

Ітеративна розробка, натомість, зосереджена не на розширенні, а на вдосконаленні. Та сама функція проходить кілька етапів розвитку: від простого прототипу до стабільного та оптимізованого рішення. Спочатку логіка може бути мінімальною, без глибокої обробки помилок чи оптимізації. З кожною ітерацією додаються перевірки, покращується структура коду, з’являється логування, метрики, підвищується продуктивність. У результаті функція залишається тією ж, але її якість суттєво зростає.

На практиці ці підходи майже ніколи не використовуються окремо. Сучасні команди поєднують їх. Наприклад, протягом одного спринту можна додати нову функціональність — це інкремент — і одночасно покращити вже існуючу частину системи — це ітерація. Такий підхід дозволяє балансувати між швидкою доставкою нових можливостей і підтримкою якості коду.

Висновок

Застосування тактичного DDD у Go дозволяє нам у FixDrive будувати систему, яка не боїться змін.Проте в довгостроковій перспективі ви отримуєте чистий, протестований код, який розмовляє однією мовою з вашим бізнесом.
А як ви боретеся зі складністю у своїх проектах? Діліться в коментарях!

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось18
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Дякую за статтю! Не так багато матеріалів по DDD з прикладами на Go.

Тепер поговоримо про Агрегати. Агрегати — Межі цілісності.

Чи поділяєте в коді обʼєкти явно на агрегати та сутності? Наприклад, чи може малознайомий з кодом інженер дивлячись на структуру одразу зрозуміти, що вона є саме агрегатом, а не сутністю?

Репозиторій тепер — це просто «тупа» обгортка над базою даних. Він отримує готовий об’єкт Ride і кладе його в таблицю.

Зі збереженням нового обʼєкта все доволі просто. Як зберігаєте зміни у вже існуючих? Особливо це цікаво в контексті роботи з агрегатами.

Хороше питання , Дмитре , я явно поділяю агрегати , я стараюсь найти корінь того агрегату ,в моєму коді яскравий приклад це «Поїздка» це ідеальний корінь.Бо ти не можеш змінити статус оплати або адресу призначення, не звернувшись до конкретної поїздки

Спасибо за статью!

А по какой причине Вы выбрали хранить Price в float64?
Если взять для примера Uklon или Bolt там цена без центов, какая была мотивация такого подхода?
Как решаете проблему округления?

Это статья про ДДД или на микроскопический разбор деталей?
Я к тому что из всей статьи для себя только нашли то что автор хранит в 64 битовом флоате.

Мне лично всё равно что там одно поле «не такое».
Мне идея, практические нюансы более важны.

При всем уважении, Вы попусту ищете на что придираться дабы автора время на пустые полемики тратить.

Если вам хочется людей обучать микро-оптимизациям.
Клава и комп есть, пишите свои статьи.
Уважаемый, тема ДДД никогда не было про скорость.

Нет, это не

на микроскопический разбор деталей

статья, я поблагодарил автора за статью, вам возможно всё равно что там написано, Вы не поддержали комментарием, я вижу с того что Вы не оставили комментарий не поблагодарили автора, что тоже имеет смысл быть.
В статье прекрасные картинки, картинки в которых всё красиво показано, я бы мог спросить почему на картинки с слоями и описание слоев в разном порядке но я понимаю почему так может произойти. Автор не написал статью в вакууме что есть DDD и что я его вижу так, человек показал домен такси, и привёл вариант структуры, я считаю если автор что-то показывает то значит он желает чтобы люди на это обратили внимание или тогда зачем показывать структуру на 12 полей + комментарии? Если Вы видите эту статью в вакууме, она вам понравилась, то оставте комментарий, поделитесь впичетлениями.

Мой комментарий не был «а какого это ты х.ра float64 используешь», если вы не знаете проблему использовать float64 для цен, то почитайте это интересно. Тут не про оптимизацию то про прибыль которую можно потерять. Независимо ти думаешь доменами или таблицами в БД.

Уважаемый.
Пардон если я мыслю выдал как то агрессивно.
У меня не было цели в этом.

Проблему флотов знаю, на практике сталкивался при генерации налоговых отчетов для налоговой Германии, там потеря копейки в отчёте создаёт проблемы, потому мы решали хранением в целочисленном формате (заворачивая в money value object).

Насчёт поделится впечатлениями — Я не впечатлительный, чёрствый человек.

Насчёт поблагодарить — откуда инфа?) Я ему лично написал.
П.С. Трамп Зеленского тоже упрекал о неблагодарности 😂

Всех благ

По-перше я в жодному разі з міркувань NDA не виклав би комерційний код. По-друге я знайомий з концепцією Money Value Object , просто стаття не про це , а про використання DDD , це перша моя стаття та перша частина даної теми☺️. Погоджуюсь що доцільніше використовувати decimal , але як я вже і сказав стаття не про це. Я думаю я відповів на ваше запитання ☺️.

Дякую за статтю, чекаю продовження.

У своїх проєктах досі думаю категоріями «таблиць у БД», а в комерційних — мікс з DDD. Поки що жодного проєкту, де був би чистий DDD.

Дякую за статтю по DDD!

Борюсь зі складністю в проєктах, додаючи новий проєкт.

Підписатись на коментарі