5 причин, почему ваше приложение будет сложно тестировать

И как Clean Architecture и DDD помогают это исправить
Команды часто сталкиваются с проблемой: чем больше приложение, тем сложнее его тестировать.
Кажется, что без интеграционных тестов — никак. А юнит-тесты либо невозможно писать, либо они ничего не проверяют.

Звучит знакомо?

Вот 5 причин, почему ваше приложение неудобно тестировать, и как архитектура на основе DDD и Clean Architecture решает эти проблемы.
Примеры кода в этой статье приведены на языке C#. Однако сами принципы и антипаттерны, о которых мы говорим, не зависят от языка программирования. Всё сказанное справедливо для Java, Go, Python и других языков.
❌ 1. Бизнес-логика в контроллерах и хендлерах
Проблема: контроллеры (или хендлеры) содержат принятие решений, валидации, вычисления и условия.
[HttpPost("create")]
public IActionResult CreateOrder(CreateOrderRequest request)
{
    if (string.IsNullOrWhiteSpace(request.CustomerName))
        return BadRequest("Customer name required");

    var total = request.Items.Sum(i => i.Price * i.Quantity);

    if (total > 10000)
        return BadRequest("Total too high");

    _orderRepository.Save(request);
    return Ok();
}
Почему это плохо:
  • Такой код невозможно протестировать отдельно от HTTP-инфраструктуры
  • Логика расползается, копируется, не переиспользуется
  • При изменении бизнес-правил надо лезть в контроллеры

Как исправить:
  • Выделите Use Case (Application Service) — бизнес-логика должна быть вне контроллеров
  • Контроллер должен только делегировать вызов и переводить результат в HTTP-ответ
public interface ICreateOrderUseCase
{
    Task<Guid> Execute(CreateOrderDto dto);
}

public class CreateOrderUseCase : ICreateOrderUseCase
{
    private readonly IOrderRepository _repo;

    public CreateOrderUseCase(IOrderRepository repo)
    {
        _repo = repo;
    }

    public async Task<Guid> Execute(CreateOrderDto dto)
    {
        var order = new Order(dto.ProductId, dto.Quantity);
        await _repo.AddAsync(order);
        return order.Id;
    }
}
[HttpPost("create")]
public IActionResult CreateOrder(CreateOrderRequest request)
{
    var result = _createOrderUseCase.Execute(request);
    return result.IsSuccess ? Ok() : BadRequest(result.Error);
}
Выгоды:
  • Тестируется бизнес-логика без веб-сервера
  • Контроллеры становятся тонкими и устойчивыми
  • Код легче читать, поддерживать и покрывать юнит-тестами
❌ 2. Логика в UseCase + анемичные сущности
Проблема: всё поведение сосредоточено в UseCase, а доменные сущности — это просто DTO.
public class CreateOrderUseCase
{
    public Result Execute(CreateOrderRequest request)
    {
        if (request.Total > 10000)
            return Result.Fail("Too expensive");

        var order = new Order();
        order.CustomerName = request.CustomerName;
        order.Items = request.Items;
        _repo.Save(order);

        return Result.Ok();
    }
}
Почему это плохо:
  • Доменные объекты не гарантируют инварианты
  • Поведение размазано, нарушен SRP
  • Тестировать бизнес-правила сложно, они спрятаны в procedural-style коде UseCase

Как исправить:
  • Переместите бизнес-логику в доменные объекты
  • UseCase становится координатором — не носителем логики
public class Order
{
    public static Result<Order> Create(string customerName, List<Item> items)
    {
        var total = items.Sum(i => i.Price * i.Quantity);
        if (total > 10000)
            return Result.Fail("Too expensive");

        return Result.Ok(new Order(customerName, items));
    }
}
public class CreateOrderUseCase
{
    public Result Execute(CreateOrderRequest request)
    {
        var result = Order.Create(request.CustomerName, request.Items);
        if (result.IsFailure)
            return result;

        _repo.Save(result.Value);
        return Result.Ok();
    }
}
Выгоды:
  • Логика в одном месте — внутри сущностей
  • Тестирование бизнес-правил не требует моков
  • Появляется уверенность в корректности данных
А что со всем этим делать?
Как вы уже заметили, большинство этих проблем — это не про синтаксис, а про архитектуру. Когда бизнес-логика размазана по слоям, сущности анемичны, всё зависит от всего и тяжело тестируется — это сигнал не к «ещё одной проверке на null», а к пересмотру подхода к архитектуре.

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

На курсе «DDD и Clean Architecture на C#» мы пошагово реализуем архитектуру настоящего backend-продукта — с доменной моделью, use case’ами, адаптерами, интеграцией и тестами. Параллельно мы осваиваем практические паттерны SOLID, GRASP, Dependency Inversion, Builder, Factory, Strategy и другие, применяя их по делу.

👉 Если вы хотите, чтобы ваш код был гибким, тестируемым и отражал бизнес, а не мешал ему — приходите на курс.

Узнать подробнее и записаться →
3. Доступ к инфраструктуре из домена

Проблема: доменные классы напрямую используют базу, логгеры, время, ID-шники и другие внешние зависимости
public class Order
{
    public void CalculateDeliveryDate()
    {
        var now = DateTime.UtcNow;
        this.DeliveryDate = now.AddDays(3);
    }
}
Почему это плохо:
  • Нельзя изолировать домен от среды выполнения
  • Сложно писать repeatable-тесты (время, рандом, GUID и т.д.)

Как исправить:
  • Внедряйте зависимость как аргумент
  • Инфраструктура остаётся снаружи, домен — чистый
public void CalculateDeliveryDate(IDateTimeProvider timeProvider)
{
    this.DeliveryDate = timeProvider.Now.AddDays(3);
}
Или рассчитывайте дату в Application слое и передавайте в конструктор:
var deliveryDate = _clock.Now.AddDays(3);
var order = new Order(..., deliveryDate);
Выгоды:
  • Детерминированные тесты
  • Домен независим от среды и платформы
  • Поведение можно воспроизводить в любой момент
4. Отсутствие адаптеров
Проблема: слои системы связаны напрямую. Контроллеры используют Entity Framework модели, домен зависит от API моделей и наоборот.
public class OrderEntity // EF-модель
{
    public string CustomerName;
    public List<OrderItemEntity> Items;
}

public class OrderController
{
    public IActionResult Create(OrderEntity entity)
    {
        _orderService.Create(entity); // напрямую
    }
}
Почему это плохо:
  • Любое изменение модели приводит к каскадным правкам по всей системе
  • Нельзя переиспользовать логику в другом контексте (например, в консоли или тесте)

Как исправить:
  • Вводите явные адаптеры между слоями (mapper / translator)
  • Каждый слой оперирует своими моделями
public IActionResult Create(OrderRequestModel model)
{
    var command = _mapper.Map(model); // mapping от web к use case
    var result = _useCase.Execute(command);
    return result.IsSuccess ? Ok() : BadRequest(result.Error);
}
Выгоды:
  • Слои изолированы
  • Вы можете переиспользовать домен и use-case в других интерфейсах (UI, API, CLI)
  • Модели легко эволюционируют независимо
5. Нарушение SRP в UseCase: God-class с кучей зависимостей
Проблема: Application Service содержит 10+ методов и кучу зависимостей в конструкторе. Класс раздувается и становится неуправляемым.
public class OrderService
{
    private readonly IOrderRepository _repo;
    private readonly ILogger _logger;
    private readonly INotifier _notifier;
    private readonly IPaymentGateway _payment;
    private readonly ICourierScheduler _courier;
    private readonly IWarehouseClient _warehouse;
    private readonly IEmailSender _email;
    // ... ещё 3-4 зависимости

    public void Create(...) { }
    public void Cancel(...) { }
    public void Pay(...) { }
    public void Notify(...) { }
    public void Ship(...) { }
    // ... ещё методы
}
Почему это плохо:
  • Сложно понять ответственность класса
  • Тесты становятся болью — надо мокать всё
  • Изменения одного метода могут сломать другие

Как исправить:
  • Разделите Use Case на отдельные классы по действиям (CreateOrder, CancelOrder и т.д.)
  • Каждому классу — только нужные зависимости
public class CreateOrderUseCase
{
    private readonly IOrderRepository _repo;
    private readonly IEmailSender _email;

    public Result Execute(CreateOrderRequest request)
    {
        var order = Order.Create(...);
        _repo.Save(order);
        _email.SendConfirmation(order);
        return Result.Ok();
    }
}
Выгоды:
  • Маленькие классы с одной задачей
  • Минимум зависимостей в каждом классе — проще тесты
  • Код легче читать и сопровождать
Как избежать всех этих ошибок?
Если вам тяжело писать тесты — проблема почти всегда в архитектуре:
  • слои смешаны
  • логика размазана
  • отсутствуют границы и адаптеры
Хорошая архитектура делает тестирование лёгким и быстрым.

Хочешь научиться строить такие архитектуры на практике?
Приходи на курс «Domain Driven Design и Clean Architecture на языке C#» (есть версии на Go, Java).

Там мы всё это отрабатываем на реальных кейсах!