Введение

Кризис программного обеспечения

В начале 60-х годов перед американскими инженерами встала задача, которую до этого никто никогда не решал - отправить человека на Луну. Четкого представления как это сделать ни у кого не было, но они точно знали, как нужно мыслить, чтобы справиться с такой проблемой. Эта манера мыслить называется “системным мышлением”, а реализуется оно с помощью системного подхода. Системное мышление лежит в основе любой инженерной деятельности (будь это программная, строительная или аэрокосмическая инженерия) и является ее базовым фундаментом.

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

С ростом вычислительных мощностей в 60-х годах перед компьютерными инженерами эта проблема встала особенно остро, и она получила название “кризис программного обеспечения”: программы становились все более сложными, трудно поддерживаемыми и хрупкими. Проблема была настолько существенной, что ее обсуждали на международной конференции НАТО в 1968 году, где впервые и был введен термин “программная инженерия”, призванный стимулировать поиск решений для выхода из кризиса и формализовать подходы к созданию более качественного, надежного и поддерживаемого программного обеспечения. С тех пор программная инженерия выросла в полноценную дисциплину и профессию, изучающую методы проектирования, разработки и сопровождения сложных систем.

Эволюция подходов к разработке ПО

Процедурный подход

С середины 20-го века и до конца 70-х годов в разработке программ доминировал процедурный подход. Он опирался на принципы процедурного программирования, часто с конструкциями GOTO, где данные и функции их обработки формально не были связаны. Теоретической моделью процедурного программирования служит абстрактная вычислительная система - машина Тьюринга, а сам подход отражает архитектуру традиционных ЭВМ (архитектуру фон Неймана).

С ростом сложности решаемых задач, такой подход и породил тот самый кризис программного обеспечения - код разрастался, становился трудно читаемым, запутанным и хрупким: любое изменение могло сломать систему, и держать в голове общую картину становилось практически невозможно. Старые методы оказались недостаточными для поддержки и развития сложных приложений.

ООП

Ответом на этот кризис стало развитие объектно-ориентированного подхода. Уже в 1967 году в языке Simula были предложены такие революционные идеи, как объекты, классы и виртуальные методы. Практическое воплощение этих идей пришло с появлением Smalltalk в начале 80-х годов - первого широко распространенного объектно-ориентированного языка. OOП позволил объединять данные и поведение в единые смысловые объекты, структурировать систему вокруг сущностей предметной области, а не только вокруг процедур и функций. Наследование, инкапсуляция и полиморфизм стали основными инструментами управления сложностью, позволяя создавать более устойчивый, понятный и расширяемый код.

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

Объектно-ориентированный подход дал разработчикам выразительные средства моделирования и организации кода, но реальная индустрия двинулась в сторону удобства и скорости. Вместе с бурным развитием веб-фреймворков, ORM и REST-сервисов, на первый план вышел CRUD-подход (от Create, Read, Update, Delete).

CRUD

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

Но у скорости всегда есть оборотная сторона - цена сложности. Когда приложения становились богаче на сценарии и бизнес-правила, CRUD переставал справляться. Логика начинала расползаться по слоям и дублироваться: что-то проверялось в контроллере, что-то в сервисе, что-то в базе, что-то - прямо на фронте. Каждое изменение бизнес-правила превращалось в охоту за призраками: одно и то же условие нужно было менять в нескольких местах, и если что-то упустить, то могла пострадать целостность (data integrity) и непротиворечивость (consistency) данных.

CRUD отлично служит там, где данные - это просто данные. Но когда предметная область становится более сложной, со своими законами, ограничениями и поведением, CRUD начинает трещать по швам.

DDD

И именно в этот момент на сцену вышел новый подход, который поставил в центр внимания не просто хранение данных, а правила и поведение в предметной области. В 2003 году увидела свет знаменитая “синяя книга” Эрика Эванса - “Предметно-ориентированное проектирование (DDD): структуризация сложных программных систем”. Она не предложила новый язык или фреймворк, но представила системный и целостный подход к проектированию приложений через модель реального мира, возвращая разработчиков к инженерному мышлению, сосредоточенному на смысле и поведении системы, а не только на структуре данных. DDD стало не просто архитектурным шаблоном, а образом мышления. Оно позволило вновь соединить то, что разделилось в CRUD-подходе: данные и смысл.
Классы и методы сущностей в DDD отражают реальные объекты в предметной области, их свойства, поведение и взаимодействия, тем самым моделируя внешний мир. А не служат лишь структурами для хранения и извлечения данных из базы (т.н. “анемичная модель”), как это обычно происходит при CRUD-подходе , когда поведение и правила предметной области “разбросаны” по разным методам сервисов и контроллеров.

В нулевые годы индустрия и сообщество разработчиков созревали для применения DDD. После облачной революции и развитием распределенных приложений появилась возможность строить более сложные и гибкие системы, где принципы DDD особенно хорошо проявляли свою ценность. К середине 2010-х DDD стал активно применяться в профессиональных командах, особенно при проектировании микросервисов и масштабируемых высоконагруженных корпоративных решений.

Сразу отмечу, что не стоит воспринимать DDD как противоположность ООП. Как в свою очередь ООП вырос из штанишек процедурного программирования, так и предметно-ориентированное проектирование является результатом развития и применения основных принципов и методов ООП.

Фундаментом для DDD является ООП. А фундаментом ООП в свою очередь выступает процедурное программирование.

Cтоит понимать, что DDD не противопоставляется и CRUD. Это не конкурирующие подходы, а инструменты для решения разных типов задач. CRUD-подход фокусируется на простоте и технической стороне работы с данными - создать, прочитать, обновить, удалить. Это удобно для простых систем, где бизнес-логика минимальна.

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

Структура DDD

DDD разделяется на две части

  1. Стратегические паттерны
  2. Тактические паттерны

Стратегические паттерны

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

К стратегическим паттернам относятся такие понятия как:

  • Предметная область (Domain)
  • Ограниченный контекст (Bounded context).
  • Единный язык (Ubiquitous Language)
  • Предметные подобласти (Subdomain)
  • Смысловое ядро (Core domain)
  • Служебные подобласти (Supporting subdomain)
  • Неспециализированные подобласти (Generic subdomain).
  • Карта контекстов (Context map)
  • Штурм событий (Event storming)

Тактические паттерны

Тактические паттерны помогают реализовать модель на практике, в виде кода. Этап реализации. Они описывают сущности, их поведение и взаимодействия внутри контекста. К ним относятся:

  • Сущность (Entity)
  • Объект-Значение (Value Object)
  • Агрегат (Aggregate)
  • Служба Предметной Области (Domain Service)
  • Событие (Domain Event)
  • Модуль (Module)
  • Фабрика (Factory)
  • Хранилищe (Repository)

С помощью DDD удобно проектировать как распределенные микросервисные системы, так и модульные монолиты с четко изолированными частями, благодаря ясной декомпозиции предметной области на ограниченные контексты (Bounded Context), разграничению ответственности агрегатов и согласованному использованию единого языка (Ubiquitous Language). Такой подход позволяет моделировать сложное поведение внутри каждого контекста, минимизируя нежелательные зависимости между частями системы и облегчая эволюцию приложения без разрушения его целостности.

В данной статье мы не будем подробно разбирать все эти паттерны, особенно стратегические, а сосредоточимся на практической стороне. Реализуем небольшой бизнес-процесс сначала с помощью CRUD подхода, а затем посредством DDD. И уже в ходе непосредственной DDD реализации разберем некоторые ключевые моменты основных тактических паттернов.

Чтобы не перегружать повествование излишними деталями, возьмем небольшую и довольно простую мини-модель предметной области - процесс онлайн-записи к врачу. Она простая на поверхности, но с кучей скрытых бизнес-правил. C ее помощью можно показать почти все, что делает DDD полезным: слоты расписания, ограничения по времени, отмена записей на прием, смена статуса, проверка пересечений, оплата и напоминания.

Постановка задачи

Необходимо разработать систему онлайн-записи к врачу, которая позволяет пациентам выбирать доступное время приема, оплачивать визит и при необходимости отменять его.
Система должна учитывать расписание врачей, контролировать доступность слотов и корректность оплат в рамках одного часового пояса.

Основные участники

  • Врач - создает расписание приемов, определяя даты, время и стоимость.
  • Пациент - выбирает врача и записывается на свободное время.
  • Пользователь - может совмещать роли врача и пациента, но не в рамках одного приема.

Функциональные сценарии

  1. Создание расписания врача
    Врач задает время и стоимость приемов. Система не допускает пересечений по времени и создание расписания на прошедшие даты.
  2. Запись пациента на прием
    Пациент может создать запись на свободный слот в статусе запланировано. Другой пациент может записаться на этот же слот только если пока нет оплаты от другого пациента.
  3. Подтверждение приема
    Запись становится подтвержденной после полной оплаты или если прием бесплатный.
  4. Отмена приема
    Пациент может отменить запись в любое время до начала приема. При отмене за 2 часа и более оплаченные средства возвращаются.
  5. Завершение приема
    После окончания визита врач переводит запись в статус завершено или отменено, если пациент не пришел. Завершенные и отмененные приемы изменению не подлежат.
  6. Учет оплат Система фиксирует все платежи по приему. Один прием может иметь несколько оплат, но их общая сумма не может превышать стоимость слота. После отмены или завершения визита новые оплаты запрещены.

Диаграмма переходов статусов записи на прием

flowchart TD

A["Создание записи"] --> B["Запланировано"]
B -->|Пациент оплатил или прием бесплатный| C["Подтверждено"]
B -->|Пациент отменил до начала приема| D["Отменено"]
C -->|Пациент отменил до начала или не пришел| D
C -->|Врач завершил прием| E["Завершено"]

D -->|Если была оплата и отмена за 2 часа и более до начала - вернуть средства| Z((Конец))
E --> Z

CRUD решение

В CRUD подходе вся логика реализации строится вокруг структуры хранения. Поэтому в первую очередь определяем сущности, которые мы будем сохранять в БД. Иными словами проектируем структуру таблиц БД, на базе объектно-ориентированного похода, стараясь следовать первым 3-4 правилам нормализации данных, в некоторых моментах сознательно пренебрегая ими, ради увеличения производительности и упрощения запросов.

Модель хранения данных

Исходя из этих соображений, проектируется так называемый “доменный слой” CRUD-приложения. Кавычки здесь намеренные: встречал, что создают проект или папку с именем Domain, где просто лежат сущности для Entity Framework. И на этом основании делается вывод, что в проектеы применяется DDD.

erDiagram
  USER ||--o{ DOCTOR : "has (cascade delete)"
  USER ||--o{ PATIENT : "has (cascade delete)"

  DOCTOR ||--o{ SCHEDULE : "has (cascade delete)"
  DOCTOR ||--o{ APPOINTMENT : "has"
  PATIENT ||--o{ APPOINTMENT : "books"
  SCHEDULE ||--o{ APPOINTMENT : "provides_slot_for"
  APPOINTMENT ||--o{ PAYMENT : "may_have"

  USER {
    int Id
    string Name
    string Email
    string Phone
  }

  DOCTOR {
    int Id
    int UserId
    string Specialization
  }

  PATIENT {
    int Id
    int UserId
    date DateOfBirth
  }

  SCHEDULE {
    int Id
    int DoctorId
    date Date
    time StartTime
    time EndTime
    bool IsBusy
    decimal Price
  }

  APPOINTMENT {
    int Id
    int DoctorId
    int PatientId
    int ScheduleId
    datetime ScheduledTime
    int Status
  }

  PAYMENT {
    int Id
    int AppointmentId
    decimal Amount
    int Status
    datetime PaymentDate
  }

Для наглядности я представил ER диаграмму таблиц БД, которая по своей структуре и связям соответствуют классам “доменной” модели CRUD решения.

Ниже можно посмотреть сами классы EF моделей и DbContext:

Как часто бывает в реальных CRUD-проектах, добавим паттерны Repository и UnitOfWork. Лично я не сторонник их использования поверх DbContext, поскольку он уже реализует оба паттерна, и дополнительный слой абстракций без явной необходимости обычно избыточен. Однако для большей реалистичности примера мы их все же применим.

Бизнес правила

Модель предметной области специально упрощена для учебных целей, но даже в таком виде она задает немало бизнес-ограничений, которые нужно проверять в коде. В реальных проектах подобных правил как правило значительно больше.

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

User

  • User.Email уникален в системе.

Doctor / Patient

  • Doctor.UserId и Patient.UserId должны ссылаться на существующий User.Id.
  • Один и тот же User может быть как Doctor так и Patient.
  • Один User не может быть связан с двумя разными Doctor или с двумя разными Patient.

Schedule

  • Schedule.StartTime < Schedule.EndTime.
  • Schedule.Date не может быть в прошлом
  • Для одного Doctor.Id не допускаются пересечения по времени слотов (Date, StartTime, EndTime).
  • Schedule.Price >= 0 (допускаются бесплатные приемы).
  • Нельзя изменять или удалять Schedule, если существует связанный Appointment
    со статусом AppointmentStatus.Confirmed или AppointmentStatus.Completed.
  • Schedule.IsBusy = true, если существует Appointment со статусом
    Confirmed.

Appointment

  • Appointment.Status при создании = Scheduled.
  • Один Patient не может иметь более одной активной записи (Scheduled или Confirmed) на один и тот же слот (Schedule.Id).
  • Другой пациент может создать запись на тот же слот в Scheduled только если нет Confirmed записи на этом слоте.
  • ScheduledTime не в прошлом и в пределах [Schedule.Date + StartTime, Schedule.Date + EndTime].
  • Appointment.DoctorId = Schedule.DoctorId.
  • Appointment.Doctor.UserIdAppointment.Patient.UserId.
  • Статус нельзя менять после Completed или Cancelled.
  • Completed можно установить только из Confirmed.

Payment

  • Amount > 0, Status оплат только Paid или Refunded.
  • Сумма всех Paid для одного Appointment.IdSchedule.Price.
  • Платеж запрещен, если для этого Schedule.Id уже есть Paid от другого пациента.
  • При полной оплате Appointment.Status = Confirmed, слот блокируется Schedule.IsBusy = true.
  • При отмене за ≥2 часа до начала слота все Paid переводятся в Refunded.
  • После Cancelled или Completed новые платежи запрещены для этого Appointment.Id.
  • Payment.AppointmentId должен ссылаться на существующий Appointment.Id.

При выполнении платежа требуется изменить статус записи на прием и обновить флаг занятости слота врача. В реальных проектах часто можно встретить, что это делается напрямую в базе данных, в сервисе PaymentService, что нарушает принцип единой ответственности. Правильнее разделить логику: смену статуса записи - в AppointmentService, смену занятости слота - в ScheduleService.

Однако возникает циклическая зависимость: AppointmentService должен обращаться к PaymentService при возврате оплаты, а PaymentService - к AppointmentService при подтверждении оплаты. Эту проблему решает PaymentOrchestrationService.

PaymentOrchestrationService
  • Координирует операции с платежами, изменением статуса записи и управлением занятостью слота в рамках одной транзакции (IUnitOfWork.ExecuteInTransactionAsync).
  • Устраняет циклическую зависимость между PaymentService и AppointmentService.
  • Централизует бизнес-правила:
    • Перевод записи между Scheduled и Confirmed.
    • Управление занятостью слота (IsBusy = true/false).
    • Контроль конкурирующих платежей: новый платеж не принимается, если слот уже оплачен другим пациентом.
  • Используется при создании, обновлении и удалении платежей:
    • Пересчет общей суммы оплат для записи.
    • Автоматическое подтверждение записи при полной оплате.
    • Обновление Schedule.IsBusy в соответствии с активными записями.
    • Блокировка возможности оплаты другими пользователями, если слот уже занят.
  • Все операции выполняются в одной транзакции UnitOfWork, гарантируя согласованность данных между платежами, записью и занятостью слота.

Заключение по CRUD решению

Даже в упрощенной учебной предметной области, далекой от сценариев реальных проектов, уже появляется большое количество бизнес-правил, условий и проверок, которые необходимо учитывать. В нашем примере сервис AppointmentService уже несколько раз рефакторился и оптимизировался, но в реальных проектах он часто выглядит совсем иначе:

  • проверки статусов записи и ограничений по слотам распределены по разным методам, иногда дублируются;
  • логика обработки оплаты и отмены перемешана с логикой создания записи;
  • методы разрастаются, становятся длинными и сложными для понимания;
  • взаимодействие с другими сервисами (например, уведомлениями или платежами) порой приводит к циклическим зависимостям.

При дальнейшем развитии системы и добавлении новой функциональности такие проблемы только усугубляются. Даже после многократного применения принципа DRY для устранения дублирования кода удерживать целостную картину реализации становится крайне сложно - разработчик постоянно рискует упустить важные условия и нарушить бизнес-правила.

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

DDD предлагает элегантное решение этих проблем. Вместо разрозненных проверок, разбросанных по сервисам, бизнес-правила инкапсулируются внутри агрегатов через инварианты. Например, агрегат предметной области может сам гарантировать:

  • нельзя создать сущность в состоянии, противоречащем бизнес-правилам;
  • определенные действия невозможны без предварительных условий (например, оплаты или подтверждения);
  • при отмене или изменении состояния автоматически выполняются сопутствующие действия, предусмотренные правилами.

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

Код демонстрационного примера

В рамках данного примера сознательно опущены вопросы сквозной функциональности, решаемые в реальных проектах, такие как:

  • централизованное логирование;
  • глобальная обработка ошибок;
  • локализация;
  • работа с часовыми поясами;
  • трассировка приложения;
  • авторизация и аутентификация;
  • а также другие инфраструктурные задачи.

В данном материале также не рассматриваются вопросы оптимизации производительности (например, замена вызовов ToLower() и ToUpper() на StringComparison.OrdinalIgnoreCase) и применения паттерна Specification для выноса логики работы с EF из сервисов. При желании валидации можно оформить в виде комбинированных правил на основе этого паттерна - для переиспользования и объединения в цепочки бизнес-правил, что упростит тестирование.

Рассмотрение этих аспектов выходит за рамки данной статьи и не влияет существенно на основную цель - анализ и сравнение CRUD и DDD подходов для структурирования сложных программных систем (а как мы увидели, даже такая упрощенная предметная область создання в учебных целях уже породила достаточно много сложности, что уж говорить о реальных кейсах).

Контроллеры и представления сгенерированы с помощью механизма скаффолдинга (MVC Controller with views, using Entity Framework). Полный проект с работающим демонстрационным примером CRUD-архитектуры доступен по адресу: DoctorBooking.CRUD.