Skip to content

Chapter 3: Entities and Associations

In this chapter we will build the Order aggregate with child entities and associations, giving our bookstore the ability to take orders.

The Order Aggregate

A bookstore doesn't just sell books — it takes orders. An order contains multiple items, has a shipping address, and tracks its status. Let's model it:

from enum import Enum

from protean import Domain
from protean.fields import (
    Float,
    HasMany,
    Integer,
    String,
    Text,
    ValueObject,
)
class OrderStatus(Enum):
    PENDING = "PENDING"
    CONFIRMED = "CONFIRMED"
    SHIPPED = "SHIPPED"
    DELIVERED = "DELIVERED"


@domain.aggregate
class Order:
    customer_name: String(max_length=150, required=True)
    customer_email: String(max_length=254, required=True)
    shipping_address = ValueObject(Address)
    status: String(
        max_length=20, choices=OrderStatus, default=OrderStatus.PENDING.value
    )
    items = HasMany("OrderItem")

Notice that:

  • shipping_address uses the Address value object we created in the previous chapter.
  • status uses a Python Enum with a default of PENDING.
  • items is a HasMany association — it holds a collection of OrderItem entities.

Child Entities

Each order item is an entity — an object with its own identity that belongs to the Order aggregate. Unlike value objects, entities can be individually tracked:

@domain.entity(part_of=Order)
class OrderItem:
    book_title: String(max_length=200, required=True)
    quantity: Integer(required=True)
    unit_price = ValueObject(Money)

The part_of=Order parameter tells Protean this entity belongs to the Order aggregate. It cannot exist independently — it is always accessed through its parent.

Creating an Order

Let's create an order with items and a shipping address:

    with domain.domain_context():
        repo = domain.repository_for(Order)

        # Create an order with items
        order = Order(
            customer_name="Alice Johnson",
            customer_email="alice@example.com",
            shipping_address=Address(
                street="456 Oak Ave",
                city="Portland",
                state="OR",
                zip_code="97201",
            ),
            items=[
                OrderItem(
                    book_title="The Great Gatsby",
                    quantity=1,
                    unit_price=Money(amount=12.99),
                ),
                OrderItem(
                    book_title="Brave New World",
                    quantity=2,
                    unit_price=Money(amount=14.99),
                ),
            ],
        )

        print(f"Order for: {order.customer_name}")
        print(f"Status: {order.status}")
        print(f"Ship to: {order.shipping_address.city}, {order.shipping_address.state}")
        print(f"Items ({len(order.items)}):")
        for item in order.items:
            print(f"  - {item.book_title} x{item.quantity} @ ${item.unit_price.amount}")
            print(f"    Item ID: {item.id}")

Run it:

$ python bookshelf.py
Order for: Alice Johnson
Status: PENDING
Ship to: Portland, OR
Items (2):
  - The Great Gatsby x1 @ $12.99
    Item ID: a1b2c3d4-...
  - Brave New World x2 @ $14.99
    Item ID: e5f6g7h8-...

Notice that each OrderItem has its own unique ID — that is what makes it an entity rather than a value object. The entire cluster (Order + OrderItems + Address) is persisted as a single unit.

Adding Items After Creation

We can also add items to an existing order:

        saved_order.add_items(
            OrderItem(
                book_title="Sapiens",
                quantity=1,
                unit_price=Money(amount=18.99),
            )
        )
        repo.add(saved_order)

        # Verify the update
        updated = repo.get(order.id)
        print(f"After adding item: {len(updated.items)} items")

        # Verify
        assert updated.customer_name == "Alice Johnson"
        assert len(updated.items) == 3
        assert updated.shipping_address.city == "Portland"
        print("\nAll checks passed!")

The output should show:

Retrieved order: Alice Johnson
Items: 2
After adding item: 3 items
All checks passed!

What We Built

  • An Order aggregate with status tracking and a shipping address.
  • OrderItem entities — child objects with their own identity, linked to the parent Order via HasMany.
  • An aggregate cluster: Order + OrderItems + Address persisted as one unit.

Our domain now has two aggregates: Book and Order. In the next chapter, we will add business rules that keep these aggregates in a valid state.

Full Source

from enum import Enum

from protean import Domain
from protean.fields import (
    Float,
    HasMany,
    Integer,
    String,
    Text,
    ValueObject,
)

domain = Domain()


@domain.value_object
class Money:
    currency: String(max_length=3, default="USD")
    amount: Float(required=True)


@domain.value_object
class Address:
    street: String(max_length=200, required=True)
    city: String(max_length=100, required=True)
    state: String(max_length=50)
    zip_code: String(max_length=20, required=True)
    country: String(max_length=50, default="US")


@domain.aggregate
class Book:
    title: String(max_length=200, required=True)
    author: String(max_length=150, required=True)
    isbn: String(max_length=13)
    price = ValueObject(Money)
    description: Text()


class OrderStatus(Enum):
    PENDING = "PENDING"
    CONFIRMED = "CONFIRMED"
    SHIPPED = "SHIPPED"
    DELIVERED = "DELIVERED"


@domain.aggregate
class Order:
    customer_name: String(max_length=150, required=True)
    customer_email: String(max_length=254, required=True)
    shipping_address = ValueObject(Address)
    status: String(
        max_length=20, choices=OrderStatus, default=OrderStatus.PENDING.value
    )
    items = HasMany("OrderItem")




@domain.entity(part_of=Order)
class OrderItem:
    book_title: String(max_length=200, required=True)
    quantity: Integer(required=True)
    unit_price = ValueObject(Money)




domain.init(traverse=False)


if __name__ == "__main__":
    with domain.domain_context():
        repo = domain.repository_for(Order)

        # Create an order with items
        order = Order(
            customer_name="Alice Johnson",
            customer_email="alice@example.com",
            shipping_address=Address(
                street="456 Oak Ave",
                city="Portland",
                state="OR",
                zip_code="97201",
            ),
            items=[
                OrderItem(
                    book_title="The Great Gatsby",
                    quantity=1,
                    unit_price=Money(amount=12.99),
                ),
                OrderItem(
                    book_title="Brave New World",
                    quantity=2,
                    unit_price=Money(amount=14.99),
                ),
            ],
        )

        print(f"Order for: {order.customer_name}")
        print(f"Status: {order.status}")
        print(f"Ship to: {order.shipping_address.city}, {order.shipping_address.state}")
        print(f"Items ({len(order.items)}):")
        for item in order.items:
            print(f"  - {item.book_title} x{item.quantity} @ ${item.unit_price.amount}")
            print(f"    Item ID: {item.id}")

        # Persist the entire aggregate (order + items together)
        repo.add(order)

        # Retrieve and verify
        saved_order = repo.get(order.id)
        print(f"\nRetrieved order: {saved_order.customer_name}")
        print(f"Items: {len(saved_order.items)}")

        # Add another item to the order
        saved_order.add_items(
            OrderItem(
                book_title="Sapiens",
                quantity=1,
                unit_price=Money(amount=18.99),
            )
        )
        repo.add(saved_order)

        # Verify the update
        updated = repo.get(order.id)
        print(f"After adding item: {len(updated.items)} items")

        # Verify
        assert updated.customer_name == "Alice Johnson"
        assert len(updated.items) == 3
        assert updated.shipping_address.city == "Portland"
        print("\nAll checks passed!")

Next

Chapter 4: Business Rules →