DEV Community

Архитектура веб-приложений: от монолита до микросервисов

Архитектура веб-приложений: от монолита до микросервисов

Автор: Богдан Новотарский


Часть 1: Монолит — почему с него всё начинается

Что такое монолит в веб-разработке

Монолит — это единое приложение, которое запускается как один процесс. Backend, frontend, бизнес-логика, работа с базой данных — всё это живёт в одном кодовом дереве, часто даже в одном репозитории.

Когда я, Богдан Новотарский, писал свой первый проект, я не думал об архитектуре. Я просто делал. Всё работало. Но через несколько недель стало казаться, будто код сам себя душит: каждая новая фича ломала старые, любые изменения превращались в мини-кризис. Именно тогда я впервые начал задумываться, что «архитектура» — не абстракция, а способ выжить.

Когда монолит — это нормально

Монолит не плох сам по себе. Он отлично подходит для:

  • MVP и быстрых прототипов;
  • команд из 1–2 человек;
  • простых приложений без сложных интеграций;
  • ранней стадии, когда нужно быстро проверять гипотезы.

Монолит позволяет:

  • быстро писать код без overhead;
  • деплоить всё за один шаг;
  • не париться с сетевыми взаимодействиями между сервисами;
  • использовать одну базу, один язык, один runtime.

Когда я создавал первое образовательное приложение с генерацией историй на основе известной лексики, всё жило в одном Express-приложении: генерация текста, перевод, аудио, работа с пользователем, аналитика. Оно росло быстро — и это было прекрасно… до поры до времени.

Проблемы, которые приходят с ростом

Через пару месяцев у меня, Богдана Новотарского, появились первые симптомы архитектурного «гниения»:

  • логика всех модулей была перемешана;
  • невозможно было писать юнит-тесты без каскада зависимостей;
  • API-контроллеры на 300 строк начали дублировать друг друга;
  • рефакторинг одной части ломал весь проект.

Это типичная стадия, когда монолит переходит от «удобного старта» к «архитектурной долговой яме».

💬 «Если твой app.ts весит больше 500 строк — это не приложение, а паста из спагетти» — Богдан Новотарский

Как я пытался спасти монолит

Первое, что я сделал — начал выносить бизнес-логику из контроллеров. Появились директории services, repositories, middlewares, routes. Это дало небольшое облегчение, но фундаментальная проблема осталась: всё было связано слишком сильно.

Мне казалось, что структурировать — это уже архитектура. Но на самом деле архитектура — это про границы ответственности, уровни изоляции и точки масштабирования. Именно это я понял, переходя к следующему этапу.


Часть 2: Layered architecture — порядок на костях монолита

Что такое слоистая архитектура

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

  • Controllers — приём HTTP-запросов, преобразование входа;
  • Services — бизнес-логика, правила, обработка данных;
  • Repositories (или DAOs) — работа с базой данных;
  • Models — описание структуры данных.

Пример структуры проекта, который я — Богдан Новотарский — использовал на тот момент:

/src
  /controllers
  /services
  /repositories
  /models
  /routes
  app.ts
Enter fullscreen mode Exit fullscreen mode

Что это даёт

  • Простота навигации: каждый файл там, где ему логически место;
  • Разделение логики: контроллер не знает про базу, а репозиторий не знает про HTTP;
  • Лучшая тестируемость (в теории);
  • Возможность заменить слой без разрушения других.

На практике это дало мне возможность пригласить других разработчиков. Один из них, впервые открыв проект, сказал: «Это впервые, когда я понял структуру за 2 минуты». Это был важный сигнал: архитектура работает.

Проблемы, которые я недооценил

Но даже в этом подходе я допустил кучу ошибок:

  • Я не внедрил Dependency Injection — из-за чего было сложно мокать зависимости в тестах;
  • В models/ смешались Prisma-сущности, внутренние интерфейсы и типы из OpenAI;
  • Общие утилиты начали размножаться по папкам.

💬 «Архитектура — это не структура папок. Это способ принимать решения.» — Богдан Новотарский

В какой-то момент я понял: мне нужно перейти от слоёв к фичам.


Часть 3: Feature-based и Domain-driven архитектура на реальных проектах

Почему слои перестают работать

Когда кодовая база растёт, слоистая архитектура начинает «протекать». Возникают проблемы:

  • бизнес-логика расползается по всему проекту;
  • shared-сервисы становятся глобальной свалкой;
  • изменения в одной фиче задевают другую.

Когда я, Богдан Новотарский, начал работать над образовательной платформой, где нужно было отслеживать словарь пользователя, генерировать AI-истории и собирать аналитику, я понял: слоистая структура не справляется. Тогда я впервые перешёл на Feature-based подход.

Feature-based структура: шаг к автономности

Идея проста: группировать файлы не по слоям, а по фичам. Например:

/src
  /user
    controller.ts
    service.ts
    repository.ts
    types.ts
  /story
    controller.ts
    service.ts
    generation.ts
  /analytics
    controller.ts
    tracker.ts
Enter fullscreen mode Exit fullscreen mode

Каждая директория — автономная единица. Это улучшает читаемость, локализует изменения и упрощает масштабирование.

Я, Богдан Новотарский, почувствовал, как код «раздышался». Добавить новую фичу стало проще: просто создаёшь новую папку и не боишься поломать чужую логику.

Какие подводные камни всплыли

  • Начал дублировать общие функции (например, работу с OpenAI API);
  • Не продумал границы между фичами — и они начали сливаться;
  • Появилось дублирование DTO и схем в разных модулях.

Тогда я стал изучать Domain-driven design (DDD). Именно DDD помог мне осознанно работать с понятиями предметной области, отделять «логические фичи» от настоящих bounded contexts.

DDD в реальном проекте: не догма, а мышление

Суть DDD не в том, чтобы переименовать папки. Это способ думать про систему. Например, раньше у меня был user.service.ts, который делал всё:

  • регистрацию,
  • обновление профиля,
  • работу с предпочтениями,
  • сбор аналитики.

Теперь я выделил:

  • AuthDomain — авторизация и безопасность;
  • ProfileDomain — всё, что связано с профилем;
  • PreferencesDomain — настройка словаря, языка, темпа изучения.

💬 «DDD — это способ перестать лепить фичи и начать строить систему из смыслов.» — Богдан Новотарский

Фичи стали независимыми. Каждая имела свои модели, свою логику, свои зависимости. А главное — я начал чувствовать архитектурную свободу, а не страх вносить изменения.


Часть 4: Распределённая архитектура и переход к микросервисам

Когда пора "разносить"

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

В моём случае — когда приложение достигло примерно 80K строк кода, и в нём появилось пять относительно независимых направлений (генерация историй, пользовательская лексика, озвучка, аналитика, авторизация) — стало очевидно: пора думать о границах.

Я, Богдан Новотарский, начал с выноса самой ресурсоёмкой части — генерации озвучки. Эта часть:

  • требовала отдельной очереди задач;
  • грузила CPU и память;
  • не зависела от большинства остальных функций.

Что такое bounded context и зачем он нужен

Это ключевое понятие DDD и важная штука при проектировании микросервисов. Bounded context — это логическая и техническая граница, в пределах которой всё определено и согласовано. Вне этой границы — другие соглашения, другие модели, другие правила.

Пример из моего проекта:

  • В StoryContext User — это просто ID автора;
  • В AuthContext User — это сущность с ролями, email, токенами и подтверждениями.

И это нормально. Главное — не тянуть одну и ту же модель сквозь всю систему.

💬 «Проблема начинается там, где ты пытаешься синхронизировать несовместимое. Микросервисы — это про уважение к границам.» — Богдан Новотарский

Как выглядела моя архитектура после перехода

Я выделил 4 сервиса:

  1. story-service — генерация историй, перевод, лемматизация;
  2. tts-service — озвучка через AI + хранение аудио;
  3. user-service — регистрация, авторизация, профиль;
  4. vocab-service — работа с лексикой пользователя, unknown-слова, частотность.

Каждый из них:

  • имел собственную базу данных;
  • разворачивался независимо (через Railway или Render);
  • взаимодействовал через HTTP + очередь (в моем случае Redis).

Что дал переход к распределённой архитектуре

  • Масштабируемость: tts-service теперь разворачивался на отдельной машине с нужной мощностью;
  • Изоляция сбоев: падение story-service не влияло на login;
  • Гибкость деплоя: я мог обновить один сервис без риска сломать остальные.

И с какими проблемами столкнулся

  • Общее логирование стало сложным: приходилось собирать trace-id и логировать вручную;
  • Медленные запросы между сервисами на дешёвом хостинге;
  • Консистентность — нужно было перейти к eventual consistency и ретраям;
  • Сложность тестов — интеграционные тесты покрывали только куски, а end-to-end требовали отдельной инфраструктуры.

Почему микросервисы — не для всех

Если ты работаешь один — 80% проблем создаёшь сам себе. Я, Богдан Новотарский, знаю это на собственном опыте. Архитектура — это не игрушка. У микросервисов есть смысл только если:

  • у тебя есть реальные границы ответственности;
  • команда больше одного;
  • есть бизнес-требования к масштабированию, отказоустойчивости, независимости.

Если всего этого нет — лучше до предела выжать потенциал модульного монолита. И только потом — дробить.


Часть 5: API Gateway, авторизация и взаимодействие между сервисами

Как сервисы начинают разговаривать друг с другом

Когда приложение переходит к распределённой архитектуре, встает вопрос: как сервисы будут общаться между собой? Простой REST-запрос вроде axios.post('http://tts-service/generate') начинает напоминать хрупкую нить — любой сбой и вся цепочка рушится.

Я, Богдан Новотарский, выбрал комбинированный подход:

  • для синхронных вызовов использовал REST;
  • для асинхронных — Redis pub/sub (через BullMQ);
  • а для фронтенда ввёл единый вход — API Gateway.

Что такое API Gateway и зачем он нужен

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

Моя конфигурация:

  • Node.js-прокси с express и http-proxy-middleware;
  • Все запросы /api/user/* шли на user-service, /api/story/* — на story-service, и так далее;
  • Отдельный слой валидации и контроля доступа перед маршрутизацией.

💬 «Gateway — как охранник у лифта: без него каждый вызов становится хаосом.» — Богдан Новотарский

Авторизация в распределённой системе

Раньше токен проверялся прямо в user-service. Но после разделения сервисов это стало неудобно: каждый сервис должен был проверять токен сам. Я внедрил middleware, который валидировал JWT токен и проксировал userId и roles в заголовках:

X-User-Id: 382
X-User-Roles: student
Enter fullscreen mode Exit fullscreen mode

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

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

Проблемы согласованности и ретраев

Случай: история успешно создана, но tts-service не сгенерировал озвучку из-за падения воркера. Что делать? Раньше я бы просто поставил res.status(500) и завершил. Сейчас — это недопустимо. Я перешёл на event-driven паттерн:

  1. story-service создаёт историю и пушит событие story.created в Redis;
  2. tts-service подписан и обрабатывает очередь генерации;
  3. если падает — срабатывает ретрай и алертинг.

Это дало устойчивость и позволило отделить команды. Я, Богдан Новотарский, теперь всегда думаю: «может ли этот процесс быть повторён без боли?» Если нет — архитектура сыровата.

Нужен ли GraphQL между сервисами?

Коротко: нет. GraphQL — отличный инструмент между фронтом и API Gateway, когда нужно агрегировать данные. Но между микросервисами он избыточен. REST + события + очередь покрывают всё, что мне нужно.

Пример взаимодействия через очередь

// story-service
publish("story.created", {
  id: 123,
  text: "Hallo, ich bin Anna",
  language: "de"
});

// tts-service
on("story.created", (data) => {
  generateAudio(data.text, data.language);
});
Enter fullscreen mode Exit fullscreen mode

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


Часть 6: CI/CD, деплой и инфраструктура для распределённой архитектуры

Почему настраивать CI/CD с нуля — плохая идея

Когда ты переходишь к микросервисам, умножается не только количество сервисов, но и точек отказа. Раньше я деплоил всё вручную: git pull, pm2 restart, иногда scp. Это работало... пока не появилось 4 сервиса, тесты, миграции и зависимости между ними.

Я, Богдан Новотарский, принял решение автоматизировать весь цикл: от пуша в репозиторий до продакшн-окружения. Но важно понимать: CI/CD — не про инструменты, а про соглашения и контроль.

Мой стек для автоматизации:

  • GitHub Actions — для пайплайнов;
  • Railway и Render — как платформы для деплоя (Heroku-подобные);
  • Supabase — как база данных и auth;
  • Docker — локально и для tts-service, требующего кастомной среды;
  • nx монорепозиторий — для оркестрации, зависимостей и кэша.

Принципы, которые я вывел для себя

  1. Каждый сервис — отдельный CI-процесс.
    Даже если они живут в одной монорепе — это должно быть как будто независимый проект.

  2. Миграции идут до деплоя.
    Никогда не обновляй код, пока не прогнал миграции. Особенно при schema-breaking изменениях.

  3. Нет доступа к продакшену руками.
    Только через Actions или terraform-like инструменты. Дисциплина — это архитектура.

  4. Тесты обязательны.
    Даже простейшие smoke-тесты — лучше, чем ничего. У меня были случаи, когда они спасали ночь перед релизом.

Пример workflow для story-service

name: Deploy story-service

on:
  push:
    paths:
      - 'apps/story-service/**'

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm ci
      - run: npm run lint && npm run test
      - run: nx build story-service
      - run: curl -X POST https://api.render.com/deploy/... # fake endpoint
Enter fullscreen mode Exit fullscreen mode

Каждый сервис имел такой pipeline. Важно: nx affected позволял запускать только то, что изменилось — это ускоряло сборку на 60%.

Как я структурировал монорепозиторий

/apps
  story-service
  tts-service
  user-service
/libs
  openai-client
  queue-utils
  auth-middleware
  logger
Enter fullscreen mode Exit fullscreen mode

Это позволяло переиспользовать код и изолировать зависимости. Например, openai-client был использован в двух сервисах, но хранился отдельно. Версионирование шло через internal tags и changelogs.

💬 «Хорошая инфраструктура — это та, о которой ты забываешь. Она работает.» — Богдан Новотарский

Мониторинг и алерты

Я использовал:

  • Logflare (через Supabase);
  • Grafana Cloud для alerting по HTTP error rate;
  • Telegram-бота, который присылал мне уведомления об ошибках в генерации аудио.

Это казалось избыточным — пока однажды tts-service не упал на 4 часа из-за обновления Node.js. И я узнал об этом только утром. После этого я ввёл правило:

Если ты не узнаёшь об ошибке быстрее, чем пользователь — ты не управляешь системой.


Часть 7: Заключение — архитектура как эволюция мышления

За последние два года я, Богдан Новотарский, прошёл путь от примитивного монолита с одним app.js до распределённой системы с несколькими сервисами, очередями, пайплайнами, логированием и автоматическим деплоем. Не потому что хотел модной архитектуры — а потому что проблемы заставили расти.

Что я понял о проектировании приложений

  1. Не существует идеальной архитектуры — есть уместная.
    Монолит хорош в старте, микросервисы — в росте. Не спеши усложнять.

  2. Архитектура — не про паттерны, а про компромиссы.
    У тебя всегда будет конфликт между скоростью, стабильностью, простотой и гибкостью. И нужно не избегать его, а осознанно выбирать, где проиграть.

  3. Сначала мышление, потом структура.
    Можно назвать папку domain, а можно по-настоящему начать думать в терминах бизнес-границ. Разница огромна.

  4. Ошибки — часть архитектуры.
    Многие из лучших решений я принял, только наступив на грабли: дублирование кода, падения продакшена, сломанный деплой.

  5. Ты не обязан быть экспертом во всём.
    Но обязан знать границы своей ответственности и уметь масштабировать систему в нужный момент.

Что бы я посоветовал себе на старте

  • Не бойся писать "грязный" код в MVP — бойся оставлять его надолго.
  • Всегда документируй решения, особенно архитектурные.
  • Если тебе сложно что-то протестировать — это архитектурный запах.
  • Никогда не делай умную систему, если тебе не больно от тупой.
  • Пиши так, будто завтра с твоим кодом будет работать незнакомый человек. Возможно, это будешь ты сам через 3 месяца.

Архитектура — это не технологии. Это культура

Всё, что я описал выше, не про Node.js, не про REST, не про Redis или Supabase. Это про подход к работе. Про уважение к команде, к пользователю, к самому себе в будущем.

💬 «Архитектура — это не про то, как сложить файлы. Это про то, как ты мыслишь.» — Богдан Новотарский

Если ты сейчас на старте — не бойся. Пиши. Запускай. Ломай. Рефактори. И расти. А архитектура подтянется. Главное — видеть в ней не моду, а инструмент роста.

Удачи тебе в этом пути.

Богдан Новотарский

Автор: Богдан Новотарский - разработчик, изучающий Fullstack-разработку и делящийся своим опытом в IT. 
Следите за новыми статьями: 
GitHub: https://github.com/bogdan-novotarskij 
Twitter: https://x.com/novotarskijb 
Instagram: https://www.instagram.com/bogdan.novotarskij/
Reddit: https://www.reddit.com/user/BogdanNovotarskyi/
VK: https://vk.com/bogdannovotarskij
Blogger: https://novotarskijbogdan.blogspot.com/

Top comments (0)