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.
Comments
Be the first to comment on this lesson!