From 00f6b3d4f9b0869fcb8ba6f311a5064aecae7e30 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Sun, 27 Sep 2026 19:41:02 +0200 Subject: [PATCH] Value Objects: net10.0, Money that refuses negative amounts, EF Core 10 Retarget both projects from net7.0 to net10.0 and move the tests from NUnit to xUnit v3 (plain Assert). Money now refuses a negative amount and stores the upper-case currency code, with the Result/Error shape and a MoneyErrors class. Add Program.cs running the article's examples in order, a PaymentsDbContext mapping Money as an EF Core 10 complex type on SQLite in memory, a Route value object with collection equality, and tests for validation, persistence and route equality (10 in total). --- .../Tests/MoneyValidationUnitTest.cs | 33 ++++++++++ .../ValueObjects/Tests/PersistenceUnitTest.cs | 35 +++++++++++ .../Tests/RouteEqualityUnitTest.cs | 16 +++++ .../Tests/SerializationUnitTest.cs | 22 +++---- .../ValueObjects/Tests/Tests.csproj | 10 ++-- .../ValueObjects/Tests/Usings.cs | 2 +- .../Tests/ValueEqualityUnitTest.cs | 40 ++++++------- .../ValueObjects/Entities/Payment.cs | 9 ++- .../Persistence/PaymentsDbContext.cs | 19 ++++++ .../ValueObjects/ValueObjects/Program.cs | 60 +++++++++++++++++++ .../TypeSafety/TicketPriceProvider.cs | 4 +- .../ValueObjects/ValueObjects.csproj | 7 ++- .../ValueObjects/ValueObjects/EmailAddress.cs | 2 +- .../ValueObjects/ValueObjects/Money.cs | 22 +++---- .../ValueObjects/ValueObjects/MoneyErrors.cs | 10 ++++ .../ValueObjects/ValueObjects/Result.cs | 47 +++++++++++---- .../ValueObjects/ValueObjects/Route.cs | 16 +++++ 17 files changed, 290 insertions(+), 64 deletions(-) create mode 100644 domain-driven design/ValueObjects/Tests/MoneyValidationUnitTest.cs create mode 100644 domain-driven design/ValueObjects/Tests/PersistenceUnitTest.cs create mode 100644 domain-driven design/ValueObjects/Tests/RouteEqualityUnitTest.cs create mode 100644 domain-driven design/ValueObjects/ValueObjects/Persistence/PaymentsDbContext.cs create mode 100644 domain-driven design/ValueObjects/ValueObjects/Program.cs create mode 100644 domain-driven design/ValueObjects/ValueObjects/ValueObjects/MoneyErrors.cs create mode 100644 domain-driven design/ValueObjects/ValueObjects/ValueObjects/Route.cs diff --git a/domain-driven design/ValueObjects/Tests/MoneyValidationUnitTest.cs b/domain-driven design/ValueObjects/Tests/MoneyValidationUnitTest.cs new file mode 100644 index 0000000000..18ecac22ae --- /dev/null +++ b/domain-driven design/ValueObjects/Tests/MoneyValidationUnitTest.cs @@ -0,0 +1,33 @@ +using ValueObjects.ValueObjects; + +namespace Tests; + +public class MoneyValidationUnitTest +{ + [Fact] + public void GivenANegativeAmount_WhenCreatingMoney_ThenItFails() + { + var result = Money.Create(-5, "USD"); + + Assert.False(result.IsSuccess); + Assert.Equal("Money.NegativeAmount", result.Error!.Code); + } + + [Fact] + public void GivenAnUnsupportedCurrency_WhenCreatingMoney_ThenItFails() + { + var result = Money.Create(100, "GBP"); + + Assert.False(result.IsSuccess); + Assert.Equal("Money.UnsupportedCurrency", result.Error!.Code); + } + + [Fact] + public void GivenALowerCaseCurrency_WhenCreatingMoney_ThenItEqualsTheUpperCaseOne() + { + var typed = Money.Create(100, "usd").Value; + + Assert.Equal("USD", typed.Currency); + Assert.Equal(Money.Create(100, "USD").Value, typed); + } +} diff --git a/domain-driven design/ValueObjects/Tests/PersistenceUnitTest.cs b/domain-driven design/ValueObjects/Tests/PersistenceUnitTest.cs new file mode 100644 index 0000000000..57847011b3 --- /dev/null +++ b/domain-driven design/ValueObjects/Tests/PersistenceUnitTest.cs @@ -0,0 +1,35 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using ValueObjects.Entities; +using ValueObjects.Persistence; +using ValueObjects.ValueObjects; + +namespace Tests; + +public class PersistenceUnitTest +{ + [Fact] + public async Task GivenASavedPayment_WhenQueryingByMoney_ThenItIsFound() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(cancellationToken); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + + await using (var context = new PaymentsDbContext(options)) + { + await context.Database.EnsureCreatedAsync(cancellationToken); + context.Payments.Add(new Payment(Money.Create(100, "usd").Value)); + await context.SaveChangesAsync(cancellationToken); + } + + await using (var context = new PaymentsDbContext(options)) + { + var price = Money.Create(100, "USD").Value; + + var payment = await context.Payments.SingleAsync(p => p.Quoted == price, cancellationToken); + + Assert.Equal(price, payment.Quoted); + } + } +} diff --git a/domain-driven design/ValueObjects/Tests/RouteEqualityUnitTest.cs b/domain-driven design/ValueObjects/Tests/RouteEqualityUnitTest.cs new file mode 100644 index 0000000000..55b1a2a266 --- /dev/null +++ b/domain-driven design/ValueObjects/Tests/RouteEqualityUnitTest.cs @@ -0,0 +1,16 @@ +using ValueObjects.ValueObjects; + +namespace Tests; + +public class RouteEqualityUnitTest +{ + [Fact] + public void GivenTwoRoutes_WhenTheyHaveTheSameStops_ThenTheyShouldBeEqual() + { + Station[] stops = [new("JFK", "John F. Kennedy International Airport"), new("YVR", "Vancouver International Airport")]; + Station[] sameStops = [new("JFK", "John F. Kennedy International Airport"), new("YVR", "Vancouver International Airport")]; + + Assert.Equal(new Route(stops), new Route(sameStops)); + Assert.Single(new HashSet { new(stops), new(sameStops) }); + } +} diff --git a/domain-driven design/ValueObjects/Tests/SerializationUnitTest.cs b/domain-driven design/ValueObjects/Tests/SerializationUnitTest.cs index 59b03b458a..ffe95b249d 100644 --- a/domain-driven design/ValueObjects/Tests/SerializationUnitTest.cs +++ b/domain-driven design/ValueObjects/Tests/SerializationUnitTest.cs @@ -6,26 +6,26 @@ namespace Tests; public sealed class SerializationUnitTest { - [Test] + [Fact] public void GivenAUserWithEmailAddressObject_WhenUsingEmailAddressConverter_ThenCanSerializeItIntoString() { var user = new User(new("email@example.com")); - var settings = new JsonSerializerOptions {Converters = {new EmailAddressConverter()}}; + var settings = new JsonSerializerOptions { Converters = { new EmailAddressConverter() } }; var json = JsonSerializer.Serialize(user, settings); - + const string expectedJson = "{\"EmailAddress\":\"email@example.com\"}"; - Assert.That(json, Is.EqualTo(expectedJson)); + Assert.Equal(expectedJson, json); } - [Test] + [Fact] public void GivenAUserWithEmailAddressJson_WhenUsingEmailAddressConverter_ThenCanDeserializeItIntoUser() { const string json = "{\"EmailAddress\":\"email@example.com\"}"; - var settings = new JsonSerializerOptions {Converters = {new EmailAddressConverter()}}; + var settings = new JsonSerializerOptions { Converters = { new EmailAddressConverter() } }; var user = JsonSerializer.Deserialize(json, settings); - - Assert.That(user, Is.Not.Null); - Assert.That(user!.EmailAddress, Is.Not.Null); - Assert.That(user.EmailAddress.Address, Is.EqualTo("email@example.com")); + + Assert.NotNull(user); + Assert.NotNull(user.EmailAddress); + Assert.Equal("email@example.com", user.EmailAddress.Address); } -} \ No newline at end of file +} diff --git a/domain-driven design/ValueObjects/Tests/Tests.csproj b/domain-driven design/ValueObjects/Tests/Tests.csproj index 6afc537130..b79a1e6ab2 100644 --- a/domain-driven design/ValueObjects/Tests/Tests.csproj +++ b/domain-driven design/ValueObjects/Tests/Tests.csproj @@ -1,7 +1,7 @@ - net7.0 + net10.0 enable enable @@ -10,11 +10,9 @@ - - - - - + + + diff --git a/domain-driven design/ValueObjects/Tests/Usings.cs b/domain-driven design/ValueObjects/Tests/Usings.cs index cefced4969..c802f4480b 100644 --- a/domain-driven design/ValueObjects/Tests/Usings.cs +++ b/domain-driven design/ValueObjects/Tests/Usings.cs @@ -1 +1 @@ -global using NUnit.Framework; \ No newline at end of file +global using Xunit; diff --git a/domain-driven design/ValueObjects/Tests/ValueEqualityUnitTest.cs b/domain-driven design/ValueObjects/Tests/ValueEqualityUnitTest.cs index 6b23cb7b66..8609996b25 100644 --- a/domain-driven design/ValueObjects/Tests/ValueEqualityUnitTest.cs +++ b/domain-driven design/ValueObjects/Tests/ValueEqualityUnitTest.cs @@ -5,35 +5,35 @@ namespace Tests; public class ValueEqualityUnitTest { - [Test] + [Fact] public void GivenTwoRecords_WhenTheyHaveTheSameData_ThenTheyShouldBeEqual() { var hundredUsd = Money.Create(100, "USD"); var another100Usd = Money.Create(100, "USD"); - - Assert.That(hundredUsd.IsSuccess, Is.True); - Assert.That(another100Usd.IsSuccess, Is.True); - Assert.That(another100Usd.Value, Is.EqualTo(hundredUsd.Value)); + + Assert.True(hundredUsd.IsSuccess); + Assert.True(another100Usd.IsSuccess); + Assert.Equal(hundredUsd.Value, another100Usd.Value); } - - [Test] - public void GivenTwoRecords_WhenTheyHaveTheDifferentData_ThenTheyShouldNotBeEqual() + + [Fact] + public void GivenTwoRecords_WhenTheyHaveDifferentData_ThenTheyShouldNotBeEqual() { var hundredUsd = Money.Create(100, "USD"); var hundredEur = Money.Create(100, "EUR"); - - Assert.That(hundredUsd.IsSuccess, Is.True); - Assert.That(hundredEur.IsSuccess, Is.True); - Assert.That(hundredEur.Value, Is.Not.EqualTo(hundredUsd.Value)); + + Assert.True(hundredUsd.IsSuccess); + Assert.True(hundredEur.IsSuccess); + Assert.NotEqual(hundredUsd.Value, hundredEur.Value); } - - [Test] + + [Fact] public void GivenTwoClasses_WhenTheyHaveTheSameData_ThenTheyShouldNotBeEqual() { - var hundredUsd = Money.Create(100, "USD"); - var payment1 = new Payment(hundredUsd.Value!); - var payment2 = new Payment(hundredUsd.Value!); - - Assert.That(payment1, Is.Not.EqualTo(payment2)); + var hundredUsd = Money.Create(100, "USD").Value; + var payment1 = new Payment(hundredUsd); + var payment2 = new Payment(hundredUsd); + + Assert.NotEqual(payment1, payment2); } -} \ No newline at end of file +} diff --git a/domain-driven design/ValueObjects/ValueObjects/Entities/Payment.cs b/domain-driven design/ValueObjects/ValueObjects/Entities/Payment.cs index 090b3cbcfc..b82e71d54f 100644 --- a/domain-driven design/ValueObjects/ValueObjects/Entities/Payment.cs +++ b/domain-driven design/ValueObjects/ValueObjects/Entities/Payment.cs @@ -1,4 +1,4 @@ -using ValueObjects.ValueObjects; +using ValueObjects.ValueObjects; namespace ValueObjects.Entities; @@ -14,4 +14,9 @@ public Payment(Money quoted) Quoted = quoted; CreatedAt = DateTime.UtcNow; } -} \ No newline at end of file + + private Payment() + { + Quoted = null!; + } +} diff --git a/domain-driven design/ValueObjects/ValueObjects/Persistence/PaymentsDbContext.cs b/domain-driven design/ValueObjects/ValueObjects/Persistence/PaymentsDbContext.cs new file mode 100644 index 0000000000..016ea0b48f --- /dev/null +++ b/domain-driven design/ValueObjects/ValueObjects/Persistence/PaymentsDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using ValueObjects.Entities; + +namespace ValueObjects.Persistence; + +public sealed class PaymentsDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet Payments => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().ComplexProperty(p => p.Quoted, money => + { + money.Property(m => m.Amount); + money.Property(m => m.Currency).HasMaxLength(3); + }); + } +} diff --git a/domain-driven design/ValueObjects/ValueObjects/Program.cs b/domain-driven design/ValueObjects/ValueObjects/Program.cs new file mode 100644 index 0000000000..6be307c1d3 --- /dev/null +++ b/domain-driven design/ValueObjects/ValueObjects/Program.cs @@ -0,0 +1,60 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using ValueObjects.Entities; +using ValueObjects.Persistence; +using ValueObjects.TypeSafety; +using ValueObjects.ValueObjects; + +var refused = Money.Create(-5m, "USD"); + +Console.WriteLine(refused.IsSuccess); +Console.WriteLine(refused.Error); + +var originCountry = new Country("US", "United States of America"); +var originStation = new Station("JFK", "John F. Kennedy International Airport"); +var destinationCountry = new Country("CA", "Canada"); +var destinationStation = new Station("YVR", "Vancouver International Airport"); + +Console.WriteLine(new TicketPriceProvider().GetTicketPrice(originCountry, originStation, destinationCountry, destinationStation)); + +var hundredUsd = Money.Create(100m, "USD").Value; +var typedInLowerCase = Money.Create(100m, "usd").Value; +var hundredEur = Money.Create(100m, "EUR").Value; + +Console.WriteLine(hundredUsd == typedInLowerCase); +Console.WriteLine(hundredUsd == hundredEur); +Console.WriteLine(new Payment(hundredUsd) == new Payment(hundredUsd)); + +await using var connection = new SqliteConnection("Data Source=:memory:"); +await connection.OpenAsync(); + +var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + +await using (var context = new PaymentsDbContext(options)) +{ + await context.Database.EnsureCreatedAsync(); + Console.WriteLine(context.Database.GenerateCreateScript().TrimEnd()); + + context.Payments.Add(new Payment(typedInLowerCase)); + await context.SaveChangesAsync(); +} + +await using (var context = new PaymentsDbContext(options)) +{ + var price = Money.Create(100m, "USD").Value; + var query = context.Payments.Where(p => p.Quoted == price); + + Console.WriteLine(query.ToQueryString()); + + var payment = await query.SingleAsync(); + + Console.WriteLine(payment.Quoted); + Console.WriteLine(payment.Quoted == hundredUsd); +} + +Station[] stops = [new("JFK", "John F. Kennedy International Airport"), new("YVR", "Vancouver International Airport")]; +Station[] sameStops = [new("JFK", "John F. Kennedy International Airport"), new("YVR", "Vancouver International Airport")]; + +Console.WriteLine(new Route(stops) == new Route(sameStops)); diff --git a/domain-driven design/ValueObjects/ValueObjects/TypeSafety/TicketPriceProvider.cs b/domain-driven design/ValueObjects/ValueObjects/TypeSafety/TicketPriceProvider.cs index 6805e60f18..7bb24960b2 100644 --- a/domain-driven design/ValueObjects/ValueObjects/TypeSafety/TicketPriceProvider.cs +++ b/domain-driven design/ValueObjects/ValueObjects/TypeSafety/TicketPriceProvider.cs @@ -6,6 +6,6 @@ public class TicketPriceProvider : ITicketPriceProvider { public Money GetTicketPrice(Country originCountry, Station originStation, Country destinationCountry, Station destinationStation) { - return Money.Create(100, "USD").Value!; + return Money.Create(100, "USD").Value; } -} \ No newline at end of file +} diff --git a/domain-driven design/ValueObjects/ValueObjects/ValueObjects.csproj b/domain-driven design/ValueObjects/ValueObjects/ValueObjects.csproj index 6836c6808f..0198a2578c 100644 --- a/domain-driven design/ValueObjects/ValueObjects/ValueObjects.csproj +++ b/domain-driven design/ValueObjects/ValueObjects/ValueObjects.csproj @@ -1,9 +1,14 @@ - net7.0 + Exe + net10.0 enable enable + + + + diff --git a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/EmailAddress.cs b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/EmailAddress.cs index a7713ff18a..28fc5bcde4 100644 --- a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/EmailAddress.cs +++ b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/EmailAddress.cs @@ -1,3 +1,3 @@ namespace ValueObjects.ValueObjects; -public record EmailAddress(string Address); \ No newline at end of file +public record EmailAddress(string Address); //validation omitted for brevity diff --git a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Money.cs b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Money.cs index 4ca89eed5f..dd6dea3389 100644 --- a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Money.cs +++ b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Money.cs @@ -1,12 +1,12 @@ namespace ValueObjects.ValueObjects; -public record Money +public sealed record Money { - private static readonly IReadOnlyCollection SupportedCurrencies = new[]{"USD", "EUR"}; + private static readonly string[] SupportedCurrencies = ["USD", "EUR"]; public decimal Amount { get; } public string Currency { get; } - + private Money(decimal amount, string currency) { Amount = amount; @@ -15,12 +15,14 @@ private Money(decimal amount, string currency) public static Result Create(decimal amount, string currency) { - if(string.IsNullOrWhiteSpace(currency)) - return Result.Failure($"{nameof(currency)} cannot be null or whitespace."); + if (amount < 0) + return MoneyErrors.NegativeAmount(amount); + + var code = currency.ToUpperInvariant(); + + if (!SupportedCurrencies.Contains(code)) + return MoneyErrors.UnsupportedCurrency(currency); - if(!SupportedCurrencies.Contains(currency.ToUpperInvariant())) - return Result.Failure($"'{currency}' is not supported."); - - return Result.Success(new(amount, currency)); + return new Money(amount, code); } -} \ No newline at end of file +} diff --git a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/MoneyErrors.cs b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/MoneyErrors.cs new file mode 100644 index 0000000000..4f6967860f --- /dev/null +++ b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/MoneyErrors.cs @@ -0,0 +1,10 @@ +namespace ValueObjects.ValueObjects; + +public static class MoneyErrors +{ + public static Error NegativeAmount(decimal amount) => + new("Money.NegativeAmount", $"Amount {amount} is negative.", ErrorType.Validation); + + public static Error UnsupportedCurrency(string currency) => + new("Money.UnsupportedCurrency", $"Currency '{currency}' is not supported.", ErrorType.Validation); +} diff --git a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Result.cs b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Result.cs index 1002736613..d9cdaf8f91 100644 --- a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Result.cs +++ b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Result.cs @@ -1,13 +1,40 @@ namespace ValueObjects.ValueObjects; -public record Result +public enum ErrorType { - public bool IsSuccess { get; private init; } - public T? Value { get; private init; } - public string? ErrorMessage { get; private init; } - - private Result(){} - - public static Result Success(T value) => new() {IsSuccess = true, Value = value}; - public static Result Failure(string errorMessage) => new() {IsSuccess = false, ErrorMessage = errorMessage}; -} \ No newline at end of file + NotFound, + Conflict, + Validation +} + +public sealed record Error(string Code, string Description, ErrorType Type); + +public class Result +{ + protected Result(Error? error) => Error = error; + + public Error? Error { get; } + public bool IsSuccess => Error is null; + + public static Result Success() => new(null); + + public static implicit operator Result(Error error) => new(error); +} + +public sealed class Result : Result +{ + private readonly TValue? _value; + + private Result(TValue? value, Error? error) + : base(error) => _value = value; + + public TValue Value => IsSuccess + ? _value! + : throw new InvalidOperationException("A failed result has no value."); + + public static implicit operator Result(TValue value) => + new(value, null); + + public static implicit operator Result(Error error) => + new(default, error); +} diff --git a/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Route.cs b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Route.cs new file mode 100644 index 0000000000..dd5c38a214 --- /dev/null +++ b/domain-driven design/ValueObjects/ValueObjects/ValueObjects/Route.cs @@ -0,0 +1,16 @@ +namespace ValueObjects.ValueObjects; + +public sealed record Route(IReadOnlyList Stops) +{ + public bool Equals(Route? other) => + other is not null && Stops.SequenceEqual(other.Stops); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var stop in Stops) + hash.Add(stop); + + return hash.ToHashCode(); + } +}