Mid📖Теория4 min

Стратегии декомпозиции

DDD, bounded contexts, декомпозиция по бизнес-домену и по данным с PHP-примерами модулей

Стратегии декомпозиции

Проблема декомпозиции

Неправильная декомпозиция -- главная причина провала микросервисных проектов. Если границы сервисов проведены неправильно, каждое изменение затрагивает несколько сервисов, а коммуникация между ними становится узким местом.

Domain-Driven Design (DDD)

DDD предоставляет методологию определения границ сервисов через анализ бизнес-домена.

Стратегические паттерны

Концепция Описание
Domain Предметная область бизнеса
Subdomain Часть домена (core, supporting, generic)
Bounded Context Граница, внутри которой модель единообразна
Ubiquitous Language Единый язык между разработчиками и бизнесом
Context Map Карта связей между bounded contexts

Типы поддоменов

Тип Описание Пример (E-commerce) Стратегия
Core Конкурентное преимущество Рекомендации, ценообразование Делать самим, лучшая команда
Supporting Поддерживает core, но не уникально Каталог, отзывы Делать самим или аутсорс
Generic Стандартные решения Авторизация, рассылки Купить готовое решение

Bounded Context = Граница сервиса

<?php

declare(strict_types=1);

/**
 * Different Bounded Contexts have different models
 * for the same real-world entity
 */

// Bounded Context: Catalog
namespace App\Catalog;

final readonly class Product
{
    public function __construct(
        public string $id,
        public string $name,
        public string $description,
        public string $category,
        public array $images,
        public array $specifications,
    ) {}
}

// Bounded Context: Orders
namespace App\Orders;

// Same "Product" but completely different model!
final readonly class OrderItem
{
    public function __construct(
        public string $productId,  // Reference only
        public string $productName, // Snapshot at order time
        public float $price,        // Price at order time (may differ from catalog)
        public int $quantity,
    ) {}
}

// Bounded Context: Inventory
namespace App\Inventory;

// Again "Product" but from inventory perspective
final readonly class StockItem
{
    public function __construct(
        public string $sku,        // SKU, not product ID
        public int $quantity,
        public string $warehouse,
        public int $reservedCount,
    ) {}
}

Ключевой инсайт: один и тот же объект реального мира (Product) имеет разные модели в разных контекстах. Не пытайтесь создать единую модель.

Context Map

┌────────────┐         ┌────────────┐
│  Catalog   │ ──OHS──>│   Orders   │
│  Context   │         │  Context   │
└────────────┘         └─────┬──────┘
                             │
                        ACL  │
                             │
┌────────────┐         ┌─────┴──────┐
│  Payments  │ <──CF── │ Inventory  │
│  Context   │         │  Context   │
└────────────┘         └────────────┘

OHS = Open Host Service (публичный API)
ACL = Anti-Corruption Layer (адаптер)
CF  = Conformist (принимает модель другого контекста)
<?php

declare(strict_types=1);

/**
 * Anti-Corruption Layer: translate between bounded contexts
 */
namespace App\Orders\Infrastructure;

use App\Orders\Domain\ProductSnapshot;

final class CatalogAntiCorruptionLayer
{
    public function __construct(
        private readonly CatalogApiClient $catalogClient,
    ) {}

    /**
     * Translate Catalog's model to Orders' model
     * This prevents Catalog's changes from breaking Orders
     */
    public function getProductSnapshot(string $productId): ProductSnapshot
    {
        // External model (Catalog context)
        $catalogProduct = $this->catalogClient->getProduct($productId);

        // Translate to internal model (Orders context)
        return new ProductSnapshot(
            productId: $catalogProduct['id'],
            name: $catalogProduct['title'], // Different field name!
            price: (float) $catalogProduct['prices']['retail'], // Nested structure
            currency: $catalogProduct['prices']['currency'],
            imageUrl: $catalogProduct['media'][0]['url'] ?? null,
        );
    }
}

Стратегии декомпозиции

1. По бизнес-домену (рекомендуемый)

Каждый сервис соответствует бизнес-возможности (business capability):

E-commerce platform:

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│   Catalog   │  │   Orders    │  │  Payments   │
│  Service    │  │  Service    │  │  Service    │
│             │  │             │  │             │
│ - Products  │  │ - Cart      │  │ - Charges   │
│ - Search    │  │ - Checkout  │  │ - Refunds   │
│ - Reviews   │  │ - History   │  │ - Billing   │
└─────────────┘  └─────────────┘  └─────────────┘

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│  Shipping   │  │    Users    │  │Notifications│
│  Service    │  │  Service    │  │  Service    │
│             │  │             │  │             │
│ - Tracking  │  │ - Auth      │  │ - Email     │
│ - Rates     │  │ - Profiles  │  │ - Push      │
│ - Labels    │  │ - Prefs     │  │ - SMS       │
└─────────────┘  └─────────────┘  └─────────────┘

2. По данным (database per service)

Сервис владеет своими данными. Другие сервисы обращаются только через API.

<?php

declare(strict_types=1);

/**
 * Each service owns its data exclusively
 */

// Order Service: owns orders table
namespace App\OrderService;

final class OrderRepository
{
    public function __construct(
        private readonly \PDO $orderDb, // Separate database
    ) {}

    public function findById(string $id): ?array
    {
        $stmt = $this->orderDb->prepare('SELECT * FROM orders WHERE id = :id');
        $stmt->execute(['id' => $id]);
        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
    }

    // This service NEVER reads from users or products tables directly
    // It calls other services' APIs to get that data
}

// User Service: owns users table
namespace App\UserService;

final class UserRepository
{
    public function __construct(
        private readonly \PDO $userDb, // Different database
    ) {}

    public function findById(string $id): ?array
    {
        $stmt = $this->userDb->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);
        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
    }
}

3. Пример модульной структуры PHP-проекта

<?php

declare(strict_types=1);

/**
 * Modular monolith structure (step before microservices)
 *
 * src/
 * ├── Module/
 * │   ├── Catalog/
 * │   │   ├── Api/                    # Public API (interface)
 * │   │   │   ├── CatalogModuleApi.php
 * │   │   │   └── Dto/
 * │   │   │       └── ProductDto.php
 * │   │   ├── Domain/                 # Business logic
 * │   │   │   ├── Product.php
 * │   │   │   └── ProductRepository.php
 * │   │   ├── Infrastructure/         # DB, external services
 * │   │   │   ├── PostgresProductRepository.php
 * │   │   │   └── ElasticsearchProductSearch.php
 * │   │   └── Application/            # Use cases
 * │   │       ├── CreateProduct.php
 * │   │       └── SearchProducts.php
 * │   │
 * │   ├── Orders/
 * │   │   ├── Api/
 * │   │   │   └── OrderModuleApi.php
 * │   │   ├── Domain/
 * │   │   ├── Infrastructure/
 * │   │   │   └── CatalogModuleAdapter.php  # Uses CatalogModuleApi
 * │   │   └── Application/
 * │   │
 * │   └── Payments/
 * │       ├── Api/
 * │       ├── Domain/
 * │       ├── Infrastructure/
 * │       └── Application/
 */

// Module API: the ONLY way to communicate between modules
namespace App\Module\Catalog\Api;

interface CatalogModuleApi
{
    public function getProduct(string $productId): ProductDto;
    public function searchProducts(string $query, int $limit = 20): array;
    public function checkAvailability(array $productIds): array;
}

// Orders module uses Catalog through its API, never directly
namespace App\Module\Orders\Infrastructure;

use App\Module\Catalog\Api\CatalogModuleApi;

final class CatalogModuleAdapter
{
    public function __construct(
        private readonly CatalogModuleApi $catalog,
    ) {}

    public function getProductForOrder(string $productId): OrderProductSnapshot
    {
        $product = $this->catalog->getProduct($productId);

        return new OrderProductSnapshot(
            productId: $product->id,
            name: $product->name,
            price: $product->price,
        );
    }
}

Правила хорошей декомпозиции

Высокая связность (High Cohesion)

Связанные функции -- в одном сервисе. Если изменение одной функции требует изменения другой, они должны быть в одном сервисе.

Слабая связанность (Loose Coupling)

Минимум зависимостей между сервисами. Изменение внутренней реализации сервиса не должно затрагивать другие сервисы.

Тест: правильно ли проведены границы?

Вопрос Плохо если Хорошо если
Сколько сервисов затрагивает фича? 3+ сервиса 1 сервис
Нужна ли синхронная связь? Цепочка вызовов Асинхронные события
Нужна ли распределённая транзакция? Часто Редко
Можно ли деплоить независимо? Нужен координированный деплой Да
Владеет ли сервис своими данными? Shared database Database per service

Итоги

  • DDD и bounded contexts -- лучший инструмент для определения границ
  • Один реальный объект может иметь разные модели в разных контекстах
  • Anti-Corruption Layer защищает контекст от изменений в другом контексте
  • Начинайте с модульного монолита, извлекайте сервисы по необходимости
  • Хорошая декомпозиция: высокая связность внутри, слабая связанность между

Проверь себя

🧪

С какого модуля лучше начать миграцию по паттерну Strangler Fig?

🧪

Что такое Anti-Corruption Layer в контексте DDD?

🧪

Что помогает определить границы Bounded Contexts?

🧪

Почему Shared Database считается анти-паттерном в микросервисах?

🧪

Как правильно декомпозировать монолит на микросервисы?