Architecture First: как в одиночку с ИИ сделать альтернативу Jira — Часть 9.
В Предыдущей статье в приложении появился бэкенд для авторизации. В этой мы разрабатываем первую реальную фичу — домен issues — и проектируем для него базу данных.
Домен issues — часть приложения, связанная с работой с задачами, их статусами и эпиками, позволяющая управлять всем этим через веб-интерфейс. Мы не будем разбирать, как сделать CRUD для работы с задачами — исходный код открыт, да и в вебе полно разных гайдов. Расскажем об одной из практических проблем при проектировании схемы БД: как представить issue, не принадлежащий ни одному эпику. Очевидный ответ — сделать внешний ключ StatusId nullable, и мы начали с такого подхода. Но позже это сильно усложнило весь код, и мы разберём подробно — почему.
Модели Issue, Status и Epic
Issue — это карточка на доске, которую создаёт бот при обработке сообщения. Она также может быть создана и через веб-интерфейс напрямую:
public class Issue
{
public long Id { get; set; }
[MaxLength(4096)]
public required string? Content { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public Guid UserId { get; set; }
public User? User { get; set; }
public long StatusId { get; set; }
public Status? Status { get; set; }
public TelegramMessage? TelegramMessage { get; set; }
public long? TelegramMessageId { get; set; }
}
Ключевая связь — StatusId: issue всегда находится в каком-то статусе, а статус всегда связан с какой-то доской (то есть эпиком). То есть Status — это одна колонка на доске:
public class Status
{
public long Id { get; set; }
[MaxLength(128)]
public string Name { get; set; } = string.Empty;
[MaxLength(7)]
public string Color { get; set; } = string.Empty;
public long EpicId { get; set; }
public Epic? Epic { get; set; }
public int SortOrder { get; set; }
public IList<Issue>? Issues { get; set; }
}
Статус принадлежит эпику через StatusId. Цепочка получается такая: Issue → Status → Epic — issue не ссылается на свой эпик напрямую, а добирается до него через статус.
Nullable внешний ключ или строка по умолчанию?
Большинство issue находятся в каком-то эпике; но приложение также предусматривает и бэклог — в котором задачи еще не принадлежат ни к одному эпику. Получается, что схема БД должна учитывать возможности создания issue без эпика и сделать это можно двумя способами.
Первый способ: сделать внешний ключ nullable.
В бэклоге у задач нет статуса, а значит с точки зрения моделей issue могло бы иметь nullable поле StatusId, и null означал бы бэклог. С подобной конструкции мы и начали.
На практике подход принес достаточно много проблем. Когда null означает не «отсутствие», а «бэклог», каждый кусок кода обязан это помнить и учитывать в своей реализации. В коде множатся ветки if (StatusId == null) || …, постоянно приходится задаваться вопросом, нужно ли писать какую-то дополнительную логику или валидацию, если в поле статуса находится null. Словом, каждая новая фича, работающая с issue, вынуждена заново обрабатывать null-случай.
Вторая проблема: у бэклога теряется возможность кастомизации. С точки зрения интерфейса, бэклог может вести себя частично как остальные эпики (переименование, изменение цвета), и nullable-модель не позволяет обработать такие случаи изящно: либо нужно создавать отдельную таблицу, дублирующую поля из epics, с ветвлением логики на бизнес-слое, либо смириться с бэклогом, который нельзя будет настраивать.
Второй способ: строка по умолчанию.
Для каждого пользователя при его создании в базу добавляется дефолтный эпик с одним дефолтным статусом, то есть бэклог - это эпик, содержащий флаг IsDefault в модели:
public class Epic
{
public long Id { get; set; }
[MaxLength(128)]
public required string Name { get; set; }
public Guid UserId { get; set; }
public long SpaceId { get; set; }
public bool IsDefault { get; set; }
public IList<Status>? Statuses { get; set; }
// ... временные метки
}
С таким подходом в коде сложнее допустить критические ошибки, вроде показа задач из бэклога пользователю, которому они не принадлежат. Но, как и у любого подхода, он имеет свою цену. Дефолтный эпик обязан существовать у каждого пользователя, и нужно гарантировать его создание — мы остановились на схеме, когда бэклог создается при регистрации. Кроме того, неизбежны появления ветвлений в стиле, если эпик является бэклогом - то удаление запрещено (как и на бэкенде так и на фронтенде). Однако, подобные ветвления реализуются только в нескольких местах, где логика у обычных эпиков и бэклога должна отличаться, а не расползаются по коду всех сервисов.
На будущее мы определились, что nullable внешний ключ используем, когда null означает действительно отсутствующую связь: сотрудник без руководителя в manager_id, заказ без назначенного курьера. Хотя для некоторых случаев nullable колонка и кажется хорошим вариантом, стоит помнить, что с разрастанием функциональности может появиться кейс, который тяжело обработать подобным подходом.
Избыточность данных или дополнительный join
Цепочка связей Issue → Status → Epic означает, что для поиска эпика issue базе приходится делать джойн: issues → statuses → epics. Этого можно было бы избежать, добавив связь с EpicId сразу в issue — к такой оптимизации иногда прибегают, чтобы ускорить чтение.
Мы отказались от преждевременных оптимизаций, ведь хранение дополнительного поля потребовало бы дублирования информации в базе и корректного ее обновления — это потенциальный источник багов. На текущем масштабе лишний join не приносит проблем, а вот отлов потенциальных багов мог бы отнять много времени у разработчиков.
Бэкенд: контроллеры, сервисы уровня Host, сервисы уровня Core
При разработке бэкенда мы стараемся придерживаться многослойной архитектуры. Проще всего это увидеть на конкретном примере - создании issue.
Запрос принимает — тонкий IssuesController. Все его эндпоинты требуют авторизации — контроллер помечен атрибутом [Authorize]. Задача каждого из методов контроллера — обогатить запрос данными пользователя из HttpContext.User и перенаправить его к сервису уровня Host IssuesService:
public interface IIssuesService
{
Task<ColumnIssues[]> GetBoard(
GetBoardRequest request,
CancellationToken cancellationToken);
Task Delete(DeleteIssueRequest request, CancellationToken ct);
Task<long> Create(CreateIssueRequest request, CancellationToken ct);
Task Update(UpdateIssueRequest request, CancellationToken ct);
Task<IssueDetailDto> GetIssue(
GetIssueRequest request,
CancellationToken cancellationToken);
}
В задачу уровня сервиса входит проверка прав вызывающего, валидация, открытие транзакции, если это необходимо, маппинг данных. Сервис не выполняет самостоятельно в базе операции create/update/delete - за это ответственен сервис уровня Core ICoreIssuesService, обрабатывающий общую логику изменений:
public interface ICoreIssuesService
{
Task<long> Create(
CreateIssueRequest request,
CancellationToken cancellationToken);
Task Update(
long issueId,
Action<UpdateSettersBuilder<Issue>> setters,
CancellationToken cancellationToken);
Task Delete(
long id,
CancellationToken cancellationToken);
}
Как результат, маршрут запроса при создании issue выглядит так: контроллер → host-сервис (проверка прав, открытие транзакции) → core-сервис (запись строки).
Разделение ответственности между Host и Core сервисами
Задачей Core-сервиса является инкапсуляция сложной переиспользуемой логики. Сервисы уровня Host могут просто вызвать обновление issue и не думать о том, что помимо обновления сущности Issue нужно не забыть обновить TouchedAt у эпика, в котором issue находится, или добавить запись об изменении в таблицу истории изменений.
Причина разделения: core-сервисы используют и web API и telegram хосты. Issue может быть изменен и из Telegram-чата и из Web-интерфейса, при этом изменение должно быть корректно обработано в обоих случаях. Мы избегаем дублирования кода, но в то же время оставляем пространство для маневров на уровне хостов (например, делая валидацию по-разному).
Проверка прав доступа
На текущем этапе доступ разграничивается достаточно просто — пользователь может читать и менять только созданные им issue и эпики. Но так как в будущем закладывается возможность добавления режима организации, выносим методы для получения доступных сущностей в отдельный класс AccessService — это позволит сократить время на рефакторинге:
public interface IAccessService
{
Task<T> GetAvailableEpics<T>(
Guid userId,
Func<IQueryable<Epic>, Task<T>> map,
CancellationToken cancellationToken);
Task<T> GetAvailableIssues<T>(
Guid userId,
Func<IQueryable<Issue>, Task<T>> map,
CancellationToken cancellationToken);
Task<bool> CanMoveToStatus(
Guid userId, long statusId, CancellationToken cancellationToken);
Task<bool> CanModifyStatus(
Guid userId, long statusId, CancellationToken cancellationToken);
}
GetAvailableEpics и GetAvailableIssues принимают userId и функцию map, которая выполняет маппинг доступных пользователю сущностей. Host-сервис сам выбирает, что делать с доступными сущностями (посчитать, спроецировать, достать строку), — и в принципе не может получить IQueryable с объектами, не принадлежащими пользователю.
Транзакции
Транзакцией всегда управляет host сервис. Только он знает, когда операция завершена целиком. Можно было бы использовать вложенные транзакции или же счетчик транзакций на уровне приложения и отказаться от этих правил. Но, как показывает практика, легче задать ограничения на уровне архитектуры, чем столкнуться потом с ограничениями на уровне инфраструктуры (например, вложенная транзакция поддерживается не во всех БД).
Несмотря на то что транзакцией управляет сервис уровня host, бывают ситуации, когда core сервису атомарность нужна независимо от того, кто его вызвал — он пишет в несколько таблиц сразу. Он не может слепо надеяться на то, что вызывающий уже открыл транзакцию. Для этого внутренний сервис использует EnsureTransaction: если транзакция не была открыта на уровне host, выбрасывается исключение.
Из HTML-прототипа от AI в Vue-компоненты
После того как API готов, фронтенд может отображать настоящие данные. Визуальный дизайн был сгенерирован в начале цикла статей. Но он являлся лишь HTML-макетом. Настало время превратить его в настоящие компоненты.
Вообще, ИИ может разбить файл макета на компоненты самостоятельно, но мы в основном делали это вручную: смотрели на сгенерированную разметку и просили ИИ сделать один компонент из конкретного куска кода. Повторяющийся <div class="card"> — компонент Card, повторяющаяся обёртка колонки — Column.
Причина, по которой мы не попросили сгенерировать все компоненты сразу — желание контролировать процесс. Огромный pull request невозможно проревьюить внимательно, там будут ошибки, которые мы даже не заметим. Вынос по одному компоненту делает каждый шаг маленьким и проверяемым. Получившийся результат можно увидеть в папке компонентов.
Клиенты API на фронтенде
Каждому контроллеру бэкенда соответствует composable-клиент на фронтенде, в том же стиле, что и userApi из прошлой статьи. У эпиков — epicsApi.ts, у issues свой.
Мы пишем их самостоятельно. Альтернатива — генерация клиентов из документа OpenAPI/Swagger, тогда типы фронтенда будут автоматически синхронизированы с бэкендом. Но на это придется потратить время - чтобы код генерировался в том виде, что нам нужен. Пока API не разросся мы остановились на ручном варианте.
Пара слов об applyInsets: вписываем приложение в рамку Telegram
При открытии приложения в Telegram Mini App мы столкнулись с тем, что его часть закрывается фреймами самого Telegram. Для исправления этого Telegram отдаёт insets — отступы, описывающие, сколько места UI Telegram занимает по краям. Приложение применяет их в app.vue через шаг applyInsets, добавляя layout'у внутренние отступы.
Одни insets проблему полностью не решают: если применить их безопасным образом, как описано в документации, можно получить большие пустые области на экране. Если небезопасным - на элементы UI приложения будут наезжать кнопки управления Mini App Telegram. Мы остановились на том, что лучше определить, что приложение запущено внутри Telegram, и подправить CSS отдельно под этот случай. Отлаживать эту часть рекомендуем локально — на попытки отрисовать все красиво может уйти много времени.
Итоги
Веб-приложение показывает issues пользователя на доске, они подтягиваются с аутентифицированного бэкенда. Issue можно создавать, двигать между статусами и удалять, каждый пользователь работает только со своими данными. Построчная реализация CRUD в статье не разбиралась — код есть в репозиториях (бэкенд, фронтенд).
Теперь настало время показать приложение людям и получить честную реакцию. Мы рассказали о нём в своих Telegram-каналах, написали короткую презентацию продукта в Threads для людей, которые о нём не слышали, и отправили личные сообщения друзьям с просьбой попробовать. Цель — посмотреть, как реальные люди пользуются приложением, и выяснить, где оно работает, а где нет.
Что дальше
Следующая статья — про эту обратную связь. Она о продуктовой ошибке, что мы допустили, и о том, как ее исправляли.