Onion Architecture: что это?

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

Слоистая архитектура

Чтобы понять плюсы Onion Architecture, сначала стоит разобраться в недостатках Layered Architecture, или слоистой архитектуры. Это очень популярный подход, его можно встретить в большинстве backend-приложений.
В классической слоистой архитектуре каждый слой зависит от слоя под ним, и в самом низу находится база данных.
Зависимости в классической слоистой архитектуре
Зависимости в классической слоистой архитектуре
Все зависимости ведут к базе. Поэтому проектирование в таком подходе часто начинается не с use case и не с доменной логики, а со схемы базы данных.
Доменных сущностей в такой архитектуре обычно нет. Сущности повторяют таблицы и больше похожи на DTO, чем на доменные объекты с поведением. Именно с ними работает ORM:
[Table("slots")]
public class Slot
{
    public int Id { get; set; }
    public int DoctorId { get; set; }
    public DateTime StartsAt { get; set; }
    public bool IsBooked { get; set; }
}

[Table("appointments")]
public class Appointment
{
    public int Id { get; set; }
    public int SlotId { get; set; }
    public int PatientId { get; set; }
    public string Status { get; set; } = "";
    public DateTime CreatedAt { get; set; }
}
Добавлять логику в DTO из слоя инфраструктуры странно: все знают, что ей там не место. Поэтому логика, как правило, уходит в use case:
public class BookAppointmentUseCase
{
    private readonly ClinicDbContext _db;
    private readonly SmsClient _sms;

    public BookAppointmentUseCase(ClinicDbContext db, SmsClient sms)
    {
        _db = db;
        _sms = sms;
    }

    public async Task<int> Execute(BookAppointmentRequest request)
    {
        var slot = await _db.Slots.FirstOrDefaultAsync(s => s.Id == request.SlotId);
        if (slot == null)
            throw new InvalidOperationException("Слот не найден");

        if (slot.StartsAt <= DateTime.UtcNow)
            throw new InvalidOperationException("Нельзя записаться на прошедшее время");

        // «Один слот — одна запись»: флаг в slots плюс запрос в appointments
        var alreadyBooked = slot.IsBooked || await _db.Appointments
            .AnyAsync(a => a.SlotId == slot.Id && a.Status != "cancelled");
        if (alreadyBooked)
            throw new InvalidOperationException("Слот уже занят");

        var patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == request.PatientId);
        if (patient == null)
            throw new InvalidOperationException("Пациент не найден");

        var appointment = new Appointment
        {
            SlotId = slot.Id,
            PatientId = patient.Id,
            Status = "booked",
            CreatedAt = DateTime.UtcNow
        };
        _db.Appointments.Add(appointment);
        slot.IsBooked = true;
        await _db.SaveChangesAsync();

        await _sms.SendAsync(patient.Phone, $"Вы записаны на {slot.StartsAt:dd.MM HH:mm}");
        return appointment.Id;
    }
}
По сути, use case «прибит гвоздями» к инфраструктуре: флаг в таблице, запрос к базе, создание DTO. Слои вроде есть, а толку нет. Можно, конечно, добавить репозитории. Это немного улучшит ситуацию, но зависимость от базы никуда не денется.
Я думаю, ты видел такие проекты, а возможно, у тебя сейчас такой.

Что такое Onion Architecture

Onion Architecture — это архитектура, в центре которой стоит доменная модель, а все зависимости направлены к ней. Название подходу дал Джеффри Палермо в 2008 году. Его ключевая формулировка: «The database is not the center. It is external». База данных перестаёт быть центром и становится внешней деталью.
Кольца Onion Architecture
Чем ближе слой к центру, тем стабильнее его код
Чем ближе кольцо к центру, тем реже оно меняется. Внутренние кольца ничего не знают о внешних.
Число колец у Палермо не фиксировано, но роли у них такие:
  • доменная модель — сущности и их правила, ни от чего не зависит;
  • доменные сервисы — доменная логика, которая не принадлежит одной сущности;
  • Application Services — use case, то есть что делает система;
  • внешнее кольцо — всё, что часто меняется: UI, ORM, репозитории, интеграции и тесты.

Принципы: куда смотрят стрелки

Палермо предлагал называть Onion Architecture только решение, которое соблюдает все четыре принципа:
  1. Приложение построено вокруг независимой доменной модели.
  2. Внутренние слои объявляют интерфейсы, внешние их реализуют.
  3. Связанность направлена к центру.
  4. Код ядра компилируется и запускается отдельно от инфраструктуры.
Любое кольцо может зависеть от тех, что ближе к центру, но не от тех, что дальше:
Направление зависимостей в Onion Architecture
Направление зависимостей в Onion Architecture
Как видишь, из доменной модели не выходит ни одной стрелки: она не знает ни про базу, ни про фреймворк. В этом и состоит суть Onion Architecture.

Инверсия зависимостей

Инверсия зависимостей — это приём, при котором внутренний слой сам объявляет нужный ему интерфейс в своих терминах, а внешний слой его реализует. Такой интерфейс называют портом: через него ядро взаимодействует с внешним миром, не зная о конкретной реализации.
Инверсия зависимостей через порт
Инверсия зависимостей через порт
Порт отражает потребности домена и приложения: «найди слот», «сохрани слот». Палермо кладёт интерфейсы репозиториев в первое кольцо вокруг модели, а Ник Тьюн — в Application Services. Но в обоих случаях порт лежит внутри ядра (Core).

Запись к врачу по кольцам

Посмотрим, как изменится приложение, если применить Onion Architecture.

Доменная модель

Доменные сущности находятся в самом центре, в ядре. Они отражают понятия и поведение предметной области, а не таблицы в базе данных. Про БД и ORM они ничего не знают:
public sealed class Slot
{
    public Guid Id { get; }
    public DateTime StartsAt { get; }
    public Guid? BookedBy { get; private set; }

    public Slot(Guid id, DateTime startsAt, Guid? bookedBy = null)
    {
        Id = id;
        StartsAt = startsAt;
        BookedBy = bookedBy;
    }

    public void Book(Guid patientId, DateTime now)
    {
        if (StartsAt <= now)
            throw new DomainException("Нельзя записаться на прошедшее время");
        if (BookedBy is not null)
            throw new DomainException("На один слот — одна запись");

        BookedBy = patientId;
    }

    public void Cancel() => BookedBy = null;
}

public sealed class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
}

Application Services: тонкий use case и порты

Application Services — это сценарии приложения: загрузить данные, передать работу домену, сохранить результат, уведомить. Бизнес-решений use case не принимает. Его задача — оркестрировать конкретный сценарий, например «Записаться к врачу»:
public interface ISlotRepository
{
    Task<Slot?> FindAsync(Guid slotId);
    Task SaveAsync(Slot slot);
}

public interface IPatientNotifier
{
    Task NotifyBookedAsync(Guid patientId, DateTime startsAt);
}

public sealed record BookAppointment(Guid SlotId, Guid PatientId);

public sealed class BookAppointmentUseCase
{
    private readonly ISlotRepository _slots;
    private readonly IPatientNotifier _notifier;
    private readonly TimeProvider _clock;

    public BookAppointmentUseCase(ISlotRepository slots, IPatientNotifier notifier, TimeProvider clock)
    {
        _slots = slots;
        _notifier = notifier;
        _clock = clock;
    }

    public async Task Execute(BookAppointment command)
    {
        var slot = await _slots.FindAsync(command.SlotId)
            ?? throw new InvalidOperationException("Слот не найден");

        slot.Book(command.PatientId, _clock.GetUtcNow().UtcDateTime);

        await _slots.SaveAsync(slot);
        await _notifier.NotifyBookedAsync(command.PatientId, slot.StartsAt);
    }
}
Обрати внимание, как просто читается такой use case: из него сразу понятен сценарий, а инфраструктурные детали спрятаны за портами.

Внешнее кольцо: адаптеры

Внешнее кольцо отвечает либо за API, либо за инфраструктуру. Разберём это на примере репозитория для базы данных.
Таблица — это SlotRow, а доменная сущность — это Slot. Репозиторий переводит одно в другое:
[Table("slots")]
public class SlotRow
{
    public Guid Id { get; set; }
    public DateTime StartsAt { get; set; }
    public Guid? BookedBy { get; set; }
}

public sealed class EfSlotRepository : ISlotRepository
{
    private readonly ClinicDbContext _db;

    public EfSlotRepository(ClinicDbContext db) => _db = db;

    public async Task<Slot?> FindAsync(Guid slotId)
    {
        var row = await _db.Slots.FindAsync(slotId);
        return row is null ? null : new Slot(row.Id, row.StartsAt, row.BookedBy);
    }

    public async Task SaveAsync(Slot slot)
    {
        var row = await _db.Slots.FindAsync(slot.Id)
            ?? throw new InvalidOperationException("Слот не найден");

        row.BookedBy = slot.BookedBy;
        await _db.SaveChangesAsync();
    }
}
Если сменится ORM или база, придётся изменить только репозиторий. А если изменится доменное правило, мы поменяем только домен. Ответственность в приложении чётко разделена, поэтому его легче развивать, тестировать и поддерживать.

Поток выполнения

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

Чем Onion отличается от слоистой архитектуры

КритерийLayered ArchitectureOnion Architecture
Что в центреБаза данныхДоменная модель
Куда смотрят зависимостиСверху вниз, к даннымСнаружи внутрь, к домену
Где бизнес-правилаВ use case рядом с запросамиВ доменных сущностях и сервисах
ТестируемостьНужна база или моки ORMДомен тестируется без инфраструктуры
Цена замены инфраструктурыПравки во всех слояхНовая реализация порта

Когда Onion окупается, а когда нет

Для небольших приложений с простой бизнес-логикой этот подход может оказаться слишком дорогим. А вот долгоживущим бизнес-приложениям со сложным поведением Onion Architecture подходит отлично. Более высокие затраты на старте со временем окупаются.
Точка окупаемости Onion Architecture
Onion дороже на старте, но после точки окупаемости изменения обходятся дешевле
Onion дороже на старте, но его кривая растёт полого, и после точки окупаемости изменения обходятся дешевле.

Итог

  • Onion Architecture строится вокруг доменной модели: ядро объявляет порты, а внешние кольца их реализуют.
  • Onion Architecture отлично подходит для проектов со сложной бизнес-логикой.
  • Onion отвечает на вопрос, где разместить доменную модель, но не на вопрос, как её реализовать. За это отвечает другая практика — Domain-Driven Design.