Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 6

Creating Immutable Value Objects with Python Dataclasses

Recommended resources

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 Customer with a customer ID).
  • Value Objects are defined purely by their attributes and are immutable (e.g., a Money object 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 dataclasses module simplifies this process.
  • How to enforce immutability using the frozen=True parameter.
  • 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:

  1. It raises a dataclasses.FrozenInstanceError if you try to assign a new value to a field.
  2. 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:

This image shows a `UserConfig` dataclass defined with `frozen=True`. The subsequent attempt to change the `username` attribute correctly results in a `FrozenInstanceError`, demonstrating immutability in action.

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 Price object should not have a negative value. We can enforce this using the special __post_init__ method, which is called by the dataclass after 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.

Value objects with Python

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=True makes 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.