8
.
05
.
2024
25
.
10
.
2022
Ruby on Rails
Domain-Driven Design
Backend
Tutorial

Aggregate - DDD in Ruby on Rails

Paweł Strzałkowski
Chief Technology Officer

In everyday life, the term "aggregate" has multiple meanings. This fact often leads Domain-Driven Design wannabes to confusion. It is an extremely important and needed modeling concept, which is too often forgotten in the realm of Ruby on Rails. Let's see how it may be useful to you, when implementing RoR applications.


"An AGGREGATE is a cluster of associated objects that we treat as a unit for the purpose of data changes. Each AGGREGATE has a root and a boundary. The boundary defines what is inside the AGGREGATE. The root is a single, specific ENTITY contained in the AGGREGATE. The root is the only member of the AGGREGATE that outside objects are allowed to hold references to."

— Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software

Group of objects

As you know from the article about Entities, we shouldn't treat objects as bags for data. They should encapsulate behaviour and protect their inner implementation details. An aggregate takes that to the next level.

Aggregate root and boundary

An aggregate is a group of objects (entities and associated value objects), which guards business rules. In the DDD nomenclature, these rules are called invariants. Aggregate is a conceptual and an organizational being. You may go through an entire codebase and see no explicit mention of the “aggregate” term. However, you will see entities which are called only by one entity. You will see others making sure that rules are followed. Like in the example above - Rate is used only by the Customer. You will also see namespaces and code organization patterns to support this approach.

Invariants

Every time an aggregate receives a command, it checks whether the requested change leads to a valid state. For the state to be valid, it has to be allowed by invariants. For example:

  • a client has less than 3 cars rented
  • a room is booked by up to one client
  • an order is approved only if the total price of items is lower than $300
  • up to one of the assigned processes is in processing state at a time

With each change request, one of two things happens:

  • the change is allowed by invariants and therefore the aggregate is modified,
  • the change breaks some of the invariants and is rejected as a whole.

In RoR, we are used to putting validations at (ActiveRecord) model level. We put form object validations on top of that and database level validations below. Such a sandwich is supposed to keep our data consistent. However, it's not what DDD is about. Here, we aim towards making the state and data consistent by applying business logic.

Business processes are not invariants

Consider the following scenario: A client chooses a new laptop. The current promo says that a discount coupon for a next order will be granted after the purchase. The order is finalized and payed for.

When the payment is complete, we have to:

  • save the fact the payment has been made
  • progress order's state
  • create shipment package
  • generate a discount coupon
  • assign the coupon to the client

Often, these changes are handled in a single request and persisted in one transaction. Effectively, they are modeled as a single, vast aggregate. Just imagine how much data has to be gathered and handled by the database within such a transaction. What's also important, the transaction effectively locks all handled objects for writing for concurrent processes (see the next chapters for details).

Keep the aggregates small

How small? The smallest aggregate is a lone entity, which automatically becomes its root and the boundary. It is usually not possible to model a real domain using such simple building blocks.

A change, to be consistent, has to be performed in a transaction. It has to be atomic. Otherwise, a processing or an infrastructure malfunction would leave the application in an inconsistent state. The proper size of an aggregate is the needed size of consistency boundary.

Transactions are natural for Ruby on Rails developers and well handled by the framework. There is a lot to say about db transactions, which goes far beyond the scope of this article. But one thing is certain - the smaller and shorter a transaction is, the better. It's ok to atomically update an order and its items. But it's not ok, when we lock and update tens or even hundreds of objects at a time.

References to identities of other aggregate roots are ok

The fact that an aggregate contains a reference to another aggregate, doesn't automatically merge them into one. An aggregate root or entities inside the boundary may reference another aggregate. However, Ruby on Rails encourages programmers to tightly couple entities using associations like :belongs_to or :has_many. It enables loading entities inside of other entities and prevents thoughtful modeling altogether.

The coupling is far lower when entities use identity references to other aggregates. In RoR, it means that an entity may hold another_entity_id attribute to reference another aggregate. However, it should not use belong_to :another_entity when that other entity is outside of its aggregate boundaries. It prevents loading and updating elements of other aggregates.

Eventual consistency

Going back to our client and the discount coupon. It may be the case that:

  • payment has to completed to progress order state from "ordered" to "payed"
  • an order may be in "Payed" state only if there is no payment due

We have to update both at the same time to keep the state consistent.

But it's almost certain that discount coupon generation is not bound to this process. A client doesn't need to receive the discount in the very millisecond the order is payed. The payment should not be rejected because of a discount coupon module disfunction. This part may be performed asynchronously. Either by the means of a scheduled job (ie. with Sidekiq) or a Pub/Sub mechanism.

Concurrent changes

Aggregates are all about data integrity and state consistency. In order to be consistent, an aggregate cannot be modified by two concurrent processes at the same time.

Let's analyze the simplest of aggregates - a client can have up to two addresses. A client object has a behaviour of add_address(address) defined. Inside, there is an invariant check:


def add_address(address)
  raise TooManyAddressesError if addresses.size > 1

  addresses.push(address)
end

Let us imagine that two concurrent processes load the same Address object. Let's assume that there had been one address added so far. Each process sees a single address and allows adding a new one. When both save the state, three addresses are persisted.

To avoid this situation, take a look at the idea of Optimistic Locking. It disallows saving of the second of the two concurrent processes. The second one would raise ActiveRecord::StaleObjectError and reject the second transaction. It's very easy to introduce optimistic locking to a RoR application and the benefit is tremendous.

Aggregate root

Aggregate is a cluster of objects. However, for the rest of the system it is visible as a single being. The entry point to an aggregate is a single entity, which provides all the behaviour of the group. Any outside element may only hold the reference (ie. database identity) to the root of an aggregate. Entities inside of an aggregate boundaries may hold references to outside aggregate roots, but themselves are hidden and never directly operated on by the outside world.

In the case of the example of a client with two addresses, there would never be a service operating directly on the addresses. Such a service may supply a new address to the client entity via theadd_address method. However, it would never load and change an address directly.

Ruby on Rails aggregate example

Ruby on Rails aggregate example From the code perspective, an aggregate root is just an entity. It is the usage and context which promote it to an aggregate root. Check out this simple example of an Invoice class. It uses InvoiceItem objects to create items within the boundary of the Invoice aggregate.


# Invoice aggregate

class Invoice < ApplicationRecord
  OperationNotAllowedError = Class.new(StandardError)

  # References to other aggregates
  validates :client_id, :order_id, presence: true

  validates :status, inclusion: { in: %w(draft booked voided) }

  # A collection of items (entities) within the boundary
  has_many :invoice_items

  def add_item(description, quantity, price, tax_definition)
    # Invariant guard
    raise OperationNotAllowedError if status != 'draft'

    # Calculation could also be done using another domain object (ie. service)
    tax = calculate_tax(price, quantity, tax_definition.rate)

    # Add a new entity to items' collection
    invoice_items.build(
      description: description,
      quantity: quantity,
      price: price,
      tax: tax,
      tax_label: tax_definition.label
    )

    # Recalculate the state of the aggregate root
    build_totals
  end

  def void
    raise OperationNotAllowedError if status == 'booked'

    self.status = 'voided'
  end

  private

  def build_totals
    # ...
  end

  def calculate_tax(price, quantity, tax_rate)
    # ...
  end

  # ...
end

class InvoiceItem < ApplicationRecord
  validates :invoice_id, presence: true

  # it is NOT needed to use belongs_to. We never fetch aggregate root from here
end

Summary

Aggregate is a transaction boundary. It's a guardian of consistency and validity of both data and business rules. It gives you the ability to model safe and reliable applications. It is not trivial to learn how to use this tool properly. Please, take some time to familiarize with it, even if you have no plans of using the full toolkit of Domain-Driven Design. It will make your applications more mature, secure and less error prone.

To learn even more, be sure to check out a recording of our webinar about Aggregate Pattern in Ruby on Rails.

Resources

Articles in this series

Do you want to know more? Register for our DDD webinar

Paweł Strzałkowski
Chief Technology Officer

Check my Twitter

Check my Linkedin

Did you like it? 

Sign up To VIsuality newsletter

READ ALSO

How to start your UX/UI designer career

14
.
11
.
2023
Bartłomiej Bednarski
Design
Tutorial
HR

WebUSB - Bridge between USB devices and web browsers

14
.
11
.
2023
Burak Aybar
Ruby on Rails
Frontend
Backend
Tutorial

Visuality comes to town - this time it's Poznań

14
.
11
.
2023
Michał Piórkowski
Visuality
HR

CSS Modules in Rails

14
.
11
.
2023
Adam Król
Ruby on Rails
Tutorial
Backend
Frontend

How to choose a software house.

14
.
11
.
2023
Michał Piórkowski
Ruby on Rails
Business
Visuality

JSON API versus the NIH syndrome

14
.
11
.
2023
Nadia Miętkiewicz
Backend
Frontend
Tutorial

From Idea to Concept

02
.
10
.
2024
Michał Krochecki
Ruby on Rails
Business
Startups

Styling React Components

14
.
11
.
2023
Umit Naimian
Ruby on Rails
Frontend
Tutorial

How good design can help your business grow

14
.
11
.
2023
Lukasz Jackiewicz
Design
Business
Marketing

TODO not. Do, or do not.

29
.
11
.
2023
Stanisław Zawadzki
Ruby on Rails
Software

CS Lessons #003: Density map in three ways

14
.
11
.
2023
Michał Młoźniak
Ruby
Backend
Tutorial
Software

Clean code for the win

14
.
11
.
2023
Michał Piórkowski
Ruby on Rails
Backend
Frontend
Business

Crowd-operated Christmas Lights

14
.
11
.
2023
Nadia Miętkiewicz
Ruby on Rails
Backend

How to startup and be mature about it

14
.
11
.
2023
Rafał Maliszewski
Ruby on Rails
Startups
Business

A journey of a thousand miles begins with workshops

14
.
11
.
2023
Michał Piórkowski
Software
HR

CS Lessons #002: Data structures

14
.
11
.
2023
Michał Młoźniak
Ruby
Software

Summary of Phoenix workshop at Visuality

14
.
11
.
2023
Karol Słuszniak
Ruby on Rails
Visuality
Backend

CS Lessons #000: Introduction and motivation

14
.
11
.
2023
Michał Młoźniak
Ruby
Software

CS Lessons #001: Working with binary files

14
.
11
.
2023
Michał Młoźniak
Ruby
Software

Working with 40-minute intervals

14
.
11
.
2023
Sakir Temel
Software
HR

THE MATURE TECH STARTUP DILEMMA: WHAT'S NEXT

14
.
11
.
2023
Susanna Romantsova
Startups

Win MVP workshop!

14
.
11
.
2023
Susanna Romantsova
Startups

FINTECH WEEK IN OSLO: WHATs & WHYs

14
.
11
.
2023
Susanna Romantsova
Conferences

MY FIRST MONTH AT VISUALITY

14
.
11
.
2023
Susanna Romantsova
Visuality
HR

NASA 1st global hackaton in Poland? Visuality Created it!

14
.
11
.
2023
Rafał Maliszewski
Ruby on Rails