10 вопросов от junior для senior разработчиков
Не только ответы но и конкретные примеры .NET: C#, ASP.NET Core, EF Core Стек: .NET (C#, ASP.NET Core, EF Core).
1. Как понять, где действительно нужен ИИ, а где это «модный шум»?
ИИ оправдан, когда задача недетерминированная по своей природе и её нельзя свести к правилам:
- классификация текста, писем, тикетов поддержки;
- извлечение структурированных данных из неструктурированных (PDF, письма, чаты);
- генерация и перефразирование текста;
- нечёткий поиск и рекомендации;
- распознавание изображений и речи.
ИИ — плохой выбор, когда есть чёткие правила:
- валидация форм, расчёт налогов, бухгалтерские проводки — это
switchи обычный код; - задача, где ошибка стоит дорого и нужна 100% предсказуемость (проведение платежа, списание со счёта);
- «нам нужно, чтобы бот отвечал на всё» без KPI качества.
Практический чек-лист перед внедрением:
- Есть ли размеченные данные или хотя бы 100–200 примеров «правильных ответов»?
- Какую метрику качества вы измеряете (точность классификации, F1, доля корректных ответов)?
- Какая цена ошибки? Если ответ можно дёшево проверить человеком — ИИ подходит.
- Есть ли простое правило, дающее 80% результата? Тогда начните с правила, ИИ добавьте сверху как fallback.
Для бизнеса формулировка одна: «Ручная обработка стоит X рублей в месяц. Решение на ИИ стоит Y рублей и обрабатывает Z% автоматически с качеством N%. Отбиваемся за M месяцев».
2. Как интегрировать стороннюю ML-модель через API в .NET-сервис, чтобы это не стало «чёрным ящиком»?
Ключевые принципы: модель — это внешняя зависимость с деградирующим SLA, а не магия. Относитесь к ней как к unreliable third-party API.
Резервный тайм-аут, retry, circuit breaker — через Polly:
services.AddHttpClient<ITextClassifier, OpenAiTextClassifier>(client =>
{
client.BaseAddress = new Uri("https://api.openai.com/v1/");
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddResilienceHandler("ml-pipeline", b => b
.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential
})
.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
MinimumThroughput = 10,
SamplingDuration = TimeSpan.FromSeconds(30)
}));
Деградация вместо падения. У клиента должен быть fallback-режим: если ML недоступен, тикет уходит в ручную очередь модерации, а не в исключение.
Наблюдаемость. Логируйте идентификатор запроса к модели, версию модели, промпт (в обрезанном виде), токены, латентность и итоговый ответ. Всё в OpenTelemetry: метрика «доля ответов, отвергнутых проверкой качества» — главный сигнал, что модель деградировала.
Контракт. Модель не должна возвращать «просто текст» в домен. Определите DTO с валидацией:
public record ClassificationResult(
string Category,
double Confidence,
string RawResponse,
string ModelVersion);
// Валидация на границе — модель может вернуть мусор
public bool IsValid(out string error)
{
if (Confidence < 0.6) { error = "Low confidence"; return false; }
if (!KnownCategories.Contains(Category)) { error = "Unknown category"; return false; }
error = null;
return true;
}
Версионирование промптов. Храните промпты в конфигурации или БД с версией, логируйте версию вместе с ответом — иначе не сможете объяснить, почему поведение изменилось после обновления.
Откат. Если модель работает хуже (или провайдер поднял цены), вы переключаетесь на предыдущую версию или на fallback-режим через feature flags, без деплоя.
3. Как тестировать код, зависящий от недетерминированных ответов LLM?
Ключ: отделить детерминированную логику от недетерминированной модели и тестировать каждую по-своему.
Слои тестов:
- Unit-тесты вашей логики — модель замокана интерфейсом:
public interface ITextClassifier
{
Task<ClassificationResult> ClassifyAsync(string text, CancellationToken ct);
}
[Fact]
public async Task Low_confidence_result_goes_to_manual_review()
{
var classifier = Substitute.For<ITextClassifier>();
classifier.ClassifyAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ClassificationResult("spam", 0.3, "...", "gpt-4o"));
var handler = new TicketHandler(classifier, reviewQueue: mockRepo);
await handler.HandleAsync(new Ticket("Some text"));
await mockRepo.Received(1).EnqueueForReviewAsync(Arg.Any<Ticket>());
}
-
Golden-датасет — 50–200 реальных примеров с ожидаемыми ответами. Прогоняете промпт через реальную модель (можно в ночном CI с температурой 0) и сравниваете. Порог: «95% примеров классифицированы верно — релиз ок».
-
Снапшот-тесты промптов — зафиксированные «хорошие» промпты. Изменение промпта требует обновления снапшота и осознанного review.
-
Регрессия при смене версии модели. Держите пайплайн, который при обновлении модели прогоняет golden-датасет и сравнивает метрики со старой версией. Падение качества — блокер.
-
Сэмплы в проде — сэмплирование ответов и ручная проверка, плюс реакция пользователей (кнопка «это неверный ответ»).
Главное правило: не тестируйте саму модель — тестируйте ваш контракт с ней и реакцию системы на любой ответ.
4. Наследование или композиция? Красные флаги наследования
Правило по умолчанию: композиция. Наследование — осознанное исключение, когда есть настоящая иерархия «является» и подкласс действительно специализирует базовый класс по контракту.
Красные флаги, что наследование выбрано плохо:
- Базовый класс знает о наследниках (
if (this is PremiumAccount)в базовом классе); - Наследник переопределяет метод и игнорирует контракт базового (бросает
NotSupportedException— это нарушение LSP); - Наследование используется ради переиспользования кода, а не ради полиморфизма («
Userнаследуется отPersonради методаGetFullName»); - Базовый класс постоянно меняется, и это ломает наследников («хрупкий базовый класс»);
- Наследник переопределяет метод лишь для того, чтобы вставить логику до/после вызова
base— это композиция с декоратором.
Классический .NET-пример неудачного наследования — List<T> как базовый класс. Нужен список с валидацией? Используйте композицию:
// Плохо: List<T> наружу, нарушения контракта неизбежны
public class Cart : List<CartItem> { }
// Хорошо: инкапсуляция
public class Cart
{
private readonly List<CartItem> _items = new();
public void Add(CartItem item)
{
if (item.Quantity <= 0)
throw new ArgumentException("Quantity must be positive");
_items.Add(item);
}
public IReadOnlyCollection<CartItem> Items => _items;
}
Официальные guidelines .NET (Framework Design Guidelines) говорят то же: предпочитайте композицию, публичное наследование от собственных коллекций — антипаттерн.
Композиция + декоратор — штатный способ «дорисовать» поведение в .NET: middleware, ILogger-обёртки, IAsyncEnumerable, Polly-политики — везде внутри именно композиция.
5. Как распознать God Object и безопасно его разбить
Признаки:
- Файл больше 500–1000 строк, а класс — больше 15–20 публичных методов;
- Вы не можете написать unit-тест без
Substitute.For<>для 10+ зависимостей; - Класс называется
Manager,Service,Processor,Helper,Controller— и при этом делает всё; - Половина полей класса не используется в половине методов;
- Одно изменение ломает сразу несколько непохожих фич.
Метрики, на которые стоит смотреть в анализаторах (Roslyn Analyzer, SonarQube, NDepend): Cyclomatic Complexity > 15 на метод, Coupling (число зависимостей) > 7, LCOM (Lack of Cohesion of Methods) — низкая связность данных внутри класса.
План безопасного разбиения:
- Покрыть тестами до рефакторинга. Характеризационные тесты — фиксируем текущее поведение, даже если оно кривое.
- Найти естественные швы: группы методов, использующие одно и то же подмножество полей. Это кандидаты в новые классы.
- Извлекать по одному классу за раз, не меняя внешнее поведение. Каждый шаг — отдельный PR, который можно откатить.
- Использовать переходные фасады: старый класс остаётся точкой входа, новые классы приходят к нему как зависимости.
- Прогонять миграцию под feature flags, если риск велик.
Типичная структура после разбиения God-контроллера:
// Было: OrderProcessor на 2000 строк
// Стало:
public class CreateOrderHandler // use case
{
private readonly IOrderRepository _orders;
private readonly IPricingService _pricing;
private readonly IUnitOfWork _uow;
public async Task<Result<OrderId>> HandleAsync(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId);
foreach (var line in cmd.Lines)
{
var price = await _pricing.GetPriceAsync(line.Sku, ct);
order.AddLine(line.Sku, line.Quantity, price);
}
_orders.Add(order);
await _uow.SaveChangesAsync(ct);
return Result.Ok(order.Id);
}
}
6. SRP на практике: как не превратить его в «каждый класс — одна строчка»
Формулировка Роберта Мартина, которую стоит держать в голове: «класс должен иметь одну причину для изменения» — и она связана с конкретным стейкхолдером (кто заинтересован в этом коде), а не с числом строк.
Плохой критерий — «маленький». Хороший — «кто спрашивает об этом классе изменения?»
// Одна причина для изменения — «правила расчёта заказа»
public class OrderCalculator
{
public Money CalculateTotal(Order order, IDiscountPolicy discounts) =>
order.Lines.Sum(l => l.Price * l.Quantity) - discounts.Apply(order);
}
// Другая причина для изменения — «способ сохранения»
public class OrderRepository : IOrderRepository { /* EF Core */ }
// Третья — «формат уведомлений»
public class OrderConfirmationEmailComposer { /* ... */ }
Золотая середина:
- Класс
Orderс методамиAddLine,RemoveLine,CalculateTotal— норм. Это один доменный концепт. - Класс, который парсит email, сохраняет заказ, отправляет уведомление и логирует — не норм. У него минимум четыре причины меняться: формат email, схема БД, шаблон письма, требования к логированию.
- Не делайте классы на 5 строк из-за SRP. Если класс не имеет самостоятельного смысла без соседа — он не отдельная ответственность, а приватная деталь.
Практический тест: если описание класса требует союза «и» («этот класс валидирует и сохраняет заказы») — там два класса.
7. Настоящий агрегат в DDD: границы, инварианты и типичные ошибки
Агрегат — это граница транзакционной консистентности. Внутри — инварианты, которые обязаны выполняться всегда, после любой операции. Снаружи — только идентификатор корня.
Критерии правильного агрегата:
- Все инварианты агрегата проверяются за одну транзакцию;
- Внешний мир ссылается на агрегат только по ID, а не по объектной ссылке;
- Изменения внутри агрегата идут только через корень (
order.AddItem(...), а неitem.Quantity = 5извне).
Типичная ошибка — неверно определённая граница. Например, инвариант «сумма позиций заказа не превышает кредитный лимит клиента» не может жить в агрегате Order, если лимит в агрегате Customer:
// Инвариант: total <= creditLimit. Это пересекает два агрегата!
// Решение: доменный сервис, а не «растягивание» агрегата
public class OrderPlacementService
{
public async Task<Result> PlaceAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
var customer = await _customers.LoadAsync(cmd.CustomerId);
var order = Order.Create(cmd.CustomerId, cmd.Lines);
// Кросс-агрегатное правило — здесь, в сервисе
if (order.Total > customer.AvailableCredit)
return Result.Fail("Credit limit exceeded");
_orders.Add(order);
customer.RegisterOrder(order.Id);
await _uow.SaveChangesAsync(ct);
return Result.Ok();
}
}
Ошибки, которые делают все:
- Слишком большие агрегаты («Order содержит Customer, Product, Store...») — приводят к проблемам блокировок и параллелизма. Правило: агрегат — минимально необходимый для инвариантов набор.
- Слишком мелкие агрегаты — «каждая сущность — корень» — теряют инварианты, логика уезжает в сервисы-обёртки над CRUD.
- Навигационные свойства вместо ID. В EF Core не оставляйте навигации на объекты других агрегатов — только
OrderId,CustomerId. - Репозитории для не-корневых сущностей.
IOrderLineRepository— сигнал, что границу вы нарисовали неверно. - Игнорирование eventual consistency. Между агрегатами обновления — через доменные события (
OrderPlaced,CreditReserved), а не в одной транзакции.
8. Как внедрять DDD в легаси с «таблично-ориентированной» моделью
Пошагово, без слома за неделю:
Шаг 1. Карта домена. Установите ubiquitous language с бизнесом: список терминов («заявка», «отгрузка», «ставка») и контекстов, где они живут. Без этого DDD не взлетает — вы будете спорить о словах в PR.
Шаг 2. Выделите один ограниченный контекст — тот, где больше всего боли от легаси (частые баги, сложные изменения). Не пытайтесь переписать всё.
Шаг 3. ACL (антикоррупционный слой). Новая модель не должна тянуть легаси-таблицы напрямую. Стройте адаптер:
// Легаси: flat-таблица ORDERS с 40 колонками
// Новая модель: чистая доменная сущность
public class LegacyOrderAdapter : IOrderRepository
{
public async Task<Order?> LoadAsync(OrderId id, CancellationToken ct)
{
var row = await _db.Orders.FirstOrDefaultAsync(o => o.Id == id.Value, ct);
if (row is null) return null;
// Транслируем легаси-представление в доменную модель
var order = Order.Rehydrate(new OrderId(row.Id),
new CustomerId(row.CustomerRef),
MapStatus(row.StatusCode),
row.Lines.Select(MapLine));
return order;
}
private static OrderStatus MapStatus(string legacy) => legacy switch
{
"N" => OrderStatus.New,
"C" => OrderStatus.Confirmed,
"X" => OrderStatus.Cancelled,
_ => throw new InvalidOperationException($"Unknown status: {legacy}")
};
}
Шаг 4. Постепенно переносите логику из сервисов. Анемичная модель + OrderService на 3000 строк → начинаем двигать методы в Order. Подход: стрижка, а не big bang. Сначала новые use cases пишутся на новой модели, старые — трогаем по мере касания.
Шаг 5. Стратификация. Внутри нового контекста: домен (без зависимостей от EF, HTTP, JSON) → application (use cases) → infrastructure (EF Core, интеграции). В легаси эта граница обычно стёрта — восстанавливайте её.
Шаг 6. Интеграция через доменные события, а не через прямые запросы между контекстами. Публикуете OrderPlaced в брокер (RabbitMQ/Kafka/MassTransit) — соседний контекст подписывается.
Типичные ошибки: пытаться внедрить DDD «сверху» во всём приложении сразу; строить ACL, который со временем обрастает собственной логикой бизнес-правил; отправлять доменные события, не обеспечив атомарность (используйте Outbox-паттерн).
9. Паттерны, которые чаще всего переиспользуют неправильно
Singleton. В .NET правильный «синглтон» — DI-контейнер с Singleton lifetime. Ручной static-класс с Instance — антипаттерн: скрытая зависимость, проблемы в тестах, статики под нагрузкой и в async-мире. Для кэша: IMemoryCache / HybridCache из Microsoft.Extensions.Caching — а не свой singleton-словарь (который ещё и не потокобезопасен, пока вы не задумались).
// Плохо
public sealed class Cache
{
public static Cache Instance { get; } = new();
private readonly Dictionary<string, object> _map = new();
// не потокобезопасно, негде подменить в тесте
}
// Хорошо
services.AddMemoryCache();
services.AddSingleton<IOrderCache, OrderCache>();
Repository. Ошибки: репозиторий поверх EF Core, который просто проксирует DbSet (GetAll(), GetById()) — это лишний слой без пользы; репозиторий, возвращающий IQueryable наружу — бизнес-логика по фильтрации утекает в контроллеры. Правильно: репозиторий для агрегата (см. вопрос 7), возвращающий готовые агрегаты, а не таблицы.
Service. OrderService, UserService с 30 методами — это тот же God Object из вопроса 5, просто с модным именем. Замените на use-case-классы (CreateOrderHandler, CancelOrderHandler).
Factory. Если для создания объекта нужен просто конструктор — фабрика не нужна. Фабрика оправдана, когда: создание с побочными эффектами (загрузка из БД), условный выбор семейства объектов, объект должен создаваться внутри агрегата для сохранения инвариантов (Order.Create(...) — это фабричный метод, и он уместен).
Observer / события. Для UI — да, reactive-подход. Для бизнес-потока «создать заказ → списать деньги → отправить письмо» синхронные C#-события не годятся: не контролируете порядок, отсутствие ретраев, «теряются» подписчики. Используйте message broker (MassTransit + RabbitMQ) или хотя бы MediatR с осознанным пониманием, что это in-process диспетчер, а не очередь.
DTO ≠ Domain Model. Возвращать доменную сущность из API-эндпоинта напрямую — путь к утечке контракта и проблемам с сериализацией циклов. Слой DTO/маппинга обязателен на границах.
10. Как выбрать паттерн, когда подходят 2–3 варианта, и обосновать выбор команде
Фреймворк принятия решения — пять вопросов:
- Что изменится вероятнее всего? (новые требования). Если сценарий «добавить новый тип X» — Strategy/полиморфизм. Если «добавить новое поведение к существующим типам» — декоратор/расширение.
- Какая цена ошибочного решения? Обратимо ли (дёшево откатить) или дорого (изменение схемы БД, публичного API)?
- Простота vs гибкость. Не платить за абстракцию, которая не окупится (YAGNI). Один интерфейс с одной реализацией — чаще всего ок, если оправдан тестированием.
- Тестируемость. Можно ли подменить зависимость в тесте? Если нет — выбор плох.
- Читаемость. Поймёт ли разработчик через полгода без вас?
Пример trade-off таблицы для выбора между стратегиями кэширования:
| Критерий | In-memory (IMemoryCache) | Distributed (Redis) | Без кэша |
|---|---|---|---|
| Латентность | ~1 мкс | ~1 мс | — |
| Консистентность | eventual per-node | eventual | сильная |
| Сложность | низкая | средняя | нет |
| Масштабирование | нет (per-node) | да | — |
| Стоимость отказа | падает узел | падает Redis | нет риска |
Как описывать компромисс в документации: формат ADR (Architecture Decision Record). Короткий шаблон:
# ADR-007: Хранение корзины в Redis вместо In-Memory
## Контекст
Кластер из 5 узлов; корзина должна быть доступна независимо от узла.
## Решение
Используем Redis как единственное хранилище корзины, TTL 72 часа.
## Альтернативы
- In-memory + sticky sessions — отклонено: не выдержит автоскейлинг.
- SQL — отклонено: частые записи корзины создадут нагрузку.
## Последствия
Нужен fallback-режим при недоступности Redis (корзина пуста + запрос повторить).
В PR-комментарии — компактнее: «Здесь использую декоратор вместо наследования, потому что X, альтернатива Y — вот её минусы». Три-четыре предложения достаточно. Если решение требует абзаца — оформляйте ADR, а не прячьте в комментарии.
Видео
Мои видео
Boosty.to | YouTube | Yandex.Дзен | RuTube | VK Video
Еще вопросы и еще ответы
На странице Вопросы и ответы (FAQ) есть другие ответы на другие вопросы. Простые и сложные, на разные темы.
