Chapter 2: Rich Fields and Value Objects
In this chapter we will enrich our Book aggregate with more field types
and replace the plain Float price with a Money value object that
captures both amount and currency.
Enriching the Book Aggregate
Our Book currently has just title, author, isbn, and price. A real
bookstore needs more. Let's add several new fields:
from enum import Enum
from protean import Domain
from protean.fields import (
Boolean,
Date,
Float,
Integer,
Text,
ValueObject,
)
domain = Domain()
class Genre(Enum):
FICTION = "FICTION"
@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()
publication_date: Date()
We have added:
Textfordescription— long-form text, unlikeStringwhich has amax_lengthcap.Dateforpublication_date— a date without time.Integerforpage_count— whole numbers.Booleanforin_print— true/false with a default ofTrue.Listfortags— a list of strings.choices=Genreon thegenrefield restricts values to a PythonEnum.
For the complete list of field types and options, see the Fields reference.
Constraining with Choices
The genre field uses a Python Enum to restrict valid values.
Attempting to create a book with an invalid genre raises a ValidationError:
>>> Book(title="Test", author="Test", genre="ROMANCE")
ValidationError: {'genre': ["Value 'ROMANCE' is not a valid choice. ..."]}
Notice that Protean catches invalid values at the domain boundary — invalid aggregates never enter your domain.
From Float to Money
A Float stores only a number, but a price also has a currency. Let's
create a Money value object to capture both. A value object is an
immutable object defined by its attributes, with no identity of its own
(see Value Objects
for more).
BIOGRAPHY = "BIOGRAPHY"
FANTASY = "FANTASY"
MYSTERY = "MYSTERY"
Now embed it in the Book aggregate using a ValueObject field:
@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()
publication_date: Date()
Notice that price is now declared with ValueObject(Money) instead of
Float(). When we create a book, we pass a Money instance:
book = Book(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
price=Money(amount=12.99),
)
print(f"Price: ${book.price.amount} {book.price.currency}")
# Price: $12.99 USD
The currency defaults to "USD" because we set default="USD" on the
field. The price now carries both pieces of information together.
Value Equality
Two value objects with the same attributes are considered equal — identity does not matter, only the values:
price1 = Money(amount=12.99, currency="USD")
price2 = Money(amount=12.99, currency="USD")
price3 = Money(amount=14.99, currency="USD")
print(price1 == price2) # True — same values
print(price1 == price3) # False — different amount
The Address Value Object
Let's also create an Address value object for shipping addresses:
amount: Float(required=True)
@domain.value_object
class Address:
We will use this in the next chapter when we build the Order aggregate.
Putting It Together
domain.init(traverse=False)
if __name__ == "__main__":
with domain.domain_context():
repo = domain.repository_for(Book)
# Create a book with rich fields and a Money value object
gatsby = Book(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
isbn="9780743273565",
price=Money(amount=12.99),
description="A story of the mysteriously wealthy Jay Gatsby.",
page_count=180,
genre=Genre.FICTION.value,
tags=["classic", "american", "jazz-age"],
)
repo.add(gatsby)
print(f"Book: {gatsby.title}")
print(f"Price: ${gatsby.price.amount} {gatsby.price.currency}")
print(f"Genre: {gatsby.genre}, Pages: {gatsby.page_count}")
print(f"Tags: {gatsby.tags}")
# Value objects are equal by value, not identity
price1 = Money(amount=12.99, currency="USD")
price2 = Money(amount=12.99, currency="USD")
price3 = Money(amount=14.99, currency="USD")
print(f"\nMoney(12.99, USD) == Money(12.99, USD)? {price1 == price2}")
print(f"Money(12.99, USD) == Money(14.99, USD)? {price1 == price3}")
# Create an Address value object
shipping = Address(
street="123 Main St",
city="Springfield",
state="IL",
zip_code="62704",
)
print(f"\nAddress: {shipping.street}, {shipping.city}, {shipping.state}")
print(f"Country (default): {shipping.country}")
# Retrieve and verify persistence
saved = repo.get(gatsby.id)
print(
f"\nRetrieved: {saved.title}, ${saved.price.amount} {saved.price.currency}"
)
# Verify
assert saved.price.amount == 12.99
assert saved.price.currency == "USD"
assert price1 == price2
assert price1 != price3
assert shipping.country == "US"
print("\nAll checks passed!")
Run it:
$ python bookshelf.py
Book: The Great Gatsby
Price: $12.99 USD
Genre: FICTION, Pages: 180
Tags: ['classic', 'american', 'jazz-age']
Money(12.99, USD) == Money(12.99, USD)? True
Money(12.99, USD) == Money(14.99, USD)? False
Address: 123 Main St, Springfield, IL
Country (default): US
Retrieved: The Great Gatsby, $12.99 USD
All checks passed!
Notice that the Money value object persisted and retrieved correctly —
the repository handles it transparently.
What We Built
- Rich field types:
Text,Date,Integer,Boolean,List, andchoicesfor constraining values. Moneyvalue object: An immutable object that groups amount and currency together.Addressvalue object: Ready for use in orders.ValueObjectfield: Embeds a value object inside an aggregate.
Our Book aggregate now has rich fields and a proper price model. In the
next chapter, we will build the Order aggregate with child entities.
Full Source
from enum import Enum
from protean import Domain
from protean.fields import (
Boolean,
Date,
Float,
Integer,
List,
String,
Text,
ValueObject,
)
domain = Domain()
class Genre(Enum):
FICTION = "FICTION"
NON_FICTION = "NON_FICTION"
SCIENCE = "SCIENCE"
HISTORY = "HISTORY"
BIOGRAPHY = "BIOGRAPHY"
FANTASY = "FANTASY"
MYSTERY = "MYSTERY"
@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()
publication_date: Date()
page_count: Integer()
in_print: Boolean(default=True)
genre: String(max_length=20, choices=Genre)
tags: List(content_type=String)
domain.init(traverse=False)
if __name__ == "__main__":
with domain.domain_context():
repo = domain.repository_for(Book)
# Create a book with rich fields and a Money value object
gatsby = Book(
title="The Great Gatsby",
author="F. Scott Fitzgerald",
isbn="9780743273565",
price=Money(amount=12.99),
description="A story of the mysteriously wealthy Jay Gatsby.",
page_count=180,
genre=Genre.FICTION.value,
tags=["classic", "american", "jazz-age"],
)
repo.add(gatsby)
print(f"Book: {gatsby.title}")
print(f"Price: ${gatsby.price.amount} {gatsby.price.currency}")
print(f"Genre: {gatsby.genre}, Pages: {gatsby.page_count}")
print(f"Tags: {gatsby.tags}")
# Value objects are equal by value, not identity
price1 = Money(amount=12.99, currency="USD")
price2 = Money(amount=12.99, currency="USD")
price3 = Money(amount=14.99, currency="USD")
print(f"\nMoney(12.99, USD) == Money(12.99, USD)? {price1 == price2}")
print(f"Money(12.99, USD) == Money(14.99, USD)? {price1 == price3}")
# Create an Address value object
shipping = Address(
street="123 Main St",
city="Springfield",
state="IL",
zip_code="62704",
)
print(f"\nAddress: {shipping.street}, {shipping.city}, {shipping.state}")
print(f"Country (default): {shipping.country}")
# Retrieve and verify persistence
saved = repo.get(gatsby.id)
print(
f"\nRetrieved: {saved.title}, ${saved.price.amount} {saved.price.currency}"
)
# Verify
assert saved.price.amount == 12.99
assert saved.price.currency == "USD"
assert price1 == price2
assert price1 != price3
assert shipping.country == "US"
print("\nAll checks passed!")