Реалізація Domain-Driven Design (DDD) для веб-застосунку

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Domain-Driven Design (DDD) для веб-застосунку
Складна
від 2 тижнів до 3 місяців
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація Domain-Driven Design (DDD) для веб-додатку

DDD — методологія проектування, при якій архітектура системи відображає бізнес-домен. Код говорить тією ж мовою, що й експерти предметної області. Не «оновити запис у таблиці users», а «призупинити облік за порушення політики». DDD виправданий для складних доменів — електронної комерції з нетривіальними правилами ціноутворення, фінансових систем, SaaS з гнучкими тарифами.

Ubiquitous Language

Перший крок — створення єдиного словника з бізнес-експертом. Терміни кодуються в код безпосередньо.

Погано: updateUserStatus(userId, 1) або setActive(true) Добре: customer.activate(), subscription.suspend(reason), order.submit()

Словник фіксується у документі та підтримується актуальним при змінах вимог.

Bounded Context

Велику систему ділять на Bounded Context — обмежені контексти, в кожному з яких терміни мають строго визначений сенс. «Продукт» у контексті Каталогу — це опис, фото, SEO. «Продукт» у контексті Замовлень — SKU, ціна, кількість. «Продукт» у контексті Складу — фізична одиниця з місцем зберігання.

┌─────────────────────┐  ┌─────────────────────┐  ┌─────────────────────┐
│   Catalog Context   │  │   Orders Context    │  │ Inventory Context   │
│                     │  │                     │  │                     │
│ Product             │  │ OrderItem           │  │ StockItem           │
│ Category            │  │ Order               │  │ Warehouse           │
│ PriceList           │  │ Customer            │  │ StockMovement       │
└─────────────────────┘  └─────────────────────┘  └─────────────────────┘
         ↑ Anti-Corruption Layer між контекстами

Явні межі та маппінг між контекстами. Context Map документує взаємодію.

Будівельні блоки

Entity — об'єкт з ідентичністю, збереженою при зміні атрибутів:

class Order {
  private readonly _id: OrderId;
  private _status: OrderStatus;
  private _items: OrderItem[] = [];
  private _domainEvents: DomainEvent[] = [];

  constructor(id: OrderId, customerId: CustomerId) {
    this._id = id;
    this._status = OrderStatus.Draft;
    this.raise(new OrderCreatedEvent(id, customerId));
  }

  addItem(product: Product, quantity: Quantity): void {
    if (this._status !== OrderStatus.Draft) {
      throw new OrderNotEditableError(this._id);
    }
    if (quantity.isZero()) {
      throw new InvalidQuantityError();
    }

    const existing = this._items.find(i => i.productId.equals(product.id));
    if (existing) {
      existing.increaseQuantity(quantity);
    } else {
      this._items.push(new OrderItem(product.id, product.price, quantity));
    }
  }

  submit(): void {
    this.ensureCanTransitionTo(OrderStatus.Submitted);
    if (this._items.length === 0) throw new EmptyOrderError();
    this._status = OrderStatus.Submitted;
    this.raise(new OrderSubmittedEvent(this._id, this.calculateTotal()));
  }

  get id(): OrderId { return this._id; }
  get total(): Money { return this.calculateTotal(); }
  pullDomainEvents(): DomainEvent[] { /* ... */ }
}

Value Object — об'єкт без ідентичності, визначуваний значеннями. Невіддільний:

class Money {
  private constructor(
    private readonly _amount: number,
    private readonly _currency: Currency
  ) {
    if (_amount < 0) throw new NegativeAmountError();
  }

  static of(amount: number, currency: Currency): Money {
    return new Money(amount, currency);
  }

  add(other: Money): Money {
    if (!this._currency.equals(other._currency)) {
      throw new CurrencyMismatchError();
    }
    return new Money(this._amount + other._amount, this._currency);
  }

  multiply(factor: number): Money {
    return new Money(Math.round(this._amount * factor * 100) / 100, this._currency);
  }

  equals(other: Money): boolean {
    return this._amount === other._amount && this._currency.equals(other._currency);
  }
}

class Email {
  private constructor(private readonly value: string) {}

  static create(email: string): Email {
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      throw new InvalidEmailError(email);
    }
    return new Email(email.toLowerCase());
  }
}

Aggregate — кластер пов'язаних сутностей з однією точкою входу (Aggregate Root). Зовнішній код працює тільки з коренем:

// Order — Aggregate Root
// OrderItem — входить в агрегат Order, не доступний напрямку
class Order {
  // ...все взаємодія з items тільки через Order
  removeItem(productId: ProductId): void { /* ... */ }
  updateQuantity(productId: ProductId, qty: Quantity): void { /* ... */ }
}

Правило: одна трансакція = один агрегат. Між агрегатами — eventual consistency через доменні події.

Domain Service — операція, яка не належить жодній сутності:

class OrderPricingService {
  constructor(
    private discountRepo: DiscountRepository,
    private taxService: TaxCalculationService
  ) {}

  async calculateTotal(order: Order, customer: Customer): Promise<PricingResult> {
    const discounts = await this.discountRepo.findApplicable(
      customer.segment, order.items
    );

    let subtotal = order.items.reduce(
      (sum, item) => sum.add(item.price.multiply(item.quantity.value)),
      Money.zero(Currency.USD)
    );

    const discountAmount = this.applyDiscounts(subtotal, discounts, customer);
    const taxAmount = await this.taxService.calculate(subtotal, customer.address);

    return new PricingResult(subtotal, discountAmount, taxAmount);
  }
}

Repository — абстракція доступу до сховища для агрегату:

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByCustomer(customerId: CustomerId, options?: FindOptions): Promise<Order[]>;
  save(order: Order): Promise<void>;
  delete(id: OrderId): Promise<void>;
}

// Реалізація відокремлена від інтерфейсу (інверсія залежностей)
class PostgresOrderRepository implements OrderRepository {
  async findById(id: OrderId): Promise<Order | null> {
    const row = await this.db.queryOne(
      'SELECT * FROM orders WHERE id = $1', [id.value]
    );
    return row ? this.toDomain(row) : null;
  }

  private toDomain(row: OrderRow): Order {
    return Order.reconstitute({
      id: OrderId.from(row.id),
      status: OrderStatus[row.status],
      customerId: CustomerId.from(row.customer_id),
      items: row.items.map(this.itemToDomain)
    });
  }
}

Application Layer

Тонкий шар, що оркеструє доменні об'єкти. Не містить бізнес-логіки:

class PlaceOrderUseCase {
  async execute(dto: PlaceOrderDto): Promise<PlaceOrderResult> {
    const customer = await this.customerRepo.findById(
      CustomerId.from(dto.customerId)
    );
    if (!customer) throw new CustomerNotFoundError(dto.customerId);

    const order = new Order(OrderId.generate(), customer.id);

    for (const item of dto.items) {
      const product = await this.productRepo.findById(ProductId.from(item.productId));
      order.addItem(product, Quantity.of(item.quantity));
    }

    const pricing = await this.pricingService.calculateTotal(order, customer);
    order.applyPricing(pricing);
    order.submit();

    await this.orderRepo.save(order);
    await this.eventBus.publishAll(order.pullDomainEvents());

    return { orderId: order.id.value, total: order.total };
  }
}

Шарова архітектура

Domain Layer          — Entities, Value Objects, Aggregates, Domain Services, Events
Application Layer     — Use Cases, Application Services, DTOs, Ports
Infrastructure Layer  — Repositories (PostgreSQL/Redis), External APIs, Message Brokers
Presentation Layer    — HTTP Controllers, GraphQL Resolvers, CLI Commands

Залежності спрямовані тільки всередину: Presentation → Application → Domain. Domain не залежить ні від чого.

Терміни реалізації

  • Проектування домену, Context Map, Ubiquitous Language — 1–2 тижні
  • Реалізація одного Bounded Context з 3–5 агрегатами — 3–5 тижнів
  • Повна система з кількома контекстами та інтеграцією — 2–4 місяці