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_addressuses theAddressvalue object we created in the previous chapter.statususes a PythonEnumwith a default ofPENDING.itemsis aHasManyassociation — it holds a collection ofOrderItementities.
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!")