LEX — AI Legal Platform for Law Firms

AI-powered legal analysis platform for law firms and corporate counsel.

Features

Resources

Blog Articles

Technology

Built on AWS (EC2, Bedrock Claude AI, ALB, WAF, S3, ACM, KMS). PostgreSQL, Redis, Qdrant vector database. TypeScript, React, Node.js.

Start free — 50 credits on registration. Sign up

TECH 14 хв

Distributed Monolith: коли мікросервіси — це моноліт із мережевими затримками

3 сервіси, 1 PostgreSQL, спільний Redis, один docker-compose — і ілюзія незалежності. Як розпізнати distributed monolith у власній архітектурі, коли він корисний, і коли настає час справжнього розділення.

Distributed Monolith: коли мікросервіси — це моноліт із мережевими затримками

Ви розділили код на сервіси. Ви маєте окремі контейнери. Ви навіть маєте gateway. Але чому деплой одного сервісу все ще ламає інший?


Що таке distributed monolith

Distributed monolith — це архітектура, яка виглядає як мікросервіси, але поводиться як моноліт. Сервіси розділені на рівні коду, але залишаються зв'язаними на рівні інфраструктури, даних або деплою.

Класичні ознаки:

Звучить знайомо? Це наша архітектура. І ми вважаємо, що зараз це — правильний вибір.


Анатомія нашого distributed monolith

SecondLayer складається з трьох MCP-серверів:

┌─────────────────────────────────────────────────────┐
│                    nginx (gateway)                  │
│               ┌───────┬───────┬──────┐              │
│               │       │       │      │              │
│               ▼       ▼       ▼      ▼              │
│          lexwebapp  backend  rada  openreyestr      │
│          (React)   (MCP)    (MCP)   (MCP)           │
│               │       │       │      │              │
│               │       ▼       ▼      ▼              │
│               │    ToolRegistry (gateway pattern)   │
│               │       │       │      │              │
│               └───┬───┘       │      │              │
│                   │           │      │              │
│                   ▼           ▼      ▼              │
│              PostgreSQL (1 інстанс, 3 схеми)        │
│              Redis (1 інстанс, 512 MB)              │
│              Qdrant (1 інстанс)                     │
└─────────────────────────────────────────────────────┘

Що розділено

Аспект Статус
Кодова база Окремі директорії: mcp_backend/, mcp_rada/, mcp_openreyestr/
HTTP-сервери Окремі Express-додатки на портах 3000, 3001, 3005
Деплой Blue-green для кожного сервісу незалежно
Міграції Окремі директорії міграцій, окремі DB-юзери
Схеми БД Schema isolation: public, rada, openreyestr
CI/CD Детекція змінених сервісів — білд тільки того, що змінилось

Що залишається зв'язаним

Аспект Проблема
PostgreSQL Один процес — не можна масштабувати БД окремо для rada
Redis 512 MB LRU-евікшн глобальний — rada витісняє кеш backend
packages/shared Без semver — зміна ламає всіх одночасно
docker-compose Один файл, одна мережа, один docker compose up
ToolRegistry Hardcoded URL-и сервісів через env vars
RemoteServiceClient 60s timeout, без circuit breaker — якщо rada впав, backend чекає

7 ознак distributed monolith

Як відрізнити здорову сервісну архітектуру від distributed monolith? Ось чеклист:

1. Каскадні відмови

Якщо падіння одного сервісу каскадно ламає інші — у вас distributed monolith.

rada не відповідає
  → RemoteServiceClient timeout 60s
    → backend thread зайнятий очікуванням
      → інші запити до backend повільніші
        → nginx 504 Gateway Timeout

Лікування: circuit breaker pattern. Після N невдалих запитів — перестати викликати rada і повертати fallback відразу.

2. Координований деплой

Якщо ви не можете задеплоїти сервіс A без перевірки, що сервіс B оновлений — це coupling.

У нас: зміна tool signature в rada вимагає оновлення ToolRegistry в backend. Деплоїти треба разом.

Лікування: API contracts з версіонуванням. Нова версія tool-а не ламає старий контракт.

3. Shared database

Три "незалежні" сервіси → один PostgreSQL процес → одна точка відмови.

-- rada робить важкий запит
SELECT * FROM parliament_bills WHERE full_text @@ to_tsquery('конституц');

-- backend одночасно
SELECT * FROM edrsr_decisions WHERE ...;
-- ↑ сповільнюється, бо той самий CPU/RAM/IO

Лікування: окремі PostgreSQL інстанси. Schema isolation — це не process isolation.

4. Спільний кеш без namespace

Redis 512 MB, volatile-LRU policy

backend: SET legislation:254 → 200 KB
rada: SET bill:12345 → 50 KB
openreyestr: SET entity:98765 → 30 KB

→ Коли пам'ять закінчується, Redis видаляє найстаріший ключ
→ Може видалити legislation:254, який backend кешував 2 хвилини тому
→ Backend робить повторний запит до EDRSR API

Лікування: окремі Redis-інстанси (3 контейнери замість 1) або namespace з окремими maxmemory.

5. Shared library як single point of failure

packages/shared експортує:

Зміна будь-чого в shared → перезбірка всіх трьох сервісів. Без semver — немає гарантії сумісності.

Сценарій:
1. Розробник оновлює ModelSelector в shared
2. backend працює з новим API
3. rada використовує старий API
4. rada ламається після наступного npm install

Лікування: semver для shared package. Кожен сервіс фіксує версію: "@secondlayer/shared": "^2.1.0".

6. Конфігурація через 50+ env vars

Один docker-compose з 50+ змінними середовища. Зміна одного OPENAI_API_KEY вимагає рестарту всіх сервісів.

Лікування: кожен сервіс отримує тільки свої змінні. Розділити compose на окремі файли.

7. Один health check для всього

Якщо /health одного сервісу перевіряє доступність іншого — це coupling.

Лікування: health check перевіряє тільки локальні залежності (своя БД, свій Redis).


Коли distributed monolith — правильний вибір

Ось непопулярна думка: distributed monolith — це не завжди проблема. Для певного масштабу це оптимальна архітектура.

Переваги, які ми отримуємо

1. Простота операцій

Один docker compose up піднімає все. Один docker compose logs показує все. Один сервер — повна система.

Порівняйте з Kubernetes: helm charts, service mesh, ingress controllers, pod autoscaling, persistent volumes. Для команди з 2–3 розробників це overhead, який не окупається.

2. Швидкість розробки

Shared package означає DRY. Один Logger, одна BaseDatabase, один ModelSelector. Зміна в одному місці — працює скрізь. Для мікросервісів — це 3 окремих PR, 3 окремих деплої, 3 рази перевірити сумісність.

3. Транзакційна цілісність

Один PostgreSQL = можливість JOIN між схемами, якщо колись знадобиться. Cross-service транзакції в мікросервісах — це saga pattern, eventually consistent, distributed locks. Складність × 10.

4. Debuggability

Один docker compose logs -f → бачиш весь request flow від nginx до backend до rada. В мікросервісах — це distributed tracing, correlation IDs, Jaeger/Zipkin.

5. Вартість

Один сервер замість трьох. Один PostgreSQL замість трьох. Один Redis замість трьох. Для стартапу різниця суттєва.

Формула: коли distributed monolith достатньо

ЯКЩО:
  команда < 5 розробників
  ТА навантаження < 1000 RPS
  ТА деплой < 5 разів на день
  ТА один сервер справляється
  ТА немає вимог до незалежного масштабування
ТО:
  distributed monolith = оптимум

Поетапний план еволюції

Коли distributed monolith перестає справлятись — не переходьте на мікросервіси одним стрибком. Еволюціонуйте поетапно.

Фаза 1: Зміцнення (зусилля: низьке, ефект: 80%)

Мінімальні зміни, які дають більшість переваг мікросервісів без їхньої складності.

1.1 Розділити Redis

# Було: один Redis на всіх
redis:
  image: redis:7
  command: redis-server --maxmemory 512mb

# Стало: окремий для кожного сервісу
redis-backend:
  image: redis:7
  command: redis-server --maxmemory 256mb
redis-rada:
  image: redis:7
  command: redis-server --maxmemory 128mb
redis-openreyestr:
  image: redis:7
  command: redis-server --maxmemory 128mb

Час: 1 година. Ефект: кеш кожного сервісу ізольований, евікшн не каскадує.

1.2 Версіонувати shared package

// packages/shared/package.json
{ "version": "2.1.0" }

// mcp_backend/package.json
{ "@secondlayer/shared": "^2.1.0" }

// mcp_rada/package.json
{ "@secondlayer/shared": "^2.0.0" }  // може використовувати старішу версію

Час: 2 години. Ефект: зміна shared не ламає всіх одночасно.

1.3 Circuit breaker в RemoteServiceClient

class CircuitBreaker {
  private failures = 0;
  private lastFailure = 0;
  private state: 'closed' | 'open' | 'half-open' = 'closed';

  async call<T>(fn: () => Promise<T>): Promise<T> {
    if (this.state === 'open') {
      if (Date.now() - this.lastFailure > 30_000) {
        this.state = 'half-open'; // спробувати один запит
      } else {
        throw new Error('Circuit open — rada unavailable');
      }
    }

    try {
      const result = await fn();
      this.reset();
      return result;
    } catch (err) {
      this.failures++;
      this.lastFailure = Date.now();
      if (this.failures >= 3) this.state = 'open';
      throw err;
    }
  }
}

Час: 3 години. Ефект: падіння rada не каскадує на backend.

Фаза 2: Інфраструктурна незалежність (коли навантаження росте)

2.1 Окремі PostgreSQL інстанси

Було:                          Стало:
postgres (1 процес)            postgres-backend (8 GB RAM)
├── public schema              postgres-rada (2 GB RAM)
├── rada schema                postgres-openreyestr (4 GB RAM)
└── openreyestr schema

Тепер можна масштабувати БД openreyestr (340M+ записів) незалежно від backend.

2.2 Розділити docker-compose

compose.infra.yml      # postgres, redis, qdrant, minio
compose.backend.yml    # app + migrations
compose.rada.yml       # rada app + rada migrations
compose.openreyestr.yml # openreyestr app + migrations
compose.frontend.yml   # lexwebapp + nginx

Кожен сервіс деплоїться окремо: docker compose -f compose.rada.yml up -d.

2.3 API contracts між сервісами

// packages/shared/src/contracts/rada-tools.ts
export interface RadaToolContract {
  search_parliament_bills: {
    input: { query: string; limit?: number };
    output: { bills: Bill[]; total: number };
  };
}

Зміна контракту — compile-time помилка в обох сервісах.

Фаза 3: Справжні мікросервіси (команда > 5, мультисервер)

3.1 Service discovery замість env vars

Було: RADA_MCP_URL=http://rada-mcp-app:3001
Стало: DNS-based discovery через Docker Swarm або Consul

3.2 Message queue для async операцій

Було: HTTP POST /api/tools/start_import → sync response
Стало: publish to queue → worker picks up → status via SSE

Redis Streams (вже є Redis) або RabbitMQ.

3.3 Незалежні CI/CD pipelines

Кожен сервіс — свій workflow, свої тести, свій деплой. Зміна в rada не тригерить білд backend.


Антипатерни: чого НЕ робити

Не переходьте на Kubernetes до 10+ сервісів

K8s overhead для 3 сервісів: helm charts, ingress controllers, persistent volume claims, pod disruption budgets, resource quotas, network policies, service accounts, RBAC...

Docker Compose + blue-green = 95% результату K8s при 5% складності.

Не розбивайте моноліт на нано-сервіси

❌ auth-service, billing-service, user-service,
   document-service, search-service, embedding-service,
   legislation-service, vault-service, consultation-service...

✅ mcp_backend (модульний моноліт з 76 сервісами всередині)

76 внутрішніх сервісів тісно пов'язані (billing ↔ auth ↔ consultations). Розділити їх = distributed transactions, eventual consistency, saga pattern. Модульний моноліт — правильна відповідь.

Не додавайте event sourcing заради event sourcing

CQRS і event sourcing вирішують конкретну проблему: rebuild стану з подій. Якщо у вас немає цієї проблеми — це зайва складність без користі.

Не проєктуйте для гіпотетичного навантаження

"А що якщо у нас буде 100K юзерів?"
→ Спочатку отримайте 1K. Потім оптимізуйте.

Premature scaling — це premature optimization для інфраструктури. Реальне навантаження завжди відрізняється від уявного.


Матриця рішень

Сигнал Дія Фаза
Redis cache contention між сервісами Розділити Redis на окремі інстанси 1
Зміна shared ламає сервіс Додати semver до shared package 1
Падіння rada каскадує на backend Додати circuit breaker 1
БД openreyestr (340M записів) гальмує Окремий PostgreSQL інстанс 2
Деплой backend чіпає rada Розділити docker-compose 2
Зміна tool signature ламає gateway API contracts з типами 2
Команда > 5, незалежні стріми Service discovery, окремі CI/CD 3
Потрібен async processing Message queue (Redis Streams) 3

Висновок

Distributed monolith — це не діагноз. Це етап еволюції архітектури. Для команди з 2–3 розробників і одного сервера — це оптимум, який дає простоту моноліту з деякими перевагами сервісної архітектури.

Проблема не в тому, що у вас distributed monolith. Проблема — якщо ви не знаєте, що він у вас є, і намагаєтесь масштабувати його як мікросервіси.

Знайте свої точки coupling. Мійте план еволюції. І не переходьте на мікросервіси, доки конкретний bottleneck цього не вимагає.

80% переваг мікросервісів можна отримати за 20% зусиль — розділивши Redis, додавши circuit breaker і версіонувавши shared package. Решта 20% переваг коштуватиме 80% зусиль. Платіть цю ціну тільки коли дійсно потрібно.


Реєстрація: legal.org.ua