Value Object w DDD

Value Object w DDD — implementacja w C# z rekordem

Tworzysz domenę i masz decimal Price, string Email, string Currency… Wszystko działa, dopóki ktoś nie przekaże ujemnej ceny, pustego emaila albo waluty "DUPA". Walidacja rozsiana po kontrolerach, serwisach i handlerach. Każdy sprawdza po swojemu. Albo nie sprawdza wcale.

Value Object rozwiązuje ten problem u źródła — obiekt, który nie może istnieć w nieprawidłowym stanie. Jeśli masz instancję Money, wiesz że kwota jest dodatnia i waluta jest poprawna. Bez ifów. Bez dodatkowej walidacji. Zawsze.

W tym artykule zobaczysz:

  • Czym Value Object naprawdę jest (i czym NIE jest)
  • Dlaczego record w C# to idealny building block
  • 5 produkcyjnych implementacji: Money, Email, Address, DateRange, Percentage
  • Jak mapować Value Objects na kolumny w EF Core
  • Gotowe testy xUnit do każdego przykładu

📖 Ten artykuł jest rozszerzeniem pytania #03 z mojego postu 10 pytań rekrutacyjnych Junior .NET Developer, gdzie Money pojawił się jako przykład record vs class.


Spis treści

  1. Czym jest Value Object
  2. Value Object vs Entity — kiedy co wybrać
  3. Dlaczego record, a nie class
  4. Bazowa klasa ValueObject
  5. Money — kwota z walutą
  6. Email — walidacja na poziomie typu
  7. Address — złożony Value Object
  8. DateRange — zakres dat z logiką
  9. Percentage — ograniczony zakres wartości
  10. Value Object w EF Core — mapowanie na kolumny
  11. Testy jednostkowe
  12. Antywzorce i pułapki
  13. Checklist

Czym jest Value Object

Value Object to obiekt domenowy, który:

1. Nie ma tożsamości — dwa Value Objects z tymi samymi danymi są równe. Nie mają ID. Nie pytasz „który to?”, tylko „ile to?” albo „jaki to?”.

Analogia z życia: banknot 100 zł. Nie obchodzi Cię który to konkretny banknot — obchodzi Cię wartość. Każdy banknot 100 zł jest wymienny na inny.

2. Jest immutable — po stworzeniu nie zmienia stanu. Zmiana = nowy obiekt. Jak string w C# — "hello".ToUpper() nie modyfikuje oryginału, zwraca nowy string.

3. Jest samowalidujący — jeśli instancja istnieje, to jest poprawna. Niepoprawne dane = wyjątek w konstruktorze. Nie ma takiego stanu jak „częściowo prawidłowy Value Object”.

// ❌ Primitive obsession — string może być CZYMKOLWIEK
public class Order
{
    public string CustomerEmail { get; set; }  // "nie-email", "", null...
    public decimal Price { get; set; }          // -100, 0, decimal.MaxValue...
    public string Currency { get; set; }        // "DUPA", "", null...
}

// ✅ Value Objects — jeśli istnieją, są poprawne
public class Order
{
    public Email CustomerEmail { get; }    // zawsze poprawny format
    public Money Price { get; }            // zawsze > 0, zawsze z walutą
}

Value Object vs Entity — kiedy co wybrać

To pytanie pada na rozmowach rekrutacyjnych na mid/senior stanowiska. Odpowiedź jest prosta:

KryteriumValue ObjectEntity
TożsamośćPrzez wartość (dane)Przez ID (klucz)
PorównanieMoney(100, "PLN") == Money(100, "PLN")User(id:1) != User(id:2) nawet z tymi samymi danymi
MutowalnośćImmutable — zmiana = nowy obiektMutable — zmienia stan w cyklu życia
Cykl życiaNie istnieje samodzielnie — żyje wewnątrz EntityMa własny cykl życia i jest persystowany w DB
PrzykładyMoney, Email, Address, DateRangeUser, Order, Product, Invoice

Reguła: Jeśli pytasz „ile?” albo „jaki?” — Value Object. Jeśli pytasz „który?” — Entity.

Kilka przykładów decyzji, które wymagają myślenia:

  • Address — Value Object w sklepie (adres dostawy). Ale Entity w systemie GIS (adres ma ID, historię zmian, geocoding).
  • PhoneNumber — Value Object w CRM (numer kontaktowy). Ale Entity w systemie telekomunikacyjnym.

Kontekst decyduje. To jest sedno Bounded Context w DDD.


Dlaczego record, a nie class

C# record daje za darmo to, co Value Object wymaga:

Wymaganie Value Objectclassrecord
Porównanie przez wartość (Equals, ==)Ręcznie✅ Auto
GetHashCode zgodny z EqualsRęcznie✅ Auto
ImmutabilityRęcznie (private set)init domyślnie
with expression (kopia ze zmianą)Brak✅ Wbudowane
ToString z wartościami pólRęcznie✅ Auto
Destrukturyzacja (Deconstruct)Ręcznie✅ Auto

To eliminuje boilerplate i skupia kod na tym, co ważne: walidacja i logika domenowa.

// class — musisz napisać ~40 linii boilerplate
public class MoneyClass
{
    public decimal Amount { get; }
    public string Currency { get; }
    
    public override bool Equals(object? obj) => /* ... 10 linii ... */
    public override int GetHashCode() => /* ... */
    public static bool operator ==(MoneyClass? a, MoneyClass? b) => /* ... */
    public static bool operator !=(MoneyClass? a, MoneyClass? b) => /* ... */
    public override string ToString() => /* ... */
}

// record — kompilator generuje to za Ciebie
public record Money(decimal Amount, string Currency);
// 
Equals ✅ 
GetHashCode ✅ 
== ✅ 
!= ✅ 
ToString ✅ 
with ✅

Kiedy record NIE wystarczy — gdy potrzebujesz walidacji w konstruktorze. Wtedy używasz record z jawnym konstruktorem (jak w przykładach poniżej).


Bazowa klasa ValueObject

Jeśli nie chcesz (lub nie możesz) używać record — np. musisz wspierać starszy codebase — oto bazowa klasa, z której dziedziczą Value Objects:

public abstract class ValueObject : IEquatable<ValueObject>
{
    protected abstract IEnumerable<object?> GetEqualityComponents();

    public override bool Equals(object? obj)
    {
        if (obj is null || obj.GetType() != GetType())
            return false;

        return Equals((ValueObject)obj);
    }

    public bool Equals(ValueObject? other)
    {
        if (other is null) return false;
        return GetEqualityComponents()
            .SequenceEqual(other.GetEqualityComponents());
    }

    public override int GetHashCode()
    {
        return GetEqualityComponents()
            .Aggregate(0, (hash, component) =>
                HashCode.Combine(hash, component));
    }

    public static bool operator ==(ValueObject? a, ValueObject? b)
    {
        if (a is null && b is null) return true;
        if (a is null || b is null) return false;
        return a.Equals(b);
    }

    public static bool operator !=(ValueObject? a, ValueObject? b)
        => !(a == b);
}

W dalszych przykładach używam record — to jest idiomatyczne C# 9+. Jeśli jesteś na starszym frameworku, podmień record na klasę dziedziczącą z ValueObject powyżej.


Money — kwota z walutą

Najpopularniejszy Value Object w każdej domenie, w której pojawiają się pieniądze. Pokazywałem go już w pytaniach rekrutacyjnych — tutaj pełna, produkcyjna wersja.

Dlaczego nie decimal?

// ❌ Primitive obsession
public decimal CalculateTotal(decimal price, decimal tax, decimal discount)
{
    return price + tax - discount; // Jakie waluty? Ujemne? Kto waliduje?
}

// Czy to jest legalne?
var total = CalculateTotal(100, 23, -50); // ujemny rabat?! 
var madness = CalculateTotal(price_PLN, tax_EUR, discount_USD); // ???

Z decimal kompilator nie pomoże — wszystko jest dozwolone. Z Money — niemożliwe operacje nie kompilują się lub rzucają wyjątek.

Implementacja

public record Money : IComparable<Money>
{
    // Dozwolone waluty — rozszerz według potrzeb
    private static readonly HashSet<string> ValidCurrencies =
        new(StringComparer.OrdinalIgnoreCase)
        { "PLN", "EUR", "USD", "GBP", "CHF", "CZK" };

    public decimal Amount   { get; init; }
    public string  Currency { get; init; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new ArgumentException(
                "Amount cannot be negative.", nameof(amount));

        if (string.IsNullOrWhiteSpace(currency))
            throw new ArgumentException(
                "Currency is required.", nameof(currency));

        var normalized = currency.ToUpperInvariant();

        if (!ValidCurrencies.Contains(normalized))
            throw new ArgumentException(
                $"Unknown currency: '{currency}'. " +
                $"Valid: {string.Join(", ", ValidCurrencies)}",
                nameof(currency));

        Amount   = amount;
        Currency = normalized;
    }

    // ── Operacje domenowe ─────────────────────────

    public Money Add(Money other)
    {
        EnsureSameCurrency(other);
        return this with { Amount = Amount + other.Amount };
    }

    public Money Subtract(Money other)
    {
        EnsureSameCurrency(other);

        if (Amount < other.Amount)
            throw new InvalidOperationException(
                $"Cannot subtract {other} from {this} — result would be negative.");

        return this with { Amount = Amount - other.Amount };
    }

    public Money MultiplyBy(decimal factor)
    {
        if (factor < 0)
            throw new ArgumentException(
                "Factor cannot be negative.", nameof(factor));

        return this with { Amount = Math.Round(Amount * factor, 2) };
    }

    public Money ApplyDiscount(Percentage discount)
    {
        var reduction = Amount * discount.Value / 100m;
        return this with { Amount = Math.Round(Amount - reduction, 2) };
    }

    // ── Fabryki ───────────────────────────────────

    public static Money Zero(string currency) => new(0, currency);
    public static Money PLN(decimal amount)   => new(amount, "PLN");
    public static Money EUR(decimal amount)   => new(amount, "EUR");
    public static Money USD(decimal amount)   => new(amount, "USD");

    // ── Porównanie ────────────────────────────────

    public int CompareTo(Money? other)
    {
        if (other is null) return 1;
        EnsureSameCurrency(other);
        return Amount.CompareTo(other.Amount);
    }

    public static bool operator >(Money a, Money b)  => a.CompareTo(b) > 0;
    public static bool operator <(Money a, Money b)  => a.CompareTo(b) < 0;
    public static bool operator >=(Money a, Money b) => a.CompareTo(b) >= 0;
    public static bool operator <=(Money a, Money b) => a.CompareTo(b) <= 0;

    // ── Helpers ───────────────────────────────────

    public bool IsZero => Amount == 0;

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

    private void EnsureSameCurrency(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException(
                $"Cannot operate on {Currency} and {other.Currency}. " +
                $"Convert first.");
    }
}

Użycie w domenie

var price    = Money.PLN(299.99m);
var tax      = price.MultiplyBy(0.23m);            // 69.00 PLN
var discount = new Percentage(10);
var final    = price.Add(tax).ApplyDiscount(discount); // 332.09 PLN

// ❌ Kompilator / runtime nie pozwoli na bzdury:
var nope = price.Add(Money.EUR(50));  // InvalidOperationException!
var bad  = new Money(-100, "PLN");    // ArgumentException!

Email — walidacja na poziomie typu

Zamiast walidować string email w 15 miejscach — zwaliduj raz, w konstruktorze.

public record Email
{
    public string Value { get; init; }

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

        // Normalizacja — zawsze lowercase
        var normalized = value.Trim().ToLowerInvariant();

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

        Value = normalized;
    }

    // Domena lokalna (przed @)
    public string LocalPart => Value[..Value.IndexOf('@')];

    // Domena (po @)
    public string Domain => Value[(Value.IndexOf('@') + 1)..];

    public override string ToString() => Value;

    // Implicit conversion — ułatwia użycie z bibliotekami
    public static implicit operator string(Email email) => email.Value;

    private static bool IsValidFormat(string email)
    {
        // Pragmatyczna walidacja — nie RFC 5322 (zbyt permisywne),
        // ale pokrywa 99.9% realnych adresów
        var atIndex = email.IndexOf('@');
        if (atIndex <= 0 || atIndex >= email.Length - 1)
            return false;

        var domain = email[(atIndex + 1)..];
        if (!domain.Contains('.') || domain.EndsWith('.'))
            return false;

        // Brak spacji, podwójnych kropek
        return !email.Contains(' ') && !email.Contains("..");
    }
}

Użycie

var email = new Email("  Jan.Kowalski@Gmail.COM  ");
Console.WriteLine(email);           // jan.kowalski@gmail.com
Console.WriteLine(email.Domain);    // gmail.com

// ❌ Nie przejdzie:
var bad1 = new Email("");            // ArgumentException
var bad2 = new Email("nie-email");   // ArgumentException
var bad3 = new Email("a@.com");      // ArgumentException

// ✅ Implicit conversion — działa z bibliotekami oczekującymi string
SendSmtp(email); // void SendSmtp(string to)

Address — złożony Value Object

Value Object może składać się z wielu pól. Adres to klasyczny przykład.

public record Address
{
    public string Street  { get; init; }
    public string City    { get; init; }
    public string ZipCode { get; init; }
    public string Country { get; init; }

    public Address(string street, string city, string zipCode, string country)
    {
        if (string.IsNullOrWhiteSpace(street))
            throw new ArgumentException("Street is required.", nameof(street));
        if (string.IsNullOrWhiteSpace(city))
            throw new ArgumentException("City is required.", nameof(city));
        if (string.IsNullOrWhiteSpace(zipCode))
            throw new ArgumentException("Zip code is required.", nameof(zipCode));
        if (string.IsNullOrWhiteSpace(country))
            throw new ArgumentException("Country is required.", nameof(country));

        Street  = street.Trim();
        City    = city.Trim();
        ZipCode = NormalizeZipCode(zipCode, country);
        Country = country.Trim().ToUpperInvariant();
    }

    // Zmiana miasta = NOWY adres (immutability)
    public Address WithCity(string newCity) =>
        this with { City = newCity };

    public string ToSingleLine() =>
        $"{Street}, {ZipCode} {City}, {Country}";

    public override string ToString() => ToSingleLine();

    private static string NormalizeZipCode(string zip, string country)
    {
        var clean = zip.Trim().Replace(" ", "");

        return country.Trim().ToUpperInvariant() switch
        {
            "PL" when clean.Length == 5 =>
                $"{clean[..2]}-{clean[2..]}",      // 00-000
            "PL" when clean.Length == 6 && clean[2] == '-' =>
                clean,                              // już poprawny
            _ => clean
        };
    }
}

Użycie

var addr = new Address("Marszałkowska 1", "Warszawa", "00001", "PL");
Console.WriteLine(addr);  // Marszałkowska 1, 00-001 Warszawa, PL

var moved = addr.WithCity("Kraków");
// addr niezmieniony — moved to NOWY obiekt

// Porównanie przez wartość:
var a1 = new Address("Długa 5", "Gdańsk", "80-001", "PL");
var a2 = new Address("Długa 5", "Gdańsk", "80-001", "PL");
Console.WriteLine(a1 == a2);  // TRUE — te same dane

DateRange — zakres dat z logiką

Value Object nie musi być „prosty” — może zawierać logikę domenową.

public record DateRange
{
    public DateOnly Start { get; init; }
    public DateOnly End   { get; init; }

    public DateRange(DateOnly start, DateOnly end)
    {
        if (end < start)
            throw new ArgumentException(
                $"End ({end}) cannot be before Start ({start}).");

        Start = start;
        End   = end;
    }

    // ── Logika domenowa ───────────────────────────

    public int Days => End.DayNumber - Start.DayNumber + 1;

    public bool Contains(DateOnly date) =>
        date >= Start && date <= End;

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

    public DateRange? Intersect(DateRange other)
    {
        if (!OverlapsWith(other))
            return null;

        var start = Start > other.Start ? Start : other.Start;
        var end   = End < other.End ? End : other.End;
        return new DateRange(start, end);
    }

    public DateRange Extend(int days) =>
        this with { End = End.AddDays(days) };

    // ── Fabryki ───────────────────────────────────

    public static DateRange ThisMonth()
    {
        var today = DateOnly.FromDateTime(DateTime.Today);
        var start = new DateOnly(today.Year, today.Month, 1);
        var end   = start.AddMonths(1).AddDays(-1);
        return new DateRange(start, end);
    }

    public static DateRange Between(DateOnly start, DateOnly end) =>
        new(start, end);

    public override string ToString() => $"{Start:yyyy-MM-dd} → {End:yyyy-MM-dd}";
}

Użycie w domenie

var vacation   = new DateRange(new DateOnly(2024, 7, 1), new DateOnly(2024, 7, 14));
var conference = new DateRange(new DateOnly(2024, 7, 10), new DateOnly(2024, 7, 12));

Console.WriteLine(vacation.OverlapsWith(conference));  // TRUE
Console.WriteLine(vacation.Days);                      // 14

var overlap = vacation.Intersect(conference);
Console.WriteLine(overlap);  // 2024-07-10 → 2024-07-12

Percentage — ograniczony zakres wartości

Mały Value Object, który eliminuje klasyczne bugi: „rabat 150%”, „VAT -23%”, „marża 0.23 czy 23?”.

public record Percentage
{
    public decimal Value { get; init; }

    public Percentage(decimal value)
    {
        if (value < 0 || value > 100)
            throw new ArgumentOutOfRangeException(
                nameof(value),
                $"Percentage must be between 0 and 100, got {value}.");

        Value = value;
    }

    // ── Logika ────────────────────────────────────

    public decimal AsFraction => Value / 100m;

    public decimal Of(decimal amount) =>
        Math.Round(amount * AsFraction, 2);

    // ── Fabryki ───────────────────────────────────

    public static Percentage Zero => new(0);
    public static Percentage Full => new(100);

    /// <summary>
    /// Tworzy Percentage z ułamka (0.23 → 23%)
    /// </summary>
    public static Percentage FromFraction(decimal fraction)
    {
        if (fraction < 0 || fraction > 1)
            throw new ArgumentOutOfRangeException(
                nameof(fraction), "Fraction must be between 0 and 1.");

        return new Percentage(fraction * 100);
    }

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

Użycie

var vat      = new Percentage(23);
var discount = new Percentage(10);

var netPrice = 1000m;
var taxAmount     = vat.Of(netPrice);         // 230.00
var discountAmount = discount.Of(netPrice);   // 100.00

// ❌ Nie przejdzie:
var absurd = new Percentage(150);  // ArgumentOutOfRangeException
var nope   = new Percentage(-5);   // ArgumentOutOfRangeException

// Konwersja z ułamka (EF Core / API zwraca 0.23):
var fromApi = Percentage.FromFraction(0.23m);  // 23%

Value Object w EF Core — mapowanie na kolumny

Value Object żyje wewnątrz Entity. EF Core mapuje go na kolumny tej samej tabeli — bez osobnej tabeli.

Owned Types (EF Core 2.0+)

// Entity
public class Order
{
    public int Id { get; private set; }
    public Email CustomerEmail { get; private set; } = null!;
    public Money TotalPrice { get; private set; } = null!;
    public Address ShippingAddress { get; private set; } = null!;

    // EF Core wymaga prywatnego konstruktora
    private Order() { }

    public Order(Email email, Money price, Address address)
    {
        CustomerEmail   = email;
        TotalPrice      = price;
        ShippingAddress = address;
    }
}

Konfiguracja w DbContext

public class AppDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>(builder =>
        {
            // Email → jedna kolumna
            builder.OwnsOne(o => o.CustomerEmail, email =>
            {
                email.Property(e => e.Value)
                     .HasColumnName("CustomerEmail")
                     .HasMaxLength(320)
                     .IsRequired();
            });

            // Money → dwie kolumny
            builder.OwnsOne(o => o.TotalPrice, money =>
            {
                money.Property(m => m.Amount)
                     .HasColumnName("TotalPrice_Amount")
                     .HasPrecision(18, 2)
                     .IsRequired();

                money.Property(m => m.Currency)
                     .HasColumnName("TotalPrice_Currency")
                     .HasMaxLength(3)
                     .IsRequired();
            });

            // Address → cztery kolumny
            builder.OwnsOne(o => o.ShippingAddress, addr =>
            {
                addr.Property(a => a.Street).HasColumnName("Ship_Street")
                    .HasMaxLength(200);
                addr.Property(a => a.City).HasColumnName("Ship_City")
                    .HasMaxLength(100);
                addr.Property(a => a.ZipCode).HasColumnName("Ship_ZipCode")
                    .HasMaxLength(10);
                addr.Property(a => a.Country).HasColumnName("Ship_Country")
                    .HasMaxLength(2);
            });
        });
    }
}

Wynikowa tabela w bazie

Orders
├── Id                  INT         PK
├── CustomerEmail       NVARCHAR(320)
├── TotalPrice_Amount   DECIMAL(18,2)
├── TotalPrice_Currency NVARCHAR(3)
├── Ship_Street         NVARCHAR(200)
├── Ship_City           NVARCHAR(100)
├── Ship_ZipCode        NVARCHAR(10)
└── Ship_Country        NVARCHAR(2)

Jedna tabela, zero JOINów. Value Objects to płaskie kolumny — zero narzutu wydajnościowego.

EF Core 8 — Complex Types (alternatywa)

Od EF Core 8 masz ComplexProperty — prostszą konfigurację bez semantyki „owned”:

builder.ComplexProperty(o => o.TotalPrice, money =>
{
    money.Property(m => m.Amount).HasColumnName("TotalPrice_Amount");
    money.Property(m => m.Currency).HasColumnName("TotalPrice_Currency");
});

Różnica: ComplexProperty nie wymaga nawigacji, nie tworzy shadow FK, i lepiej działa z record. Jeśli jesteś na EF Core 8+ — preferuj ComplexProperty.


Testy jednostkowe

Value Objects są idealne do testów — brak zależności, brak I/O, czysty input → output.

public class MoneyTests
{
    [Fact]
    public void Two_Money_With_Same_Amount_And_Currency_Are_Equal()
    {
        var a = Money.PLN(100);
        var b = Money.PLN(100);

        Assert.Equal(a, b);
        Assert.True(a == b);
        Assert.Equal(a.GetHashCode(), b.GetHashCode());
    }

    [Fact]
    public void Add_Same_Currency_Returns_Sum()
    {
        var result = Money.PLN(100).Add(Money.PLN(50));

        Assert.Equal(150m, result.Amount);
        Assert.Equal("PLN", result.Currency);
    }

    [Fact]
    public void Add_Different_Currency_Throws()
    {
        Assert.Throws<InvalidOperationException>(
            () => Money.PLN(100).Add(Money.EUR(50)));
    }

    [Fact]
    public void Negative_Amount_Throws()
    {
        Assert.Throws<ArgumentException>(
            () => new Money(-1, "PLN"));
    }

    [Fact]
    public void Invalid_Currency_Throws()
    {
        Assert.Throws<ArgumentException>(
            () => new Money(100, "XYZ"));
    }

    [Fact]
    public void ApplyDiscount_Reduces_Amount()
    {
        var price = Money.PLN(200);
        var discount = new Percentage(25);

        var result = price.ApplyDiscount(discount);

        Assert.Equal(150m, result.Amount);
    }
}

public class EmailTests
{
    [Theory]
    [InlineData("jan@test.pl")]
    [InlineData("  Jan.Kowalski@Gmail.COM  ")]
    public void Valid_Email_Normalizes_To_Lowercase(string input)
    {
        var email = new Email(input);
        Assert.Equal(input.Trim().ToLowerInvariant(), email.Value);
    }

    [Theory]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("nie-email")]
    [InlineData("a@")]
    [InlineData("@b.com")]
    [InlineData("a@.com")]
    public void Invalid_Email_Throws(string input)
    {
        Assert.Throws<ArgumentException>(() => new Email(input));
    }

    [Fact]
    public void Two_Emails_Same_Value_Are_Equal()
    {
        var a = new Email("jan@test.pl");
        var b = new Email("JAN@TEST.PL");

        Assert.Equal(a, b);  // normalizacja → lowercase
    }
}

public class DateRangeTests
{
    [Fact]
    public void Overlapping_Ranges_Detected()
    {
        var a = new DateRange(new(2024, 1, 1), new(2024, 1, 15));
        var b = new DateRange(new(2024, 1, 10), new(2024, 1, 20));

        Assert.True(a.OverlapsWith(b));
    }

    [Fact]
    public void End_Before_Start_Throws()
    {
        Assert.Throws<ArgumentException>(
            () => new DateRange(new(2024, 12, 31), new(2024, 1, 1)));
    }

    [Fact]
    public void Days_Counts_Inclusive()
    {
        var range = new DateRange(new(2024, 1, 1), new(2024, 1, 3));
        Assert.Equal(3, range.Days);
    }
}

Antywzorce i pułapki

1. Value Object z setterem

// ❌ NIGDY — łamie immutability
public record Money
{
    public decimal Amount { get; set; }  // ← set zamiast init
}

Jeśli ktoś zmieni Amount po stworzeniu, GetHashCode się zmieni — zepsuje Dictionary, HashSet, i każdą kolekcję, która cache’uje hash.

2. Value Object z ID

// ❌ Value Object NIE MA tożsamości
public record Email
{
    public int Id { get; init; }       // ← to jest Entity, nie Value Object
    public string Value { get; init; }
}

Jeśli potrzebujesz ID — to jest Entity, nie Value Object.

3. Walidacja poza konstruktorem

// ❌ Walidacja w serwisie — Value Object może istnieć w nieprawidłowym stanie
public class OrderService
{
    public void PlaceOrder(Money price)
    {
        if (price.Amount <= 0) // ← za późno! Money powinien to wymusić
            throw new ArgumentException();
    }
}

Walidacja musi być w konstruktorze. Jeśli instancja istnieje — jest poprawna. Koniec dyskusji.

4. Zbyt duży Value Object

Jeśli Twój Value Object ma 10+ pól, 15 metod i zaczyna zarządzać stanem — prawdopodobnie to nie jest Value Object, tylko ukryta Entity albo Aggregate.


Checklist

Przed dodaniem Value Object do domeny sprawdź:

  • [ ] Brak tożsamości — dwa obiekty z tymi samymi danymi są równe.
  • [ ] Immutable — wszystkie properties to init, zmiana = nowy obiekt.
  • [ ] Samowalidujący — konstruktor rzuca wyjątek dla nieprawidłowych danych.
  • [ ] Normalizacja — dane są spójne (lowercase email, uppercase currency, trimmed strings).
  • [ ] Logika domenowa — operacje na danych żyją w Value Object, nie w serwisie.
  • [ ] Testy — equality, walidacja, edge cases, operacje domenowe.
  • [ ] EF CoreOwnsOne / ComplexProperty skonfigurowane.
  • [ ] ToString — czytelna reprezentacja do logów i debugowania.

Co dalej?

Value Object to fundament Domain-Driven Design — ale to dopiero początek. Jeśli chcesz zgłębić DDD w C#:

🗺️ Pobierz darmową roadmapę Junior .NET Developer — DDD i Clean Architecture to kroki 8–10 na ścieżce.

🎓 7 dni bezpłatnego dostępu do kursów — w tym kurs programowania obiektowego, gdzie budujemy domenę od zera.

🎬 Subskrybuj kanał YouTube — nowe odcinki co tydzień.


Który Value Object dodasz jako pierwszy do swojego projektu? Napisz w komentarzu! 💬


Dodaj komentarz