Sample DDD explanation in Ruby with Event-sourcing and Event-driven development

Domain Driven Design (DDD) is an approach to software engineering that focuses on modelling the domain of a business problem to ensure effective design and implementation of information systems.
In DDD, business language and an understanding of the problem domain play a critical role. System developers should work with domain experts to develop a common language and domain model that addresses real business needs.
DDD is based on a set of design patterns that help in designing information systems, such as Aggregate, Entity, Value Object, Repository, and Factory. An important aspect of DDD is the division of the system into layers, such as presentation, application, domain, and infrastructure layers.

The benefits of Domain Driven Design include

  • A better understanding of the business problem domain by system developers
  • Flexibility and ease of making changes to the system
  • Easy testing and verification of the system
  • Improved scalability and modularity of the system
  • Improved code readability and understandability.

One of the key principles of DDD is that business rules should be the primary focus of the information system, not technology or programming tools. This makes the system more efficient and flexible, and its development less costly and more effective in the long run. In this article, we want to focus more on how to get our sticky notes into the code than on how to plan and prepare for meetings with the business. Let's start with that example:

# Value object representing a product SKU
class ProductSku
  attr_reader :value

  def initialize(value)
    raise ArgumentError, 'Invalid SKU' unless valid?(value)

    @value = value
  end

  def ==(other)
    other.is_a?(self.class) && value == other.value
  end

  private

  def valid?(value)
    # Logic to validate the SKU value
    true
  end
end

# Entity representing a product
class Product
  attr_reader :sku, :name, :description

  def initialize(sku, name, description)
    @sku = sku
    @name = name
    @description = description
  end
end

# Aggregate root representing a collection of products
class ProductCatalog
  def initialize(products = [])
    @products = products
  end

  def add_product(product)
    @products << product
  end

  def find_by_sku(sku)
    @products.find { |p| p.sku == sku }
  end
end

# Factory for creating Product entities
class ProductFactory
  def self.create(sku, name, description)
    Product.new(ProductSku.new(sku), name, description)
  end
end

# Repository for storing and retrieving Product entities
class ProductRepository
  def initialize
    @catalog = ProductCatalog.new
  end

  def save(product)
    @catalog.add_product(product)
  end

  def find_by_sku(sku)
    @catalog.find_by_sku(ProductSku.new(sku))
  end
end

# Create a new product
product = ProductFactory.create(name: "Example Product", price: Money.new(1000, "USD"))

# Save the product to the repository
ProductRepository.save(product)

# Load the product from the repository
loaded_product = ProductRepository.find_by_id(product.id)

# Update the product's price
new_price = Money.new(1200, "USD")
loaded_product.change_price(new_price)

# Save the updated product to the repository
ProductRepository.save(loaded_product)

In this example, we have:

  • ProductSku as a value object representing a product SKU.
  • Product as an entity representing a product with properties such as SKU, name, and description.
  • ProductCatalog as an aggregate root representing a collection of products. It contains methods for adding products and finding products by SKU.
  • ProductFactory as a factory for creating new Product entities.
  • ProductRepository as a repository for storing and retrieving Product entities using the ProductCatalog.

Note that in this example, the ProductCatalog serves as the aggregate root for the collection of products, and all interactions with the Product entities made through the ProductCatalog.

What is the Event-driven design?

Domain-driven design (DDD) emphasizes the importance of modelling the business domain and understanding the business problem. By using event-driven design (EDD) in conjunction with DDD, changes in the business domain can be effectively propagated throughout the system, ensuring that all relevant components can respond and adapt accordingly. In essence, the event-driven design complements domain-driven design by providing a way to propagate changes in the domain and enable components to respond to those changes in a timely and efficient manner.

class EventPublisher
  def self.publish(event)
    event_type = event.class.name
    payload = event.to_h
    KafkaProducer.send(event_type, payload.to_json) # or similar tool to publish events
  end
end

# Define the product created event
class ProductCreatedEvent
  attr_reader :product

  def initialize(product)
    @product = product
  end
end

# Define the product price changed event
class ProductPriceChangedEvent
  attr_reader :product, :old_price, :new_price

  def initialize(product, old_price, new_price)
    @product = product
    @old_price = old_price
    @new_price = new_price
  end
end

# Create a new product
product = ProductFactory.create(name: "Example Product", price: Money.new(1000, "USD"))

# Publish the product created event
product_created_event = ProductCreatedEvent.new(product)
EventPublisher.publish(product_created_event)

# Save the product to the repository
# this could be caught by an event handler than inline (for example kafka consumer)
ProductRepository.save(product)

# Load the product from the repository
loaded_product = ProductRepository.find_by_id(product.id)

# Update the product's price
old_price = loaded_product.price
new_price = Money.new(1200, "USD")
loaded_product.change_price(new_price)

# Publish the product price changed event
product_price_changed_event = ProductPriceChangedEvent.new(loaded_product, old_price, new_price)
EventPublisher.publish(product_price_changed_event)

# Save the updated product to the repository
# this could be caught by an event handler than inline (for example kafka consumer)
ProductRepository.save(loaded_product)

In this example, we've introduced two event classes: ProductCreatedEvent and ProductPriceChangedEvent. When a new product is created, we publish a ProductCreatedEvent using an EventPublisher (which could be implemented as a message queue like kafka/rabbitmq or using a simple DB table). Similarly, when a product's price is changed, we publish a ProductPriceChangedEvent.

Using this approach gives us some additional benefits:

  • History of changes (because we're storing historical events)
  • We can eliminate the repository that stores the product in the database because we can rebuild the state of the product using historical events. In the event-sourcing world, it's called projections.
class ProductProjection
  attr_reader :product

  def initialize
    @product = nil
  end

  def apply(event)
    case event
    when ProductCreatedEvent
      @product = event.product
    when ProductPriceChangedEvent
      @product.change_price(event.new_price)
    end
  end
end

# Create a new product projection
projection = ProductProjection.new

# Event is our source where we stored all the events
event_stream = Event.where(product_id: 1)

# Replay all the events that were published by the EventPublisher
event_stream.each do |event|
  projection.apply(event)
end

# Retrieve the current state of the product based on the events that were published
projection.product
Basic projection class to rebuild the production from events history

In this example, EventStream is an iterator that returns all the events published by EventPublisher. Using the ProductProjection class, we will iterate through all the events, and finally it will give us the latest state of the product object.

By communicating events between different parts of the system, we can decouple them and make them more scalable and flexible. Other parts of the system can subscribe to these events and respond to them, for example, by sending notifications or updating related data-all with a nice list of historical changes - and that is the power of event-driven development.