Назад к статьям
Статья

Как мы переписали фронтенд — архитектура Nuxt с явными зависимостями, тесты на Vitest и правила для ИИ-агентов

Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 23.
В предыдущей статье мы разобрали комментарии. Интерфейс для них, как и для истории изменений, делал Николай. Эта статья — о том, как он переписал весь фронтенд.

Краткое содержание

Как мы получили новый фронтенд:

  1. Старый интерфейс стал неудобным: с каждой новой функцией он усложнялся, а бэкенд-разработчик не знал, как улучшить UI и UX.
  2. Весь фронтенд переписал Николай — фронтенд-разработчик, который присоединился к проекту летом.
  3. В новой архитектуре каждая страница явно объявляет, какие операции ей нужны, а реализации этих операций лежат отдельно и обращаются к API.
  4. Ожидаемые ошибки операции — часть её типа: страница знает, что запрос может вернуть «нет доступа» или «задача не найдена», и показывает это без исключений.
  5. Типы API генерируются из OpenAPI-описания бэкенда.
  6. Код проверяется тестами на Vitest, линтерами и проверкой типов перед каждым коммитом.
  7. Правила архитектуры записаны в AGENTS.md: Николай много работает с ИИ, и этот файл задаёт правила для агентов, которые пишут код.

Почему интерфейс пришлось переписать

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

Над Boards тогда работал один бэкенд-разработчик, и как улучшить UI и UX, он не знал. Новые экраны добавлялись, но общий интерфейс от этого лучше не становился.

Летом к проекту присоединился Николай, фронтенд-разработчик. Сначала он поправил несколько вещей в бэкенде, чтобы удобно запускать проект локально, — о них расскажем в следующей статье. А потом переписал весь фронтенд: в одном пулл-реквесте с новым интерфейсом изменилось почти пятьсот файлов.

Страница объявляет свои зависимости

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

export type IssuePageDeps = {
  deleteIssue: DeleteIssue
  issueDetails: IssueDetailsDeps
  moveIssue: MoveIssue
  updateIssue: UpdateIssue
  viewIssuePage: ViewIssuePage
}

Каждая операция — отдельный тип в своём файле. Он описывает вход, результат и ошибки, которые можно ожидать:

type DeleteIssueResult = null

type DeleteIssueError =
  | 'AccessDenied'
  | 'IssueNotFound'
  | 'TemporarilyUnavailable'

export type DeleteIssue = (input: {
  issueKey: string
}) => Promise<ActionResult<DeleteIssueResult, DeleteIssueError>>

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

Сама страница не знает, как выполняется операция: через HTTP, из кэша или из тестовых данных.

Реализации живут отдельно

Реализации операций лежат в папке infrastructure, по одному файлу на операцию. Только там есть HTTP, сгенерированные типы API и обработка сетевых ошибок:

export const openApiDeleteIssue =
  (baseUrl: string): DeleteIssue =>
  async ({ issueKey }) => {
    const client = createApiClient(baseUrl)
    try {
      const response = await client.DELETE('/api/issues/{key}', {
        params: { path: { key: issueKey } },
      })
      switch (response.response.status) {
        case 401:
        case 403:
          return err('AccessDenied')
        case 404:
          return err('IssueNotFound')
        default:
          break
      }
      if (!response.response.ok) {
        return err('TemporarilyUnavailable')
      }
      return ok(null)
    } catch {
      return err('TemporarilyUnavailable')
    }
  }

Здесь HTTP-статусы превращаются в ошибки, которые понимает страница. Страница не видит ни 404, ни DTO из API, только IssueNotFound.

Типы для запросов к API не пишутся вручную. Незадолго до прихода Николая мы включили в бэкенде OpenAPI-описание, и фронтенд генерирует по нему типы командой pnpm api (через openapi-typescript), а запросы отправляет через openapi-fetch. Если в бэкенде поменяется контракт, фронтенд перестанет собираться.

Собираются зависимости в файле маршрута Nuxt. Он создаёт реализации и передаёт их странице:

const deps = {
  deleteIssue: openApiDeleteIssue(baseUrl),
  issueDetails: {
    loadAssignees: openApiLoadAssignees(baseUrl),
    loadMoveBoards: openApiLoadMoveBoards(baseUrl),
    loadMoveSpaces: openApiLoadMoveSpaces(baseUrl),
    loadStatuses: openApiLoadStatuses(baseUrl),
  },
  moveIssue: openApiMoveIssue(baseUrl),
  updateIssue: openApiUpdateIssue(baseUrl),
  viewIssuePage: openApiViewIssuePage(baseUrl),
}

Зависимости передаются явно, сверху вниз. Глобального хранилища и provide/inject для них нет: по коду страницы видно всё, что ей нужно.

Тесты на Vitest

Тесты написаны на Vitest. В первой версии они проверяли в основном функции, которые переводят ответы API в данные для страницы, и вспомогательную логику. Такие тесты работают без браузера и без бэкенда.

Раз страница зависит только от типов операций, её можно проверить, подставив вместо настоящих реализаций тестовые. Тесты компонентов в браузере с такими подставными зависимостями появились позже. Перед коммитом запускается одна команда, которая исправляет форматирование, проверяет типы, гоняет тесты и собирает проект:

"pre-commit": "pnpm fix && pnpm typecheck && pnpm test && pnpm build"

Правила для ИИ-агентов

Николай много работает с ИИ и перенёс свой подход к разработке во фронтенд Boards. Правила архитектуры записаны в файле AGENTS.md, который начинается так: «This file is the source of truth for changes in this repository». Его читают и люди, и ИИ-агенты, которые пишут код.

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

Такие правила особенно полезны, когда код пишет ИИ: агент склонен добавлять «на всякий случай» слои и общие сервисы, а явный запрет удерживает код простым.

Как мы делили работу

Задачи на фронтенд брались с той же доски в Boards, а изменения приходили пулл-реквестами. Бэкенд-часть изменений проходила ревью. А фронтенд мы доверили Николаю целиком.

Итоги

  • Интерфейс переписали, когда он перестал быть удобным. С каждой функцией он усложнялся, а улучшать UX было некому.
  • Страница явно объявляет зависимости. Каждая операция — отдельный тип с результатом и ожидаемыми ошибками.
  • HTTP и типы API — только в инфраструктуре. Страница видит ошибки вроде IssueNotFound, а не статусы HTTP.
  • Типы API генерируются из OpenAPI бэкенда. Расхождение контрактов ловится при сборке.
  • Тесты, линтеры и типы — перед каждым коммитом.
  • Правила — в AGENTS.md. Они одинаковы для людей и для ИИ-агентов.

Что дальше

Прежде чем переписать фронтенд, Николай поменял несколько вещей в бэкенде: токен переехал из localStorage в HttpOnly-cookie, появились настройки CORS и docker-compose для локального запуска. Об этом — следующая статья.