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:

        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:

        # 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!")

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 →