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 theAddressvalue 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 makesOrderItema child entity that belongs to theOrderaggregate. 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=...). HasManycreates 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.