Skip to content

Chapter 4: Entities and Associations

A bookstore doesn't just sell books — it takes orders. An order contains line items, each referencing a book with a quantity and price. In this chapter we introduce entities (child objects with identity) and associations (relationships between objects) to build the Order aggregate.

Introducing Orders

Let's define the Order aggregate. An order belongs to a customer, has a shipping address, a status, and a list of items:

    shipping_address = ValueObject(Address)
    status = String(
        max_length=20, choices=OrderStatus, default=OrderStatus.PENDING.value
    )
    items = HasMany("OrderItem")

Notice two things:

  • ValueObject(Address) — we reuse the Address value object from Chapter 3 for the shipping address.
  • HasMany("OrderItem") — this declares that an Order has many OrderItem entities. The string reference "OrderItem" is resolved when the domain initializes.

Entities: Objects with Identity

An OrderItem represents a single line in an order — which book, how many, and at what price. Each item needs its own identity because you might need to update or remove a specific line item:

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

Key points:

  • @domain.entity(part_of=Order) — this makes OrderItem a child entity that belongs to the Order aggregate. It cannot exist independently.
  • Auto-generated ID — like aggregates, entities get an automatic identifier. Each line item has its own unique ID.
  • ValueObject(Money) — entities can embed value objects too.

Entity vs Value Object

Both are objects within an aggregate, but they differ in a fundamental way:

  • Entities have identity. Two order items with the same book and quantity are still different items if they have different IDs.
  • Value Objects have no identity. Two Money(amount=12.99) are interchangeable.

Use entities when you need to track or reference individual items. Use value objects when only the values matter.

Associations: Connecting Elements

The HasMany field creates a one-to-many relationship. An Order has many OrderItem entities:

items = HasMany("OrderItem")

You can pass items at creation time as a list, or add them afterward:

# At creation time
order = Order(
    customer_name="Alice Johnson",
    items=[
        OrderItem(book_title="The Great Gatsby", quantity=1, ...),
        OrderItem(book_title="Brave New World", quantity=2, ...),
    ],
)

# After creation — method is add_<field_name>
order.add_items(
    OrderItem(book_title="Sapiens", quantity=1, ...)
)

The Aggregate Cluster

An aggregate and its child entities form an aggregate cluster — a unit of consistency that is always persisted and retrieved together:

graph TB
    subgraph "Order Aggregate Cluster"
        O[Order<br/>customer_name, status]
        OI1[OrderItem<br/>Gatsby x1]
        OI2[OrderItem<br/>Brave New World x2]
        A[Address VO<br/>Portland, OR]
        M1[Money VO<br/>$12.99]
        M2[Money VO<br/>$14.99]

        O --> OI1
        O --> OI2
        O --> A
        OI1 --> M1
        OI2 --> M2
    end

When you call repo.add(order), the order and all its items are persisted as a single unit. When you call repo.get(order.id), you get the order with all its items loaded. This guarantees that the aggregate is always in a consistent state.

Building a Complete Order

Let's put it all together:

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)

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-...

Retrieved order: Alice Johnson
Items: 2
After adding item: 3 items

All checks passed!

Other Association Types

Protean provides three association types:

Association Description Example
HasMany One-to-many Order → OrderItems
HasOne One-to-one User → Profile
Reference A reference to another aggregate OrderItem → Book (by ID)

We have used HasMany here. The other types work similarly — HasOne for a single child entity, and Reference for cross-aggregate links (which store only the ID, not the full object).

Cross-Aggregate References

Notice that OrderItem stores book_title as a plain string rather than a reference to the Book aggregate. This is intentional — aggregates are independent consistency boundaries. They should not directly reference each other's objects. Instead, store the relevant data (title, price at time of purchase) within the order.

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

Summary

In this chapter you learned:

  • Entities are child objects with identity, defined with @domain.entity(part_of=...).
  • HasMany creates a one-to-many relationship between an aggregate and its child entities.
  • An aggregate cluster (the aggregate + its entities and VOs) is always persisted as a single unit.
  • Entities can contain value objects and other fields, just like aggregates.

Our domain model is taking shape — we have Book and Order aggregates, with OrderItem entities and Money/Address value objects. In the next chapter, we will add business rules with invariants to make sure our domain stays consistent.

Next

Chapter 5: Business Rules and Invariants →