As C# developers, we’re often juggling a mix of classes, structs, and interfaces to craft clean, maintainable code.
But there’s one concept that doesn’t always get the spotlight it deserves: Value Objects. If you’ve ever wondered how to model data that’s more than just a bag of properties: data with meaning, identity, and immutability, then Value Objects might just become your new best friend.
Let’s dive into what they are, why they matter, when to use them, and where they shine, complete with a real-world example and some bonus ideas to spark your creativity.
What Are Value Objects?
At their core, Value Objects are small, immutable objects that represent a concept in your domain where the value itself defines their identity, not some arbitrary ID or reference.
Unlike entities, which rely on a unique identifier (think CustomerId
or OrderId
), Value Objects are all about equality based on their contents. If two Value Objects have the same data, they’re considered the same.
Think of them as the building blocks of your domain that don’t change once created.
They’re lightweight, focused, and carry intrinsic meaning, like a Money amount or a DateRange.
In C#, they’re typically implemented as structs or classes with readonly properties, ensuring their immutability.
Why Use Value Objects?
So, why bother with Value Objects when a plain class or primitive type might do? The answer lies in clarity and safety. Value Objects let you:
- Encapsulate Logic: Instead of scattering validation or behavior across your codebase, you bundle it into the object itself.
- Ensure Immutability: Once created, they don’t change, reducing bugs from unexpected mutations.
- Improve Readability: A
Money
object with a currency and amount is far more expressive than a lone decimal. - Enable Type Safety: No more mixing up
int
parameters or passing invalid data. Value Objects enforce their own rules.
In short, they make your code more robust and your domain model more meaningful.
When Should You Reach for Value Objects?
Value Objects shine in situations where you’re dealing with concepts that have no distinct identity but carry specific rules or constraints. Use them when:
- You’re modeling something defined by its attributes (e.g., a color, a coordinate, or a temperature).
- You want to avoid primitive obsession; relying too much on
int
,string
, ordecimal
without context. - You need to enforce business rules or invariants (e.g., a percentage can’t exceed 100).
- Immutability makes sense for the concept.
They’re not a fit for everything, though. If your object needs a lifecycle or a unique ID, like a User
or Product
, stick with entities instead.
Where Do Value Objects Fit In?
You’ll find Value Objects popping up in your domain layer, often as part of a larger Domain-Driven Design (DDD) approach.
They’re perfect for representing attributes of entities or standalone concepts in your business logic.
Whether you’re building an e-commerce platform, a game, or a financial app, Value Objects help you model the “what” of your system with precision.
Now, let’s bring this to life with a real-world scenario.
A Real-World Example: Modeling a Distance in a Fitness App
Imagine you’re building a fitness tracking app. Users log their runs, and you need to represent the distance they’ve covered.
A simple double for kilometers might work, but what if you want to support miles too? And what if negative distances sneak in by mistake? Enter the Distance Value Object.
Here’s how we might implement it:
public sealed class Distance
{
public double Value { get; }
public string Unit { get; }
private Distance(double value, string unit)
{
if (value < 0)
{
throw new ArgumentException("Distance cannot be negative.");
}
if (string.IsNullOrWhiteSpace(unit) || (unit != "km" && unit != "miles"))
{
throw new ArgumentException("Unit must be 'km' or 'miles'.");
}
Value = value;
Unit = unit;
}
public static Distance FromKilometers(double value) => new Distance(value, "km");
public static Distance FromMiles(double value) => new Distance(value, "miles");
public Distance ConvertTo(string targetUnit)
{
if (Unit == targetUnit) return this;
double convertedValue = Unit switch
{
"km" when targetUnit == "miles" => Value * 0.621371,
"miles" when targetUnit == "km" => Value * 1.60934,
_ => throw new ArgumentException("Invalid conversion unit.")
};
return new Distance(convertedValue, targetUnit);
}
public override bool Equals(object? obj)
{
if (obj is not Distance other) return false;
// Convert to the same unit for comparison
var thisInOtherUnit = Unit == other.Unit ? this : ConvertTo(other.Unit);
return Math.Abs(thisInOtherUnit.Value - other.Value) < 0.0001;
}
public override int GetHashCode() => HashCode.Combine(Value, Unit);
public override string ToString() => $"{Value} {Unit}";
}
Let’s break this down:
- Immutability: The properties
Value
andUnit
are readonly, set only via the private constructor. - Factory Methods:
FromKilometers
andFromMiles
provide a clean way to create instances while enforcing rules. - Business Logic: The constructor validates that distances aren’t negative and units are valid.
- Behavior: The
ConvertTo
method handles unit conversion, returning a newDistance
instance. - Equality: The
Equals
method compares distances by converting them to the same unit, ensuring 5 km equals 3.10686 miles (within a small tolerance).
Here’s how you’d use it:
var runDistance = Distance.FromKilometers(10.5);
Console.WriteLine($"You ran {runDistance}"); // Output: You ran 10.5 km
var distanceInMiles = runDistance.ConvertTo("miles");
Console.WriteLine($"That’s {distanceInMiles}"); // Output: That’s 6.5244 miles
var anotherRun = Distance.FromMiles(6.5244);
Console.WriteLine($"Equal? {runDistance.Equals(anotherRun)}"); // Output: Equal? True
See how much cleaner this is than juggling raw doubles and strings?
The Distance
Value Object encapsulates the concept, enforces rules, and provides useful behavior, all while keeping your code expressive.
More Value Object Examples to Inspire You
Let’s explore a few more ideas to show how versatile Value Objects can be.
Some are complex, while others are simple enough to leverage C#’s record
type for a concise, immutable implementation.
1. Money (Classic Example)
A Money
Value Object could track an amount and currency, ensuring you don’t accidentally mix dollars and euros. It might include methods for addition or conversion, much like Distance
.
2. Temperature (With Record)
For a weather app, a Temperature
Value Object could enforce valid ranges.
Using a record, it’s beautifully simple:
public record Temperature(double Value, string Unit)
{
public Temperature(double value, string unit) : this(value, unit)
{
if (unit != "C" && unit != "F")
{
throw new ArgumentException("Unit must be 'C' or 'F'.");
}
if (unit == "C" && value < -273.15)
{
throw new ArgumentException("Temperature below absolute zero!");
}
}
}
Usage: var temperature = new Temperature(25, "C");
.
The record gives you immutability and value-based equality for free.
3. Coordinate (With Record)
In a mapping app, a Coordinate
could represent latitude and longitude:
public record Coordinate(double Latitude, double Longitude)
{
public Coordinate(double latitude, double longitude) : this(latitude, longitude)
{
if (latitude < -90 || latitude > 90)
throw new ArgumentException("Latitude must be between -90 and 90.");
if (longitude < -180 || longitude > 180)
throw new ArgumentException("Longitude must be between -180 and 180.");
}
}
Usage: var location = new Coordinate(40.7128, -74.0060);
(hello, New York!).
Again, record keeps it concise.
4. Email Address
An EmailAddress
Value Object could validate format and ensure uniqueness in a user management system, wrapping a simple string with domain-specific rules.
Wrapping Up
Value Objects might not steal the show like inheritance or async programming, but they’re a quiet powerhouse in C#.
They bring clarity to your domain, reduce errors, and make your code scream its intent, whether you’re modeling distances, temperatures, or coordinates. With C#’s record
type, simple Value Objects are easier than ever to implement, giving you immutability and equality out of the box.
Next time you’re tempted to pass around a naked int or string, pause and ask: could this be a Value Object?
So, go ahead and experiment with them in your next project. The possibilities are endless, and the benefits are real.
Happy coding! ⚡