Mastering Value Objects in Python: Equality, Validation, and Immutability

by Damian Piatkowski 9 min read
Design Python Object-Oriented Programming Domain-Driven Design
Hero image for Mastering Value Objects in Python: Equality, Validation, and Immutability

Mastering object-oriented programming is a journey that’s both challenging and rewarding. At first, the focus is simply on learning how to create objects — the fundamental building blocks that encapsulate data and behavior.

As you grow, new questions start to arise before writing a class: Does it have enough cohesion? Should this responsibility belong in the constructor or a separate method? To find answers, you might turn to programming books and blogs and, along the way, discover the world of design patterns — proven techniques for building better objects.

One particularly powerful pattern you may encounter is the value object. In Python, value objects encapsulate data in dedicated classes, emphasizing immutability and equality based on their attributes rather than identity. This approach improves clarity, centralizes validation, and makes systems easier to maintain. Let’s explore how value objects can elevate your Python projects and your programming skills.

What is a Value Object?

Let’s begin with what it isn’t. A value object is not an entity, which is always unique even when two instances hold identical data. Nor is it a data transfer object (DTO), whose main purpose is simply to move data between processes.

“Whenever we have a business concept that has data but no identity, we often choose to represent it using the Value Object pattern. A value object is any domain object that is uniquely identified by the data it holds”

Harry Percival and Bob Gregory, Architecture Patterns with Python

A value object is a small object that represents a simple concept, where equality is defined by value rather than identity. This idea is central to Domain-Driven Design (DDD) and is typically used to model attributes described solely by their data. Crucially, value objects are immutable: once created, their state cannot change. This immutability ensures consistency, predictability, and makes them a powerful tool for building robust, maintainable systems.

Why Use Value Objects?

One of the main reasons to use value objects is to avoid repeating validation logic while making the codebase more expressive and maintainable. By wrapping values in dedicated classes, validation happens once — when the object is created — rather than being scattered throughout the code. Replacing primitive types with value objects also makes the purpose and constraints of the data much clearer.

Consider this passage from Noback’s Object Design Style Guide:

“Wrapping values inside new objects called value objects isn’t just useful for avoiding repeated validation logic. As soon as you notice that a method accepts a primitive-type value (string, int, etc.), you should consider introducing a class for it. The guiding question for deciding whether or not to do this is, ‘Would any string, int, etc., be acceptable here?’ If the answer is no, introduce a new class for the concept. You should consider the value object class itself to be a type, just like string, int, etc., are types. By introducing more objects to represent domain concepts, you’re effectively extending the type system. Your language’s compiler or runtime will be able to support you much better, because it can do type-checking for you and make sure that only the right types end up being used when passing method arguments and returning values.”

This captures the essence of value objects: they extend the type system with domain-specific types, enhancing type safety and readability. In Python, even though type safety is enforced at runtime rather than at compile time, value objects still provide significant advantages in validation, clarity, and maintainability. Python’s dataclasses make it especially convenient to implement this pattern in a Pythonic way.

Another key feature of value objects is immutability. Once created, their state cannot change. This simplifies reasoning about code, prevents unintended side effects, and reinforces the principle that value objects are defined by what they contain rather than by their identity.

Implementing Value Objects in Python

Python’s dataclasses module, introduced in Python 3.7, offers a convenient way to create value objects. Dataclasses automatically generate special methods like __init__, __repr__, and __eq__, which makes them a natural fit.

Here’s an example of a value object representing a postal address:

from dataclasses import dataclass


@dataclass(frozen=True)
class Address:
    street: str
    city: str
    country: str
    zip_code: str


    def __post_init__(self):
        # Example of validation logic
        if not isinstance(self.street, str) or len(self.street.strip()) == 0:
            raise ValueError("Street name must be a non-empty string")


        if not isinstance(self.city, str) or len(self.city.strip()) == 0:
            raise ValueError("City name must be a non-empty string")


        if not isinstance(self.country, str) or len(self.country.strip()) == 0:
            raise ValueError("Country name must be a non-empty string")


        if not isinstance(self.zip_code, str) or len(self.zip_code.strip()) != 5:
            raise ValueError("Zip code must be a string of 5 characters")

In this example, address acts as a value object that extends the type system with a domain-specific type. The @dataclass(frozen=True) decorator makes it immutable, ensuring its state cannot change after creation. Within our domain, this means that two identical addresses (say, from members of the same household) are considered equal.

Once Address is treated as its own type, it makes sense to put all validation logic inside the class. This keeps the code consistent and easier to maintain. For more advanced validation needs, libraries like Pydantic offer stronger mechanisms.

Why Not NamedTuple?

NamedTuple can also create lightweight, immutable value-like objects with automatic equality. However, it’s more limited when modeling domain concepts.

Adding validation or business logic requires awkward overrides of __new__ or__init__, whereas dataclass(frozen=True) naturally supports methods and validation through __post_init__.

In modern Python, dataclasses are the idiomatic choice for value objects, while NamedTuple is better reserved for simple, schema-like records (e.g., rows from a CSV file) where no extra logic is needed.

Performance Considerations — When Attrs Comes into Play

While value objects improve code clarity and maintainability, they can introduce performance overhead in large-scale systems where many instances are created and compared. Immutability in particular may increase memory usage and garbage collection activity. To address this, developers should profile their applications and optimize performance-critical paths where value objects are heavily used.

For example, consider a financial application processing thousands of transactions per second, each represented by a value object. Creating immutable objects for every transaction can lead to substantial memory allocation and deallocation costs. Profiling tools like cProfile or memory_profiler are useful for identifying hotspots and memory-intensive operations in such scenarios.

import attr
from memory_profiler import profile


@attr.s(frozen=True, slots=True)
class Transaction:
    amount = attr.ib()
    currency = attr.ib()
    timestamp = attr.ib()


@profile
def process_transactions(transactions):
    total = 0
    for transaction in transactions:
        if transaction.currency == 'USD':
            total += transaction.amount
    return total


transactions = [Transaction(amount=100, currency='USD', timestamp='2023-06-21') for _ in range(1000000)]
print(process_transactions(transactions))

In this example, the attrs library is used with slots=True to reduce memory overhead by storing attributes in __slots__. This optimization can significantly lower the memory footprint and improve performance compared to regular dataclasses. The profile decorator from memory_profiler then monitors the memory usage of the process_transactions function, highlighting potential bottlenecks.

Other techniques, such as pooling frequently used value objects, can further reduce allocation costs. With careful profiling and selective optimization, developers can enjoy the benefits of value objects without sacrificing efficiency.

How About Superb Validation? Enhancing Data Integrity with Pydantic

While attrs excels in performance and simplicity, Pydantic stands out for its strong validation capabilities. It’s a relatively newer library that provides powerful data validation and parsing through Python type annotations. Pydantic automatically enforces constraints and generates detailed error messages, making it an excellent choice for applications that demand rigorous input validation and error handling.

For example, consider an application that processes user-submitted transactions. Ensuring that all input data is valid and follows the expected format is critical. With Pydantic, you can define a model that embeds validation logic directly in the class definition:

from pydantic import BaseModel, Field, ValidationError
from datetime import datetime


class Transaction(BaseModel):
    amount: float = Field(..., gt=0, description="Transaction amount must be greater than zero")
    currency: str = Field(..., regex="^[A-Z]{3}$", description="Currency must be a three-letter code")
    timestamp: datetime


    class Config:
        frozen = True


try:
    transaction = Transaction(amount=100, currency='USD', timestamp=datetime.now())
    print(transaction)
except ValidationError as e:
    print(e.json())

In this example, Pydantic ensures that amount is greater than zero and currency matches a three-letter uppercase code. If the input data violates these rules, a ValidationError is raised with detailed feedback. This kind of validation is invaluable for systems that need to guarantee data integrity.

Another advantage is Pydantic’s tight integration with type hints, which improves readability and maintainability. By leveraging Python’s type system, Pydantic models serve as explicit data contracts, making the codebase clearer and easier to work with.

Although Pydantic introduces a slight performance cost compared to attrs, its advanced validation features and developer-friendly design make it a compelling choice in many scenarios. It helps ensure robust data handling, reducing bugs and improving the overall reliability of applications.

Value Objects and Persistence

You might wonder: what if we want to persist value objects in a database? How does that work when these objects have no unique identity, as is typical for value objects?

By definition, value objects do not have a unique identifier (ID); they are identified solely by their attributes. This creates an interesting challenge in relational databases, where records are usually tied to a primary key.

Storing Value Objects in a Database

When you need to persist value objects, there are a few approaches you can take:

1. Embedding Value Objects

Value objects can be embedded directly within an entity that has a unique identity. For example, consider an Order entity that includes an Address value object:

from dataclasses import dataclass


@dataclass(frozen=True)
class Address:
    street: str
    city: str
    country: str
    zip_code: str


class Order:
    def __init__(self, order_id: int, address: Address):
        self.order_id = order_id
        self.address = address

In this case, the Address is part of the Order entity. The database table might look like this:

   CREATE TABLE orders (
       order_id INTEGER PRIMARY KEY,
       street TEXT,
       city TEXT,
       country TEXT,
       zip_code TEXT
   );

The address fields are stored directly in the orders table alongside the order’s primary key.

2. Using Surrogate Keys

If you expect identical value objects to appear multiple times (i.e., rows with the same attributes), a surrogate key ensures uniqueness at the database level.

@dataclass(frozen=True)
class Address:
    street: str
    city: str
    country: str
    zip_code: str

Schema:

CREATE TABLE addresses (
    id SERIAL PRIMARY KEY,
    street TEXT NOT NULL,
    city TEXT NOT NULL,
    country TEXT NOT NULL,
    zip_code TEXT NOT NULL
);

Here:

  • id uniquely identifies each row.
  • street, city, country, and zip_code can contain duplicates.

Example inserts::

INSERT INTO addresses (street, city, country, zip_code) VALUES ('123 Main St', 'Anytown', 'USA', '12345');
INSERT INTO addresses (street, city, country, zip_code) VALUES ('123 Main St', 'Anytown', 'USA', '12345'); -- Allowed, unique IDs distinguish them


3. Using Composite Keys

Another option is to define a composite key, where multiple columns together form the primary key:

CREATE TABLE addresses (
    street TEXT NOT NULL,
    city TEXT NOT NULL,
    country TEXT NOT NULL,
    zip_code TEXT NOT NULL,
    PRIMARY KEY (street, city, country, zip_code)
);

Here, the combination of all fields ensures uniqueness. Attempting to insert the same address twice will fail due to the primary key constraint. While this enforces strict uniqueness, it increases schema complexity and can slow down queries involving large composite keys.

Considerations for Persistence

  • Normalization vs. Denormalization: Embedding value objects inside entities denormalizes the schema, which simplifies queries but may duplicate data. Storing them in separate tables normalizes the design but adds query complexity.
  • Database Constraints: Use database constraints to maintain the integrity of value objects.
  • Immutable Objects: Since value objects are typically immutable, updates involve replacing the entire object rather than changing individual fields. This should be reflected in your persistence logic.

By carefully choosing how value objects are represented in the database, you can preserve their benefits in the domain model while still achieving efficient and consistent data storage.

Conclusion

Value objects are a fundamental pattern in Python for representing domain concepts that have no identity and are defined solely by their values. By encapsulating primitive types in dedicated classes, developers can create code that is more robust, readable, and maintainable.

Whether using dataclasses or other mechanisms, the principles of immutability, centralized validation, and enhanced type safety make value objects a powerful tool in any Python project. Embracing this pattern leads to cleaner, more expressive code that faithfully reflects the underlying business logic.