Mert Özen Further With Every Line
Software Design Clean Code — #06

Objects or Data Structures? Records, DTOs and Primitive Obsession

Mert Özen Nov 7, 2026 16 min 1 views
Objects or Data Structures? Records, DTOs and Primitive Obsession

An object hides its data and exposes behavior, a data structure exposes data and carries no behavior, and the hybrid in between is the most expensive of all. Use records for DTOs, encapsulation for the domain and value objects such as Money and Email for concepts carried in strings and decimals; we also give a short introduction to the Law of Demeter.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First: Two VIP Customers, Two Different Prices

We add two new VIP (Very Important Person) customers to the series' legacy OrderService, and both live in Istanbul. Then we ask for a quote for the same cart for each of them: a single copy of Emma, 180 TRY (the international currency code of the Turkish lira).

// Signature: CreateCustomer(string name, string email, string street, string city,
//                           string zip, string country, string phone, string type, bool newsletter)
int linus = service.CreateCustomer("Linus Torvalds", "linus@example.com", "Istanbul", "Bagdat Cd. 7",
    "34710", "Turkey", "5550000000", "VIP", false);
int barbara = service.CreateCustomer("Barbara Liskov", "barbara@example.com", "Bagdat Cd. 9", "Istanbul",
    "34710", "Turkey", "5550000001", "VIP", false);

var cart = new Dictionary<string, int> { ["978-0141439518"] = 1 };   // Emma, 180 TRY
Console.WriteLine($"Linus:   {service.GetQuote(linus, cart, null, 1)}");
Console.WriteLine($"Barbara: {service.GetQuote(barbara, cart, null, 1)}");

Same book, same customer type, same city. Do both lines print the same number? Does the code compile, and if it does, does the compiler say anything?

Objects and Data Structures: Two Opposite Things

The output:

Linus:   228.00
Barbara: 218.00

The code compiles without a warning and Linus pays ten lira more. The cause is in the first call: street and city have swapped places. Linus's city is now "Bagdat Cd. 7", so the city == "Istanbul" discount inside KargoUcretiHesapla never applies to him: a 171 TRY subtotal, 188.10 with 10% tax, plus 39.90 shipping. Barbara's shipping is 29.90. Seven of the nine parameters are string; to the compiler, a street, a city, a phone number and a customer type are the same thing. Both topics of this post come from here: how we model data, and how much the types carrying that data actually say.

In the objects and data structures chapter of Clean Code (2008; second edition 2025), Robert C. Martin (Uncle Bob) draws a sharp line between the two concepts. An object hides its data behind an abstraction and exposes behavior; you tell it what to do. A data structure exposes its data and has no meaningful behavior; the code that reads it decides what to do. Both are legitimate. The problem is not knowing which one you are writing.

The Order in the legacy code is a pure data structure:

public class Order
{
    public int Id;
    public Customer Customer;
    public DateTime Date;
    public int Status; // 0 new, 1 paid, 2 confirmed, 3 shipped, 4 cancelled, 9 payment failed
    public List<OrderItem> Items;
    public decimal Discount;
    public decimal Shipping;
    public decimal Total;
    public string PaymentRef;
    public string Note;
}

No methods, every field public. The discount, tax, shipping and Total are computed by the 600-line OrderService. That alone is not a mistake; a DTO (Data Transfer Object) should look exactly like this. The mistake is that this structure sits at the heart of the domain: the only thing guaranteeing that Total is consistent with Items is everybody calling OrderService in the right order.

Anti-Symmetry: Is a New Type Cheap, or a New Operation?

What Martin calls "data/object anti-symmetry" is the fact that the two approaches react to change in exactly opposite ways. Let's first turn the legacy int t shipping type into an enum and procedural functions:

public enum ShippingMethod
{
    Standard,
    Express,
    StorePickup,
    Locker
}

// Data structure + procedural functions
public static class ShippingRules
{
    public static decimal Fee(ShippingMethod method, decimal orderAmount) => method switch
    {
        ShippingMethod.Standard => orderAmount >= 500m ? 0m : 39.90m,
        ShippingMethod.Express => 89.90m,
        ShippingMethod.StorePickup => 0m,
        _ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown shipping method.")
    };

    public static int EstimatedDays(ShippingMethod method) => method switch
    {
        ShippingMethod.Standard => 3,
        ShippingMethod.Express => 1,
        ShippingMethod.StorePickup => 2,
        _ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown shipping method.")
    };
}

In this design, adding a new operation is cheap: you write CarbonFootprint(ShippingMethod) and touch nothing that exists. Adding a new type is expensive: I added Locker to the enum but forgot to update the switches. The build was clean and the failure arrived at run time:

Standard: 39.90
Express: 89.90
StorePickup: 0
Locker: Unknown shipping method. (Parameter 'method')
Actual value was Locker.

Why was the compiler silent? Because the discard (_) arm catches everything. Remove the discard and C# warns with CS8524 (a C# compiler warning code) about "unnamed enum values", since (ShippingMethod)42 is also assignable to an enum; most teams put the discard back to silence that warning and lose the protection they were getting for free. The alternative is to turn off only CS8524 and write the switches without a discard: after adding <NoWarn>$(NoWarn);CS8524</NoWarn> to the project file and deleting the _ arms, the compiler points at every incomplete switch the moment a new enum value appears:

ShippingRules.cs(13,85): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Shipping.ShippingMethod.Locker' is not covered.
ShippingRules.cs(20,70): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'Bookstore.Shipping.ShippingMethod.Locker' is not covered.

Let's solve the same problem with objects:

// Objects: the behavior lives inside the type
public interface IShippingOption
{
    string Name { get; }
    int EstimatedDays { get; }
    decimal Fee(decimal orderAmount);
}

public sealed class StandardShipping : IShippingOption
{
    public string Name => "Standard";
    public int EstimatedDays => 3;
    public decimal Fee(decimal orderAmount) => orderAmount >= 500m ? 0m : 39.90m;
}

public sealed class LockerShipping(decimal lockerFee) : IShippingOption
{
    public string Name => "Locker";
    public int EstimatedDays => 2;
    public decimal Fee(decimal orderAmount) => lockerFee;
}
Standard: 39.90, 3d
Express: 89.90, 1d
Locker: 19.90, 2d

Here the symmetry flips. Locker is a single new class; no other file changed. But if you want to add CarbonFootprint to the interface, you have to open every implementation. This is the tension Philip Wadler (one of the designers of Haskell and of Java generics) named the "expression problem" in 1998: no structure makes both new types and new operations cheap, so you decide which one will change more often. If shipping options change once a year but each carries different rules, use objects; if the shipping types are fixed but operations such as reporting, labels and pricing keep arriving, use a data structure.

.NET itself shows this clearly where it deliberately chose the second path. Nodes in System.Linq.Expressions such as BinaryExpression and MethodCallExpression are data structures that expose Left, Right and NodeType properties; new operations are added through classes deriving from ExpressionVisitor. Entity Framework Core's (EF Core) query translation can produce SQL (Structured Query Language) without adding a single line to the node classes, because the node types barely change while the code that processes them is rewritten for every provider. The C# Advanced series post "Expression Trees in C#: Holding a Lambda as Data, and the Bridge to EF Core" covers this structure in detail.

Records for DTOs, Encapsulation for the Domain

The most expensive option is the one in between. Someone adds a method to the legacy Order with the idea of "let's add a bit of behavior now", but leaves the fields alone:

public record OrderItem(string Isbn, decimal Price, int Quantity);

// Half object, half data structure
public class Order
{
    public List<OrderItem> Items { get; set; } = [];
    public decimal Total { get; set; }

    public void Recalculate() => Total = Items.Sum(i => i.Price * i.Quantity);
}
var hybrid = new Bookstore.Hybrid.Order();
hybrid.Items.Add(new("978-0441013593", 450m, 1));
hybrid.Recalculate();
hybrid.Items.Add(new("978-0141439518", 180m, 1));
Console.WriteLine($"Items: {hybrid.Items.Count}, Total: {hybrid.Total}");
Items: 2, Total: 450

Martin calls this a hybrid and says it combines the worst of both worlds: it has behavior, but because the data is open, the rule that behavior is supposed to protect (Total is the sum of Items) can be broken from anywhere. Adding a new operation and adding a new type both become harder. In a real project this happens when a few "helper" methods are added to an EF Core entity while its setters stay public, and one day orders whose totals don't add up show up in a report.

On the domain side, the answer is encapsulation: the data stays inside, the rule lives in the behavior.

public sealed record OrderLine(Isbn Isbn, Money UnitPrice, int Quantity)
{
    public Money LineTotal => UnitPrice * Quantity;
}

public sealed class Order
{
    private readonly List<OrderLine> _lines = [];

    public Order(int id, Email customerEmail, string currency)
    {
        Id = id;
        CustomerEmail = customerEmail;
        Currency = currency;
    }

    public int Id { get; }
    public Email CustomerEmail { get; }
    public string Currency { get; }
    public IReadOnlyList<OrderLine> Lines => _lines;
    public Money Total => _lines.Aggregate(Money.Zero(Currency), (sum, line) => sum + line.LineTotal);

    public void AddLine(Isbn isbn, Money unitPrice, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        if (unitPrice.Currency != Currency)
            throw new InvalidOperationException($"Order {Id} is in {Currency}, price is in {unitPrice.Currency}.");

        int index = _lines.FindIndex(l => l.Isbn == isbn);
        if (index >= 0)
            _lines[index] = _lines[index] with { Quantity = _lines[index].Quantity + quantity };
        else
            _lines.Add(new OrderLine(isbn, unitPrice, quantity));
    }
}

Total is no longer stored; it is computed from the lines on every read, so it cannot be inconsistent. Lines is exposed as IReadOnlyList<OrderLine>, the only way to add a line is AddLine, and that method gathers the quantity check, the currency check and the merging of identical ISBNs (International Standard Book Numbers) in one place. This is the order-sized version of the rules from the C# OOP (Object-Oriented Programming) series post "Encapsulation and Access Modifiers in C#: public, private, protected, internal".

What the API (Application Programming Interface) hands out should still be a data structure, but this time on purpose. A record is the right tool here: short, immutable, with value equality and ready to be serialized.

public sealed record OrderLineDto(string Isbn, decimal UnitPrice, int Quantity);

public sealed record OrderSummaryDto(int Id, string CustomerEmail, decimal Total, string Currency, IReadOnlyList<OrderLineDto> Lines);

public static class OrderMapping
{
    public static OrderSummaryDto ToSummaryDto(this Order order) => new(
        order.Id,
        order.CustomerEmail.Value,
        order.Total.Amount,
        order.Currency,
        [.. order.Lines.Select(l => new OrderLineDto(l.Isbn.Value, l.UnitPrice.Amount, l.Quantity))]);
}
var order = new Order(1042, Email.Create(" Ada@Example.com "), "TRY");
order.AddLine(new Isbn("978-0441013593"), new Money(450m, "TRY"), 1);
order.AddLine(new Isbn("978-0141439518"), new Money(180m, "TRY"), 2);
order.AddLine(new Isbn("978-0441013593"), new Money(450m, "TRY"), 1);
Console.WriteLine($"Lines: {order.Lines.Count}, Total: {order.Total}, Minor: {order.Total.ToMinorUnits()}");

OrderSummaryDto dto = order.ToSummaryDto();
Console.WriteLine(JsonSerializer.Serialize(dto, JsonSerializerOptions.Web));
Console.WriteLine(dto == order.ToSummaryDto());
Lines: 2, Total: 1260.00 TRY, Minor: 126000
{"id":1042,"customerEmail":"ada@example.com","total":1260,"currency":"TRY","lines":[{"isbn":"978-0441013593","unitPrice":450,"quantity":2},{"isbn":"978-0141439518","unitPrice":180,"quantity":2}]}
False

Look at the last line: two DTOs produced from the same order are not equal. The Equals a record generates compares each property with EqualityComparer<T>.Default, and for IReadOnlyList<T> that means reference equality; every mapping produces a new collection object. If you replace Lines with [] on both sides, the result becomes True, because an empty collection expression reuses the same empty array each time. If you will compare DTOs in tests or use them as cache keys, use collection properties with this behavior in mind. The rest of the record rules are in the C# OOP series post "C# record and record struct: Value Equality and the with Expression"; manual mapping versus libraries is covered in the ASP.NET Core Web API series post "DTOs and Mapping in ASP.NET Core Web API: Hand Mapping, the AutoMapper License and Mapperly".

From Primitive Obsession to Value Objects

The primitive obsession smell in the catalog of Martin Fowler's Refactoring (1999; second edition 2018) is carrying domain concepts around in primitive types such as string, int and decimal. The legacy signatures read like a catalog example: CreateCustomer takes nine parameters, seven of them string; ProcessData passes the shipping type as int t, the customer type as "vip", and the amount to the payment provider as long amountMinor. Email validation is email.Contains("@"), and the currency is the "TRY" literal.

A value object gives these concepts a type of their own: it has no identity, it is compared by value, it is immutable and it cannot be constructed in an invalid state. A readonly record struct is a good start for money:

public readonly record struct Money
{
    public Money(decimal amount, string currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        if (currency is not { Length: 3 } || !currency.All(char.IsAsciiLetterUpper))
            throw new ArgumentException($"'{currency}' is not an ISO 4217 code.", nameof(currency));
        Amount = amount;
        Currency = currency;
    }

    public decimal Amount { get; }

    // default(Money) skips the constructor; Currency stays null
    public string Currency { get => field ?? throw new InvalidOperationException("Money was not initialized."); }

    public static Money Zero(string currency) => new(0m, currency);

    public static Money operator +(Money left, Money right)
    {
        if (left.Currency != right.Currency)
            throw new InvalidOperationException($"Cannot add {left.Currency} and {right.Currency}.");
        return new Money(left.Amount + right.Amount, left.Currency);
    }

    public static Money operator *(Money price, int quantity) => new(price.Amount * quantity, price.Currency);

    public long ToMinorUnits() => (long)Math.Round(Amount * 100m, MidpointRounding.AwayFromZero);

    public override string ToString() => $"{Amount.ToString("0.00", CultureInfo.InvariantCulture)} {Currency}";
}

In the legacy code the expression (long)(o.Total * 100) was written in two separate places; now the rule for converting to minor units has a single home. When we try to add a EUR (euro)-priced line to the order, Order refuses: Order 1042 is in TRY, price is in EUR. The deeper sides of Money, such as allocating cents, rounding VAT (value-added tax) and persisting it, are in the Solution Patterns series post "Money and Rounding: decimal, a Money Value Object, VAT and Distributing Cents".

For email I chose a sealed record instead of a struct; we will see why in the pitfalls shortly. I leave the syntax check to the BCL's (Base Class Library, .NET's core class library) System.Net.Mail.MailAddress and add the domain rule on top:

public sealed record Email
{
    private Email(string value) => Value = value;

    public string Value { get; }

    public static bool TryCreate(string? input, [NotNullWhen(true)] out Email? email)
    {
        email = null;
        string candidate = input?.Trim() ?? "";
        if (!MailAddress.TryCreate(candidate, out MailAddress? parsed)
            || parsed.Address != candidate
            || !parsed.Host.Contains('.'))
            return false;

        email = new Email(candidate.ToLowerInvariant());
        return true;
    }

    public static Email Create(string input) =>
        TryCreate(input, out Email? email) ? email : throw new FormatException($"'{input}' is not a valid email address.");

    public override string ToString() => Value;
}
[ada@example.com] -> ada@example.com
[ ADA@Example.com ] -> ada@example.com
[ada@example] -> invalid
[Ada Lovelace <ada@example.com>] -> invalid
[ada] -> invalid

On its own, MailAddress accepts ada@example and Ada Lovelace <ada@example.com>; it is right as far as the RFC (Request for Comments, the document series that defines internet standards) goes, but for the Bookstore a customer email shouldn't carry a display name, and mail doesn't go to a domain without a dot. Hence the two extra rules. The BCL itself is full of value objects: TimeSpan, DateOnly, Guid, Uri, Version, IPAddress and MailAddress. One interesting detail: MailAddress is a class and doesn't override the == operator; two identical addresses return False with == and True with Equals (case-insensitively). When you write your own value object as a record, that inconsistency never appears.

The part of the gain the compiler can see makes the opening bug impossible. The new registration method has the signature Register(int id, string name, Email email, PostalAddress address), and when you pass a plain string instead of an email:

Bookstore.Customers.CustomerRegistration.Register(4, "Linus Torvalds", "linus@example.com", new("Bagdat Cd. 7", "34710", new("Istanbul", "Turkey")));
Program.cs(79,72): error CS1503: Argument 3: cannot convert from 'string' to 'Bookstore.Domain.Email'

To be honest, inside PostalAddress(string Street, string Zip, City City) the street is still a string. Value objects move primitive obsession up a level; they don't eliminate it. Making the city its own City type solves the street/city mix-up from the opening; the risk of mixing up the street and the zip code can be closed with named arguments (new PostalAddress(Street: ..., Zip: ...)) or with yet another type. You don't need a type for every string; you introduce one where a mix-up is expensive.

The Law of Demeter and Train Wrecks: A Short Introduction

One more pattern repeats in the legacy code: c.Address.City.Name (quotes), o.Customer.Address.City.Country (shipping), o.Customer.Address.Street (invoices). Martin calls these chains train wrecks. Let's see what happens when we request a quote for a customer who was imported without an address:

NullReferenceException: Object reference not set to an instance of an object.
   at Bookstore.Legacy.OrderService.GetQuote(Int32 cId, Dictionary`2 d, String coupon, Int32 t) in .../OrderService.cs:line 190

There's a line number, but not which link of the chain is null. The real problem is bigger: OrderService knows how the customer's address, the address's city and the city's country are stored. On the day the address model changes, the pricing, shipping and invoicing code will all change together.

The Law of Demeter was born in 1987 in the Demeter project at Northeastern University from a proposal by Ian Holland, and spread through papers by Karl Lieberherr and Holland in 1988–89. Roughly, it says that a method should only call methods on its own object, its parameters, objects it creates and its fields; it shouldn't "talk to strangers' strangers". The fix is usually to ask the question to the owner of the knowledge:

public sealed class Customer(int id, string name, Email email, PostalAddress? shippingAddress)
{
    public int Id { get; } = id;
    public string Name { get; } = name;
    public Email Email { get; } = email;

    public bool ShipsDomestically => shippingAddress?.City.Country == "Turkey";

    public string ShippingCity =>
        shippingAddress?.City.Name ?? throw new InvalidOperationException($"Customer {Id} has no shipping address.");
}
False
Customer 6 has no shipping address.

Now the error message says what is missing and for whom, and only Customer knows the inner structure of the address. There are two important limits. First, according to Martin, the rule applies to objects; reading dto.Customer.Address.City on a pure data structure such as a DTO is not a violation, because no behavior is being hidden. Second, LINQ (Language Integrated Query) and fluent API chains return a new object of the same kind at every step, so they are not what this rule is about. Tell, Don't Ask, grading coupling with connascence, and when the rule turns into needless wrappers will be covered in depth in the upcoming SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Coupling and Cohesion: Two Measures of Good Design, Connascence and the Law of Demeter".

Common Pitfalls

The first pitfall is assuming the constructor of a readonly record struct always runs. Structs always have a default: the elements of a new Money[2] array, an unassigned Money field in a class, or new Money() pass through none of the constructor's checks. The Currency getter above catches this with C# 14's field keyword:

0
Money was not initialized.

Amount silently returns 0, while ToString() throws. That's an acceptable compromise, because the failure comes on first use and with a meaningful message. If you mean "an invalid state must not exist at all", choose a class (sealed record) over a struct; I did that for Email, because an empty email has no reasonable default.

The second pitfall is the with expression. If you write the properties as { get; init; }, price with { Amount = -5m } skips the constructor and produces negative money; I tried it, and got Money { Amount = -5, Currency = TRY }. If validation lives only in the constructor, leave the properties as { get; }; the compiler then rejects with with CS0200.

The third pitfall is forgetting the value object at the EF Core and JSON (JavaScript Object Notation) boundaries. When Money is an entity property, you have to tell EF Core how to map it (complex type, owned type or value converter); the EF Core series posts "Owned Types and Value Converters in EF Core: Addresses, Enums and Strongly Typed Ids" and "What's New in EF Core 8: Complex Types, Primitive Collections and SqlQuery<T>" cover that part. At the API boundary, mapping to a DTO instead of serializing the domain type directly keeps the JSON contract independent of the domain.

The fourth pitfall is adding behavior to a DTO. The moment you put a CalculateTax() method on OrderSummaryDto, you are back to a hybrid. A DTO's job is to carry data; the rules belong in the domain.

When Not to Use It

For a lookup screen in an admin panel, a report's row model, or a query that only reads from the database and prints to the screen, building encapsulation and value objects is usually overhead. For data without rules, a data structure is enough and an honest choice; write it as a record and move on.

Don't create a type for every primitive. Concepts like Money, Email and Isbn appear in many places, are expensive to mix up and have rules of their own. A CustomerName type for Customer.Name prevents no bug in most projects and only adds mapping work. The test is simple: has passing this value to the wrong place, or building it in the wrong format, ever produced a bug, or could it?

Don't make the anti-symmetry decision once and for all, either. Starting shipping types with an enum and a switch today is perfectly reasonable. When each new option starts requiring you to open five different switches, that's the moment to move to objects.

A Small Experiment

★☆☆ Replace the int t and string strCoupon parameters of the legacy ProcessData with the ShippingMethod enum and a CouponCode value object. Let CouponCode normalize case by itself, so that "WELCOME10" and "welcome10" are equal.

★★☆ Turn Money into a sealed record class and compare the two versions: what happens to the default pitfall, and do allocations change in the Order.Total calculation? Measure the total of a 1,000-line order with BenchmarkDotNet.

★★★ Move the ShippingMethod enum into the IShippingOption hierarchy without losing the legacy "10 TRY off for Istanbul" rule. Does the rule belong to the shipping option, to the customer, or to a separate campaign object? Justify your choice in terms of the Law of Demeter.

In this post we decided which type should carry the data. The next post looks at the noisiest part of the same OrderService: return codes going from -1 to -99, the static LastError field and catch blocks that do nothing. We will put exceptions, the Try pattern and the Result pattern side by side and talk about which error deserves which.