Hello! Welcome back to our course on Domain-Driven Design.
Introduction
In our last lesson, we established the crucial conceptual distinction between Entities and Value Objects. As a quick recap:
- Entities have a unique identity and a lifecycle (e.g., a
Customerwith a customer ID). - Value Objects are defined purely by their attributes and are immutable (e.g., a
Moneyobject representing "$10.00").
Today, we move from theory to practice. Our learning outcome is to implement immutable Value Objects in Python using features like dataclasses(frozen=True). We will see how modern Python makes it easy to create objects that are not only concise but also perfectly embody the principles of a Value Object.
This lesson will cover:
- Why standard Python classes require a lot of boilerplate code for Value Objects.
- How Python's
dataclassesmodule simplifies this process. - How to enforce immutability using the
frozen=Trueparameter. - How to add validation and behavior to your immutable Value Objects.
1. The Challenge of Implementing Value Objects Manually
Before we jump into the modern solution, it's useful to appreciate the problem it solves. To create a proper Value Object using a standard Python class, you need to manually implement several methods to ensure it behaves correctly:
__init__(): To initialize the attributes.__repr__(): For a developer-friendly string representation.__eq__(): To ensure equality is based on attribute values (structural equality).__hash__(): To allow the object to be used in dictionaries and sets.- Mechanisms to enforce immutability.
This is a lot of repetitive code for what should be a simple concept.
Python dataclasses will save you HOURS, also featuring attrs
This video from the mCoding channel perfectly illustrates the amount of boilerplate code required to create a robust data-holding class from scratch.
Watch the segment from 00:58 to 03:12. You don't need to follow every line of code, but focus on the sheer volume of methods needed (__init__, __repr__, __eq__, properties for immutability, etc.) just to create a simple Comment class.
As you can see, it's tedious and error-prone. Every time you add a new attribute, you have to update multiple methods. This is where dataclasses come in.
2. A Modern Solution: Python dataclasses
Python dataclasses were introduced in version 3.7 to dramatically reduce the boilerplate associated with creating data-oriented classes. By using the @dataclass decorator, Python automatically generates methods like __init__, __repr__, and __eq__ for you.
This Is Why Python Data Classes Are Awesome
Let's watch a general introduction to dataclasses from the ArjanCodes channel. This will give you a solid overview of their purpose and basic usage.
Watch from 00:51 to 05:38. Pay attention to how a regular class is refactored into a much simpler dataclass, and how the decorator handles the __init__ and __str__ (similar to __repr__) methods automatically.
The basic syntax is clean and expressive. What previously took dozens of lines of code can now be expressed in just a few:
from dataclasses import dataclass
@dataclass
class Address:
street: str
city: str
state: str
zip_code: str
# Both __init__ and __eq__ are generated for us!
address1 = Address("123 Main St", "Springfield", "IL", "12345")
address2 = Address("123 Main St", "Springfield", "IL", "12345")
print(address1) # Output: Address(street='123 Main St', city='Springfield', ...)
print(address1 == address2) # Output: True
This is already a huge improvement. The generated __eq__ method performs structural equality, which is exactly what we need for Value Objects. But one crucial piece is still missing: immutability.
3. Enforcing Immutability with frozen=True
The key to creating a true Value Object with dataclasses is the frozen=True parameter. When you add this to the decorator, the resulting object becomes immutable. Any attempt to modify an attribute after the object has been created will raise an error.
This directly enforces the core DDD principle that Value Objects cannot be changed; they can only be replaced.
Python dataclasses will save you HOURS, also featuring attrs
Now, let's focus on the frozen=True feature. This short clip from the mCoding video you saw earlier demonstrates it perfectly.
Watch from 04:17 to 05:07. Notice how adding frozen=True not only makes the object immutable but also automatically adds a __hash__ method, making it usable as a dictionary key.
When you set frozen=True, Python does two important things:
- It raises a
dataclasses.FrozenInstanceErrorif you try to assign a new value to a field. - It generates a
__hash__()method, allowing instances to be stored in sets and as dictionary keys. This is possible only because the object is immutable.
Here is a visual example of the error you would get:

So, our Address Value Object is now complete and robust:
from dataclasses import dataclass
@dataclass(frozen=True)
class Address:
street: str
city: str
state: str
zip_code: str
address = Address("123 Main St", "Springfield", "IL", "12345")
# This would raise a FrozenInstanceError:
# address.city = "Shelbyville"
4. Adding Validation and Behavior
Value Objects are not just simple data containers; they are a core part of your domain model. They should be responsible for validating their own state and can contain business logic.
-
Validation: A Value Object should not be constructible in an invalid state. For example, a
Priceobject should not have a negative value. We can enforce this using the special__post_init__method, which is called by thedataclassafter the object has been initialized. -
Behavior: Since Value Objects are immutable, any method that would logically "change" the object must instead return a new instance with the updated values.
The following resource provides excellent, practical examples of implementing Value Objects with validation and behavior.
The article 'Value objects with Python' by Szymon Miks provides excellent code examples of well-formed Value Objects. We'll focus on the Price and EmailAddress examples.
Please review the code snippets for the Price and EmailAddress classes. For each example, notice: The use of @dataclass(frozen=True). The validation logic inside the __post_init__ method. How methods like discount() or change() create and return a new instance of the class.
Let's break down the Price example from the article to highlight these patterns:
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Price:
value: Decimal
# 1. Validation in __post_init__
def __post_init__(self) -> None:
if self.value < 0:
raise ValueError("Price can not be smaller than 0")
# 2. Behavior returns a new instance
def __add__(self, other: "Price") -> "Price":
return Price(self.value + other.value)
# This is valid
price1 = Price(Decimal("10.00"))
price2 = Price(Decimal("5.00"))
total_price = price1 + price2 # Creates a new Price(Decimal("15.00")) object
# This would raise a ValueError
# invalid_price = Price(Decimal("-5.00"))
This pattern of using frozen=True, __post_init__ for validation, and returning new instances from methods is the standard and most effective way to implement Value Objects in Python.
Conclusion
In this lesson, we've bridged the gap between the theory of Value Objects and their practical implementation in Python.
Key Takeaways:
- Prefer
@dataclass(frozen=True): This is the idiomatic Python way to create immutable Value Objects, saving you from writing extensive boilerplate code. - Immutability is Enforced:
frozen=Truemakes your objects truly immutable, preventing accidental modifications and making your code safer and more predictable. - Validate in
__post_init__: Use the__post_init__method to ensure a Value Object can never exist in an invalid state. - Return New Instances: Any business logic that operates on a Value Object must return a new instance rather than modifying the existing one.
By mastering this pattern, you can create a rich domain model where simple values are elevated to first-class citizens, carrying their own validation and behavior, which is a cornerstone of effective Domain-Driven Design.
Preview of the Next Lesson:
Now that we've covered how to implement Value Objects, our next lesson will focus on their counterparts: implementing Entities in Python. We will explore how to manage their unique identity and state changes throughout their lifecycle.