Skip to content

The Essential Value of Value Objects

Published: at 05:51 AM

Table of Contents

Open Table of Contents

Introduction

Hello.

In my earlier article on code smells, I wrote about the discomfort I feel when code repeatedly checks conditions throughout its logic, even though those domain constraints could be expressed in the types themselves. This time, I would like to explore value objects in particular.

Value objects are a concept you encounter fairly early when studying domain-driven design (DDD). Instead of treating an email address as a plain str, you use an EmailAddress. Instead of representing a monetary amount with an int, you use Money.

From that description alone, it might seem as though we are simply wrapping primitive values in classes for no particular reason. But I believe value objects offer considerable value. What matters most is that the very existence of a value object means we can consider it to satisfy its domain invariants.

With that guarantee, we no longer need to reconsider whether a value is valid every time we write business logic. This is about more than adding another class: it reduces the number of things we need to think about when reading or changing code.

Knowledge About a Value Gets Scattered Across the Code

Suppose we have some code that handles usernames. In this service, a username must contain between 3 and 30 characters.

Without a value object, the implementation might look like this:

def register_user(name: str):
    if not 3 <= len(name) <= 30:
        raise ValueError("The username must be between 3 and 30 characters long")

    # Create the user

On its own, this does not look particularly problematic. But if profile updates also handle usernames, they need the same check.

def change_user_name(name: str):
    if not 3 <= len(name) <= 30:
        raise ValueError("The username must be between 3 and 30 characters long")

    # Change the username

The same rule must hold when registering users through an API or a batch job, or when changing their names through an admin interface. Checks for the validity of the value itself then become embedded in the use case logic we actually want to write.

Naturally, we might extract those checks into a shared validation function. Even then, however, callers have to know that they must call that function before using the value. If any execution path forgets to call it, an invalid value can pass straight through.

And when the username constraints change, we have to investigate where the checks happen and which paths omit them. Knowledge about the value is scattered across the code that uses it.

I think it is more natural for the value itself to own that knowledge. To me, this is one of the essential ideas behind value objects.

Successful Construction Means the Constraints Are Satisfied

Let us express the same rule with a value object called UserName.

from dataclasses import dataclass


@dataclass(frozen=True)
class UserName:
    value: str

    def __post_init__(self):
        if not isinstance(self.value, str):
            raise TypeError("The username must be a string")

        if not 3 <= len(self.value) <= 30:
            raise ValueError("The username must be between 3 and 30 characters long")

With this implementation, successfully constructing a UserName means that its length constraint has been satisfied.

name = UserName("akito")


def register_user(name: UserName):
    # No need to check the username's length here
    ...

The user registration code can focus on registering the user. It no longer needs to keep going back to the question of whether this string is acceptable as a username.

The important point here is that validation during construction alone is not enough. If someone can later assign name.value = "a", the constraint we protected will be broken. That is why this example uses frozen=True to prohibit normal field assignment. To change the value, we create a new UserName.

A dataclass also generates value-based equality, so UserName("akito") == UserName("akito") evaluates to True. This expresses another property of value objects: objects with the same values are treated as equal, unlike entities whose identity is determined by an identifier. You can read more about these behaviors in the official Python documentation.

However, Python type annotations do not enforce argument types at runtime. Annotating an argument as UserName does not, by itself, prevent someone from passing a string. This design therefore assumes that we use type checking and properly construct value objects from input and when restoring data from the database.

The important thing is not simply to define a class. It is to protect the invariants across both the ways objects are constructed and the operations allowed after construction.

Primitive Types Alone Do Not Convey a Value’s Meaning

Another major reason to use value objects is that they let us express the meaning of a value in code.

Consider a function like this:

def calculate(price: int):
    ...

The name price suggests a monetary amount, but it does not tell us whether it is in yen or dollars, or whether it includes tax. We have to inspect the implementation or its callers before we can determine how to handle the value.

If we define a Money type that holds both an amount and a currency, we can at least design it to prevent calculations that ignore the currency. If the domain also requires a distinction between amounts that include tax and those that exclude it, we might express that through separate types or explicit operations. Of course, merely naming a type Money does not explain everything. What matters is the knowledge we give that type.

The same applies to distance. With a plain float, we have to rely on variable names or comments to determine whether it represents meters or feet. Separate types such as Meter and Foot, together with explicit conversions, can express the difference between units in code.

Confusing units has caused serious problems in real systems. A famous example is NASA’s Mars Climate Orbiter, which failed because of a navigation error caused by a missing conversion from English units to metric units.

Separate types do not guarantee that we can prevent every such incident. Still, I think there is value in making unit differences explicit and defining within the types how operations involving different units should be handled.

The range of values that str can represent is also far too broad. Email addresses, usernames, and even fragments of SQL are all strings as far as the type is concerned. Rather than passing them around unchanged deep inside the domain, converting them to EmailAddress or UserName narrows the range of acceptable values and makes the code easier to reason about for both readers and implementers.

Of course, value objects are no substitute for protection against SQL injection. Executing SQL still requires separate measures such as parameterized queries; value objects alone cannot ensure security. But narrowing values early to those that can exist in the domain reduces the states that subsequent code has to handle. I believe this is another essential aspect of value objects.

Isn’t Input Validation Enough?

At this point, you might ask whether validation with Laravel Form Requests or Pydantic is enough.

For me, the key question is where the knowledge behind a constraint belongs.

For example, we need to check at the entry point whether a request contains the required fields and whether an input can be accepted as a string. On the other hand, rules such as “product codes in this business must follow this format,” “usernames in this service cannot contain these characters,” and “monetary amounts for this purpose must not be less than zero yen” belong to the domain.

A maximum character count is not always just an input format requirement, either. If the service defines a maximum of 30 characters for usernames, as in the earlier example, that is a constraint the domain should also enforce. Even when two checks look identical, the reason for the constraint determines where the responsibility belongs.

If we implement such a rule only in API input validation, calls from a batch job, a CLI, or another use case may bypass it. Even if the API is the only caller today, that may not remain true in the future.

I therefore find it natural to check at the entry point whether the input can be accepted, while also protecting domain invariants through value objects. Checking input at the entry point so that we can return multiple errors together serves one purpose; preventing invalid values from existing in the domain serves another.

That does not mean every rule belongs in a value object. Checking whether an email address is already in use, for example, requires information about other users. Even if we can construct an EmailAddress with a valid format, we still need to decide separately whether registration with that address is allowed. We need to distinguish invariants of the value itself from business conditions that depend on its relationship with other state.

Stop Invalid Values as Early as Possible

Personally, I think processing should stop as early as possible when we detect an invalid state.

name = UserName(request.name)

If the input cannot form a valid username at this point, there is no reason to pass it to subsequent processing. This example raises an exception, but returning a construction result that distinguishes success from failure is also a reasonable design. Either way, what matters is that we do not construct a value object containing an invalid value.

In a web application, we translate that failure into an HTTP error at the application boundary. For example, we can use FastAPI’s or Laravel’s exception handling mechanisms to map domain errors to appropriate status codes and responses. An exception raised in the domain is not necessarily treated as an input error automatically, so we need to make that conversion explicit.

The domain decides whether the value is valid, while the outer layer decides how to respond over HTTP. With that separation, we can call the same domain logic from a batch job without bringing HTTP concerns into it. I think this helps us separate responsibilities properly.

Is Having More Classes a Problem?

One concern about value objects is that they add classes and might therefore make the system more complex.

I do not think an increase in the number of classes is much of a problem in itself. Even with fewer classes, code is harder to read if a single class has too many responsibilities or if constraints on values are scattered everywhere. I think the cognitive load—the amount of information we need to keep in mind when making a change—matters more than the number of files.

That said, I do not believe everything should become a value object. If we wrap every string and number in a class without understanding its domain meaning or what we want to protect, we simply increase the number of types we have to handle.

One useful criterion, I think, is whether we can explain what knowledge the type brings together and what we no longer have to think about as a result.

Existing Systems May Require a Gradual Introduction

In practice, one of the difficult parts of introducing value objects is dealing with existing data.

I find value objects relatively easy to use in operations that create or update data. When creating users or products, or updating profiles, we can first convert the input into value objects and stop there if it is invalid. If we proceed using successfully constructed values, we can protect the constraints on newly stored data.

When replacing a legacy system, however, the existing database may contain values that the current domain rules do not allow. Perhaps the rules used to be different, or perhaps those values were saved through a path that bypassed validation.

What happens if we strictly convert every value retrieved from the database into a value object?

name = UserName(row.name)

If an existing username contains only two characters, this line raises an exception. We may only want to display a list, yet a single old record can cause the entire read operation to fail.

We want the design to enforce the constraints, but the data that already exists does not satisfy them. This is a very real issue when replacing systems, and I have encountered it several times myself.

One possible approach is to display existing data as it is through read DTOs or similar structures, while using strict value objects for operations that create or update data. However, passing those read values directly into an update operation would bypass the constraints. We therefore need to construct value objects again at the point of updating.

In some cases, the newly defined invariant may not accurately represent the existing business in the first place. It would be premature to assume that the data is wrong simply because it does not fit the new rule.

As I wrote in my earlier article on code smells, design is about more than making code look clean. We need to understand the current system and business, then work toward making the system easier to change. Knowing the ideal form of a value object does not necessarily mean we can introduce it everywhere at once.

Even so, I consider value objects a highly effective option with one of the lowest barriers to adoption when working toward a sustainable system.

Conclusion

Value objects let us write business logic that trusts the validity of the values it receives. If we receive a UserName, we do not need to check its length again. If we receive an EmailAddress, we can consider it to satisfy the format constraints defined by that type.

The type expresses the value’s meaning, and knowledge about that value is brought together in one place. This reduces the conditional branches scattered across use cases and makes it easier to see where to make changes when requirements change.

To me, the greatest value of value objects is that they reduce the number of states we have to consider within the domain. Instead of continually accounting for the possibility of invalid values in every operation, we enforce the constraints when constructing the value. Then, within the domain, we can focus on the business logic we actually want to implement.

It is easy to remember value objects simply as a DDD pattern. But thinking about why we use them and what they make easier may help us incorporate them into everyday design.

Consider making value objects your first option when deciding how to refactor your own system.