PunchNLNG unveils AI tool to cut MRI scan timeThe Jerusalem PostWearable counter‑drone tech enters frontline service across US, Ukraine and IDF unitsBollywood HungamaMahakavya Shri Ramayan Katha producer Prakash Mahobiya alleges ‘negative marketing’; hints at big-budget Ramayana saying, “We never got a chance to reach our audience”ESPNTransfer rumors, news: Man United, Arsenal, Chelsea battle for Freiburg strikerInquirerSara Duterte: Mom prefers Baste for national politicsCNN Türk"Fon" ödemesi hangi formülle olacak?UOLTelevangelista americano Jim Bakker, envolvido em escândalos de fraude e sexo, morre aos 86 anos20 MinutenXena (24): «Sie trauen mir als Frau den Chefposten nicht zu»ZDF heuteEntdecken Sie das ZDF-NachrichtenstudioHet Laatste NieuwsVoormalig hoofd van Duitse inlichtingendienst aangehouden op verdenking van spionage en landverraadWirtualna PolskaPrezes UOKiK: Liczymy na refleksję po stronie Google'aNHK 社会デヴィ夫人 元マネージャーなど暴行の罪で罰金20万円
The Daily Newsstand · Free, Always
Tuesday, October 6, 2026

От Architecture Baseline к рабочему Full Stack MVP: как не усложнить архитектуру раньше времени

Translate

В предыдущей статье https://habr.com/ru/articles/1086836/ разбирал как спроектировать корпоративную GenAI платформу до начала реализации: определить границы системы, разделить Core API и AI workloads, зафиксировать NFR, выбрать PostgreSQL + pgvector, определить security boundaries и сохранить ключевые решения в ADR. Но Architecture Baseline сам по себе ничего не доказывает. Следующий шаг не RAG и не подключение LLM а проверка архитектуры одной рабочей вертикалью. Для этого используется Chats: намеренно простой сценарий, который должен пройти весь путь React > Core API > PostgreSQL и при этом не потребовать пересмотра принятых архитектурных решений.

Проверка Architecture Baseline

После архитектурного этапа легко уйти либо в бесконечное проектирование, либо слишком рано объявить архитектуру законченной. Architecture Baseline должен задавать направление реализации, но его состоятельность всё равно нужно проверить реальным вертикальным срезом.

Ранее было принято несколько принципиальных решений:

Core API         -> Modular Monolith
Web              -> React + TypeScript
persistent state -> PostgreSQL
data access      -> EF Core
AI workloads     -> отдельные компоненты
deployment MVP   -> без Kubernetes

Переход к реализации не требует пересмотра этих архитектурных решений.

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

Поэтому критерий успеха здесь не:

приложение запускается.

А:

frontend обращается к стабильному HTTP контракту, Core API выполняет прикладную операцию, состояние сохраняется в PostgreSQL, инфраструктурные ошибки наблюдаемы, а основное поведение воспроизводимо автоматическими проверками.

Это уже значительно ближе к архитектурной готовности.

Физическая структура должна следовать архитектуре

После C4 и ADR архитектурные блоки впервые начинают превращаться в реальные директории и проекты.

Текущая структура репозитория выглядит примерно так:

enterprise-genai-platform/
├── docs/
├── scripts/
├── src/
│   ├── core-api/
│   └── web/
├── tests/
│   └── core-api-tests/
├── compose.yaml
├── EnterpriseGenAIPlatform.slnx
└── global.json

Здесь важно не количество проектов, а соответствие границ ответственности: core-api это основной backend, web это его клиент, tests это автоматическая проверка поведения, docs это архитектурные решения, scripts это воспроизводимая проверка репозитория. При этом логическая граница ещё не означает необходимость отдельного процесса. Модуль это ещё не сервис.

Почему Core API всё ещё Modular Monolith

В Architecture Baseline Core API был зафиксирован как Modular Monolith.

Первая реализация не дала причины это решение менять.

Сейчас фактически реализован один функциональный модуль Chats. Остальные области являются следующим развитием платформы.

То есть Core API можно представить так:

Core API
│
├── Chats              <- реализован
│
├── Identity           <- дальнейшее развитие
├── Documents          <- дальнейшее развитие
├── Authorization      <- дальнейшее развитие
├── Audit              <- дальнейшее развитие
└── Integrations       <- дальнейшее развитие

У Chats нет требований к независимому deployment, отдельному масштабированию, собственной failure model или другому технологическому стеку. Выделение Chat Service сейчас лишь добавило бы новую сетевую границу, отдельный deployment и дополнительные operational concerns, не решая существующей проблемы. Поэтому Chats остаётся модулем внутри одного deployable Core API.

Ничто из этого пока не компенсируется реальной необходимостью.

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

разделять систему физически нужно там, где отличается жизненный цикл компонента, а не там, где удалось придумать отдельное существительное.

В коде модульная граница уже видна:

Modules/
└── Chats/
    ├── Api/
    ├── Domain/
    └── Infrastructure/

При этом приложение остаётся одним deployable Core API.

Это позволяет начать формировать внутреннюю структуру модулей без ранней цены распределённой системы. Такое разделение соответствует зафиксированному Architecture Baseline и ADR по application boundaries.

ASP.NET Core как композиционная граница

Program.cs на этом этапе остаётся небольшим.

И это скорее хороший признак.

В нём соединяются инфраструктурные зависимости приложения:

builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString));

builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>("database");

builder.Services
    .AddOptions<ApplicationOptions>()
    .Bind(builder.Configuration.GetSection(ApplicationOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services.AddValidation();
builder.Services.AddProblemDetails();

Дальше собирается HTTP pipeline:

app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<RequestLoggingMiddleware>();

app.UseExceptionHandler();
app.UseStatusCodePages();

app.UseCors("Web");

app.MapHealthChecks("/health");
app.MapChatEndpoints();

Практический смысл здесь не в синтаксисе ASP.NET Core. Program.cs остаётся composition root: здесь регистрируются инфраструктурные зависимости и собирается HTTP pipeline. Configuration поставляет настройки, DI связывает зависимости, а middleware оформляет cross-cutting concerns. Прикладной код при этом не должен самостоятельно создавать инфраструктурные зависимости или знать откуда именно получена конфигурация.

Configuration это часть контракта приложения

Для deployable приложения configuration такой же входной контракт как HTTP API. Если обязательный параметр задан неверно, ошибка должна проявиться при запуске, а не во время первого пользовательского запроса. Поэтому используется Options pattern с startup validation.

Например:

public sealed class ApplicationOptions
{
    public const string SectionName = "Application";

    [Required]
    public string Name { get; set; } = string.Empty;
}

Сейчас это ещё очень небольшой объект.

Но важен сам механизм:

По мере развития сюда будут приходить параметры внешних интеграций, ограничения, настройки AI компонентов и другие значения.

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

PostgreSQL должен стать реальным хранилищем как можно раньше

В Architecture Baseline PostgreSQL выбран source of truth для постоянного состояния платформы.

Поэтому первая реализация сразу опирается на реальное постоянное хранилище, а не на временный in-memory repository.

Первый же вертикальный срез должен закончиться реальной записью в PostgreSQL.

Это важный момент.

Можно было сначала реализовать:

React -> API -> List<Chat>

и отложить базу данных до следующего этапа.

Такой вариант быстрее показывает интерфейс, но почти ничего не проверяет из реальной архитектуры приложения.

Как только появляется PostgreSQL, появляются настоящие вопросы:

  • жизненный цикл DbContext;

  • mapping доменной модели;

  • schema migrations;

  • connection configuration;

  • ошибки подключения;

  • persistence между перезапусками;

  • health checks;

  • интеграция локальной инфраструктуры.

Поэтому первая рабочая вертикаль сразу использует EF Core и Npgsql.

AppDbContext при этом остаётся небольшим:

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Chat> Chats => Set<Chat>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }
}

Mapping Chat вынесен в отдельную конфигурацию: таблица chats, primary key, обязательный Title с ограничением длины и CreatedAt.

То есть даже маленький вертикальный срез уже проходит через реальную relational model.

Почему EF Core основной, а Dapper точечный инструмент

Для Core API основным способом доступа к данным выбран EF Core.

Причина не в том что Dapper хуже. На текущем этапе важнее согласованно управлять model mapping, migrations, queries и transactions, чем оптимизировать отдельный SQL запрос.

Поэтому правило простое:

EF Core default 
Dapper точечно, если появляется измеримая причина

Например Dapper может появиться для сложного read model или hot path, если EF Core действительно становится ограничением.

В текущем Chats такой причины нет, поэтому второй data-access механизм пока только увеличил бы поверхность решения.

Первый вертикальный срез: Chats

После инфраструктурной основы нужен сценарий, который пересечёт все слои. Первым таким вертикальным срезом стал Chats. Не полноценный AI-чат.

Пока здесь нет LLM, RAG, retrieval или истории сообщений.

Реализована минимальная прикладная сущность с операциями:

POST /chats
GET  /chats
GET  /chats/{id}

Этого достаточно, чтобы проверить полный путь.

Например, создание чата проходит через HTTP endpoint, создаёт доменный объект, добавляет его через EF Core и фиксирует транзакцию в PostgreSQL:

endpoints.MapPost("/chats", async (CreateChatRequest request, AppDbContext dbContext) =>
{
    var chat = new Chat(Guid.NewGuid(), request.Title, DateTimeOffset.UtcNow);

    dbContext.Chats.Add(chat);
    await dbContext.SaveChangesAsync();

    var response = new ChatResponse(chat.Id, chat.Title, chat.CreatedAt);

    return Results.CreatedAtRoute("GetChatById", new { id = chat.Id }, response);
});

Здесь намеренно нет repository layer, mediator или command bus: при текущей сложности они не добавляют отдельной ответственности. Такие abstraction layers должны появляться тогда, когда их потребует реальная application logic, а не заранее.

REST контракт не должен совпадать с внутренней моделью случайно

Даже в минимальном модуле frontend не получает EF entity напрямую.

Для входа используется CreateChatRequest, для ответа ChatResponse.

Например, входной контракт определяет ограничения:

public sealed class CreateChatRequest
{
    [Required]
    [StringLength(200, MinimumLength = 1)]
    public string Title { get; init; } = string.Empty;
}

Это небольшое решение, но оно определяет важную границу:

HTTP contract != persistence model

Сегодня свойства могут почти совпадать. Позже это почти наверняка изменится.

У сущности могут появиться внутренние поля, ownership, технические статусы или связи, которые frontend видеть не должен.

И наоборот, API может возвращать вычисляемое представление, которого вообще нет в таблице.

Поэтому HTTP контракт должен принадлежать границе API, а не быть случайной сериализацией внутреннего состояния backend.

То же относится к ошибкам: validation и ProblemDetails сразу задают единый error contract для будущих модулей.

Frontend не должен знать устройство backend

На стороне React применяется тот же принцип границ.

Компонент интерфейса не должен знать:

  • какая ORM используется;

  • как называется таблица;

  • какие классы находятся внутри Core API;

  • как устроен Modular Monolith;

  • где позднее появятся Authorization или Audit.

Для frontend backend заканчивается HTTP контрактом.

Поэтому работа с API вынесена в отдельную границу:

export async function getChats(): Promise<Chat[]> {
  const response = await fetch(`${config.apiBaseUrl}/chats`)

  if (!response.ok) {
    throw new Error(`Failed to load chats: ${response.status}`)
  }

  return response.json() as Promise<Chat[]>
}

Даже базовый URL не зашит в компонент.

Он приходит из конфигурации frontend:

VITE_API_BASE_URL

В результате React зависит от HTTP контракта а не от внутреннего устройства Core API. Backend можно перестраивать внутри этой границы не заставляя frontend следовать за внутренней реализацией.

Почему server state вынесен в TanStack Query

При появлении первого GET запроса можно было ограничиться обычным React:

useEffect
useState
fetch

Для одной страницы это работает.

Проблема начинается не с первой загрузки, а с жизненного цикла данных.

Состояние полученное от сервера отличается от обычного UI state.

У него есть собственные вопросы:

когда загрузить?
данные ещё актуальны?
что происходит во время запроса?
как представить ошибку?
что делать после mutation?
когда перечитать данные?

Если каждый компонент решает их самостоятельно через useEffect, useState и ручные флаги, frontend постепенно начинает содержать собственный слой управления серверным кэшем.

Поэтому управление server state сразу передано TanStack Query.

Чтение:

const chatsQuery = useQuery({
  queryKey: ["chats"],
  queryFn: getChats,
})

Изменение:

const createChatMutation = useMutation({
  mutationFn: createChat,
  onSuccess: async () => {
    setTitle("")
    await queryClient.invalidateQueries({
      queryKey: ["chats"],
    })
  },
})

После POST /chats frontend не синхронизирует локальные копии данных вручную. Он помечает ["chats"] как устаревший, после чего TanStack Query получает актуальное состояние с сервера. Источником истины остаётся backend.

После этого первая вертикаль замыкается:

UI -> HTTP -> Core API -> PostgreSQL -> refetch -> UI

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

Observability нужна до появления распределённой системы

Ещё одна распространённая идея:

логирование и tracing понадобятся потом, когда появятся микросервисы.

Полноценная distributed tracing здесь пока не нужна и не реализована.

Но возможность связать запрос с логами нужна уже сейчас.

Поэтому первым в HTTP pipeline идёт CorrelationIdMiddleware.

Он принимает существующий X-Correlation-ID или создаёт новый, сохраняет его в TraceIdentifier и возвращает клиенту.

Дальше RequestLoggingMiddleware пишет структурированные поля:

logger.LogInformation(
    "HTTP {Method} {Path} responded {StatusCode} " +
    "in {ElapsedMs} ms. CorrelationId: {CorrelationId}",
    context.Request.Method,
    context.Request.Path,
    context.Response.StatusCode,
    stopwatch.Elapsed.TotalMilliseconds,
    context.TraceIdentifier);

Важны именно структурированные поля:

Method
Path
StatusCode
ElapsedMs
CorrelationId

Они уже пригодны для поиска и агрегации. Correlation ID также помещается в logging scope и возвращается клиенту через HTTP response header. Полноценная OpenTelemetry инфраструктура пока не нужна достаточно заложить наблюдаемый HTTP контракт который позже можно расширить.

Health check должен проверять способность обслужить запрос

Самый простой health endpoint возвращает 200 OK если процесс ASP.NET Core жив.

Но для текущей вертикали этого недостаточно: Chats зависит от PostgreSQL. API может принимать HTTP соединения, но при недоступной базе основной пользовательский сценарий всё равно не работает.

Поэтому /health проверяет AppDbContext.

При доступной PostgreSQL endpoint возвращает: 200 Healthy
При недоступной: 503 Unhealthy

То есть health check отвечает уже не только на вопрос "процесс существует?", а на более полезный: "текущая вертикаль способна выполнять свою работу?"

У самого PostgreSQL контейнера отдельно используется pg_isready. Для текущего MVP этого уровня проверки достаточно.

pgvector есть, но RAG ещё нет

PostgreSQL уже запускается с pgvector, потому что это следует из Architecture Baseline для будущего semantic search.

Но текущий Chats его не использует.

Это подготовленная архитектурная возможность, а не повод заранее реализовывать embeddings, retrieval и RAG.

Текущая реализация остаётся обычной Full Stack вертикалью.

Автоматические проверки: часть архитектурной готовности

Автоматические проверки появляются вместе с первой рабочей вертикалью: задача не только в том чтобы код заработал, но и в том чтобы следующие изменения можно было вносить безопасно.

Backend проверяется через xUnit и WebApplicationFactory. Покрываются создание и получение Chat, validation, Not Found, Correlation ID и сам HTTP pipeline.

Для HTTP integration tests используется EF Core InMemory. Он хорошо проверяет application behavior, но не специфичное для PostgreSQL поведение, migrations или relational constraints, поэтому не заменяет реальную инфраструктурную приёмку.

Frontend проверяется через Vitest и Testing Library. Здесь важны два сценария:

загрузка chats и create -> invalidate query -> refetch

Второй сценарий проверяет именно выбранную модель управления server state, а не просто существование формы.

Все основные проверки объединены в одну команду:

.\scripts\verify.ps1

Она выполняет .NET build, backend tests, frontend lint, frontend tests и production build.

Это ещё не CI/CD pipeline, но уже единый repository verification contract. Позже тот же контракт можно перенести в GitHub Actions, GitLab CI, Jenkins или корпоративную систему сборки.

Автоматические тесты не заменяют End-to-End acceptance

Даже зелёные backend и frontend tests ещё не доказывают работу реальной цепочки:

Browser -> ASP.NET Core -> PostgreSQL

Frontend tests используют mock API, а backend integration tests InMemory provider.

Поэтому первая рабочая вертикаль должна завершаться сквозной приёмкой: Chat создаётся через React, сохраняется в PostgreSQL, появляется после query invalidation и остаётся после reload страницы.

Отдельно проверяются /health, Correlation ID и repository verification.

Особенно важен reload: он исключает ситуацию когда UI только локально показывает созданный объект хотя реального persistence не произошло.

Что пока сознательно остаётся за рамками

После появления первой рабочей вертикали появляется соблазн сразу продолжить инфраструктурой:

Kubernetes
service mesh
Redis
Kafka
agents
distributed tracing
multi-provider LLM routing
Local LLM

Но ни одна из этих технологий не нужна для доказательства текущего сценария. Это не означает, что они никогда не понадобятся. Это означает, что текущая архитектурная задача пока не создаёт необходимости их вводить.

Точно так же пока не нужно утверждать, что платформа уже реализует:

пользователей
проекты
документы
историю запросов
RAG
LLM orchestration

Architecture Baseline предусматривает дальнейшие модули и AI контур. Но фактически реализованная прикладная вертикаль сейчас Chats. Это различие важно сохранять.

Что в итоге доказала реализация

На архитектурном этапе основные решения существовали прежде всего в документах:

Следующим уровнем становится уже физическая реализация архитектуры:

И вокруг этой вертикали уже работают:

Configuration
Validation
ProblemDetails
Correlation ID
Structured Logging
Health Checks
Backend Tests
Frontend Tests
End-to-End Acceptance

Но главный результат перехода к рабочей вертикали не POST /chats и не React форма и даже не сам факт подключения PostgreSQL.

Результат в том, что Architecture Baseline получил первую реализацию, которая не потребовала отказаться от его основных решений.

Core API остался Modular Monolith потому что пока нет причины платить цену микросервисов.

EF Core используется как основной data-access механизм потому что текущая задача требует прежде всего согласованного persistence и migrations, а не точечной SQL оптимизации.

Frontend зависит от HTTP контракта, а не от внутренней модели backend.

Server state передан TanStack Query вместо создания собственного механизма синхронизации данных.

Health check проверяет не только существование процесса, но и PostgreSQL реальную зависимость пользовательского сценария.

Correlation ID и structured logging появились до распределённой системы потому что наблюдаемость становится дороже если добавлять её после роста приложения.

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

Это и есть переход от архитектуры на схеме к архитектуре в коде.

Следующий функциональный модуль теперь можно добавлять не в пустой repository и не в набор архитектурных диаграмм.

У него уже есть работающие границы:

А AI контур ingestion, RAG, AI Orchestrator и LLM Gateway можно подключать поверх этой основы тогда когда появляется соответствующий пользовательский сценарий.

Не раньше.

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

Она должна позволять реализовать минимальную рабочую систему сегодня так, чтобы завтра её не пришлось переписывать только потому что сложность была добавлена слишком рано.

Материалы проекта

Architecture Baseline, ADR, исходный код и описание Full Stack MVP находятся в репозитории Enterprise GenAI Platform: https://github.com/Andrej-Gorlov/enterprise-genai-platform/tree/7ec0d9fb936343a0a0201bcf77a595ddf5fdce3a

Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.