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.
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.
The three pains primitive obsession brings
| Pain | What goes wrong | Real example |
|---|---|---|
| Mixed-up arguments | Same type swaps silently | Passing productId as customerId |
| Scattered validation | The same check repeated everywhere | "Is this email valid?" written in 12 places |
| No shared meaning | The type says nothing about rules | An 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:
- 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.
- It can only ever hold a valid value. You write the rules once, inside the value object. After that, if you have an
Emailobject in your hands, you can trust it is a real email. No need to re-check.
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. Therecordpart gives us value equality for free, so twoEmailobjects with the same text are equal. Thestructpart 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 theFrommethod, and that door checks the input. You simply cannot make an invalidEmail. - 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, orstring.
How a value-object source generator works
Steps
You declare
Add [ValueObject] on a small partial type
Build runs
Roslyn calls the source generator
Generator expands
Writes From, equality, validation, ToString
Safe type ready
You use it like a normal type
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 CustomerIdThe 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".
Choosing between the tools
Both tools are free and open source, and both write boilerplate at build time. The difference is mostly in focus.
| Question | StronglyTypedId | Vogen |
|---|---|---|
| Main purpose | Safe entity IDs | Full value objects |
| Built-in validation | Minimal | Rich (Validate method) |
| Normalisation | No | Yes (NormalizeInput) |
| Good for | OrderId, UserId | Email, Age, Money |
| Wraps | Guid, int, long, string | Any 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
Steps
Is it an ID?
OrderId, UserId, etc.
Needs rules?
Validation, normalising, ranges
StronglyTypedId
Best for plain safe IDs
Vogen
Best for Email, Money, Age
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
uniqueidentifierorint, 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, anddecimalfor 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
- Vogen — Source-generated Value Objects (Steve Dunn)
- StronglyTypedId — Roslyn-powered generator (Andrew Lock)
- An introduction to strongly-typed entity IDs — Andrew Lock
- Value Objects: Solving Primitive Obsession in .NET — Thinktecture
- Primitive Obsession — Steve Dunn
- Modern C# Techniques: Value Records — Stephen Cleary
Related Posts
Clean Architecture Folder Structure in .NET: A Simple Guide
Learn how to organise a .NET solution with Clean Architecture. Understand the Domain, Application, Infrastructure, and Presentation layers, the dependency rule, and the exact folders, with diagrams and examples.
Vertical Slice Architecture in .NET: The Easy Guide
Learn Vertical Slice Architecture in .NET in simple words. Organise code by feature instead of by layer, with diagrams, real examples, a comparison with Clean Architecture, and when to use each.
What Is a Modular Monolith? A Beginner-Friendly Guide for .NET
Understand the modular monolith in simple words: one app, strong internal walls. Learn how it compares to monoliths and microservices, why it is the 2026 default for most .NET teams, and how to build one.
Refactoring Overgrown Bounded Contexts in Modular Monoliths (.NET)
Learn how to spot and split an overgrown bounded context in a .NET modular monolith using safe, step-by-step refactoring, with diagrams, tables and code.
8 Tips to Write Clean Code in C# and .NET
Learn 8 simple, beginner-friendly tips to write clean C# and .NET code with clear names, small methods, good error handling, and easy-to-read structure.
SOLID Principles in C# and .NET: A Beginner-Friendly Guide
Learn the 5 SOLID principles in C# and .NET with simple words, real-life examples, diagrams, and clean code you can copy and try yourself today.