Skip to content

Chapter 3: Value Objects — Describing Things

So far our Book aggregate uses primitive fields — Float for price, String for everything else. But a price is not just a number. It has a currency. An address is not just a string. It has a street, city, and zip code. In this chapter, we introduce value objects — a way to model these rich, descriptive concepts.

What Are Value Objects?

A value object is an immutable object defined by its attributes rather than an identity. Two value objects with the same attributes are considered equal — just like two $10 bills are interchangeable regardless of their serial numbers.

Key characteristics:

  • Immutable — once created, they cannot be changed
  • No identity — compared by value, not by ID
  • Self-validating — they can enforce their own rules

The Money Value Object

Let's replace Book's plain Float price with something more meaningful:

from protean import Domain
from protean.fields import Float, String, Text, ValueObject

domain = Domain()


@domain.value_object
class Money:
    currency = String(max_length=3, default="USD")
    amount = Float(required=True)

Money captures both the amount and the currency. This prevents subtle bugs — you would never accidentally add dollars to euros.

Embedding in an Aggregate

Use the ValueObject field to embed a value object inside an aggregate:

@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)

Now creating a book looks like this:

book = Book(
    title="The Great Gatsby",
    author="F. Scott Fitzgerald",
    price=Money(amount=12.99),
)
print(f"${book.price.amount} {book.price.currency}")
# $12.99 USD

The currency defaults to "USD" so we only need to specify the amount. When we need a different currency, we pass it explicitly: Money(amount=29.99, currency="EUR").

Why Not Just a Float?

A Float field stores only the number. With Money, you also capture the currency — and can later add behavior like formatting or conversion. Value objects make your domain model self-documenting: anyone reading the code immediately understands that price is a monetary amount, not just an arbitrary number.

The Address Value Object

We will need shipping addresses when we build orders later. Let's define Address now:

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

The country field defaults to "US". We will use this value object in a later chapter when we add orders and shipping.

Equality by Value

Value objects are compared by their attributes, not by some hidden identity:

price1 = Money(amount=12.99, currency="USD")
price2 = Money(amount=12.99, currency="USD")
price3 = Money(amount=14.99, currency="USD")

price1 == price2  # True  — same values
price1 == price3  # False — different amount

This is different from aggregates and entities, which are compared by their identity (ID). Two Book objects with the same title are still different books if they have different IDs.

Immutability in Practice

Value objects are frozen after creation. Attempting to modify one raises an error:

>>> price = Money(amount=12.99)
>>> price.amount = 14.99
InvalidOperationError: Money objects are immutable ...

To "change" a value, you replace the entire object:

book.price = Money(amount=14.99, currency="USD")

This guarantees that value objects are always in a consistent state — there is no way to corrupt them through partial updates.

Deciding: Field vs Value Object

When should you use a plain field and when should you extract a value object? Here is a simple heuristic:

Use a Field When... Use a Value Object When...
The value is truly a single thing (a name, a count) The value has multiple related attributes (amount + currency)
No special rules apply The value has its own validation rules
It has no domain meaning beyond the data It represents a domain concept worth naming

If you find yourself adding the same group of fields to multiple aggregates (like street, city, zip_code), that is a strong signal to extract a value object.

Full Source

from protean import Domain
from protean.fields import Float, 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()




domain.init(traverse=False)


if __name__ == "__main__":
    with domain.domain_context():
        # Create a book with a Money value object
        book = 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.",
        )

        print(f"Book: {book.title}")
        print(f"Price: {book.price.amount} {book.price.currency}")

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

        assert price1 == price2, "Same values means equal"
        assert price1 != price3, "Different values means not equal"
        print(f"\nMoney(12.99, USD) == Money(12.99, USD)? {price1 == price2}")
        print(f"Money(12.99, USD) == Money(14.99, USD)? {price1 == price3}")

        # Persist and retrieve
        repo = domain.repository_for(Book)
        repo.add(book)
        saved = repo.get(book.id)
        print(f"\nRetrieved: {saved.title}, ${saved.price.amount}")

        # 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}")

        # Verify
        assert saved.price.amount == 12.99
        assert saved.price.currency == "USD"
        assert shipping.country == "US"  # default
        print("\nAll checks passed!")

Run it:

$ python bookshelf.py
Book: The Great Gatsby
Price: 12.99 USD

Money(12.99, USD) == Money(12.99, USD)? True
Money(12.99, USD) == Money(14.99, USD)? False

Retrieved: The Great Gatsby, $12.99

Address: 123 Main St, Springfield, IL

All checks passed!

Summary

In this chapter you learned:

  • Value objects are immutable, identity-less objects compared by value.
  • The ValueObject field embeds a value object inside an aggregate.
  • Use value objects to model concepts with multiple attributes (Money, Address) rather than primitive fields.
  • Value objects cannot be modified after creation — replace them instead.

Our bookstore has books with proper prices. In the next chapter we will introduce entities and associations — child objects with identity that live inside an aggregate. We will build the Order aggregate with its OrderItem entities.

Next

Chapter 4: Entities and Associations →