Katabench
Start free
← All posts

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

Same call site, same swap. The only difference is whether the compiler can see it.

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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.