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
recordw 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
Moneypojawił się jako przykład record vs class.
Spis treści
- Czym jest Value Object
- Value Object vs Entity — kiedy co wybrać
- Dlaczego record, a nie class
- Bazowa klasa ValueObject
- Money — kwota z walutą
- Email — walidacja na poziomie typu
- Address — złożony Value Object
- DateRange — zakres dat z logiką
- Percentage — ograniczony zakres wartości
- Value Object w EF Core — mapowanie na kolumny
- Testy jednostkowe
- Antywzorce i pułapki
- 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:
| Kryterium | Value Object | Entity |
|---|---|---|
| Tożsamość | Przez wartość (dane) | Przez ID (klucz) |
| Porównanie | Money(100, "PLN") == Money(100, "PLN") ✅ | User(id:1) != User(id:2) nawet z tymi samymi danymi |
| Mutowalność | Immutable — zmiana = nowy obiekt | Mutable — zmienia stan w cyklu życia |
| Cykl życia | Nie istnieje samodzielnie — żyje wewnątrz Entity | Ma własny cykl życia i jest persystowany w DB |
| Przykłady | Money, Email, Address, DateRange | User, 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 Object | class | record |
|---|---|---|
Porównanie przez wartość (Equals, ==) | Ręcznie | ✅ Auto |
GetHashCode zgodny z Equals | Ręcznie | ✅ Auto |
| Immutability | Ręcznie (private set) | ✅ init domyślnie |
with expression (kopia ze zmianą) | Brak | ✅ Wbudowane |
ToString z wartościami pól | Rę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 Core —
OwnsOne/ComplexPropertyskonfigurowane. - [ ] 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! 💬

