Skip to main content
SEMastery
Architectureintermediate

A Modern Way to Create Value Objects to Solve Primitive Obsession in .NET

Learn how value objects fix primitive obsession in .NET. A simple, friendly guide using record structs, Vogen, and StronglyTypedId, with diagrams and examples.

12 min readUpdated October 13, 2025

A label on every jar

Picture your grandmother's kitchen. On one shelf sit many glass jars. They all look the same. One holds sugar, one holds salt, one holds chilli powder, and one holds plain flour.

Now imagine none of them have labels. They are all just "white powder in a jar". One day you grab a jar to make sweet kheer and pour in two spoons of what you think is sugar. It was salt. The whole dish is ruined.

The jars are fine. The problem is that they all look the same, so it is easy to mix them up.

This is exactly what happens in code when we use plain int, string, and Guid for everything. A customer ID is an int. An order ID is an int. An age is an int. They all look the same to the compiler, so it lets you swap them by mistake. A value object is like sticking a clear label on the jar. Now the jar of salt cannot be confused with the jar of sugar, and the compiler will stop you if you try.

Let us learn how to put labels on our data in .NET, the modern way.

What is primitive obsession?

Primitive obsession is a fancy name for a simple bad habit. It means we lean too hard on basic types like string, int, Guid, and decimal to represent real ideas in our app.

Look at this method. It seems normal at first.

public void CreateOrder(Guid customerId, Guid productId, int quantity, decimal price)
{
    // ... save the order
}

Every argument is a primitive. Now look at how easy it is to call it the wrong way:

// The compiler is perfectly happy with this. But it is WRONG.
CreateOrder(productId, customerId, price, quantity);
//          ^ swapped   ^ swapped   ^ swapped (decimal/int auto-convert)

We passed the product ID where the customer ID was expected, and the price where the quantity was expected. The code compiles. It runs. And it quietly corrupts your data. Nobody warned us, because to the compiler a Guid is a Guid and an int is an int. The jars all looked the same.

Figure 1: With raw primitives, the compiler cannot tell a CustomerId from a ProductId. Wrong values flow straight through.

The three pains primitive obsession brings

PainWhat goes wrongReal example
Mixed-up argumentsSame type swaps silentlyPassing productId as customerId
Scattered validationThe same check repeated everywhere"Is this email valid?" written in 12 places
No shared meaningThe type says nothing about rulesAn int age happily holds -5 or 900

The deepest pain is the last one. A primitive carries no rules. An int that means "age" will gladly store -5, 0, or 9999. A string that means "email" will gladly store "hello" with no @ in it. The type cannot protect you, so you must remember to check everywhere, and one day someone forgets.

What is a value object?

A value object is a small, focused type that wraps a primitive and gives it a name and rules.

Two ideas make a value object special:

  1. It is identified by its value, not by a reference. Two value objects with the same inside value are considered equal. This is just like money. Two ten-rupee notes are worth the same; you do not care which note you hold.
  2. It can only ever hold a valid value. You write the rules once, inside the value object. After that, if you have an Email object in your hands, you can trust it is a real email. No need to re-check.
Figure 2: A value object is a guarded box. Bad input is rejected at the door, so everything inside the app is already valid.

The old way, by hand

Before the modern tools, people wrote value objects fully by hand. It works, but it is a lot of typing. Here is an Email written the long way.

public readonly record struct Email
{
    public string Value { get; }
 
    private Email(string value) => Value = value;
 
    public static Email From(string input)
    {
        if (string.IsNullOrWhiteSpace(input) || !input.Contains('@'))
            throw new ArgumentException("Not a valid email.", nameof(input));
 
        return new Email(input.Trim().ToLowerInvariant());
    }
 
    public override string ToString() => Value;
}

Notice a few good choices here:

  • We use a readonly record struct. The record part gives us value equality for free, so two Email objects with the same text are equal. The struct part means it is a value type that lives on the stack, so we avoid extra heap allocations and stay fast.
  • The constructor is private. The only door in is the From method, and that door checks the input. You simply cannot make an invalid Email.
  • We normalise the value (trim spaces, lowercase it) so that " [email protected] " and "[email protected]" become the same email.

This is a fine pattern. The only problem is that you must write it again, and again, for every concept: CustomerId, Age, Money, PhoneNumber. That is a mountain of repeated code, and repeated code is where bugs hide.

The modern way: let a generator write it

Here is the happy part. You no longer have to type all that boilerplate. Source generators can write it for you at build time. You describe what you want, and the generator produces the safe, fast code automatically.

Two free, popular libraries lead the way in .NET:

  • Vogen by Steve Dunn — for full value objects with validation and normalisation.
  • StronglyTypedId by Andrew Lock — focused on safe entity IDs that wrap a Guid, int, or string.

How a value-object source generator works

You declare
Build runs
Generator expands
Safe type ready

Steps

1

You declare

Add [ValueObject] on a small partial type

2

Build runs

Roslyn calls the source generator

3

Generator expands

Writes From, equality, validation, ToString

4

Safe type ready

You use it like a normal type

You write a tiny declaration. The generator expands it into a full, validated type during the build.

Using Vogen

With Vogen, that whole hand-written Email shrinks to this:

[ValueObject<string>]
public readonly partial struct Email
{
    // You only write the rules. Vogen writes everything else.
    private static Validation Validate(string input) =>
        input.Contains('@')
            ? Validation.Ok
            : Validation.Invalid("Not a valid email.");
 
    // Optional: clean up the value before storing it
    private static string NormalizeInput(string input) =>
        input.Trim().ToLowerInvariant();
}

You write only the Validate method (and an optional NormalizeInput). Vogen generates the constructor, the From method, value equality, ToString, and more. To use it:

var email = Email.From("  [email protected] ");
Console.WriteLine(email);          // [email protected]  (trimmed + lowercased)
 
// This throws at runtime, as it should:
var bad = Email.From("oops-no-at-sign");

Even better, Vogen ships an analyzer that adds new compile-time errors. If you try new Email() or default(Email) to sneak past the validation, Vogen flags it as a build error. The jar simply cannot be filled the wrong way.

Using StronglyTypedId for IDs

When your need is "I just want IDs that cannot be mixed up", StronglyTypedId is the lighter choice:

[StronglyTypedId(Template.Guid)]
public partial struct CustomerId { }
 
[StronglyTypedId(Template.Guid)]
public partial struct ProductId { }

Now go back to our broken method. With strong IDs, the bug from the very start of this article becomes impossible:

public void CreateOrder(CustomerId customerId, ProductId productId, Quantity quantity)
{
    // ...
}
 
// This now FAILS to compile. The jars are labelled.
CreateOrder(productId, customerId, quantity);
//          ^ error: ProductId is not a CustomerId

The compiler catches the swap before the program even runs. That is the whole point. We moved the mistake from "found in production at 2 AM" to "found while typing".

Figure 3: A swapped argument is now a red compile error, not a silent runtime bug.

Choosing between the tools

Both tools are free and open source, and both write boilerplate at build time. The difference is mostly in focus.

QuestionStronglyTypedIdVogen
Main purposeSafe entity IDsFull value objects
Built-in validationMinimalRich (Validate method)
NormalisationNoYes (NormalizeInput)
Good forOrderId, UserIdEmail, Age, Money
WrapsGuid, int, long, stringAny primitive

A common, healthy setup uses both: StronglyTypedId for the many entity IDs, and Vogen for the handful of rich domain types that need real rules. They live together happily in the same project.

Picking the right tool

Is it an ID?
Needs rules?
StronglyTypedId
Vogen

Steps

1

Is it an ID?

OrderId, UserId, etc.

2

Needs rules?

Validation, normalising, ranges

3

StronglyTypedId

Best for plain safe IDs

4

Vogen

Best for Email, Money, Age

Start from what the value means. IDs lean towards StronglyTypedId; rule-rich concepts lean towards Vogen.

Working with databases and APIs

A fair worry: "If my CustomerId is no longer a plain Guid, will Entity Framework Core and JSON still work?"

Yes, and the tools help here too. Both Vogen and StronglyTypedId can generate converters for you:

  • EF Core value converters, so the database column stays a normal uniqueidentifier or int, while your C# code sees the strong type.
  • System.Text.Json converters, so the JSON over the wire stays a plain string or number, while your code stays safe.
// Vogen example: ask it to also generate the common converters
[ValueObject<Guid>(conversions: Conversions.EfCoreValueConverter | Conversions.SystemTextJson)]
public readonly partial struct CustomerId { }

So the outside world (the database, the API client) keeps seeing simple primitives. Only your domain code enjoys the labelled, safe types. Nobody outside has to change.

A quick word on what is new

These patterns keep getting easier in modern .NET. With .NET 10 as the current LTS release and C# 14 shipped, readonly record struct is the comfortable default for hand-rolled value objects: clear, fast, and value-equal out of the box. Looking ahead, C# 15 (in .NET 11 preview) is bringing union types, which will make "a result that is either a valid value object or a clear error" even tidier to express. The core idea, though, is timeless: give your data a name and rules.

One more honest note for teams: some popular libraries in this space, like MediatR and MassTransit, have moved to commercial licensing. The value-object tools we used here, Vogen and StronglyTypedId, remain free and open source at the time of writing, but it is always wise to check a library's licence before you build on it.

A small before-and-after

Let us see the whole idea in one frame. Before, a function spoke in jars with no labels:

// BEFORE: everything is a primitive
decimal Transfer(Guid from, Guid to, decimal amount);

After, it speaks in clearly labelled, self-checking types:

// AFTER: every concept has a name and its own rules
Money Transfer(AccountId from, AccountId to, Money amount);

The second version reads almost like an English sentence. You cannot pass an amount where an account belongs. You cannot create a negative Money. And you wrote those rules once, inside the value objects, instead of scattering checks all over the codebase.

Quick recap

  • Primitive obsession is using plain int, string, Guid, and decimal for real ideas. The types all look the same, so mistakes slip through, like grabbing the salt jar instead of the sugar jar.
  • A value object is a small type that wraps a primitive and gives it a name and rules. If you hold one, you can trust it is valid.
  • A readonly record struct is the modern hand-written base: value equality for free, and fast because it avoids heap allocations.
  • Source generators remove the boilerplate. Vogen is great for rich value objects with validation and normalisation. StronglyTypedId is great for safe entity IDs.
  • The biggest win: swapped arguments and invalid values become compile-time errors, not 2 AM production bugs.
  • The tools also generate EF Core and JSON converters, so your database and APIs still see plain primitives.
  • Validate once, at the door. After that, every value object in your domain is already valid.

References and further reading

Related Posts