-
Notifications
You must be signed in to change notification settings - Fork 1
Value Objects Solving Primitive Obsession in NET
Article series
- Value Objects: Solving Primitive Obsession in .NET ⬅
- Handling Complexity: Introducing Complex Value Objects in .NET
In software development, we frequently encounter bugs caused by inappropriate validation or state mutation. Consider a user registration system where email addresses can be set to invalid formats, or a financial application where a monetary amount can be modified to negative values after initial validation. Because these values are represented as primitive types, neither the compiler nor the runtime environment offers built-in protection against these domain rule violations, forcing developers to implement and remember validation checks throughout the codebase.
This scenario demonstrates primitive obsession – the overuse of primitive types like int
, string
, and decimal
to represent domain concepts that deserve their own types. This anti-pattern leads to code that's both error-prone and difficult to understand.
In this article, we'll explore Value Objects as a solution to primitive obsession using the library Thinktecture.Runtime.Extensions. We'll see how these constructs prevent entire classes of bugs, make code more expressive, and reduce the mental overhead of writing correct code.
The article targets both intermediate software engineers and those who are newer to Domain-Driven Design concepts, as well as developers looking for practical solutions to a widespread problem that affects code quality and maintenance across .NET applications without necessarily adopting full DDD practices.
The library Thinktecture.Runtime.Extensions was created to address a fundamental challenge in modern C# development: providing robust, type-safe domain modeling without excessive boilerplate code. Too often, developers spend hours tracking down issues that could have been avoided entirely with proper type safety or rather simple validation.
With the introduction of Roslyn Source Generators in .NET 5 (2020), new possibilities opened up that were previously unattainable. Before this technology, providing a flexible tool for implementation of value objects was virtually impossible without significant runtime costs or cumbersome boilerplate. I began implementing these features using source generators shortly after their release, allowing compile-time code generation without runtime overhead. One of our oldest projects has been using the value objects successfully since late 2021, demonstrating its reliability and effectiveness in production environments.
Roslyn Source Generators are a feature of the .NET Compiler Platform (Roslyn) that allow the developers to generate code at compile time. The source generators can read existing C# code and other files, analyze them, and then produce new C# code that is added to the compilation.
string
or integer
needs to become a value object – the best candidates are those that represent meaningful domain concepts with validation rules or specialized behavior.
Let's examine a common example that illustrates the issues with primitive types:
public record Payment(
decimal Amount, // How do we ensure this is positive?
string Account, // Is this properly formatted?
string Reference) // Does this follow business rules?
{
public bool IsValid()
{
return Amount > 0 &&
!string.IsNullOrWhiteSpace(Account) &&
!string.IsNullOrWhiteSpace(Reference);
}
}
// ⚠️ Usage with multiple opportunities for errors
var payment = new Payment(
-50.00m, // Negative amount
" ", // Just whitespaces
"" // Empty reference
);
if (payment.IsValid()) // Returns false, but caller might forget to check
{
// Process payment
}
This code has several problems:
- Type safety issues: Nothing prevents mixing up different concepts that share the same primitive type
- Invalid values: Properties can be set to invalid values (negative amounts, empty strings)
-
Scattered validation: Validation can be bypassed by forgetting to call
IsValid()
-
Maintenance challenges: If validation rules change, every place that creates a
Payment
needs updating - Poor readability: Method signatures with multiple parameters of the same type are error-prone
Consider this method signature:
void ProcessOrder(int customerId, int orderId, decimal amount, string currency)
{
// Process the order...
}
When you see ProcessOrder(1001, 5005, 99.95m, "EUR")
, you need to remember the parameter order or constantly check the method signature. The cognitive load of tracking which primitives represent what concepts slows development and increases the chance of mistakes.
Value objects are structures defined by their attributes rather than by a unique identity. Unlike entities (like a Customer
or Order
that have identities), value objects are fully defined by their values – much like the number 5 or the string "hello" are defined entirely by their value, not by an identity.
The key characteristics that make value objects so useful are:
- Immutability: Once created, they cannot be changed, which eliminates bugs related to unexpected mutations
- Value equality: Two value objects with the same attributes are considered equal, regardless of whether they are the same instance in memory
- Self-validation: They validate their own data and can never exist in an invalid state
- Domain-specific behavior: They can contain methods that implement domain rules related to the concept they represent
- Self-documenting code: The code clearly expresses what each value represents
Value objects have deep roots in Domain-Driven Design and functional programming principles. Their implementation in C# has evolved over the years – from verbose manual code for equality comparisons to modern approaches using Roslyn Source Generators that minimize boilerplate while maximizing type safety.
The library Thinktecture.Runtime.Extensions provides a practical way to implement value objects in C# with minimal boilerplate.
When defining value objects, you can choose between class
and struct
. The choice impacts the object's semantics, especially concerning default values and nullability.
-
Classes have reference semantics, meaning variables hold references to objects. The default value for a class variable is
null
. This might be semantically inappropriate for concepts wherenull
doesn't make sense, such as a numericAmount
where0
is the natural default or invalid state, not the absence of a value. Representing such concepts as a class could lead to unexpectedNullReferenceException
s if not handled carefully. -
Structs have value semantics, i.e. variables directly contain the struct's data. Crucially, structs cannot be
null
(unless explicitly declared as nullable, e.g.,Amount?
). Their default value corresponds to the default of their members (e.g.,0
for numeric types,false
for booleans). For many value objects, especially those wrapping primitives like numbers or dates, this default value (like0
for anAmount
) often represents a valid, meaningful state (like zero amount).
One of the simplest examples of value objects are typed identifiers. In previous section the customerId
is an integer, the implementation of the value object could be a struct
without any validation.
[ValueObject<int>]
public partial struct CustomerId;
The type CustomerId
is partial
and is marked with the ValueObjectAttribute<int>
to let the library Thinktecture.Runtime.Extensions generate necessary code via Roslyn Source Generator. The generated code implements basic features like equality comparison, GetHashCode
, ToString
, etc..
The generic parameter, like int
in ValueObject<int>
is the "underlying type" of the wrapper, and will be called "key type" or "key member type" in this and following articles.
Unless configured otherwise, there are a few ways to create an instance from a key member type.
// Using a factory method; throws if invalid
var customerId = CustomerId.Create(1001);
// Using an explicit cast (calls the factory method)
var customerId = (CustomerId)1001;
// Exception-free alternative
if (CustomerId.TryCreate(1001, out var customerId))
{
}
// Exception-free with access to the validation error
if (CustomerId.TryCreate(1001, out var customerId, out var error))
{
}
📝 In real-world applications, value objects are rarely created manually in application code. Instead, they're typically instantiated by frameworks such as ASP.NET Core, Entity Framework Core, or (de)serializers when data crosses process boundaries.
Usually, a value object has some kind of validation. Let's redesign our payment example using value objects:
// 📝 Represents a monetary amount; often combined with a Currency (e.g., EUR).
[ValueObject<decimal>]
public partial struct Amount
{
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref decimal value)
{
if (value <= 0)
validationError = new ValidationError("Amount must be positive");
}
}
The Amount
is a value object with decimal
as its key type. Besides basic features mentioned above, the library generates a partial
method ValidateFactoryArguments
. As the name suggests, this method is called by the factory method. If implemented, developers can set the validationError
to prevent the construction of an instance.
The API design using a ref ValidationError?
parameter instead of returning an error may seem odd, but this approach allows the C# compiler to eliminate the method and all its calls if the method is not implemented.
The ReferenceCode
has more advanced implementation. The method ValidateFactoryArguments
not just validates but normalizes the provided string
. The normalized value
is passed back via ref
to the caller (factory method) to create an instance. Furthermore, the ReferenceCode
provides domain-specific behavior like IsInvoice()
.
// 📝 The key members are not nullable by design
[ValueObject<string>]
public partial class ReferenceCode
{
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref string value)
{
if (string.IsNullOrWhiteSpace(value))
{
validationError = new ValidationError("Reference code cannot be empty");
return;
}
value = value.Trim().ToUpper(); // normalizes the value
}
// Domain-specific behavior
public bool IsInvoice()
{
// ⚠️ Not the best way to model "reference kind"
// but simple enought to get the idea how this works.
return _value.StartsWith("INV-");
}
}
The implementation of AccountNumber
is very similar to ReferenceCode
but contains digits only.
Last but not least, we keep the Payment
as a record
. The payment don't need further validation because all of its parts are self-validating.
public record Payment(
Amount Amount,
AccountNumber Account,
ReferenceCode Reference);
Now let's see how to use these value objects together:
// All validation happens during value object creation
var referenceCode = ReferenceCode.Create("INV-2025-001");
if(referenceCode.IsInvoice())
{
var amount = Amount.Create(50.00m);
var accountNumber = AccountNumber.Create("123456789");
var payment = new Payment(amount, accountNumber, referenceCode);
}
The following examples would fail validation.
// 📝 Use TryCreate for exception-free creation
var payment = new Payment(
Amount.Create(-50.00m), // Throws: Amount must be positive
AccountNumber.Create("ABC123"), // Throws: Account number must contain only digits
ReferenceCode.Create("")); // Throws: Reference code cannot be empty
// Type safety prevents parameter mixups
var payment = new Payment(
AccountNumber.Create("123456789"), // Compiler Error
Amount.Create(50.00m), // Compiler Error
ReferenceCode.Create("INV-2023-001"));
This redesign offers previously mentioned benefits, like type safety, centralized validation, immutability and domain-specific behavior by decorating the types with [ValueObject<T>]
and implementing ValidateFactoryArguments
.
Besides basic features like equality comparison, the library automatically provides several useful features based on their key member. The most features can be enable, disabled or modified, e.g. the type conversion can be implicit
, explicit
or disabled completely.
Type Conversion Operators: Implicit or explicit conversion to and from the key member type.
// Explicit conversion from key member type
var amount = (Amount)50.00m;
// Implicit conversion to key member type
decimal amountValue = amount;
Parsing: If the key member is IParsable
(like decimal
, int
, Guid
, DateTime
, etc.), the value object gets Parse
and TryParse
methods.
bool success = Amount.TryParse("50.00", CultureInfo.InvariantCulture, out var parsedAmount);
// Parse throws if parsing fails
var amountFromString = Amount.Parse("invalid");
Comparison Operators: For key members that support comparison (like numbers or dates), operators like ==
, !=
, >
, <
, >=
, <=
are generated.
var amount1 = (Amount)50m;
var amount2 = (Amount)100m;
var isSmaller = amount1 < amount2;
Struct Defaults: If you define a value object as a struct
and set AllowDefaultStructs = true
in the attribute, a static property named Empty
(or a custom name via DefaultInstancePropertyName
) is generated, representing the default value (e.g., 0
for numbers).
// Assuming Amount allows 0
// and is a struct with [ValueObject<decimal>(AllowDefaultStructs = true)]
var zeroAmount = Amount.Empty; // 0.00
var defaultAmount = default(Amount); // Equivalent to Amount.Empty
If the AllowDefaultStructs
is not set to true
then a Roslyn Analyzer emits a compiler error when trying to create uninitialized instance of a value object.
Roslyn analyzers are a feature of the .NET Compiler Platform (Roslyn) that allow the developers to analyze C# (and Visual Basic) code in real-time, identifying potential issues like style, quality, or maintainability problems, and providing suggestions or code fixes.
While it's possible to implement value objects manually, it's crucial to understand the significant boilerplate and potential pitfalls involved. Consider the following example of a partially manually implemented EmailAddress
:
public class EmailAddress : IEquatable<EmailAddress>
{
private readonly string _value;
// Private constructor ensures creation via factory method
private EmailAddress(string value) { _value = value; }
// Factory method with validation
public static EmailAddress Create(string value) { /* ... */ }
// Equality comparison
public bool Equals(EmailAddress? other) { /* ... */ }
public override bool Equals(object? obj) { /* ... */ }
public static bool operator ==(EmailAddress? left, EmailAddress? right) { /* ... */ }
public static bool operator !=(EmailAddress? left, EmailAddress? right) { /* ... */ }
// hash code
public override int GetHashCode() { /* ... */ }
// string representation
public override string ToString() { /* ... */ }
// Depending on the key member type and requirements:
// conversion and arithmethmetic operators, IFormattable, IParsable, IComparable, etc.
}
Besides these basic features the value objects usually require:
- Serialization support: JSON serialization support is often a MUST
- Framework support: Interoperability with ASP.NET, EF Core, etc.
This manual implementation gives complete control but comes at the cost of significant boilerplate, increased risk of bugs, and higher maintenance overhead.
Using the library Thinktecture.Runtime.Extensions significantly reduces this boilerplate.
[ValueObject<string>]
public partial class EmailAddress
{
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref string value)
{
/* ... */
}
}
C# records are sometimes suggested as an alternative to custom value objects. Let's compare the approaches:
// Using C# record
public record AmountRecord
{
public decimal Value { get; }
public AmountRecord(decimal value)
{
if (value <= 0)
throw new ArgumentException("Amount must be positive");
Value = value;
}
}
// Using value object
[ValueObject<decimal>]
public partial struct Amount
{
static partial void ValidateFactoryArguments(
ref ValidationError? validationError,
ref decimal value)
{
if (value <= 0)
validationError = new ValidationError("Amount must be positive");
}
}
Both, records and value objects provide value equality out of the box, which is a significant advantage over regular classes. However, there are important differences:
- Validation: Records require validation in constructors, while value objects provide a standardized validation pattern. Validation in constructors have no other choice than throwing an exception.
- Factory methods: Value objects encourage the use of factory methods for creation, which enables more flexible validation and error handling.
- Framework integration: The library provides integration with JSON serialization, Entity Framework Core, and ASP.NET Core for value objects. Records are treated as regular classes (which they are) leading to undesirable outcome.
- Additional behaviors: Both records and value objects can implement domain-specific behavior, but value objects provide automatic generation of many common behaviors (like parsing, formatting or arithmetic operations).
Records are an excellent tool, and in some cases, they may be sufficient, as demonstrated in one of the previous sections with the Payment
example. However, value objects provide a more comprehensive solution for domains with complex validation and behavior requirements.
When evaluating your codebase for value object opportunities, look for these indicators:
- Primitive types with validation: Any primitive that needs validation is a prime candidate (emails, phone numbers, URLs)
- Data with format requirements: Values that must follow specific formats (ISBN, credit card numbers)
- Duplicated validation: The same validation logic appearing in multiple places
-
Parameters of the same type: Methods that take multiple parameters of the same type (e.g.,
ProcessOrder(int customerId, int orderId)
) - Magic strings and numbers: Constants or special values used throughout the code (status codes, country codes)
Common domain concepts that make ideal value object candidates:
- IDs and Reference Codes: Often represented as strings or integers but have specific formats and validation rules (CustomerId, OrderNumber, CountryCode)
- Money and Quantities: Require validation and often specific operations (Money, Percentage, Quantity)
- Contact Information: Have specific validation requirements and formats (EmailAddress, PhoneNumber, PostalAddress)
Implementing these categories of value objects can immediately improve a codebase's safety and readability with minimal effort.
Value objects solve the primitive obsession anti-pattern by providing type-safe, self-validating alternatives to primitive types. They offer multiple benefits, including preventing parameter mixups through type safety, ensuring data consistency via centralized validation, preventing unintended modifications due to immutability, making code more readable through self-documentation, and encapsulating business rules with domain-specific behavior.
But what happens when a single value isn't enough? How do we handle concepts that are inherently defined by multiple related properties, like date ranges, geographic coordinates, or money with currency? In the next article, we'll dive into the world of complex value objects – a powerful technique for handling related data that tends to become inconsistent. You'll discover how to create objects that enforce relationships between their components, eliminate an entire class of validation bugs, and build a richer domain model through composition.