Skip to content
Katabench
Try free
7 min read The Katabench team Updated

Primitive obsession: the bug the compiler was begging to catch

When everything is a string, a decimal, or a Guid, the type system can't tell your arguments apart. Value objects in modern C#, the bugs they delete, and when they're overkill.

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.

A value object earns its name when it removes an invalid or ambiguous state from the rest of the program. A wrapper with no rule and no safety benefit is only ceremony.

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.

Keep parsing at the boundary

Raw primitives still exist at the edges of the system. JSON sends a string, route values arrive as text, and database columns store provider types. Convert them once where the boundary already has a way to report failure. For user input, a non-throwing factory often produces a cleaner API:

public static bool TryCreate(string? input, out EmailAddress? email)
{
    var candidate = input?.Trim();
    if (candidate is null or { Length: 0 or > 254 }
        || candidate.Count(c => c == '@') != 1)
    {
        email = null;
        return false;
    }

    email = new EmailAddress(candidate);
    return true;
}

An ASP.NET Core endpoint can turn false into a validation response. Everything behind that endpoint accepts EmailAddress, not a string plus a recurring obligation to check it. For IDs, bind or deserialize directly to the wrapper so the application layer never sees an untyped Guid.

Persistence is explicit too. EF Core can map a single-value wrapper to its underlying column with a value conversion:

modelBuilder.Entity<Customer>()
    .Property(customer => customer.Email)
    .HasConversion(
        email => email.Value,
        value => EmailAddress.Create(value));

The EF Core value-conversion documentation shows the same approach for simple value objects and calls out its limits. A converter maps one model property to one database column; a composite Money object may be better represented as an owned or complex type when amount and currency need separate, queryable columns. Do not serialize a rich value into an opaque string merely to preserve the aesthetic of one property.

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.

Also count the integration cost. Public JSON contracts, model binding, serializers, database migrations, and logging all need a deliberate representation. Introduce wrappers first where the bug risk is high and the boundary is controlled, rather than converting hundreds of properties in one sweeping refactor. The best value object makes call sites more obvious; if every caller is buried under .Value and custom adapters, revisit the boundary instead of blaming the type.

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 free C# SOLID principles exercises include two value-object katas where behavior must stay green while structural grading rejects public setters, writable fields, mutable collection leaks, and unsealed types. "I froze it" becomes a deterministic verdict instead of a feeling. 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.