Domain-Driven Design in Scala: Modeling Complex Business Logic

Domain-Driven Design (DDD) provides strategic and tactical patterns for tackling complex software projects. Scala's powerful type system and functional programming features make it an excellent language for implementing DDD concepts. In this comprehensive lesson, we'll explore how to model complex business domains using DDD principles in Scala.

Strategic Design: Understanding the Domain

Ubiquitous Language and Bounded Contexts

The foundation of DDD is establishing a common language between domain experts and developers, and organizing the system into well-defined bounded contexts.

// Define the ubiquitous language through types and documentation

/**
 * E-commerce domain model
 * 
 * Ubiquitous Language Terms:
 * - Customer: A person who can place orders
 * - Product: An item that can be ordered
 * - Order: A request to purchase products
 * - Payment: The financial transaction for an order
 * - Inventory: The stock of available products
 * - Shipping: The delivery of ordered products
 */

// Bounded Context: Sales
object SalesContext {
  // Customer in sales context focuses on purchasing behavior
  case class CustomerId(value: String) extends AnyVal
  case class Customer(
    id: CustomerId,
    name: String,
    email: String,
    preferredPaymentMethod: PaymentMethod,
    shippingAddress: Address
  )

  case class ProductId(value: String) extends AnyVal
  case class Product(
    id: ProductId,
    name: String,
    description: String,
    price: Money,
    category: ProductCategory
  )

  case class OrderId(value: String) extends AnyVal
  case class Order(
    id: OrderId,
    customerId: CustomerId,
    items: List[OrderItem],
    status: OrderStatus,
    createdAt: java.time.Instant
  )
}

// Bounded Context: Inventory
object InventoryContext {
  // Product in inventory context focuses on stock management
  case class SKU(value: String) extends AnyVal
  case class InventoryItem(
    sku: SKU,
    quantityOnHand: Int,
    quantityReserved: Int,
    reorderLevel: Int,
    supplierInfo: SupplierInfo
  ) {
    def availableQuantity: Int = quantityOnHand - quantityReserved
    def needsReorder: Boolean = availableQuantity <= reorderLevel
  }

  case class StockMovement(
    sku: SKU,
    movementType: MovementType,
    quantity: Int,
    timestamp: java.time.Instant,
    reason: String
  )

  enum MovementType {
    case Inbound, Outbound, Adjustment, Transfer
  }
}

// Bounded Context: Shipping
object ShippingContext {
  case class ShipmentId(value: String) extends AnyVal
  case class Shipment(
    id: ShipmentId,
    orderId: String, // Reference to order in sales context
    items: List[ShippedItem],
    carrier: Carrier,
    trackingNumber: String,
    status: ShipmentStatus,
    estimatedDelivery: java.time.LocalDate
  )

  case class ShippedItem(
    productSku: String,
    quantity: Int,
    weight: Weight,
    dimensions: Dimensions
  )
}

// Context mapping between bounded contexts
object ContextMapping {
  // Anti-corruption layer to translate between contexts
  class SalesInventoryTranslator {
    def translateProductIdToSKU(productId: SalesContext.ProductId): InventoryContext.SKU = 
      InventoryContext.SKU(productId.value)

    def translateSKUToProductId(sku: InventoryContext.SKU): SalesContext.ProductId = 
      SalesContext.ProductId(sku.value)
  }

  // Shared kernel - common concepts
  case class Money(amount: BigDecimal, currency: String) {
    require(amount >= 0, "Amount cannot be negative")

    def +(other: Money): Money = {
      require(currency == other.currency, "Cannot add different currencies")
      Money(amount + other.amount, currency)
    }

    def *(factor: BigDecimal): Money = Money(amount * factor, currency)
  }

  case class Address(
    street: String,
    city: String,
    state: String,
    zipCode: String,
    country: String
  )
}

Domain Events and Event Storming

Domain events capture important business occurrences and enable loose coupling between bounded contexts.

// Domain events represent significant business occurrences
trait DomainEvent {
  def eventId: String
  def occurredAt: java.time.Instant
  def aggregateId: String
  def version: Long
}

// Sales context events
object SalesEvents {
  case class CustomerRegistered(
    eventId: String,
    occurredAt: java.time.Instant,
    aggregateId: String,
    version: Long,
    customerId: String,
    customerName: String,
    email: String
  ) extends DomainEvent

  case class OrderPlaced(
    eventId: String,
    occurredAt: java.time.Instant,
    aggregateId: String,
    version: Long,
    orderId: String,
    customerId: String,
    items: List[OrderItemData],
    totalAmount: BigDecimal
  ) extends DomainEvent

  case class OrderConfirmed(
    eventId: String,
    occurredAt: java.time.Instant,
    aggregateId: String,
    version: Long,
    orderId: String
  ) extends DomainEvent

  case class OrderCancelled(
    eventId: String,
    occurredAt: java.time.Instant,
    aggregateId: String,
    version: Long,
    orderId: String,
    reason: String
  ) extends DomainEvent

  case class OrderItemData(productId: String, quantity: Int, unitPrice: BigDecimal)
}

// Event store for domain events
trait EventStore {
  def saveEvents(aggregateId: String, events: List[DomainEvent], expectedVersion: Long): Either[String, Unit]
  def getEvents(aggregateId: String): List[DomainEvent]
  def getEventsFromVersion(aggregateId: String, version: Long): List[DomainEvent]
  def getAllEventsByType[T <: DomainEvent](eventType: Class[T]): List[T]
}

// Event publishing for integration between contexts
trait EventPublisher {
  def publish(event: DomainEvent): Unit
  def publishBatch(events: List[DomainEvent]): Unit
}

// Event handlers for cross-context integration
object EventHandlers {

  // Inventory context reacts to sales events
  class InventoryEventHandler(inventoryService: InventoryService) {
    def handle(event: DomainEvent): Unit = event match {
      case orderPlaced: SalesEvents.OrderPlaced =>
        orderPlaced.items.foreach { item =>
          inventoryService.reserveStock(
            InventoryContext.SKU(item.productId),
            item.quantity
          )
        }

      case orderCancelled: SalesEvents.OrderCancelled =>
        // Release reserved inventory
        inventoryService.releaseReservation(orderCancelled.orderId)

      case _ => // Ignore other events
    }
  }

  // Shipping context reacts to sales events
  class ShippingEventHandler(shippingService: ShippingService) {
    def handle(event: DomainEvent): Unit = event match {
      case orderConfirmed: SalesEvents.OrderConfirmed =>
        shippingService.createShipment(orderConfirmed.orderId)

      case _ => // Ignore other events
    }
  }
}

// Event bus for decoupled communication
class EventBus {
  private var handlers = Map.empty[Class[_ <: DomainEvent], List[DomainEvent => Unit]]

  def subscribe[T <: DomainEvent](eventType: Class[T])(handler: T => Unit): Unit = {
    val currentHandlers = handlers.getOrElse(eventType, List.empty)
    handlers = handlers + (eventType -> (handler.asInstanceOf[DomainEvent => Unit] :: currentHandlers))
  }

  def publish(event: DomainEvent): Unit = {
    handlers.get(event.getClass).foreach { handlerList =>
      handlerList.foreach(_.apply(event))
    }
  }
}

Tactical Design: Building Blocks

Value Objects and Entities

Value objects represent concepts that are defined by their attributes, while entities have a distinct identity.

// Value objects - immutable and defined by their attributes
case class Email(value: String) {
  require(value.contains("@"), "Email must contain @")
  require(value.length <= 100, "Email too long")

  def domain: String = value.split("@")(1)
  def localPart: String = value.split("@")(0)
}

case class PhoneNumber(countryCode: String, number: String) {
  require(countryCode.matches("\\+\\d{1,3}"), "Invalid country code")
  require(number.matches("\\d{10,15}"), "Invalid phone number")

  def formatted: String = s"$countryCode $number"
}

case class Money(amount: BigDecimal, currency: Currency) {
  require(amount >= 0, "Amount cannot be negative")

  def +(other: Money): Money = {
    require(currency == other.currency, "Cannot add different currencies")
    Money(amount + other.amount, currency)
  }

  def -(other: Money): Money = {
    require(currency == other.currency, "Cannot subtract different currencies")
    require(amount >= other.amount, "Cannot subtract more than available")
    Money(amount - other.amount, currency)
  }

  def *(multiplier: BigDecimal): Money = Money(amount * multiplier, currency)

  def isZero: Boolean = amount == 0
  def isPositive: Boolean = amount > 0
}

case class Currency(code: String) {
  require(code.length == 3, "Currency code must be 3 characters")
  require(code.forall(_.isUpper), "Currency code must be uppercase")
}

// Complex value object with business rules
case class ProductPrice(basePrice: Money, discounts: List[Discount] = List.empty) {
  def finalPrice: Money = {
    discounts.foldLeft(basePrice) { (currentPrice, discount) =>
      discount.apply(currentPrice)
    }
  }

  def addDiscount(discount: Discount): ProductPrice = 
    copy(discounts = discount :: discounts)

  def removeExpiredDiscounts(now: java.time.Instant): ProductPrice = {
    val validDiscounts = discounts.filter(!_.isExpired(now))
    copy(discounts = validDiscounts)
  }
}

sealed trait Discount {
  def apply(price: Money): Money
  def isExpired(now: java.time.Instant): Boolean
}

case class PercentageDiscount(
  percentage: BigDecimal,
  validUntil: Option[java.time.Instant] = None
) extends Discount {
  require(percentage >= 0 && percentage <= 100, "Percentage must be between 0 and 100")

  def apply(price: Money): Money = 
    price * (BigDecimal(1) - percentage / 100)

  def isExpired(now: java.time.Instant): Boolean = 
    validUntil.exists(_.isBefore(now))
}

case class FixedAmountDiscount(
  amount: Money,
  validUntil: Option[java.time.Instant] = None
) extends Discount {
  def apply(price: Money): Money = {
    require(price.currency == amount.currency, "Currency mismatch")
    if (price.amount > amount.amount) price - amount else Money(BigDecimal(0), price.currency)
  }

  def isExpired(now: java.time.Instant): Boolean = 
    validUntil.exists(_.isBefore(now))
}

// Entities - have identity and lifecycle
trait Entity[ID] {
  def id: ID
  def version: Long
}

case class Customer(
  id: CustomerId,
  version: Long,
  personalInfo: PersonalInfo,
  contactInfo: ContactInfo,
  preferences: CustomerPreferences,
  membershipLevel: MembershipLevel,
  registeredAt: java.time.Instant
) extends Entity[CustomerId] {

  def updateContactInfo(newContactInfo: ContactInfo): Customer = 
    copy(contactInfo = newContactInfo, version = version + 1)

  def upgradeMembership(newLevel: MembershipLevel): Customer = {
    require(newLevel.tier > membershipLevel.tier, "Can only upgrade membership")
    copy(membershipLevel = newLevel, version = version + 1)
  }

  def isEligibleForDiscount(discount: Discount): Boolean = 
    membershipLevel.tier >= 2 // Gold and Platinum members

  def calculateLoyaltyPoints(orderAmount: Money): Int = {
    val basePoints = (orderAmount.amount / 10).toInt
    val multiplier = membershipLevel.pointsMultiplier
    (basePoints * multiplier).toInt
  }
}

case class PersonalInfo(firstName: String, lastName: String, dateOfBirth: java.time.LocalDate) {
  def fullName: String = s"$firstName $lastName"
  def age: Int = java.time.Period.between(dateOfBirth, java.time.LocalDate.now()).getYears
}

case class ContactInfo(email: Email, phone: PhoneNumber, address: Address)

case class CustomerPreferences(
  newsletterSubscription: Boolean,
  preferredLanguage: String,
  preferredCurrency: Currency,
  marketingOptIn: Boolean
)

case class MembershipLevel(name: String, tier: Int, pointsMultiplier: BigDecimal) {
  require(tier >= 1 && tier <= 4, "Tier must be between 1 and 4")
  require(pointsMultiplier >= 1.0, "Points multiplier must be at least 1.0")
}

object MembershipLevel {
  val Bronze = MembershipLevel("Bronze", 1, BigDecimal(1.0))
  val Silver = MembershipLevel("Silver", 2, BigDecimal(1.2))
  val Gold = MembershipLevel("Gold", 3, BigDecimal(1.5))
  val Platinum = MembershipLevel("Platinum", 4, BigDecimal(2.0))
}

Aggregates and Aggregate Roots

Aggregates ensure consistency boundaries and encapsulate business rules.

// Order aggregate - ensures consistency of order data
case class Order private (
  id: OrderId,
  version: Long,
  customerId: CustomerId,
  items: List[OrderItem],
  status: OrderStatus,
  shippingAddress: Address,
  billingAddress: Address,
  paymentMethod: PaymentMethod,
  createdAt: java.time.Instant,
  private val events: List[DomainEvent] = List.empty
) extends Entity[OrderId] {

  // Business rules enforced by aggregate
  def addItem(productId: ProductId, quantity: Int, unitPrice: Money): Either[String, Order] = {
    if (status != OrderStatus.Draft) {
      Left("Cannot modify confirmed order")
    } else if (quantity <= 0) {
      Left("Quantity must be positive")
    } else if (items.exists(_.productId == productId)) {
      Left("Product already in order - use updateQuantity instead")
    } else {
      val newItem = OrderItem(productId, quantity, unitPrice)
      val updatedOrder = copy(
        items = newItem :: items,
        version = version + 1,
        events = OrderItemAdded(id, productId, quantity) :: events
      )
      Right(updatedOrder)
    }
  }

  def removeItem(productId: ProductId): Either[String, Order] = {
    if (status != OrderStatus.Draft) {
      Left("Cannot modify confirmed order")
    } else if (!items.exists(_.productId == productId)) {
      Left("Product not found in order")
    } else {
      val updatedItems = items.filterNot(_.productId == productId)
      val updatedOrder = copy(
        items = updatedItems,
        version = version + 1,
        events = OrderItemRemoved(id, productId) :: events
      )
      Right(updatedOrder)
    }
  }

  def updateQuantity(productId: ProductId, newQuantity: Int): Either[String, Order] = {
    if (status != OrderStatus.Draft) {
      Left("Cannot modify confirmed order")
    } else if (newQuantity <= 0) {
      Left("Quantity must be positive")
    } else {
      items.find(_.productId == productId) match {
        case None => Left("Product not found in order")
        case Some(item) =>
          val updatedItems = items.map { i =>
            if (i.productId == productId) i.copy(quantity = newQuantity) else i
          }
          val updatedOrder = copy(
            items = updatedItems,
            version = version + 1,
            events = OrderItemQuantityUpdated(id, productId, newQuantity) :: events
          )
          Right(updatedOrder)
      }
    }
  }

  def confirm(): Either[String, Order] = {
    if (status != OrderStatus.Draft) {
      Left("Order already confirmed")
    } else if (items.isEmpty) {
      Left("Cannot confirm empty order")
    } else {
      val confirmedOrder = copy(
        status = OrderStatus.Confirmed,
        version = version + 1,
        events = SalesEvents.OrderConfirmed(
          java.util.UUID.randomUUID().toString,
          java.time.Instant.now(),
          id.value,
          version + 1,
          id.value
        ) :: events
      )
      Right(confirmedOrder)
    }
  }

  def cancel(reason: String): Either[String, Order] = {
    if (status == OrderStatus.Cancelled) {
      Left("Order already cancelled")
    } else if (status == OrderStatus.Shipped) {
      Left("Cannot cancel shipped order")
    } else {
      val cancelledOrder = copy(
        status = OrderStatus.Cancelled,
        version = version + 1,
        events = SalesEvents.OrderCancelled(
          java.util.UUID.randomUUID().toString,
          java.time.Instant.now(),
          id.value,
          version + 1,
          id.value,
          reason
        ) :: events
      )
      Right(cancelledOrder)
    }
  }

  // Calculated properties
  def subtotal: Money = {
    items.foldLeft(Money(BigDecimal(0), Currency("USD"))) { (acc, item) =>
      acc + (item.unitPrice * BigDecimal(item.quantity))
    }
  }

  def isEmpty: Boolean = items.isEmpty
  def itemCount: Int = items.map(_.quantity).sum

  // Event handling
  def markEventsAsCommitted: Order = copy(events = List.empty)
  def uncommittedEvents: List[DomainEvent] = events
}

// Factory for creating orders
object Order {
  def create(
    orderId: OrderId,
    customerId: CustomerId,
    shippingAddress: Address,
    billingAddress: Address,
    paymentMethod: PaymentMethod
  ): Order = {
    Order(
      id = orderId,
      version = 0,
      customerId = customerId,
      items = List.empty,
      status = OrderStatus.Draft,
      shippingAddress = shippingAddress,
      billingAddress = billingAddress,
      paymentMethod = paymentMethod,
      createdAt = java.time.Instant.now(),
      events = List(SalesEvents.OrderPlaced(
        java.util.UUID.randomUUID().toString,
        java.time.Instant.now(),
        orderId.value,
        0,
        orderId.value,
        customerId.value,
        List.empty,
        BigDecimal(0)
      ))
    )
  }
}

case class OrderItem(
  productId: ProductId,
  quantity: Int,
  unitPrice: Money
) {
  require(quantity > 0, "Quantity must be positive")

  def lineTotal: Money = unitPrice * BigDecimal(quantity)
}

enum OrderStatus {
  case Draft, Confirmed, Processing, Shipped, Delivered, Cancelled
}

// Supporting types
case class OrderId(value: String) extends AnyVal
case class ProductId(value: String) extends AnyVal
case class CustomerId(value: String) extends AnyVal

enum PaymentMethod {
  case CreditCard(cardNumber: String, expiryDate: String)
  case DebitCard(cardNumber: String, expiryDate: String)
  case PayPal(email: String)
  case BankTransfer(accountNumber: String)
}

// Domain events for Order aggregate
case class OrderItemAdded(orderId: OrderId, productId: ProductId, quantity: Int)
case class OrderItemRemoved(orderId: OrderId, productId: ProductId)
case class OrderItemQuantityUpdated(orderId: OrderId, productId: ProductId, newQuantity: Int)

Repositories and Domain Services

Repositories provide access to aggregates, while domain services handle operations that don't naturally fit within a single aggregate.

// Repository interfaces (ports)
trait OrderRepository {
  def save(order: Order): Either[String, Unit]
  def findById(id: OrderId): Option[Order]
  def findByCustomerId(customerId: CustomerId): List[Order]
  def findByStatus(status: OrderStatus): List[Order]
}

trait CustomerRepository {
  def save(customer: Customer): Either[String, Unit]
  def findById(id: CustomerId): Option[Customer]
  def findByEmail(email: Email): Option[Customer]
  def existsByEmail(email: Email): Boolean
}

trait ProductRepository {
  def findById(id: ProductId): Option[Product]
  def findByCategory(category: ProductCategory): List[Product]
  def search(query: String): List[Product]
}

// Domain services for complex business operations
class OrderPricingService(
  productRepository: ProductRepository,
  discountService: DiscountService
) {

  def calculateOrderPrice(order: Order, customer: Customer): Either[String, OrderPricing] = {
    for {
      itemPrices <- calculateItemPrices(order.items)
      subtotal = itemPrices.values.sum
      discounts <- discountService.calculateDiscounts(order, customer)
      shipping = calculateShipping(order, subtotal)
      taxes = calculateTaxes(order, subtotal)
    } yield OrderPricing(
      subtotal = subtotal,
      discounts = discounts,
      shipping = shipping,
      taxes = taxes,
      total = subtotal - discounts.totalDiscount + shipping + taxes
    )
  }

  private def calculateItemPrices(items: List[OrderItem]): Either[String, Map[ProductId, Money]] = {
    val itemPrices = for {
      item <- items
      product <- productRepository.findById(item.productId).toRight(s"Product ${item.productId} not found")
    } yield item.productId -> (product.price * BigDecimal(item.quantity))

    itemPrices.foldLeft(Right(Map.empty): Either[String, Map[ProductId, Money]]) { (acc, itemResult) =>
      for {
        map <- acc
        (productId, price) <- itemResult
      } yield map + (productId -> price)
    }
  }

  private def calculateShipping(order: Order, subtotal: Money): Money = {
    // Simplified shipping calculation
    if (subtotal.amount >= 100) Money(BigDecimal(0), subtotal.currency)
    else Money(BigDecimal(9.99), subtotal.currency)
  }

  private def calculateTaxes(order: Order, subtotal: Money): Money = {
    // Simplified tax calculation based on shipping address
    val taxRate = order.shippingAddress.state match {
      case "CA" => BigDecimal(0.0875) // California tax rate
      case "NY" => BigDecimal(0.08)   // New York tax rate
      case "TX" => BigDecimal(0.0625) // Texas tax rate
      case _ => BigDecimal(0.05)      // Default tax rate
    }

    subtotal * taxRate
  }
}

case class OrderPricing(
  subtotal: Money,
  discounts: DiscountBreakdown,
  shipping: Money,
  taxes: Money,
  total: Money
)

case class DiscountBreakdown(
  customerDiscount: Money,
  promotionalDiscount: Money,
  loyaltyDiscount: Money
) {
  def totalDiscount: Money = customerDiscount + promotionalDiscount + loyaltyDiscount
}

trait DiscountService {
  def calculateDiscounts(order: Order, customer: Customer): Either[String, DiscountBreakdown]
}

// Customer registration service
class CustomerRegistrationService(
  customerRepository: CustomerRepository,
  eventPublisher: EventPublisher
) {

  def registerCustomer(
    personalInfo: PersonalInfo,
    contactInfo: ContactInfo,
    preferences: CustomerPreferences
  ): Either[String, Customer] = {

    // Business rules validation
    if (customerRepository.existsByEmail(contactInfo.email)) {
      Left(s"Customer with email ${contactInfo.email.value} already exists")
    } else if (personalInfo.age < 18) {
      Left("Customer must be at least 18 years old")
    } else {
      val customerId = CustomerId(java.util.UUID.randomUUID().toString)
      val customer = Customer(
        id = customerId,
        version = 0,
        personalInfo = personalInfo,
        contactInfo = contactInfo,
        preferences = preferences,
        membershipLevel = MembershipLevel.Bronze,
        registeredAt = java.time.Instant.now()
      )

      customerRepository.save(customer) match {
        case Right(_) =>
          val event = SalesEvents.CustomerRegistered(
            java.util.UUID.randomUUID().toString,
            java.time.Instant.now(),
            customerId.value,
            0,
            customerId.value,
            personalInfo.fullName,
            contactInfo.email.value
          )
          eventPublisher.publish(event)
          Right(customer)
        case Left(error) => Left(error)
      }
    }
  }
}

// Inventory allocation service
class InventoryAllocationService(
  inventoryRepository: InventoryRepository,
  orderRepository: OrderRepository
) {

  def allocateInventory(order: Order): Either[String, AllocationResult] = {
    val allocations = order.items.map { item =>
      allocateItem(item.productId, item.quantity)
    }

    val failures = allocations.collect { case Left(error) => error }
    if (failures.nonEmpty) {
      Left(s"Allocation failed: ${failures.mkString(", ")}")
    } else {
      val successfulAllocations = allocations.collect { case Right(allocation) => allocation }
      Right(AllocationResult(successfulAllocations))
    }
  }

  private def allocateItem(productId: ProductId, quantity: Int): Either[String, ItemAllocation] = {
    inventoryRepository.findBySku(InventoryContext.SKU(productId.value)) match {
      case None => Left(s"Product ${productId.value} not found in inventory")
      case Some(inventoryItem) =>
        if (inventoryItem.availableQuantity >= quantity) {
          Right(ItemAllocation(productId, quantity, inventoryItem.sku))
        } else {
          Left(s"Insufficient inventory for ${productId.value}: available ${inventoryItem.availableQuantity}, requested $quantity")
        }
    }
  }
}

case class AllocationResult(allocations: List[ItemAllocation])
case class ItemAllocation(productId: ProductId, quantity: Int, sku: InventoryContext.SKU)

trait InventoryRepository {
  def findBySku(sku: InventoryContext.SKU): Option[InventoryContext.InventoryItem]
  def reserveStock(sku: InventoryContext.SKU, quantity: Int): Either[String, Unit]
  def releaseReservation(orderId: String): Either[String, Unit]
}

Application Services and Use Cases

Command and Query Separation

Application services orchestrate domain operations and handle cross-cutting concerns.

// Commands represent intentions to change state
sealed trait Command

case class RegisterCustomerCommand(
  personalInfo: PersonalInfo,
  contactInfo: ContactInfo,
  preferences: CustomerPreferences
) extends Command

case class PlaceOrderCommand(
  customerId: CustomerId,
  items: List[OrderItemCommand],
  shippingAddress: Address,
  billingAddress: Address,
  paymentMethod: PaymentMethod
) extends Command

case class OrderItemCommand(productId: ProductId, quantity: Int)

case class ConfirmOrderCommand(orderId: OrderId) extends Command
case class CancelOrderCommand(orderId: OrderId, reason: String) extends Command

// Queries represent requests for data
sealed trait Query[T]

case class GetCustomerQuery(customerId: CustomerId) extends Query[Option[Customer]]
case class GetOrderQuery(orderId: OrderId) extends Query[Option[Order]]
case class GetCustomerOrdersQuery(customerId: CustomerId) extends Query[List[Order]]
case class SearchProductsQuery(searchTerm: String, category: Option[ProductCategory]) extends Query[List[Product]]

// Command handlers
class OrderCommandHandler(
  orderRepository: OrderRepository,
  customerRepository: CustomerRepository,
  productRepository: ProductRepository,
  inventoryAllocationService: InventoryAllocationService,
  orderPricingService: OrderPricingService,
  eventPublisher: EventPublisher
) {

  def handle(command: PlaceOrderCommand): Either[String, OrderId] = {
    for {
      customer <- customerRepository.findById(command.customerId)
        .toRight(s"Customer ${command.customerId.value} not found")
      orderId = OrderId(java.util.UUID.randomUUID().toString)
      order = Order.create(
        orderId,
        command.customerId,
        command.shippingAddress,
        command.billingAddress,
        command.paymentMethod
      )
      orderWithItems <- addItemsToOrder(order, command.items)
      pricing <- orderPricingService.calculateOrderPrice(orderWithItems, customer)
      allocation <- inventoryAllocationService.allocateInventory(orderWithItems)
      _ <- orderRepository.save(orderWithItems)
    } yield {
      // Publish events
      orderWithItems.uncommittedEvents.foreach(eventPublisher.publish)
      orderId
    }
  }

  def handle(command: ConfirmOrderCommand): Either[String, Unit] = {
    for {
      order <- orderRepository.findById(command.orderId)
        .toRight(s"Order ${command.orderId.value} not found")
      confirmedOrder <- order.confirm()
      _ <- orderRepository.save(confirmedOrder)
    } yield {
      confirmedOrder.uncommittedEvents.foreach(eventPublisher.publish)
    }
  }

  def handle(command: CancelOrderCommand): Either[String, Unit] = {
    for {
      order <- orderRepository.findById(command.orderId)
        .toRight(s"Order ${command.orderId.value} not found")
      cancelledOrder <- order.cancel(command.reason)
      _ <- orderRepository.save(cancelledOrder)
    } yield {
      cancelledOrder.uncommittedEvents.foreach(eventPublisher.publish)
    }
  }

  private def addItemsToOrder(order: Order, items: List[OrderItemCommand]): Either[String, Order] = {
    items.foldLeft(Right(order): Either[String, Order]) { (orderResult, itemCommand) =>
      for {
        currentOrder <- orderResult
        product <- productRepository.findById(itemCommand.productId)
          .toRight(s"Product ${itemCommand.productId.value} not found")
        updatedOrder <- currentOrder.addItem(itemCommand.productId, itemCommand.quantity, product.price)
      } yield updatedOrder
    }
  }
}

class CustomerCommandHandler(
  customerRegistrationService: CustomerRegistrationService
) {

  def handle(command: RegisterCustomerCommand): Either[String, CustomerId] = {
    customerRegistrationService.registerCustomer(
      command.personalInfo,
      command.contactInfo,
      command.preferences
    ).map(_.id)
  }
}

// Query handlers
class OrderQueryHandler(
  orderRepository: OrderRepository,
  readModelService: OrderReadModelService
) {

  def handle(query: GetOrderQuery): Option[Order] = {
    orderRepository.findById(query.orderId)
  }

  def handle(query: GetCustomerOrdersQuery): List[Order] = {
    orderRepository.findByCustomerId(query.customerId)
  }
}

// Read model service for optimized queries
trait OrderReadModelService {
  def getOrderSummary(orderId: OrderId): Option[OrderSummary]
  def getCustomerOrderHistory(customerId: CustomerId): List[OrderSummary]
  def getOrdersByStatus(status: OrderStatus): List[OrderSummary]
}

case class OrderSummary(
  orderId: OrderId,
  customerId: CustomerId,
  customerName: String,
  itemCount: Int,
  totalAmount: Money,
  status: OrderStatus,
  createdAt: java.time.Instant
)

// Application service that coordinates operations
class OrderApplicationService(
  orderCommandHandler: OrderCommandHandler,
  orderQueryHandler: OrderQueryHandler,
  customerCommandHandler: CustomerCommandHandler
) {

  def placeOrder(command: PlaceOrderCommand): Either[String, OrderId] = {
    orderCommandHandler.handle(command)
  }

  def confirmOrder(orderId: OrderId): Either[String, Unit] = {
    orderCommandHandler.handle(ConfirmOrderCommand(orderId))
  }

  def cancelOrder(orderId: OrderId, reason: String): Either[String, Unit] = {
    orderCommandHandler.handle(CancelOrderCommand(orderId, reason))
  }

  def getOrder(orderId: OrderId): Option[Order] = {
    orderQueryHandler.handle(GetOrderQuery(orderId))
  }

  def getCustomerOrders(customerId: CustomerId): List[Order] = {
    orderQueryHandler.handle(GetCustomerOrdersQuery(customerId))
  }

  def registerCustomer(command: RegisterCustomerCommand): Either[String, CustomerId] = {
    customerCommandHandler.handle(command)
  }
}

Testing Domain Models

Unit Testing Domain Logic

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.EitherValues

class OrderSpec extends AnyFlatSpec with Matchers with EitherValues {

  val customerId = CustomerId("customer-123")
  val orderId = OrderId("order-456")
  val productId = ProductId("product-789")
  val address = Address("123 Main St", "Springfield", "IL", "62701", "USA")
  val paymentMethod = PaymentMethod.CreditCard("1234-5678-9012-3456", "12/25")
  val unitPrice = Money(BigDecimal(29.99), Currency("USD"))

  def createTestOrder: Order = Order.create(orderId, customerId, address, address, paymentMethod)

  "Order" should "be created in Draft status" in {
    val order = createTestOrder

    order.status shouldBe OrderStatus.Draft
    order.items shouldBe empty
    order.isEmpty shouldBe true
  }

  it should "allow adding items when in Draft status" in {
    val order = createTestOrder

    val result = order.addItem(productId, 2, unitPrice)

    result.value.items should have size 1
    result.value.items.head.productId shouldBe productId
    result.value.items.head.quantity shouldBe 2
    result.value.subtotal shouldBe Money(BigDecimal(59.98), Currency("USD"))
  }

  it should "prevent adding duplicate products" in {
    val order = createTestOrder
    val orderWithItem = order.addItem(productId, 1, unitPrice).value

    val result = orderWithItem.addItem(productId, 2, unitPrice)

    result.left.value should include("already in order")
  }

  it should "allow updating quantity of existing items" in {
    val order = createTestOrder
    val orderWithItem = order.addItem(productId, 1, unitPrice).value

    val result = orderWithItem.updateQuantity(productId, 5)

    result.value.items.head.quantity shouldBe 5
    result.value.subtotal shouldBe Money(BigDecimal(149.95), Currency("USD"))
  }

  it should "allow confirmation when it has items" in {
    val order = createTestOrder
    val orderWithItem = order.addItem(productId, 1, unitPrice).value

    val result = orderWithItem.confirm()

    result.value.status shouldBe OrderStatus.Confirmed
  }

  it should "prevent confirmation of empty order" in {
    val order = createTestOrder

    val result = order.confirm()

    result.left.value should include("empty order")
  }

  it should "prevent modification after confirmation" in {
    val order = createTestOrder
    val orderWithItem = order.addItem(productId, 1, unitPrice).value
    val confirmedOrder = orderWithItem.confirm().value

    val result = confirmedOrder.addItem(ProductId("another-product"), 1, unitPrice)

    result.left.value should include("Cannot modify confirmed order")
  }

  it should "allow cancellation before shipping" in {
    val order = createTestOrder
    val orderWithItem = order.addItem(productId, 1, unitPrice).value
    val confirmedOrder = orderWithItem.confirm().value

    val result = confirmedOrder.cancel("Customer requested cancellation")

    result.value.status shouldBe OrderStatus.Cancelled
  }

  it should "generate events for significant operations" in {
    val order = createTestOrder

    order.uncommittedEvents should not be empty
    order.uncommittedEvents.head shouldBe a[SalesEvents.OrderPlaced]
  }
}

// Testing value objects
class MoneySpec extends AnyFlatSpec with Matchers {

  val usd = Currency("USD")
  val eur = Currency("EUR")

  "Money" should "allow addition of same currency" in {
    val money1 = Money(BigDecimal(10.50), usd)
    val money2 = Money(BigDecimal(5.25), usd)

    val result = money1 + money2

    result.amount shouldBe BigDecimal(15.75)
    result.currency shouldBe usd
  }

  it should "prevent addition of different currencies" in {
    val money1 = Money(BigDecimal(10.50), usd)
    val money2 = Money(BigDecimal(5.25), eur)

    intercept[IllegalArgumentException] {
      money1 + money2
    }
  }

  it should "support multiplication" in {
    val money = Money(BigDecimal(10.50), usd)

    val result = money * BigDecimal(2)

    result.amount shouldBe BigDecimal(21.00)
    result.currency shouldBe usd
  }

  it should "not allow negative amounts" in {
    intercept[IllegalArgumentException] {
      Money(BigDecimal(-1), usd)
    }
  }
}

// Testing domain services
class OrderPricingServiceSpec extends AnyFlatSpec with Matchers with EitherValues {

  // Mock repositories for testing
  class MockProductRepository extends ProductRepository {
    private val products = Map(
      ProductId("product-1") -> Product(ProductId("product-1"), "Test Product", "Description", 
                                       Money(BigDecimal(19.99), Currency("USD")), ProductCategory.Electronics)
    )

    def findById(id: ProductId): Option[Product] = products.get(id)
    def findByCategory(category: ProductCategory): List[Product] = products.values.toList
    def search(query: String): List[Product] = products.values.toList
  }

  class MockDiscountService extends DiscountService {
    def calculateDiscounts(order: Order, customer: Customer): Either[String, DiscountBreakdown] = {
      Right(DiscountBreakdown(
        Money(BigDecimal(0), Currency("USD")),
        Money(BigDecimal(0), Currency("USD")),
        Money(BigDecimal(0), Currency("USD"))
      ))
    }
  }

  val productRepository = new MockProductRepository
  val discountService = new MockDiscountService
  val pricingService = new OrderPricingService(productRepository, discountService)

  "OrderPricingService" should "calculate order pricing correctly" in {
    val order = createOrderWithItems()
    val customer = createTestCustomer()

    val result = pricingService.calculateOrderPrice(order, customer)

    result.value.subtotal.amount shouldBe BigDecimal(39.98) // 2 * 19.99
    result.value.total.amount should be > BigDecimal(39.98) // includes shipping and tax
  }

  private def createOrderWithItems(): Order = {
    val order = Order.create(
      OrderId("test-order"),
      CustomerId("test-customer"),
      Address("123 Test St", "Test City", "CA", "90210", "USA"),
      Address("123 Test St", "Test City", "CA", "90210", "USA"),
      PaymentMethod.CreditCard("1234", "12/25")
    )
    order.addItem(ProductId("product-1"), 2, Money(BigDecimal(19.99), Currency("USD"))).value
  }

  private def createTestCustomer(): Customer = {
    Customer(
      CustomerId("test-customer"),
      0,
      PersonalInfo("John", "Doe", java.time.LocalDate.of(1990, 1, 1)),
      ContactInfo(Email("john@example.com"), PhoneNumber("+1", "5551234567"), 
                 Address("123 Test St", "Test City", "CA", "90210", "USA")),
      CustomerPreferences(true, "en", Currency("USD"), true),
      MembershipLevel.Bronze,
      java.time.Instant.now()
    )
  }
}

Advanced DDD Patterns

Specification Pattern

// Specification pattern for complex business rules
trait Specification[T] {
  def isSatisfiedBy(candidate: T): Boolean

  def and(other: Specification[T]): Specification[T] = 
    AndSpecification(this, other)

  def or(other: Specification[T]): Specification[T] = 
    OrSpecification(this, other)

  def not: Specification[T] = 
    NotSpecification(this)
}

case class AndSpecification[T](left: Specification[T], right: Specification[T]) extends Specification[T] {
  def isSatisfiedBy(candidate: T): Boolean = 
    left.isSatisfiedBy(candidate) && right.isSatisfiedBy(candidate)
}

case class OrSpecification[T](left: Specification[T], right: Specification[T]) extends Specification[T] {
  def isSatisfiedBy(candidate: T): Boolean = 
    left.isSatisfiedBy(candidate) || right.isSatisfiedBy(candidate)
}

case class NotSpecification[T](specification: Specification[T]) extends Specification[T] {
  def isSatisfiedBy(candidate: T): Boolean = 
    !specification.isSatisfiedBy(candidate)
}

// Customer specifications
object CustomerSpecifications {

  case object IsAdult extends Specification[Customer] {
    def isSatisfiedBy(customer: Customer): Boolean = 
      customer.personalInfo.age >= 18
  }

  case object IsEmailVerified extends Specification[Customer] {
    def isSatisfiedBy(customer: Customer): Boolean = 
      // Assume we have an emailVerified field
      true // Simplified for example
  }

  case class HasMembershipLevel(level: MembershipLevel) extends Specification[Customer] {
    def isSatisfiedBy(customer: Customer): Boolean = 
      customer.membershipLevel.tier >= level.tier
  }

  case class RegisteredBefore(date: java.time.Instant) extends Specification[Customer] {
    def isSatisfiedBy(customer: Customer): Boolean = 
      customer.registeredAt.isBefore(date)
  }

  // Composed specifications
  val EligibleForPremiumFeatures: Specification[Customer] = 
    IsAdult and IsEmailVerified and HasMembershipLevel(MembershipLevel.Gold)

  val LongTimeCustomer: Specification[Customer] = 
    RegisteredBefore(java.time.Instant.now().minusSeconds(365 * 24 * 3600)) // 1 year ago
}

// Order specifications
object OrderSpecifications {

  case object IsLargeOrder extends Specification[Order] {
    def isSatisfiedBy(order: Order): Boolean = 
      order.subtotal.amount >= BigDecimal(500)
  }

  case object HasMultipleItems extends Specification[Order] {
    def isSatisfiedBy(order: Order): Boolean = 
      order.items.length > 1
  }

  case object RequiresExpressShipping extends Specification[Order] {
    def isSatisfiedBy(order: Order): Boolean = 
      order.items.exists(item => isExpressShippingRequired(item.productId))

    private def isExpressShippingRequired(productId: ProductId): Boolean = 
      // Business logic to determine if product requires express shipping
      false // Simplified
  }

  case class OrderedBy(customerId: CustomerId) extends Specification[Order] {
    def isSatisfiedBy(order: Order): Boolean = 
      order.customerId == customerId
  }

  // Usage in business logic
  def calculateShippingOptions(order: Order): List[ShippingOption] = {
    val baseOptions = List(ShippingOption.Standard, ShippingOption.Priority)

    if (IsLargeOrder.isSatisfiedBy(order)) {
      ShippingOption.FreeStandard :: baseOptions
    } else if (RequiresExpressShipping.isSatisfiedBy(order)) {
      ShippingOption.Express :: baseOptions
    } else {
      baseOptions
    }
  }
}

enum ShippingOption {
  case Standard, Priority, Express, FreeStandard
}

Factory Pattern for Complex Creation

// Factory for creating complex aggregates
class OrderFactory(
  productRepository: ProductRepository,
  pricingService: OrderPricingService
) {

  def createOrderFromShoppingCart(
    customerId: CustomerId,
    cartItems: List[CartItem],
    shippingAddress: Address,
    billingAddress: Address,
    paymentMethod: PaymentMethod
  ): Either[String, Order] = {

    for {
      orderId <- Right(OrderId(java.util.UUID.randomUUID().toString))
      order = Order.create(orderId, customerId, shippingAddress, billingAddress, paymentMethod)
      orderWithItems <- addCartItemsToOrder(order, cartItems)
      validatedOrder <- validateOrder(orderWithItems)
    } yield validatedOrder
  }

  private def addCartItemsToOrder(order: Order, cartItems: List[CartItem]): Either[String, Order] = {
    cartItems.foldLeft(Right(order): Either[String, Order]) { (orderResult, cartItem) =>
      for {
        currentOrder <- orderResult
        product <- productRepository.findById(cartItem.productId)
          .toRight(s"Product ${cartItem.productId.value} not found")
        updatedOrder <- currentOrder.addItem(cartItem.productId, cartItem.quantity, product.price)
      } yield updatedOrder
    }
  }

  private def validateOrder(order: Order): Either[String, Order] = {
    if (order.isEmpty) {
      Left("Cannot create empty order")
    } else if (order.subtotal.amount < BigDecimal(1)) {
      Left("Order total too small")
    } else {
      Right(order)
    }
  }
}

case class CartItem(productId: ProductId, quantity: Int)

// Builder pattern for complex value objects
class AddressBuilder {
  private var street: Option[String] = None
  private var city: Option[String] = None
  private var state: Option[String] = None
  private var zipCode: Option[String] = None
  private var country: Option[String] = None

  def withStreet(street: String): AddressBuilder = {
    this.street = Some(street)
    this
  }

  def withCity(city: String): AddressBuilder = {
    this.city = Some(city)
    this
  }

  def withState(state: String): AddressBuilder = {
    this.state = Some(state)
    this
  }

  def withZipCode(zipCode: String): AddressBuilder = {
    this.zipCode = Some(zipCode)
    this
  }

  def withCountry(country: String): AddressBuilder = {
    this.country = Some(country)
    this
  }

  def build(): Either[String, Address] = {
    for {
      street <- street.toRight("Street is required")
      city <- city.toRight("City is required")
      state <- state.toRight("State is required")
      zipCode <- zipCode.toRight("Zip code is required")
      country <- country.toRight("Country is required")
    } yield Address(street, city, state, zipCode, country)
  }
}

object AddressBuilder {
  def apply(): AddressBuilder = new AddressBuilder
}

// Usage
val address = AddressBuilder()
  .withStreet("123 Main St")
  .withCity("Springfield")
  .withState("IL")
  .withZipCode("62701")
  .withCountry("USA")
  .build()

Conclusion

Domain-Driven Design in Scala leverages the language's powerful type system and functional programming features to create robust, maintainable domain models. Key principles and patterns include:

Strategic Design:

  • Establish ubiquitous language with domain experts
  • Define clear bounded contexts with explicit boundaries
  • Use context mapping to manage relationships between contexts
  • Leverage domain events for loose coupling between contexts

Tactical Patterns:

  • Value objects for immutable, behavior-rich concepts
  • Entities with distinct identity and lifecycle management
  • Aggregates to maintain consistency boundaries
  • Repositories for aggregate persistence abstraction
  • Domain services for operations spanning multiple aggregates

Scala-Specific Advantages:

  • Strong type system prevents invalid states
  • Case classes provide immutable value objects
  • Sealed traits model closed sets of choices
  • Option types handle optional values safely
  • Pattern matching enables exhaustive case analysis

Architecture Patterns:

  • Command and Query Separation (CQS)
  • Event sourcing for audit trails and temporal queries
  • Application services for orchestration
  • Specification pattern for complex business rules

Testing Strategies:

  • Unit test domain logic in isolation
  • Property-based testing for value objects
  • Mock repositories for testing domain services
  • Test-driven development for complex business rules

Best Practices:

  • Make illegal states unrepresentable through types
  • Keep aggregates small and focused
  • Use factories for complex object creation
  • Implement specifications for reusable business rules
  • Separate pure domain logic from infrastructure concerns

Domain-Driven Design with Scala enables the creation of software that closely mirrors the business domain, making it easier to understand, maintain, and evolve as business requirements change. The combination of DDD principles with Scala's expressive type system results in code that is both robust and readable, serving as living documentation of the business domain.