Неделя 3: Postgres для Order и Inventory, JSONB, миграции
In-memory было нужно чтобы сосредоточиться на контрактах и архитектуре. Теперь добавляем реальные хранилища: оба бизнес-сервиса (Order и Inventory) переезжают на PostgreSQL. У каталога запчастей появляются enum-категория и JSONB-атрибуты. Плюс миграции и docker-compose, чтобы локальный запуск был одной командой.
Цели недели
- Поднять два независимых инстанса PostgreSQL в docker-compose с volume-ами и healthcheck-ами (по инстансу на сервис).
- Заменить in-memory репозиторий OrderService на Postgres.
- Заменить in-memory репозиторий InventoryService на Postgres с осмысленной схемой каталога.
- Подключить миграции, обеспечить авто-накатку при старте.
- Сохранить тесты зелёными (unit с моками работают как и работали).
Один инстанс на сервис, не shared
Соблазн, поднять одну Postgres-машину и держать там схемы order_db и inventory_db, экономия RAM. Это работает в раннем dev, но мы намеренно делаем по инстансу:
- изоляция миграций, миграции одного сервиса не упираются в lock-таблицу другого,
- blast radius, упавший Postgres не валит сразу два сервиса,
- скейлинг, у каталога 99% read, у Order смешанная нагрузка с outbox-write, разные настройки
shared_buffersиwork_mem, - подготовка к k8s, в проде это всё равно отдельные StatefulSet-ы.
Это и есть тема ADR-0002 на эту неделю.
Структура deploy/compose/
deploy/
└── compose/
├── core/docker-compose.yml # общая сеть microservices_net
├── order/docker-compose.yml # postgres-order
└── inventory/docker-compose.yml # postgres-inventory
Раскладываем по разным compose-файлам, потому что:
- каждый сервис может подниматься независимо (
docker compose -f deploy/compose/order/docker-compose.yml up -d), - общая сеть переиспользуется,
- упрощается переход на k8s (один compose, один манифест),
- легко управлять отдельными зависимостями (например, поднял только Postgres для миграций).
core/docker-compose.yml
name: microservices-core
networks:
microservices_net:
name: microservices_net
driver: bridge
order/docker-compose.yml
name: microservices-order
services:
postgres-order:
image: postgres:17-alpine
container_name: postgres-order
environment:
POSTGRES_USER: ${ORDER_PG_USER:-postgres}
POSTGRES_PASSWORD: ${ORDER_PG_PASSWORD:-postgres}
POSTGRES_DB: ${ORDER_PG_DB:-order}
ports:
- "${ORDER_PG_PORT:-5432}:5432"
volumes:
- postgres-order-data:/var/lib/postgresql/data
networks:
- microservices_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${ORDER_PG_USER:-postgres} -d ${ORDER_PG_DB:-order}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres-order-data:
networks:
microservices_net:
external: true
networks: external: true означает, что сеть должна быть создана заранее (через core compose). Это форсит правильный порядок поднятия.
inventory/docker-compose.yml
name: microservices-inventory
services:
postgres-inventory:
image: postgres:17-alpine
container_name: postgres-inventory
environment:
POSTGRES_USER: ${INVENTORY_PG_USER:-postgres}
POSTGRES_PASSWORD: ${INVENTORY_PG_PASSWORD:-postgres}
POSTGRES_DB: ${INVENTORY_PG_DB:-inventory}
ports:
- "${INVENTORY_PG_PORT:-5433}:5432" # хост-порт другой, чтобы не пересекаться с order
volumes:
- postgres-inventory-data:/var/lib/postgresql/data
networks:
- microservices_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${INVENTORY_PG_USER:-postgres} -d ${INVENTORY_PG_DB:-inventory}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres-inventory-data:
networks:
microservices_net:
external: true
Внутри docker-сети сервисы видят друг друга по имени (postgres-order:5432, postgres-inventory:5432), различные хост-порты нужны только если ты хочешь подключиться с ноута через psql.
Postgres, pgx или database/sql
Для production-Go рекомендую pgx. Причины:
- нативный Postgres-протокол (быстрее database/sql),
- богаче по фичам (LISTEN/NOTIFY, COPY, batch),
- умеет в JSONB, arrays, intervals, enum без танцев с rendering-ом,
- встроенный pool (
pgxpool).
Можно использовать через database/sql-обёртку (stdlib.OpenDB), если хочется совместимости с другими тулзами (sqlx, sqlc), но в этом курсе берём напрямую.
Соединение
// order/internal/repository/order/repository.go
package orderrepo
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository struct {
pool *pgxpool.Pool
}
func New(ctx context.Context, dsn string) (*Repository, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &Repository{pool: pool}, nil
}
func (r *Repository) Close() { r.pool.Close() }
Запросы Order
// order/internal/repository/order/create.go
func (r *Repository) Create(ctx context.Context, o *model.Order) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO orders (uuid, user_uuid, total_cents, status, part_uuids, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, o.UUID, o.UserUUID, o.TotalCents, o.Status, o.PartUUIDs, o.CreatedAt)
return err
}
Конвертер domain <-> repo-model живёт в repository/converter/order.go. Если у domain-Order поле Total time.Duration, а в БД total_seconds INT, конвертация одна точка изменения.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.