Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part VIII: Extract Value Objects

"Make the implicit explicit." -- Eric Evans, Domain-Driven Design

Bounded context libraries exist (Part VI). Anti-Corruption Layers are fixed (Part VII). The architecture has structure. Now it is time to fill that structure with meaning.

Value Objects are the vocabulary of the domain -- the nouns that domain experts use every day but that the codebase represents as scattered primitives. Money is not a decimal. A subscription period is not two DateTime fields that happen to sit next to each other. An email address is not a string that you hope somebody validated before it got here.

In this phase, you extract those concepts from the primitives they hide behind. You do it under TDD. You do it with zero schema changes. And you fix the proration bug from Part II -- the one where three implementations produce three different answers -- by giving the calculation a single home.


Why Value Objects Now

Three reasons to extract Value Objects before Aggregates:

1. They are additive. You are adding types, not changing behavior. You create a Money record, update a property type from decimal + string to Money, and the system does exactly what it did before -- with stronger types. No behavioral change means minimal risk. No new code paths. No new failure modes.

2. They are the safest refactoring with the highest ROI. The compiler becomes your reviewer. Once Money.Add() requires matching currencies, every call site that mixes EUR and USD becomes a compile error. You do not need to search for bugs. The build fails on them.

3. They are the ubiquitous language made concrete. Every Value Object you extract is a word from the Event Storming wall in Part III. When the Billing team says "subscription period," they do not mean "two DateTime fields." They mean a concept with start, end, duration, proration, overlap detection, and containment checks. The Value Object is that concept in code.

Value Objects are also the easiest code to test. They are pure functions wrapped in a type. No dependencies. No database. No HTTP calls. No mocking. You construct them, call a method, and assert the result. If you have never practiced TDD before, Value Objects are where to start.

And you do not need permission. You can start in your next PR. Nobody needs to approve an extraction of two fields into a record.


Spotting Value Objects

Primitive obsession is the diagnostic tool. Look for these patterns in SubscriptionHub's code:

  • A decimal and a string always traveling together: PriceAmount + PriceCurrency on four entities. That is Money.
  • An email format check duplicated in four service classes. That is EmailAddress.
  • Two DateTime fields named Start and End or CurrentPeriodStart and CurrentPeriodEnd. That is SubscriptionPeriod.
  • A decimal that must be between 0 and 1 (or 0 and 100), always multiplied against another value. That is TaxRate.
  • A string with switch statements scattered across services. That is PlanTier.
  • A decimal that must never be negative, always paired with a unit. That is UsageQuota.

Here are the eight candidates from Event Storming, where their primitives appear, and which bounded context owns them:

Value Object Primitives Locations Owner Context
Money decimal + string Invoice, Subscription, Plan, Payment Billing
SubscriptionPeriod DateTime + DateTime Subscription, Invoice, UsageRecord Subscriptions
EmailAddress string Customer, Notification, AuditLog, User Identity
TaxRate decimal Invoice, TaxRateCache, BillingAddress Billing
PlanTier string Plan, Subscription, Analytics Subscriptions
UsageQuota int + string Plan, UsageRecord Subscriptions
DunningSchedule int[] + int PaymentRetryConfig Billing
TrialDuration int + string Plan, Subscription Subscriptions

The decision flowchart for "is this a Value Object?" is short:

Diagram
The short "is this a Value Object?" flowchart — if primitives travel together, validation is duplicated, or arithmetic is involved, and identity does not matter, then yes, extract it.

If you answer "yes" to any of the first three questions AND "yes" to the identity question, you have a Value Object. Money with amount 49.99 EUR is the same as any other Money with amount 49.99 EUR. There is no "this particular instance of $49.99." The value is the identity.


Money -- Test First

Money is the poster child of Value Objects in DDD literature, and for good reason. In SubscriptionHub, decimal PriceAmount and string PriceCurrency appear on Invoice, Subscription, Plan, and Payment. The currency comparison is ad-hoc -- BillingService checks currency == "USD" with a case-sensitive string comparison. SubscriptionController does not check at all.

The Tests

Write these first. Before the Money type exists. The tests define the contract.

public class MoneyTests
{
    [Fact]
    public void Add_SameCurrency_ReturnsSum()
    {
        var a = new Money(30.00m, Currency.EUR);
        var b = new Money(19.99m, Currency.EUR);

        var result = a.Add(b);

        result.IsSuccess.Should().BeTrue();
        result.Value.Amount.Should().Be(49.99m);
        result.Value.Currency.Should().Be(Currency.EUR);
    }

    [Fact]
    public void Add_DifferentCurrency_ReturnsFailure()
    {
        var eur = new Money(30.00m, Currency.EUR);
        var usd = new Money(30.00m, Currency.USD);

        var result = eur.Add(usd);

        result.IsFailure.Should().BeTrue();
        result.Error.Should().BeOfType<CurrencyMismatchError>();
    }

    [Fact]
    public void Multiply_PositiveFactor_ReturnsScaled()
    {
        var price = new Money(100.00m, Currency.EUR);

        var result = price.Multiply(0.5m);

        result.Amount.Should().Be(50.00m);
        result.Currency.Should().Be(Currency.EUR);
    }

    [Fact]
    public void Multiply_NegativeFactor_Throws()
    {
        var price = new Money(100.00m, Currency.EUR);

        var act = () => price.Multiply(-1m);

        act.Should().Throw<ArgumentOutOfRangeException>();
    }

    [Fact]
    public void Constructor_NegativeAmount_Throws()
    {
        var act = () => new Money(-1m, Currency.EUR);

        act.Should().Throw<ArgumentOutOfRangeException>();
    }

    [Fact]
    public void Constructor_ZeroAmount_IsValid()
    {
        var zero = new Money(0m, Currency.EUR);

        zero.Amount.Should().Be(0m);
    }

    [Fact]
    public void Equality_SameAmountAndCurrency_AreEqual()
    {
        var a = new Money(49.99m, Currency.EUR);
        var b = new Money(49.99m, Currency.EUR);

        a.Should().Be(b);
        (a == b).Should().BeTrue();
    }

    [Fact]
    public void Equality_DifferentCurrency_AreNotEqual()
    {
        var eur = new Money(49.99m, Currency.EUR);
        var usd = new Money(49.99m, Currency.USD);

        eur.Should().NotBe(usd);
    }

    [Fact]
    public void ToString_FormatsWithSymbol()
    {
        var price = new Money(49.99m, Currency.EUR);

        price.ToString().Should().Be("49.99 EUR");
    }
}

Run the tests. They fail. The Money type does not exist. This is Red.

The Implementation

Now write the minimum code to make all tests pass.

public enum Currency
{
    EUR,
    USD,
    GBP
}

public sealed record Money
{
    public decimal Amount { get; }
    public Currency Currency { get; }

    public Money(decimal amount, Currency currency)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(amount);
        Amount = amount;
        Currency = currency;
    }

    public Result<Money, CurrencyMismatchError> Add(Money other)
    {
        if (Currency != other.Currency)
            return new CurrencyMismatchError(Currency, other.Currency);

        return new Money(Amount + other.Amount, Currency);
    }

    public Money Multiply(decimal factor)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(factor);
        return new Money(Math.Round(Amount * factor, 2), Currency);
    }

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

    public override string ToString() => $"{Amount} {Currency}";
}

public sealed record CurrencyMismatchError(
    Currency Expected,
    Currency Actual)
{
    public override string ToString() =>
        $"Cannot combine {Expected} with {Actual}";
}

Run the tests. They pass. This is Green.

Notice what changed:

  • Add returns Result<Money, CurrencyMismatchError>, not a raw Money -- the caller is forced to handle the currency mismatch case. No more silent bugs. For the full treatment of the Result pattern, see Result Pattern.
  • Multiply rounds to 2 decimal places -- no more 28-digit decimals propagating through the invoice pipeline.
  • The constructor rejects negative amounts. Invalid money cannot exist. It is not "validated later." It is impossible to construct.
  • sealed record gives you structural equality for free. Two Money instances with the same amount and currency are equal. Period.

Before and After

Before -- four entities with raw primitives and ad-hoc validation scattered across services:

// Before: primitives everywhere
public class Invoice
{
    public decimal TotalAmount { get; set; }
    public string Currency { get; set; } = "USD";
}

public class Subscription
{
    public decimal CurrentPrice { get; set; }
    public string PriceCurrency { get; set; } = "USD";
}

// BillingService.cs -- ad-hoc currency check buried in a 200-line method
if (invoice.Currency != subscription.PriceCurrency)
    _logger.LogWarning("Currency mismatch"); // logs, but continues anyway

After -- the compiler catches it:

// After: Money is the type
public class Invoice
{
    public Money Total { get; private set; }
}

public class Subscription
{
    public Money CurrentPrice { get; private set; }
}

// Compiler error if currencies mismatch -- forced by Result type
var result = invoice.Total.Add(subscription.CurrentPrice);
if (result.IsFailure) return Result.Failure(result.Error);

The warning log is gone. You do not need to log currency mismatches when the compiler prevents them.

For more on how Value Objects integrate with code generation and DDD attributes, see DDD & Code Generation -- Immutability and Value Objects.


SubscriptionPeriod -- The Proration Fix

This is the one that pays for the entire migration. In Part II, we diagnosed Pathology 3: the proration calculation exists in three places -- BillingService, SubscriptionController, and sp_CalculateProration -- and each produces a different number.

Recap:

  • BillingService: (decimal)remainingDays / (decimal)totalDays -- truncated decimal division, anchored to first of month
  • SubscriptionController: Math.Round(remainingDays / totalDays, 2) -- banker's rounding, anchored to subscription start day
  • sp_CalculateProration: CAST(remaining AS FLOAT) / CAST(total AS FLOAT) -- IEEE 754 double-precision, first of month

For a plan change on March 15th of a 31-day month, the customer sees $67.32 in the preview, gets charged $51.10 on the invoice, and Finance reconciles $51.10 with a different penny because of FLOAT. Fifteen support tickets per month.

The fix: one method, in one place, used by all three call sites.

The Tests

public class SubscriptionPeriodTests
{
    private static readonly DateOnly March1 = new(2025, 3, 1);
    private static readonly DateOnly April1 = new(2025, 4, 1);

    [Fact]
    public void ProrateFraction_StartOfPeriod_ReturnsOne()
    {
        var period = new SubscriptionPeriod(March1, April1);

        var fraction = period.ProrateFraction(March1);

        fraction.Should().Be(1.0m);
    }

    [Fact]
    public void ProrateFraction_MidPeriod_ReturnsCorrectFraction()
    {
        var period = new SubscriptionPeriod(March1, April1);
        var changeDate = new DateOnly(2025, 3, 16); // 16 days remaining out of 31

        var fraction = period.ProrateFraction(changeDate);

        // 16 remaining / 31 total = 0.52 (rounded to 2 places)
        fraction.Should().Be(0.52m);
    }

    [Fact]
    public void ProrateFraction_LastDay_ReturnsOneDayFraction()
    {
        var period = new SubscriptionPeriod(March1, April1);
        var lastDay = new DateOnly(2025, 3, 31);

        var fraction = period.ProrateFraction(lastDay);

        // 1 remaining / 31 total = 0.03
        fraction.Should().Be(0.03m);
    }

    [Fact]
    public void ProrateFraction_EndOfPeriod_ReturnsZero()
    {
        var period = new SubscriptionPeriod(March1, April1);

        var fraction = period.ProrateFraction(April1);

        fraction.Should().Be(0m);
    }

    [Fact]
    public void ProrateFraction_OutsidePeriod_Throws()
    {
        var period = new SubscriptionPeriod(March1, April1);
        var before = new DateOnly(2025, 2, 28);

        var act = () => period.ProrateFraction(before);

        act.Should().Throw<ArgumentOutOfRangeException>();
    }

    [Fact]
    public void Contains_DateInRange_ReturnsTrue()
    {
        var period = new SubscriptionPeriod(March1, April1);

        period.Contains(new DateOnly(2025, 3, 15)).Should().BeTrue();
    }

    [Fact]
    public void Contains_DateOutsideRange_ReturnsFalse()
    {
        var period = new SubscriptionPeriod(March1, April1);

        period.Contains(new DateOnly(2025, 4, 2)).Should().BeFalse();
    }

    [Fact]
    public void Contains_StartDate_ReturnsTrue()
    {
        var period = new SubscriptionPeriod(March1, April1);

        period.Contains(March1).Should().BeTrue();
    }

    [Fact]
    public void Contains_EndDate_ReturnsFalse()
    {
        // End is exclusive -- standard half-open interval [start, end)
        var period = new SubscriptionPeriod(March1, April1);

        period.Contains(April1).Should().BeFalse();
    }

    [Fact]
    public void OverlapsWith_OverlappingPeriods_ReturnsTrue()
    {
        var a = new SubscriptionPeriod(March1, April1);
        var b = new SubscriptionPeriod(
            new DateOnly(2025, 3, 15), new DateOnly(2025, 4, 15));

        a.OverlapsWith(b).Should().BeTrue();
    }

    [Fact]
    public void OverlapsWith_AdjacentPeriods_ReturnsFalse()
    {
        var a = new SubscriptionPeriod(March1, April1);
        var b = new SubscriptionPeriod(
            April1, new DateOnly(2025, 5, 1));

        a.OverlapsWith(b).Should().BeFalse();
    }

    [Fact]
    public void DaysRemaining_FromMiddle_ReturnsCorrectCount()
    {
        var period = new SubscriptionPeriod(March1, April1);

        var remaining = period.DaysRemaining(new DateOnly(2025, 3, 16));

        remaining.Should().Be(16);
    }

    [Fact]
    public void TotalDays_March_Returns31()
    {
        var period = new SubscriptionPeriod(March1, April1);

        period.TotalDays.Should().Be(31);
    }

    [Fact]
    public void Constructor_EndBeforeStart_Throws()
    {
        var act = () => new SubscriptionPeriod(April1, March1);

        act.Should().Throw<ArgumentException>();
    }
}

All red. Now implement.

The Implementation

public sealed record SubscriptionPeriod
{
    public DateOnly Start { get; }
    public DateOnly End { get; }

    public SubscriptionPeriod(DateOnly start, DateOnly end)
    {
        if (end <= start)
            throw new ArgumentException(
                $"End ({end}) must be after Start ({start})");

        Start = start;
        End = end;
    }

    public int TotalDays => End.DayNumber - Start.DayNumber;

    public int DaysRemaining(DateOnly fromDate)
    {
        EnsureWithinPeriod(fromDate);
        return End.DayNumber - fromDate.DayNumber;
    }

    public decimal ProrateFraction(DateOnly changeDate)
    {
        if (changeDate == End) return 0m;
        EnsureWithinPeriod(changeDate);

        var remaining = End.DayNumber - changeDate.DayNumber;
        return Math.Round((decimal)remaining / TotalDays, 2,
            MidpointRounding.AwayFromZero);
    }

    public bool Contains(DateOnly date) =>
        date >= Start && date < End; // half-open interval [start, end)

    public bool OverlapsWith(SubscriptionPeriod other) =>
        Start < other.End && other.Start < End;

    private void EnsureWithinPeriod(DateOnly date)
    {
        if (date < Start || date > End)
            throw new ArgumentOutOfRangeException(
                nameof(date),
                $"Date {date} is outside period [{Start}, {End})");
    }

    public override string ToString() => $"[{Start}, {End})";
}

All green. Sixteen tests pass. The proration calculation now has one definition with explicit rounding (MidpointRounding.AwayFromZero -- the rounding that humans expect, not banker's rounding) and consistent anchoring.

Three Become One

This is the payoff. The three proration implementations from Part II collapse into one method call:

Diagram
The three divergent proration implementations from Part II collapse into one — ProrateFraction with AwayFromZero rounding and a single anchor, replacing both C# call sites and leaving the stored procedure orphaned for a later cleanup.

The stored procedure stays in the database for now -- you do not need to remove it in this phase. But the C# code no longer calls it. The two C# implementations (BillingService and SubscriptionController) are replaced by:

// BillingService -- before
var cycleStart = new DateTime(billingDate.Year, billingDate.Month, 1);
var cycleEnd = cycleStart.AddMonths(1);
var changeDate = subscription.PlanChangedDate.Value;
var totalDays = (cycleEnd - cycleStart).Days;
var remainingDays = (cycleEnd - changeDate).Days;
baseCharge = baseCharge * (decimal)remainingDays / (decimal)totalDays;

// BillingService -- after
var fraction = subscription.CurrentPeriod.ProrateFraction(
    DateOnly.FromDateTime(subscription.PlanChangedDate!.Value));
baseCharge = subscription.CurrentPrice.Multiply(fraction);
// SubscriptionController -- before
var today = DateTime.UtcNow.Date;
var cycleStart = new DateTime(today.Year, today.Month,
    subscription!.StartDate.Day);
if (cycleStart > today) cycleStart = cycleStart.AddMonths(-1);
var cycleEnd = cycleStart.AddMonths(1);
var totalDays = (cycleEnd - cycleStart).Days;
var usedDays = (today - cycleStart).Days;
var remainingDays = totalDays - usedDays;
var credit = Math.Round(
    subscription.CurrentPrice * remainingDays / totalDays, 2);
var charge = Math.Round(
    newPlan!.MonthlyPrice * remainingDays / totalDays, 2);

// SubscriptionController -- after
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var fraction = subscription.CurrentPeriod.ProrateFraction(today);
var credit = subscription.CurrentPrice.Multiply(fraction);
var charge = new Money(newPlan!.MonthlyPrice, subscription.CurrentPrice.Currency)
    .Multiply(fraction);

Fourteen lines of ad-hoc arithmetic replaced by two method calls. The preview and the invoice now agree. Every time. The fifteen monthly support tickets go to zero.


More Value Objects

The same TDD cycle applies to every Value Object. Here are three more, shown in abbreviated form -- test first, then implement.

EmailAddress

public class EmailAddressTests
{
    [Theory]
    [InlineData("alice@example.com")]
    [InlineData("alice.martin+billing@example.co.uk")]
    public void Constructor_ValidEmail_Succeeds(string email)
    {
        var address = new EmailAddress(email);
        address.Value.Should().Be(email.ToLowerInvariant());
    }

    [Theory]
    [InlineData("")]
    [InlineData("not-an-email")]
    [InlineData("missing@")]
    [InlineData("@missing.com")]
    [InlineData("spaces in@email.com")]
    public void Constructor_InvalidEmail_Throws(string email)
    {
        var act = () => new EmailAddress(email);
        act.Should().Throw<ArgumentException>();
    }

    [Fact]
    public void Equality_CaseInsensitive()
    {
        var a = new EmailAddress("Alice@Example.COM");
        var b = new EmailAddress("alice@example.com");
        a.Should().Be(b);
    }

    [Fact]
    public void Domain_ExtractsCorrectly()
    {
        var email = new EmailAddress("alice@example.com");
        email.Domain.Should().Be("example.com");
    }
}
public sealed record EmailAddress
{
    public string Value { get; }
    public string Domain => Value[(Value.IndexOf('@') + 1)..];

    public EmailAddress(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new ArgumentException("Email cannot be empty", nameof(value));

        var normalized = value.Trim().ToLowerInvariant();

        if (!IsValidFormat(normalized))
            throw new ArgumentException(
                $"Invalid email format: {value}", nameof(value));

        Value = normalized;
    }

    private static bool IsValidFormat(string email)
    {
        var atIndex = email.IndexOf('@');
        if (atIndex <= 0 || atIndex >= email.Length - 1)
            return false;

        var local = email[..atIndex];
        var domain = email[(atIndex + 1)..];

        return !local.Contains(' ')
            && domain.Contains('.')
            && domain.IndexOf('.') > 0
            && domain[^1] != '.';
    }

    public override string ToString() => Value;
}

Four services used to validate email format independently, with four different regex patterns. Now validation happens at construction. An EmailAddress is always valid. There is no "unvalidated email" state.

TaxRate

public class TaxRateTests
{
    [Fact]
    public void ApplyTo_ReturnsCorrectTax()
    {
        var rate = new TaxRate(20.0m); // 20% French VAT
        var subtotal = new Money(100.00m, Currency.EUR);

        var tax = rate.ApplyTo(subtotal);

        tax.Amount.Should().Be(20.00m);
        tax.Currency.Should().Be(Currency.EUR);
    }

    [Fact]
    public void ApplyTo_FractionalPenny_RoundsCorrectly()
    {
        var rate = new TaxRate(20.0m);
        var subtotal = new Money(49.99m, Currency.EUR);

        var tax = rate.ApplyTo(subtotal);

        tax.Amount.Should().Be(10.00m); // 9.998 rounded to 10.00
    }

    [Fact]
    public void Constructor_NegativeRate_Throws()
    {
        var act = () => new TaxRate(-5m);
        act.Should().Throw<ArgumentOutOfRangeException>();
    }

    [Fact]
    public void Constructor_RateOver100_Throws()
    {
        var act = () => new TaxRate(101m);
        act.Should().Throw<ArgumentOutOfRangeException>();
    }

    [Fact]
    public void Zero_HasZeroPercentage()
    {
        TaxRate.Zero.Percentage.Should().Be(0m);
    }
}
public sealed record TaxRate
{
    public decimal Percentage { get; }

    public TaxRate(decimal percentage)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(percentage);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(percentage, 100m);
        Percentage = percentage;
    }

    public Money ApplyTo(Money subtotal) =>
        subtotal.Multiply(Math.Round(Percentage / 100m, 4,
            MidpointRounding.AwayFromZero));

    public static TaxRate Zero => new(0m);

    public override string ToString() => $"{Percentage}%";
}

PlanTier

public class PlanTierTests
{
    [Fact]
    public void IsDowngradeFrom_HigherToLower_ReturnsTrue()
    {
        PlanTier.Monthly.IsDowngradeFrom(PlanTier.Annual)
            .Should().BeTrue();
    }

    [Fact]
    public void IsDowngradeFrom_LowerToHigher_ReturnsFalse()
    {
        PlanTier.Annual.IsDowngradeFrom(PlanTier.Monthly)
            .Should().BeFalse();
    }

    [Fact]
    public void IsDowngradeFrom_SameTier_ReturnsFalse()
    {
        PlanTier.Annual.IsDowngradeFrom(PlanTier.Annual)
            .Should().BeFalse();
    }

    [Fact]
    public void CompareTo_OrderIsCorrect()
    {
        var tiers = new[] { PlanTier.Enterprise, PlanTier.Monthly, PlanTier.Annual };

        var sorted = tiers.OrderBy(t => t).ToArray();

        sorted.Should().ContainInOrder(
            PlanTier.Monthly, PlanTier.Annual, PlanTier.Enterprise);
    }
}
public sealed record PlanTier : IComparable<PlanTier>
{
    public string Name { get; }
    private int Rank { get; }

    private PlanTier(string name, int rank)
    {
        Name = name;
        Rank = rank;
    }

    public static readonly PlanTier Monthly = new("Monthly", 1);
    public static readonly PlanTier Annual = new("Annual", 2);
    public static readonly PlanTier Enterprise = new("Enterprise", 3);

    public bool IsDowngradeFrom(PlanTier other) => Rank < other.Rank;

    public int CompareTo(PlanTier? other) =>
        other is null ? 1 : Rank.CompareTo(other.Rank);

    public override string ToString() => Name;
}

Notice the pattern: PlanTier is not an enum. It is a Value Object with behavior -- IsDowngradeFrom() encapsulates the business rule that determines whether a plan change is a downgrade. The switch statements in BillingService and SubscriptionController that compared string values ("Monthly", "Annual", "Enterprise") are replaced by a single method call. See Builder Pattern for how these Value Objects compose into test builders.


Zero Schema Changes

This is the part that makes managers relax. Every Value Object extraction in this phase maps to existing database columns. No migrations. No schema changes. No downtime.

EF Core owned types are the mechanism. An owned type tells EF Core: "this C# type does not have its own table. Its properties map to columns on the owner's table." The Value Object exists in C# but is invisible to the database.

OnModelCreating Configuration

public class BillingDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Money on Invoice -- maps to existing TotalAmount + Currency columns
        modelBuilder.Entity<Invoice>(invoice =>
        {
            invoice.OwnsOne(i => i.Total, money =>
            {
                money.Property(m => m.Amount)
                    .HasColumnName("TotalAmount")
                    .HasPrecision(18, 2);

                money.Property(m => m.Currency)
                    .HasColumnName("Currency")
                    .HasConversion<string>()
                    .HasMaxLength(3);
            });
        });

        // Money on Subscription -- maps to existing CurrentPrice + PriceCurrency
        modelBuilder.Entity<Subscription>(subscription =>
        {
            subscription.OwnsOne(s => s.CurrentPrice, money =>
            {
                money.Property(m => m.Amount)
                    .HasColumnName("CurrentPrice")
                    .HasPrecision(18, 2);

                money.Property(m => m.Currency)
                    .HasColumnName("PriceCurrency")
                    .HasConversion<string>()
                    .HasMaxLength(3);
            });

            // SubscriptionPeriod -- maps to existing BillingStart + BillingEnd
            subscription.OwnsOne(s => s.CurrentPeriod, period =>
            {
                period.Property(p => p.Start)
                    .HasColumnName("CurrentPeriodStart")
                    .HasConversion(
                        d => d.ToDateTime(TimeOnly.MinValue),
                        d => DateOnly.FromDateTime(d));

                period.Property(p => p.End)
                    .HasColumnName("CurrentPeriodEnd")
                    .HasConversion(
                        d => d.ToDateTime(TimeOnly.MinValue),
                        d => DateOnly.FromDateTime(d));
            });
        });

        // EmailAddress on Customer
        modelBuilder.Entity<Customer>(customer =>
        {
            customer.OwnsOne(c => c.Email, email =>
            {
                email.Property(e => e.Value)
                    .HasColumnName("Email")
                    .HasMaxLength(254);
            });
        });

        // TaxRate on Invoice
        modelBuilder.Entity<Invoice>(invoice =>
        {
            invoice.OwnsOne(i => i.Tax, tax =>
            {
                tax.Property(t => t.Percentage)
                    .HasColumnName("TaxRate")
                    .HasPrecision(5, 2);
            });
        });
    }
}

The key lines are HasColumnName. They tell EF Core to map the Value Object's properties to the columns that already exist. The database does not know anything changed. The same SQL, the same columns, the same data -- different C# types.

Before and After SQL

The generated SQL is identical. Before the extraction:

SELECT [s].[CurrentPrice], [s].[PriceCurrency],
       [s].[CurrentPeriodStart], [s].[CurrentPeriodEnd]
FROM [Subscriptions] AS [s]
WHERE [s].[Id] = @p0

After the extraction:

SELECT [s].[CurrentPrice], [s].[PriceCurrency],
       [s].[CurrentPeriodStart], [s].[CurrentPeriodEnd]
FROM [Subscriptions] AS [s]
WHERE [s].[Id] = @p0

Identical. The database does not care. EF Core materializes the same columns into Money and SubscriptionPeriod instead of decimal, string, DateTime, and DateTime. The mapping is a C#-side concern only.

Diagram
The database stays identical — EF Core materialises the same four columns into Money and SubscriptionPeriod instead of decimal, string and two DateTimes, and the generated SQL is character-for-character the same before and after.

Four primitive columns. Before: four primitive C# properties. After: two Value Objects. Database: untouched.

The DateOnly Conversion

Note the HasConversion on SubscriptionPeriod. The database stores DATETIME2 but the Value Object uses DateOnly -- a more precise type for dates without time components. The converter handles the round-trip:

.HasConversion(
    d => d.ToDateTime(TimeOnly.MinValue),   // DateOnly → DateTime for storage
    d => DateOnly.FromDateTime(d));          // DateTime → DateOnly on read

This is a one-way improvement. The database continues to store DATETIME2 (with 00:00:00.0000000 for the time component). The C# code gets a DateOnly that cannot accidentally include a time. The subtle bugs from DateTime.UtcNow vs DateTime.Now in the old proration code -- where the time component could shift the date by one day depending on timezone -- are gone.


Migration Checklist

For each Value Object, follow this sequence:

  1. Identify all locations where the primitives appear. Search for the column names in the codebase. In SubscriptionHub, CurrentPrice appears in 11 files.

  2. Write the tests first. Define the contract: construction validation, equality, operations, edge cases. Run them. Watch them fail.

  3. Implement the Value Object. sealed record with constructor validation. Pure methods. No dependencies. Run the tests. Watch them pass.

  4. Update the entity. Replace decimal CurrentPrice + string PriceCurrency with Money CurrentPrice. This will cause compile errors at every call site -- that is the point. Each compile error is a place where raw primitives were used. Fix them one by one.

  5. Add EF Core owned type mapping. Map to existing columns with HasColumnName. No migration needed.

  6. Run the characterization tests from Part V. They should pass without changes. If a characterization test fails, you changed behavior -- investigate before proceeding.

  7. Run the full test suite. Characterization tests (Phase 1) + contract tests (Phase 3) + new Value Object TDD tests (Phase 4). Everything green.

The characterization tests are the safety net. When you replace decimal with Money and DateTime + DateTime with SubscriptionPeriod, the behavior observable from the outside -- HTTP responses, database writes, notification payloads -- must remain identical. The types changed. The behavior did not.

// This characterization test from Part V still passes after extraction
[Fact]
public async Task ProcessMonthlyBilling_ActiveSubscription_GoldenMaster()
{
    // ... same setup as Part V ...

    var result = await billingService.ProcessMonthlyBilling(
        customer.Id, new DateTime(2025, 7, 1));

    // Same assertions -- same observable behavior
    result.TotalAmount.Should().Be(49.99m);
    result.Currency.Should().Be("EUR");
    result.TaxAmount.Should().Be(10.00m);
    result.Status.Should().Be("Draft");
}

Same test. Same assertions. Same result. But inside BillingService, the proration now goes through SubscriptionPeriod.ProrateFraction(), the currency comparison goes through Money.Add(), and the tax calculation goes through TaxRate.ApplyTo(). The internals are clean. The externals are unchanged.


What We Have Now

Phase 4 is complete. Here is the state of the migration:

  • Phase 1 (Part V): Characterization tests capturing the golden master. Still green.
  • Phase 2 (Part VI): Bounded context libraries with separate DbContexts. Architecture tests enforce boundaries.
  • Phase 3 (Part VII): Anti-Corruption Layers with contract tests.
  • Phase 4 (this part): Value Objects with TDD tests. Money, SubscriptionPeriod, EmailAddress, TaxRate, PlanTier -- the domain vocabulary is typed. Proration has one source of truth. Currency mismatches are compile errors. And we did not change a single database column.

The bounded contexts have structure (libraries), boundaries (ACLs), and vocabulary (Value Objects). What they do not have yet is behavior. The entities inside those contexts are still anemic -- they still have public setters, no invariants, no domain events. Subscription is still a data bag that any service can mutate from the outside.

That changes in Part IX: Build Aggregates, where we take the Value Objects built here and compose them into aggregates with typed IDs, private setters, invariant enforcement through Result returns, and domain events that fire when state transitions occur. The Subscription entity becomes the Subscription aggregate root -- and it finally has opinions about its own lifecycle.

⬇ Download