👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Storing Data in Its Richest Form in DDD - Preventing Information Loss in .NET

Storing Data in Its Richest Form in DDD - Preventing Information Loss in .NET

Author - Abdul Rahman (Bhai)

DDD

23 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

There is a silent killer lurking in many domain models. It does not crash on day one. It does not throw exceptions. It quietly strips away information every time data is saved—until the day a business requirement arrives that your model simply cannot answer.

That killer is information loss through premature reduction. It happens when a domain model stores a derived or formatted value instead of the original, specific data it was computed from. The concept of storing data in its richest form is a DDD principle that says: always prefer storing the most atomic, detailed representation of a fact, because you can always derive a simpler form from rich data—but you can never reconstruct rich data from a simplified form.

In this article, we walk through five real-world scenarios where this trap appears in .NET codebases, why each causes long-term pain, and how well-designed Value Objects lock in the richest form of every piece of information from the moment it enters your system.

Why we gonna do?

Information Loss Is a One-Way Door

Consider how a team stores a person's name for a Customer entity:


public class Customer
{
    public Guid Id { get; private set; }
    public string FullName { get; private set; }  // "Abdul Rahman"
}
            

Looks harmless. Then the marketing team asks for personalised emails that begin with "Hi Abdul,". Then the reporting team needs to sort customers by last name. Then the payments team needs the legal first name on invoices. Suddenly the team is writing fragile string-splitting logic: FullName.Split(' ')[0]—which fails catastrophically for "Mary Jane Watson" or "José García López".

The original data had a first name and a last name. By collapsing them into one string, that distinction was permanently erased. There is no reliable way to reconstruct what was intentionally separate.

The Percentage Trap

Here is a subtler case found in discount or tax calculations:


public class Discount
{
    // Stored as a pre-divided percentage, e.g. 0.3333...
    public decimal Rate { get; set; }  // ❌ mutable, no validation

    public decimal Apply(decimal amount) => amount * Rate;
}

// Usage
var discount = new Discount { Rate = 1m / 3m }; // 0.3333333333...
            

The original intent was "one-third". Once you store it as a decimal, the fraction 1/3 cannot be represented exactly — decimal gives you 28 significant digits but never an infinite repeating mantissa. Across thousands of multi-step calculations this tiny representation error compounds:


// Illustrative: multi-step rounding compounds the error
decimal rate = 1m / 3m;               // 0.3333333333333333333333333333
decimal partialTax = rate * 100m;      // 33.333333333333333333333333330
decimal fullTax = partialTax * 3m;     // 99.999999999999999999999999990
// ≠ 100.00 — and the original fraction is gone forever
            

Small error per transaction. Significant discrepancy across thousands of ledger entries. And you can never recover the exact original fraction from the stored representation.

The Formatted Address Problem

Addresses are another common offender:


public class Order
{
    public string ShippingAddress { get; private set; }
    // Stored as: "14 Baker Street, London, NW1 6XE, United Kingdom"
}
            

One day the logistics system needs to group shipments by city. Another day the tax engine needs the country code. Regex against a free-text field is not a domain model—it is a maintenance nightmare.

The Audit Trail That Cannot Be Queried

A team logs changes as human-readable sentences:


public class AuditEntry
{
    public string Message { get; private set; }
    // "Admin updated subscription price from £49 to £79 on 2026-03-01"
}
            

Works fine for displaying in a changelog. Then compliance asks: "Show me every price change greater than £20 in Q1 2026." There is no way to query that from an unstructured message string without parsing text—and text parsing breaks on the first typo.

The Loyalty Points Balance Trap

This is the most instructive example. Consider a Customer entity in a loyalty programme with two business use cases:


public class Customer : Entity
{
    public LoyaltyPoints Points { get; private set; }

    // Use case 1: customer places an order — earn points
    public void AddLoyaltyPoints(LoyaltyPoints points)
    {
        Points += points;
    }

    // Use case 2: customer redeems points — minimum balance of 250 required
    public void RedeemLoyaltyPoints(LoyaltyPoints points)
    {
        if (Points < 250 || points > Points)
            throw new Exception();

        Points -= points;
    }
}
            

The code addresses both use cases perfectly—until a new requirement arrives. When a customer removes an item from an existing order, the Order entity must subtract the difference in loyalty points. Three approaches come to mind and all three fail:

  • Reuse RedeemLoyaltyPoints—but it validates a minimum of 250 points and throws for customers with a zero balance.
  • Pass a negative value to AddLoyaltyPoints—but LoyaltyPoints cannot be negative by business rule. Allowing it here violates the always-valid domain model principle.
  • Add a new SubtractLoyaltyPoints method without the 250-point guard—but now two public methods perform point subtraction and neither name reveals which business use case it serves.

The inability to find a method name that maps cleanly to the new use case is a red flag. The single Points field is too crude: it cannot distinguish between removing earned points (order adjustment) and adding to the redeemed total (customer redemption)—two operations with completely different validation rules.

In each case above, the rich data existed at the time of capture. The model just failed to preserve it. DDD Value Objects and disciplined entity design are the mechanisms that fix this by making the richest form the only form your domain will accept.

How we gonna do?

Step 1: Replace Full Name with a PersonName Value Object

Instead of a single string, capture first and last name as separate, validated components:


public record PersonName
{
    public string First { get; }
    public string Last { get; }

    // Derive the full name on-demand, never store it
    public string Full => $"{First} {Last}";

    private PersonName(string first, string last)
    {
        if (string.IsNullOrWhiteSpace(first))
            throw new ArgumentException("First name is required.", nameof(first));

        if (string.IsNullOrWhiteSpace(last))
            throw new ArgumentException("Last name is required.", nameof(last));

        First = first.Trim();
        Last = last.Trim();
    }

    public static PersonName Of(string first, string last) =>
        new(first, last);
}
            

The entity stores PersonName, not a string:


public class Customer
{
    public Guid Id { get; private set; }
    public PersonName Name { get; private set; }

    public static Customer Register(string firstName, string lastName) =>
        new() { Id = Guid.NewGuid(), Name = PersonName.Of(firstName, lastName) };
}

// Usage
var customer = Customer.Register("Abdul", "Rahman");

Console.WriteLine(customer.Name.First);  // Abdul
Console.WriteLine(customer.Name.Last);   // Rahman
Console.WriteLine(customer.Name.Full);   // Abdul Rahman
            

Full is a computed property. It is never stored. You can always derive the formatted name from the structured data, but you can never go the other way.

Because the properties use { get; } rather than init, the record is fully immutable — with expressions are intentionally disabled. To update a name, create a fresh PersonName via PersonName.Of(...).

EF Core maps this cleanly using OwnsOne:


protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>().OwnsOne(c => c.Name, name =>
    {
        name.Property(n => n.First).HasColumnName("FirstName").HasMaxLength(100);
        name.Property(n => n.Last).HasColumnName("LastName").HasMaxLength(100);
    });
}
            

Step 2: Replace Decimal Rate with a Fraction Value Object

Instead of pre-dividing a fraction into a decimal, store the numerator and denominator:


public record Fraction
{
    public int Numerator { get; }
    public int Denominator { get; }

    // Derive the decimal representation only when needed
    // (Constructor already validates Denominator != 0; guard is defensive)
    public decimal AsDecimal =>
        Denominator == 0 ? 0m : (decimal)Numerator / Denominator;

    private Fraction(int numerator, int denominator)
    {
        if (denominator == 0)
            throw new ArgumentException(
                "Denominator cannot be zero.", nameof(denominator));

        // This Fraction models a proportion (0 <= n <= d), not a general rational number.
        // e.g. Fraction.Of(1, 3) = valid discount rate; Fraction.Of(4, 3) = invalid (> 100%).
        if (numerator < 0 || numerator > denominator)
            throw new ArgumentException(
                "Numerator must be between 0 and the denominator.", nameof(numerator));

        Numerator = numerator;
        Denominator = denominator;
    }

    public static Fraction Of(int numerator, int denominator) =>
        new(numerator, denominator);

    public static Fraction OneThird => new(1, 3);
    public static Fraction OneQuarter => new(1, 4);
    public static Fraction Half => new(1, 2);

    public override string ToString() => $"{Numerator}/{Denominator}";
}
            

The Discount entity now stores the fraction, and applies it without precision loss:


public class Discount
{
    public Fraction Rate { get; private set; }

    public static Discount Create(int numerator, int denominator) =>
        new() { Rate = Fraction.Of(numerator, denominator) };

    public decimal Apply(decimal amount) =>
        Math.Round(amount * Rate.Numerator / Rate.Denominator, 2,
            MidpointRounding.AwayFromZero);
}

// Usage
var discount = Discount.Create(1, 3);
var discounted = discount.Apply(300m); // Exactly 100.00, not 99.99999990
Console.WriteLine(discounted);         // 100.00

Console.WriteLine(discount.Rate);      // 1/3  — the intent is preserved
            

The difference is significant in regulated financial domains. Divide first, lose precision forever. Store the fraction, calculate precisely on each use.

Step 3: Replace Formatted Address with an Address Value Object

Store each postal component as a typed field, not as a single concatenated string:


public record Address
{
    public string Line1 { get; }
    public string? Line2 { get; }
    public string City { get; }
    public string PostCode { get; }
    public string CountryCode { get; }  // ISO 3166-1 alpha-2, e.g. "GB"

    private Address(
        string line1, string? line2,
        string city, string postCode, string countryCode)
    {
        // Guards: Line1, City, PostCode non-empty; CountryCode exactly 2 chars (ISO 3166-1)
        Line1       = line1.Trim();
        Line2       = line2?.Trim();
        City        = city.Trim();
        PostCode    = postCode.Trim().ToUpperInvariant();
        CountryCode = countryCode?.Trim().ToUpperInvariant() ?? string.Empty;
    }

    public static Address Of(
        string line1, string? line2,
        string city, string postCode, string countryCode) =>
        new(line1, line2, city, postCode, countryCode);

    // Derive the formatted mailing address on-demand — never store it
    public string Formatted =>
        string.Join(", ",
            new[] { Line1, Line2, City, PostCode, CountryCode }
            .Where(p => !string.IsNullOrWhiteSpace(p)));
}
            

Now the logistics service can group by city, the tax engine can read the country code, and the confirmation email can still render the formatted string—all from the same single stored value object:


// Logistics: group shipments by city
var byCity = orders
    .GroupBy(o => o.ShippingAddress.City)
    .ToDictionary(g => g.Key, g => g.ToList());

// Tax: read country code directly
var vatRate = vatRules.GetRateFor(order.ShippingAddress.CountryCode);

// Email: render formatted string
var addressLine = order.ShippingAddress.Formatted;
// "14 Baker Street, London, NW1 6XE, GB"
            

Step 4: Replace Audit Messages with Structured Audit Entries

Store the precise before and after values as typed fields, not as a pre-rendered string. Here Money represents a Value Object encapsulating an amount and currency:


public record PriceChangeAudit
{
    public Guid ChangedBy { get; }
    public Money OldPrice { get; }
    public Money NewPrice { get; }
    public DateTimeOffset ChangedAt { get; }

    // Derive the human-readable message on-demand
    public string Summary =>
        $"Price changed from {OldPrice} to {NewPrice} " +
        $"by {ChangedBy} at {ChangedAt:yyyy-MM-dd HH:mm}";

    private PriceChangeAudit(
        Guid changedBy,
        Money oldPrice,
        Money newPrice,
        DateTimeOffset changedAt)
    {
        ChangedBy = changedBy;
        OldPrice = oldPrice;
        NewPrice = newPrice;
        ChangedAt = changedAt;
    }

    public static PriceChangeAudit Record(
        Guid changedBy, Money oldPrice, Money newPrice) =>
        new(changedBy, oldPrice, newPrice, DateTimeOffset.UtcNow);
}
            

With structured fields, the compliance query that was previously impossible is now trivial:


// Compliance: price changes greater than £20 in Q1 2026
var q1Start = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var q1End   = new DateTimeOffset(2026, 3, 31, 23, 59, 59, TimeSpan.Zero);

var significantChanges = auditLog
    .Where(a => a.ChangedAt >= q1Start && a.ChangedAt <= q1End)
    .Where(a => Math.Abs(a.NewPrice.Amount - a.OldPrice.Amount) > 20m)
    .OrderByDescending(a => a.ChangedAt)
    .ToList();

// And the human-friendly log still works
foreach (var entry in significantChanges)
    Console.WriteLine(entry.Summary);
            

Step 5: Replace the Single Loyalty Points Balance with Its Source Data

The root cause is storing the remainder—a derived value—instead of the two source values that produce it. Splitting Points into PointsEarned and PointsRedeemed (both LoyaltyPoints Value Objects supporting arithmetic and comparison operators) and computing the balance on-demand dissolves every ambiguity:


public class Customer : Entity
{
    public LoyaltyPoints PointsEarned   { get; private set; }
    public LoyaltyPoints PointsRedeemed { get; private set; }

    // Derive the balance — never store it
    public LoyaltyPoints Points => PointsEarned - PointsRedeemed;

    public void IncreaseEarnedPoints(LoyaltyPoints points)
    {
        PointsEarned += points;
    }

    public void ReduceEarnedPoints(LoyaltyPoints points)
    {
        PointsEarned -= points;
    }

    public void RedeemPoints(LoyaltyPoints points)
    {
        if (Points < 250 || points > Points)
            throw new Exception(); // use a domain-specific exception in production

        PointsRedeemed += points;
    }
}
            

Each method now maps to exactly one business use case. IncreaseEarnedPoints and ReduceEarnedPoints handle order lifecycle changes without the 250-point guard. RedeemPoints handles customer redemption with full validation. The problematic SubtractLoyaltyPoints was never needed—the design problem dissolved when the data was stored in its richest form.

As a bonus, PointsEarned and PointsRedeemed can now each be displayed to the customer separately—information that was invisible when only the remainder was stored.

The guideline can be applied one step further. PointsEarned is itself derived—it is the sum of loyalty points from each order. Storing the orders instead makes PointsEarned computable rather than persisted:


public class Customer : Entity
{
    public Order[] Orders               { get; private set; }  // simplified; use IReadOnlyList<Order> in production
    public LoyaltyPoints PointsRedeemed { get; private set; }

    // Both are derived — neither is stored
    public LoyaltyPoints PointsEarned => Orders.Sum(x => x.LoyaltyPoints);
    public LoyaltyPoints Points        => PointsEarned - PointsRedeemed;

    public void RedeemPoints(LoyaltyPoints points)
    {
        if (Points < 250 || points > Points)
            throw new Exception(); // use a domain-specific exception in production

        PointsRedeemed += points;
    }
}
            

Earned points are now controlled by the Order class, so IncreaseEarnedPoints and ReduceEarnedPoints are no longer needed on Customer. Taken to the logical extreme, this progression leads to Event Sourcing—where individual events are the highest possible form of information. How far to go depends on project needs: for most systems, PointsEarned + PointsRedeemed is the right balance.

Step 6: Apply the Richest Form Checklist Before Finalising a Value Object

Before marking a value object as complete, ask these questions:


Richest Form Checklist
======================

1. Can any stored property be derived from other stored properties?
   - YES → consider removing the derived property and computing it.
   - NO  → you are already in the richest form.

2. Is any stored property a concatenation or aggregation of smaller facts?
   - YES → split those facts into individual typed properties.
   - NO  → continue.

3. Is any stored property a decimal approximation of a fraction that cannot be expressed exactly in base 10?
   - YES → store numerator and denominator; derive the decimal on use.
   - NO  → continue.

4. Is any stored property a human-readable string that contains structured data?
   - YES → parse the structure into typed fields; derive the string on use.
   - NO  → continue.

5. Is any stored property a derived total of upstream source values?
   - YES → store the source values separately; derive the total on-demand.
   - NO  → you are recording the richest available form.
            

Here is a summary of the before-and-after transformations from all five examples:


Scenario            | Reduced Form (Avoid)        | Richest Form (Prefer)
--------------------|-----------------------------|----------------------------------------------
Person Name         | "Abdul Rahman"              | First: "Abdul" + Last: "Rahman"
Discount Rate       | 0.3333333...                | Numerator: 1, Denominator: 3
Shipping Address    | "14 Baker St, London"       | Line1, City, PostCode, CountryCode
Audit Change        | "Price changed to £79"      | OldPrice: £49, NewPrice: £79, ChangedAt
Loyalty Points      | int Points (remainder)      | PointsEarned + PointsRedeemed (computed: Points)
            

In every case, the derived or formatted string can be computed from the rich data. But the rich data can never be reliably reconstructed from the simplified string.

Summary

Storing data in its richest form is a foundational discipline in Domain-Driven Design. It is not about over-engineering—it is about preserving the full intent and precision of every fact that enters your system.

Key takeaways:

  • Derived data is always computable: Format, concatenate, and compute on-demand as properties—never store the result.
  • Collapsed data is unrecoverable: Once you store "Abdul Rahman" as a single string, the first-name/last-name boundary is gone forever.
  • Fractions beat decimals: Store numerator and denominator; divide only at the point of rendering or calculation.
  • Strings are not structured data: If a string contains multiple facts, split those facts into typed value object properties.
  • Value Objects enforce the discipline: A record-based Value Object with a private constructor and factory method makes the richest form the only form your domain will accept.

Apply the Richest Form Checklist before finalising every Value Object. Your future self—and the compliance team—will thank you.

👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Value Objects
  • Information Loss
  • Primitive Obsession
  • .NET
  • C#
  • Rich Domain Models
  • Data Integrity
  • Immutability