Primitive obsession: the bug the compiler was begging to catch
Here's a method call that compiles cleanly, passes review, and refunds the wrong customer:
public Task RefundAsync(Guid customerId, Guid orderId, decimal amount) { ... }
// At the call site, six months later:
await RefundAsync(order.Id, customer.Id, refundTotal);
The arguments are swapped. The compiler, whose entire job is catching category errors like this,
says nothing, because as far as it can see you passed a Guid where a Guid goes. Both IDs are
the same type, so there is no wrong order. The type system would have caught this instantly if it
had been given anything to work with. It wasn't.
That's primitive obsession: modeling every concept in the domain as string, int, decimal,
Guid, and letting the real distinctions (this string is an email, this decimal is money,
this Guid identifies an order and no other kind of thing) live only in variable names and hope.
The primitive signature
Task RefundAsync(Guid customerId, Guid orderId, decimal amount)
await RefundAsync(order.Id, customer.Id, refundTotal);
✗ compiles, ships, refunds the wrong customer
The value object signature
Task RefundAsync(CustomerId customerId, OrderId orderId, Money amount)
await RefundAsync(order.Id, customer.Id, refundTotal);
✓ error CS1503: cannot convert from 'OrderId' to 'CustomerId'
caught before review, before tests, before production
The tax you're already paying
The swapped-argument bug is the headline, but the everyday cost is quieter and larger: when a
concept has no type, its rules have no home. Take an email address held as a string. Somewhere
it must be validated. Where? Everywhere, it turns out:
public class RegistrationService
{
public void Register(string email)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
throw new ArgumentException("Invalid email", nameof(email));
// ...
}
}
public class InvoiceMailer
{
public void Send(string email)
{
// does no validation; trusts whoever called it. Should it?
}
}
Every method that receives the string faces the same dilemma: re-validate (and duplicate the
rule, slightly differently each time) or trust the caller (and hope). The rule "an email address
is a non-empty string containing exactly one @" exists in four places, three of which disagree,
because a string can't carry its own invariants. Neither can the decimal that's sometimes
euros and sometimes a percentage, nor the string that's a country code except in the one code
path where it's a country name.
The fix: give the concept a type
A value object is a small type whose identity is its value, which validates itself at creation, and which is immutable afterward. Modern C# has made these almost free to write. A record for the email:
public sealed record EmailAddress
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static EmailAddress Create(string input)
{
var trimmed = input?.Trim()
?? throw new ArgumentNullException(nameof(input));
if (trimmed.Length is 0 or > 254 || trimmed.Count(c => c == '@') != 1)
throw new ArgumentException("Not a valid email address.", nameof(input));
return new EmailAddress(trimmed);
}
public override string ToString() => Value;
}
And a pair of one-line ID types that make the refund bug unrepresentable:
public readonly record struct CustomerId(Guid Value);
public readonly record struct OrderId(Guid Value);
public Task RefundAsync(CustomerId customerId, OrderId orderId, Money amount) { ... }
await RefundAsync(order.Id, customer.Id, refundTotal);
// error CS1503: cannot convert from 'OrderId' to 'CustomerId'
The swap is now a compile error. Not a code review catch, not a unit test, not a production incident with an apology email: a red squiggle, at the moment of writing, forever, for every developer who ever touches this signature.
The deeper shift is what the EmailAddress type does to the rest of the codebase. Validation
happens once, at the boundary where raw input enters, and from then on the type is the
proof. A method receiving an EmailAddress doesn't re-check it; an invalid EmailAddress cannot
exist, by construction. All those defensive re-validations and their subtle disagreements simply
evaporate, and "is this string safe here?" stops being a question anyone asks. (If you use EF
Core, a one-line HasConversion maps a wrapper like this to its underlying column, so persistence
is not the obstacle it used to be.)
Where the line is
Like every good idea, this one gets cargo-culted, so it's worth saying where it stops. Wrapping is
for values that have rules or can be confused. An email, an amount of money with its currency,
a quantity that must be positive, the four different Guids floating through one method signature:
wrap them, they're pulling real weight. A truly free-form string (a comment body, a display name
with no invariants) gains nothing from becoming CommentBody except a file and a hop; leave it
alone until it grows a rule. The test is simple: if there's no invariant to protect and no
same-typed neighbor to confuse it with, it's not obsession, it's just a string.
The judgment, as usual, is the skill. Knowing the pattern is a blog post; knowing which primitive in a working codebase is quietly costing you, and extracting it without breaking behavior, is practice.
Making the invisible cost visible
Primitive obsession survives because nothing fails when you commit it. The code with four duplicated email checks and three confusable Guids is green. Its cost is structural: it shows up as the next bug, the next duplicated rule, the slow accumulation of "be careful here" knowledge that lives in heads instead of types.
Structure is exactly the axis most practice never grades and Katabench does. The refactoring track hands you working code with these smells baked in and grades your cleanup on two gates at once: the behavioral tests must stay green while Roslyn re-measures the structure against thresholds, so "I made it better" becomes a number instead of a feeling. And the architecture katas check design rules like required abstractions on every submission, the same way a strict reviewer would, in seconds. Both tracks are on Pro. The compiler has been offering to catch these bugs for years; the reps teach you to take it up on that.