Skip to content

Value Objects Solving Primitive Obsession in NET

Pawel Gerr edited this page Jul 5, 2025 · 1 revision

Value Objects: Solving Primitive Obsession in .NET

Article series

  1. Value Objects: Solving Primitive Obsession in .NET ⬅
  2. Handling Complexity: Introducing Complex Value Objects in .NET

Introduction

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.

Providing solutions via a .NET library

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.

⚠️️ Value Objects (along with Smart Enums and Discriminated Unions) are powerful tools in our development arsenal, but they should be applied judiciously. As with any tool, the decision of where and when to use value objects remains firmly in the hands of developers. Not every 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.

Primitive Obsession in Practice

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.

What Are Value Objects?

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.

Solving the Problem with Value Objects

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 where null doesn't make sense, such as a numeric Amount where 0 is the natural default or invalid state, not the absence of a value. Representing such concepts as a class could lead to unexpected NullReferenceExceptions 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 (like 0 for an Amount) often represents a valid, meaningful state (like zero amount).

Basic Implementation

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.

Generated Functionality Highlights

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 analyzer forbids default values

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.

The Challenges of Manual Implementation

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)
    {
        /* ... */
    }
}

Comparing Value Objects with C# Records

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.

Identifying Value Object Candidates in Your Code

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.

Summary

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.

Clone this wiki locally