Skip to content

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:

  • Text for description — long-form text, unlike String which has a max_length cap.
  • Date for publication_date — a date without time.
  • Integer for page_count — whole numbers.
  • Boolean for in_print — true/false with a default of True.
  • List for tags — a list of strings.
  • choices=Genre on the genre field restricts values to a Python Enum.

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, and choices for constraining values.
  • Money value object: An immutable object that groups amount and currency together.
  • Address value object: Ready for use in orders.
  • ValueObject field: 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!")

Next

Chapter 3: Entities and Associations →