Best Practices

AI Rules for Domain-Driven Design

AI generates anemic data models with business logic scattered across services. Rules for ubiquitous language, bounded contexts, aggregates, value objects, domain events, and anti-corruption layers.

8 min read·February 22, 2025

500-line OrderService with scattered business logic — rules impossible to find, duplicated everywhere

Ubiquitous language, bounded contexts, aggregates, value objects, domain events, anti-corruption layers

AI Builds Anemic Models with Scattered Logic

AI generates code with: anemic domain models (classes with only getters and setters, no behavior), business logic in service layers (a 500-line OrderService that does everything), no domain language (technical terms instead of business terms — updateRecord instead of shipOrder), database-driven design (the schema dictates the model, not the domain), and no boundaries (one monolithic model for the entire application). The result: business rules are impossible to find, scattered across services, and duplicated in multiple places.

Domain-driven design solves this by: embedding business logic in domain objects (Order.ship() enforces shipping rules), using ubiquitous language (code uses the same terms as the business), defining bounded contexts (separate models for separate domains), enforcing invariants through aggregates (Order ensures its items are consistent), and communicating between contexts through domain events. AI generates none of these — but like CQRS, DDD is for complex domains, not every application.

These rules cover: ubiquitous language, bounded contexts, aggregates and aggregate roots, value objects, domain events, the repository pattern, and anti-corruption layers.

Rule 1: Ubiquitous Language in Code

The rule: 'Code must use the same language as the business domain. If the business says "ship an order," the code says order.ship() — not orderService.updateStatus(orderId, 'shipped'). If the business says "apply a discount," the code says cart.applyDiscount(coupon) — not cartService.recalculateTotal(cartId, couponCode). The ubiquitous language is: shared between developers and domain experts, used in code, documentation, and conversation, and enforced in code review.'

For naming discipline: 'Domain terms become: class names (Order, Shipment, Invoice), method names (order.ship(), invoice.void(), account.freeze()), event names (OrderShipped, InvoiceVoided, AccountFrozen), and value object names (Money, EmailAddress, PhoneNumber). Technical terms (create, update, delete, process, handle) are replaced by domain terms (place, ship, cancel, approve, reject). The code reads like a business process description, not a database operation manual.'

AI generates: updateOrderStatus, processPayment, handleShipment — generic technical verbs that reveal nothing about the business rules. What does processPayment do? Charge the card? Reserve funds? Validate the payment method? With ubiquitous language: order.chargePayment() is unambiguous. payment.reserve() is clear. The method name is the documentation.

  • Business says 'ship an order' → code says order.ship() — not updateStatus('shipped')
  • Domain terms for classes: Order, Shipment, Invoice — not Record, Entity, Item
  • Domain verbs for methods: ship, cancel, approve, reject — not update, process, handle
  • Shared language: developers and domain experts use the same terms
  • Code review enforces language — reject generic names when domain terms exist
💡 The Method Name Is the Documentation

processPayment() — what does it do? Charge? Reserve? Validate? With ubiquitous language: payment.charge() is unambiguous. payment.reserve() is clear. order.ship() reads like a business process description, not a database operation manual.

Rule 2: Bounded Contexts

The rule: 'Define bounded contexts around distinct business domains. Each context has: its own model (Order in the sales context is different from Order in the shipping context), its own language ("customer" in sales vs "recipient" in shipping), its own data store (or at minimum, its own schema), and its own team ownership. Contexts communicate through well-defined interfaces — not by sharing database tables or internal models.'

For identifying boundaries: 'A bounded context boundary exists where: the same word means different things ("product" in catalog vs "product" in inventory), different teams own different parts, or the model complexity suggests separation. In an e-commerce system: Sales context (orders, pricing, promotions), Shipping context (shipments, tracking, carriers), Billing context (invoices, payments, refunds), Catalog context (products, categories, search). Each context is a self-contained model.'

AI generates: one Product class used everywhere — in the catalog (needs description, images, SEO), in the cart (needs price, quantity, availability), in shipping (needs weight, dimensions, fragility), and in analytics (needs category, margin, conversion rate). One class with 40 fields, most irrelevant in any given context. Bounded contexts: CatalogProduct, CartItem, ShippableItem, ProductMetric — each with only the fields its context needs.

Rule 3: Aggregates and Aggregate Roots

The rule: 'An aggregate is a cluster of domain objects treated as a unit for data changes. The aggregate root is the entry point — all modifications go through it. Order (aggregate root) contains OrderItems. To add an item: order.addItem(product, quantity) — the Order validates business rules (maximum 50 items, minimum order value, item availability). Never modify OrderItems directly — always through the Order root. The aggregate enforces invariants that span multiple objects.'

For aggregate boundaries: 'Keep aggregates small. An Order aggregate contains: Order (root), OrderItems, and ShippingAddress. It does NOT contain: Customer (separate aggregate), Product (separate aggregate, different context), or Payment (separate aggregate in billing context). The rule: if two objects must be consistent within the same transaction, they belong in the same aggregate. If eventual consistency is acceptable, they belong in separate aggregates connected by domain events.'

AI generates: order.items[2].quantity = 5 — direct modification bypassing the aggregate root. No validation: is quantity > 0? Is the item still available? Does the order total exceed the credit limit? The aggregate root method order.updateItemQuantity(itemId, 5) checks all invariants before allowing the change. The root is the gatekeeper; no one reaches the internals directly.

  • Aggregate root is the only entry point for modifications
  • order.addItem() validates rules — never modify order.items directly
  • Keep aggregates small: Order + Items + Address, not Order + Customer + Payment
  • Same transaction = same aggregate. Eventual consistency = separate aggregates
  • Aggregates communicate via domain events, not direct references
⚠️ The Root Is the Gatekeeper

order.items[2].quantity = 5 — direct modification, no validation. Is quantity > 0? Is the item available? Does the total exceed the credit limit? order.updateItemQuantity(itemId, 5) checks all invariants. The aggregate root ensures no invalid state is ever reachable.

Rule 4: Value Objects for Domain Concepts

The rule: 'Model domain concepts as value objects instead of primitive types. Money instead of number (carries currency, prevents arithmetic errors between currencies). EmailAddress instead of string (validated on construction, always valid). DateRange instead of two dates (validates start < end, provides overlap/contains methods). PhoneNumber instead of string (formatted, validated, country-aware). Value objects are: immutable (cannot be changed after creation), equality by value (two Money(100, "USD") are equal), and self-validating (invalid state is impossible).'

For implementation: 'class Money { constructor(private amount: number, private currency: string) { if (amount < 0) throw new Error("Amount cannot be negative"); } add(other: Money): Money { if (this.currency !== other.currency) throw new Error("Currency mismatch"); return new Money(this.amount + other.amount, this.currency); } }. The value object prevents: negative amounts (validated in constructor), currency mixing (checked in add), and mutation (returns new instance). Business rules enforced at the type level.'

AI generates: const total = price * quantity + shipping — raw numbers with no currency, no validation, and no protection against mixing USD and EUR. With Money: const total = price.multiply(quantity).add(shipping) — currency-safe, overflow-safe, and readable. The value object makes invalid states unrepresentable. You cannot accidentally add dollars to euros.

ℹ️ Dollars Plus Euros = Error

const total = price * quantity + shipping — raw numbers with no currency protection. Money value object: price.multiply(quantity).add(shipping) throws if currencies mismatch. Invalid states are unrepresentable. You cannot accidentally add USD to EUR.

Rule 5: Anti-Corruption Layers and Context Mapping

The rule: 'When integrating with external systems or legacy code, use an anti-corruption layer (ACL) — a translation boundary that converts external models to your domain model. The ACL: translates external field names to domain terms (external "cust_nm" becomes domain Customer.name), validates external data against domain rules (rejects invalid states), and isolates your domain from external changes (the external API changes, you update the ACL, domain code is unchanged).'

For context mapping patterns: 'Shared Kernel: two contexts share a small common model (both teams must agree on changes). Customer/Supplier: one context provides data, the other consumes (upstream/downstream relationship). Conformist: you adopt the external model as-is (low effort but high coupling). Anti-Corruption Layer: you translate the external model (high isolation but more code). Choose based on: team relationship, change frequency, and how much the external model differs from your domain.'

AI generates: direct use of the external API response as the domain model — external field names, external data shapes, and external validation rules leak into your business logic. When the external API changes a field name, your domain logic breaks. An ACL: external API changes, ACL translation updates, domain code is unchanged. The ACL is the firewall between your domain and the messy external world.

Complete Domain-Driven Design Rules Template

Consolidated rules for domain-driven design.

  • Ubiquitous language: code uses business terms — order.ship() not updateStatus('shipped')
  • Bounded contexts: separate models per domain — Sales, Shipping, Billing each self-contained
  • Aggregates: cluster of objects with a root entry point — root enforces all invariants
  • Small aggregates: same-transaction consistency boundary — eventual consistency between aggregates
  • Value objects: Money, EmailAddress, DateRange — immutable, self-validating, equality by value
  • Domain events: OrderShipped, PaymentProcessed — communicate between aggregates and contexts
  • Anti-corruption layer: translate external models at the boundary — isolate domain from external changes
  • Not for every app: DDD is for complex domains — simple CRUD does not need aggregates