diff --git a/docs/adr/20241118-produce-useful-sql-server-exceptions.md b/docs/adr/20241118-produce-useful-sql-server-exceptions.md index 4b6f0f19..6594a417 100644 --- a/docs/adr/20241118-produce-useful-sql-server-exceptions.md +++ b/docs/adr/20241118-produce-useful-sql-server-exceptions.md @@ -25,3 +25,7 @@ Chosen option: "Option 1", because it does what we need and is far better than t - ✅ Strongly typed exceptions - ❌ Additional dependency added + +## Links + +- https://youtube.com/watch?v=QKwZlWvfh-o&si=yVnd5a7CVZaSV_Gr diff --git a/docs/adr/20241118-replace-automapper-with-manual-mapping.md b/docs/adr/20241118-replace-automapper-with-manual-mapping.md index b32144a7..20378471 100644 --- a/docs/adr/20241118-replace-automapper-with-manual-mapping.md +++ b/docs/adr/20241118-replace-automapper-with-manual-mapping.md @@ -49,3 +49,7 @@ Chosen option: "Option 2 - Manual Mapper", because it reduces the runtime errors - ✅ Reduced runtime issues due to missing fields or mappings - ✅ No need to learn a new library - ❌ More code needed for mapping + +## Links + +- https://www.youtube.com/watch?v=RsnEZdc3MrE diff --git a/docs/adr/20241118-use-vogen-to-simplify-strongly-typed-ids.md b/docs/adr/20241118-use-vogen-to-simplify-strongly-typed-ids.md new file mode 100644 index 00000000..99c7656a --- /dev/null +++ b/docs/adr/20241118-use-vogen-to-simplify-strongly-typed-ids.md @@ -0,0 +1,46 @@ +# Use Vogen to simplify strongly typed IDs + +- Status: approved +- Deciders: Daniel Mackay +- Date: 2024-11-19 +- Tags: ddd, vogen, strongly-typed-ids + +Technical Story: https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/364 + +## Context and Problem Statement + +Strongly typed IDs are a great way to combat primitive obsession. However, they can be a bit of a pain to set up especially with EF Core. We want to make it easier to use strongly typed IDs or any simple value objects in our projects. + +## Decision Drivers + +- Simplify usage of strongly typed IDs + +## Considered Options + +1. Manual Approach +2. Vogen + +## Decision Outcome + +Chosen option: "Option 2 - Vogen", because configuration, validation, and usage of strongly typed IDs is much simpler (espeicially from the EF Core point of view). + +## Pros and Cons of the Options + +### Option 1 - Manual Approach + +- ✅ Avoid additional dependencies +- ❌ Extra EF Core boilerplate needed +- ❌ Not easy to add value object validation with current record-based approach + +### Option 2 - Vogen + +- ✅ Simple to create and use strongly typed IDs +- ✅ Easy to add value object validation +- ✅ Easy to add EF Core configuration +- ✅ Can be used for any simple value object +- ❌ Extra dependency added + +## Links + +- https://stevedunn.github.io/Vogen/overview.html +- https://github.com/SteveDunn/Vogen diff --git a/src/Application/UseCases/Heroes/Commands/UpdateHero/UpdateHeroCommand.cs b/src/Application/UseCases/Heroes/Commands/UpdateHero/UpdateHeroCommand.cs index 0fc73372..90ec5eab 100644 --- a/src/Application/UseCases/Heroes/Commands/UpdateHero/UpdateHeroCommand.cs +++ b/src/Application/UseCases/Heroes/Commands/UpdateHero/UpdateHeroCommand.cs @@ -20,7 +20,7 @@ internal sealed class UpdateHeroCommandHandler(IApplicationDbContext dbContext) { public async Task> Handle(UpdateHeroCommand request, CancellationToken cancellationToken) { - var heroId = new HeroId(request.HeroId); + var heroId = HeroId.From(request.HeroId); var hero = await dbContext.Heroes .Include(h => h.Powers) .FirstOrDefaultAsync(h => h.Id == heroId, cancellationToken); diff --git a/src/Application/UseCases/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs b/src/Application/UseCases/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs index edb79173..28795237 100644 --- a/src/Application/UseCases/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs +++ b/src/Application/UseCases/Teams/Commands/AddHeroToTeam/AddHeroToTeamCommand.cs @@ -11,8 +11,8 @@ internal sealed class AddHeroToTeamCommandHandler(IApplicationDbContext dbContex { public async Task> Handle(AddHeroToTeamCommand request, CancellationToken cancellationToken) { - var teamId = new TeamId(request.TeamId); - var heroId = new HeroId(request.HeroId); + var teamId = TeamId.From(request.TeamId); + var heroId = HeroId.From(request.HeroId); var team = dbContext.Teams .WithSpecification(new TeamByIdSpec(teamId)) diff --git a/src/Application/UseCases/Teams/Commands/CompleteMission/CompleteMissionCommand.cs b/src/Application/UseCases/Teams/Commands/CompleteMission/CompleteMissionCommand.cs index 814c7b98..0f17a6b1 100644 --- a/src/Application/UseCases/Teams/Commands/CompleteMission/CompleteMissionCommand.cs +++ b/src/Application/UseCases/Teams/Commands/CompleteMission/CompleteMissionCommand.cs @@ -10,7 +10,7 @@ internal sealed class CompleteMissionCommandHandler(IApplicationDbContext dbCont { public async Task> Handle(CompleteMissionCommand request, CancellationToken cancellationToken) { - var teamId = new TeamId(request.TeamId); + var teamId = TeamId.From(request.TeamId); var team = dbContext.Teams .WithSpecification(new TeamByIdSpec(teamId)) .FirstOrDefault(); diff --git a/src/Application/UseCases/Teams/Commands/ExecuteMission/ExecuteMissionCommand.cs b/src/Application/UseCases/Teams/Commands/ExecuteMission/ExecuteMissionCommand.cs index 110854e2..ad9a2bad 100644 --- a/src/Application/UseCases/Teams/Commands/ExecuteMission/ExecuteMissionCommand.cs +++ b/src/Application/UseCases/Teams/Commands/ExecuteMission/ExecuteMissionCommand.cs @@ -14,7 +14,7 @@ internal sealed class ExecuteMissionCommandHandler(IApplicationDbContext dbConte { public async Task> Handle(ExecuteMissionCommand request, CancellationToken cancellationToken) { - var teamId = new TeamId(request.TeamId); + var teamId = TeamId.From(request.TeamId); var team = dbContext.Teams .WithSpecification(new TeamByIdSpec(teamId)) .FirstOrDefault(); diff --git a/src/Application/UseCases/Teams/Events/PowerLevelUpdatedEventHandler.cs b/src/Application/UseCases/Teams/Events/PowerLevelUpdatedEventHandler.cs index a3881b0f..afbb2b1f 100644 --- a/src/Application/UseCases/Teams/Events/PowerLevelUpdatedEventHandler.cs +++ b/src/Application/UseCases/Teams/Events/PowerLevelUpdatedEventHandler.cs @@ -26,7 +26,7 @@ public async Task Handle(PowerLevelUpdatedEvent notification, CancellationToken } var team = dbContext.Teams - .WithSpecification(new TeamByIdSpec(hero.TeamId)) + .WithSpecification(new TeamByIdSpec(hero.TeamId.Value)) .FirstOrDefault(); if (team is null) diff --git a/src/Application/UseCases/Teams/Queries/GetTeam/GetTeamQuery.cs b/src/Application/UseCases/Teams/Queries/GetTeam/GetTeamQuery.cs index ad85e7a8..bb8e04f0 100644 --- a/src/Application/UseCases/Teams/Queries/GetTeam/GetTeamQuery.cs +++ b/src/Application/UseCases/Teams/Queries/GetTeam/GetTeamQuery.cs @@ -16,7 +16,7 @@ public async Task> Handle( GetTeamQuery request, CancellationToken cancellationToken) { - var teamId = new TeamId(request.TeamId); + var teamId = TeamId.From(request.TeamId); var team = await dbContext.Teams .Where(t => t.Id == teamId) diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj index 39a5704f..cb8ae90e 100644 --- a/src/Domain/Domain.csproj +++ b/src/Domain/Domain.csproj @@ -7,5 +7,6 @@ + diff --git a/src/Domain/Heroes/Hero.cs b/src/Domain/Heroes/Hero.cs index e97b77e3..874824b4 100644 --- a/src/Domain/Heroes/Hero.cs +++ b/src/Domain/Heroes/Hero.cs @@ -1,13 +1,11 @@ -using SSW.CleanArchitecture.Domain.Common.Base; -using SSW.CleanArchitecture.Domain.Teams; +using SSW.CleanArchitecture.Domain.Teams; +using Vogen; namespace SSW.CleanArchitecture.Domain.Heroes; // For strongly typed IDs, check out the rule: https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/ -public readonly record struct HeroId(Guid Value) -{ - public HeroId() : this(Guid.CreateVersion7()) { } -} +[ValueObject] +public readonly partial struct HeroId; public class Hero : AggregateRoot { @@ -24,7 +22,7 @@ private Hero() { } public static Hero Create(string name, string alias) { Guid.CreateVersion7(); - var hero = new Hero { Id = new HeroId(Guid.CreateVersion7()) }; + var hero = new Hero { Id = HeroId.From(Guid.CreateVersion7()) }; hero.UpdateName(name); hero.UpdateAlias(alias); diff --git a/src/Domain/Heroes/Power.cs b/src/Domain/Heroes/Power.cs index 562b9cd7..a2cedf40 100644 --- a/src/Domain/Heroes/Power.cs +++ b/src/Domain/Heroes/Power.cs @@ -1,6 +1,4 @@ -using SSW.CleanArchitecture.Domain.Common.Interfaces; - -namespace SSW.CleanArchitecture.Domain.Heroes; +namespace SSW.CleanArchitecture.Domain.Heroes; public record Power : IValueObject { diff --git a/src/Domain/Teams/Mission.cs b/src/Domain/Teams/Mission.cs index 2cf574e0..a34eafdb 100644 --- a/src/Domain/Teams/Mission.cs +++ b/src/Domain/Teams/Mission.cs @@ -1,10 +1,10 @@ -using ErrorOr; -using SSW.CleanArchitecture.Domain.Common.Base; +using Vogen; namespace SSW.CleanArchitecture.Domain.Teams; // For strongly typed IDs, check out the rule: https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/ -public readonly record struct MissionId(Guid Value); +[ValueObject] +public readonly partial struct MissionId; public class Mission : Entity { @@ -20,7 +20,7 @@ internal static Mission Create(string description) ThrowIfNullOrWhiteSpace(description); return new Mission { - Id = new MissionId(Guid.CreateVersion7()), + Id = MissionId.From(Guid.CreateVersion7()), Description = description, Status = MissionStatus.InProgress }; diff --git a/src/Domain/Teams/Team.cs b/src/Domain/Teams/Team.cs index 6684741a..602abe91 100644 --- a/src/Domain/Teams/Team.cs +++ b/src/Domain/Teams/Team.cs @@ -1,12 +1,11 @@ -using ErrorOr; -using SSW.CleanArchitecture.Domain.Common.Base; -using SSW.CleanArchitecture.Domain.Common.Interfaces; -using SSW.CleanArchitecture.Domain.Heroes; +using SSW.CleanArchitecture.Domain.Heroes; +using Vogen; namespace SSW.CleanArchitecture.Domain.Teams; // For strongly typed IDs, check out the rule: https://www.ssw.com.au/rules/do-you-use-strongly-typed-ids/ -public sealed record TeamId(Guid Value); +[ValueObject] +public readonly partial struct TeamId; public class Team : AggregateRoot { @@ -27,7 +26,7 @@ public static Team Create(string name) { ThrowIfNullOrWhiteSpace(name); - var team = new Team { Id = new TeamId(Guid.CreateVersion7()), Name = name, Status = TeamStatus.Available }; + var team = new Team { Id = TeamId.From(Guid.CreateVersion7()), Name = name, Status = TeamStatus.Available }; return team; } diff --git a/src/Infrastructure/Persistence/ApplicationDbContext.cs b/src/Infrastructure/Persistence/ApplicationDbContext.cs index 68f6ce13..a478d17b 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContext.cs @@ -3,12 +3,12 @@ using SSW.CleanArchitecture.Domain.Common.Interfaces; using SSW.CleanArchitecture.Domain.Heroes; using SSW.CleanArchitecture.Domain.Teams; +using SSW.CleanArchitecture.Infrastructure.Persistence.Configuration; using System.Reflection; namespace SSW.CleanArchitecture.Infrastructure.Persistence; -public class ApplicationDbContext( - DbContextOptions options) +public class ApplicationDbContext(DbContextOptions options) : DbContext(options), IApplicationDbContext { public DbSet Heroes => AggregateRootSet(); @@ -26,8 +26,8 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura { base.ConfigureConventions(configurationBuilder); - configurationBuilder.Properties() - .HaveMaxLength(256); + configurationBuilder.Properties().HaveMaxLength(256); + configurationBuilder.RegisterAllInVogenEfCoreConverters(); } private DbSet AggregateRootSet() where T : class, IAggregateRoot => Set(); diff --git a/src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs b/src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs index 3070db23..4a1c6a38 100644 --- a/src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs +++ b/src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs @@ -6,16 +6,10 @@ namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration; public class HeroConfiguration : IEntityTypeConfiguration { - // TODO: Figure out a good marker (e.g. for recurring fields ID) to enforce that all entities have configuration defined via arch tests public void Configure(EntityTypeBuilder builder) { builder.HasKey(t => t.Id); - builder.Property(t => t.Id) - .HasConversion(x => x.Value, - x => new HeroId(x)) - .ValueGeneratedNever(); - builder.Property(t => t.Name) .IsRequired(); diff --git a/src/Infrastructure/Persistence/Configuration/MissionConfiguration.cs b/src/Infrastructure/Persistence/Configuration/MissionConfiguration.cs index d7802927..86eb0cae 100644 --- a/src/Infrastructure/Persistence/Configuration/MissionConfiguration.cs +++ b/src/Infrastructure/Persistence/Configuration/MissionConfiguration.cs @@ -6,16 +6,10 @@ namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration; public class MissionConfiguration : IEntityTypeConfiguration { - // TODO: Figure out a good marker (e.g. for recurring fields ID) to enforce that all entities have configuration defined via arch tests public void Configure(EntityTypeBuilder builder) { builder.HasKey(t => t.Id); - builder.Property(t => t.Id) - .HasConversion(x => x.Value, - x => new MissionId(x)) - .ValueGeneratedNever(); - builder.Property(t => t.Description) .IsRequired(); } diff --git a/src/Infrastructure/Persistence/Configuration/TeamConfiguration.cs b/src/Infrastructure/Persistence/Configuration/TeamConfiguration.cs index 1d795d02..241fa4e0 100644 --- a/src/Infrastructure/Persistence/Configuration/TeamConfiguration.cs +++ b/src/Infrastructure/Persistence/Configuration/TeamConfiguration.cs @@ -6,16 +6,10 @@ namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration; public class TeamConfiguration : IEntityTypeConfiguration { - // TODO: Figure out a good marker (e.g. for recurring fields ID) to enforce that all entities have configuration defined via arch tests public void Configure(EntityTypeBuilder builder) { builder.HasKey(t => t.Id); - builder.Property(t => t.Id) - .HasConversion(x => x.Value, - x => new TeamId(x)) - .ValueGeneratedNever(); - builder.Property(t => t.Name) .IsRequired(); diff --git a/src/Infrastructure/Persistence/Configuration/VogenEfCoreConverters.cs b/src/Infrastructure/Persistence/Configuration/VogenEfCoreConverters.cs new file mode 100644 index 00000000..b101acd3 --- /dev/null +++ b/src/Infrastructure/Persistence/Configuration/VogenEfCoreConverters.cs @@ -0,0 +1,10 @@ +using SSW.CleanArchitecture.Domain.Heroes; +using SSW.CleanArchitecture.Domain.Teams; +using Vogen; + +namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration; + +[EfCoreConverter] +[EfCoreConverter] +[EfCoreConverter] +internal sealed partial class VogenEfCoreConverters; \ No newline at end of file diff --git a/tests/Architecture.Tests/DomainTests.cs b/tests/Architecture.Tests/DomainTests.cs index 0423ab0a..198fcb18 100644 --- a/tests/Architecture.Tests/DomainTests.cs +++ b/tests/Architecture.Tests/DomainTests.cs @@ -2,10 +2,11 @@ using SSW.CleanArchitecture.Domain.Common.Base; using SSW.CleanArchitecture.Domain.Common.Interfaces; using System.Reflection; +using Xunit.Abstractions; namespace SSW.CleanArchitecture.Architecture.UnitTests; -public class DomainModel : TestBase +public class DomainModel(ITestOutputHelper output) : TestBase { private static readonly Type AggregateRoot = typeof(AggregateRoot<>); private static readonly Type Entity = typeof(Entity<>); @@ -19,11 +20,15 @@ public void DomainModel_ShouldInheritsBaseClasses() var domainModels = Types.InAssembly(DomainAssembly) .That() .DoNotResideInNamespaceContaining("Common") - .And().DoNotHaveNameEndingWith("Id") + .And().DoNotHaveNameMatching(".*Id.*") + .And().DoNotHaveNameMatching(".*Vogen.*") + .And().DoNotHaveName("ThrowHelper") .And().DoNotHaveNameEndingWith("Spec") .And().DoNotHaveNameEndingWith("Errors") .And().MeetCustomRule(new IsNotEnumRule()); + domainModels.GetTypes().Dump(output); + // Act var result = domainModels .Should() diff --git a/tests/Domain.UnitTests/Heroes/HeroTests.cs b/tests/Domain.UnitTests/Heroes/HeroTests.cs index 64f69bc6..5b3efb66 100644 --- a/tests/Domain.UnitTests/Heroes/HeroTests.cs +++ b/tests/Domain.UnitTests/Heroes/HeroTests.cs @@ -12,8 +12,8 @@ public void HeroId_ShouldBeComparable(string stringGuid1, string stringGuid2, bo // Arrange Guid guid1 = Guid.Parse(stringGuid1); Guid guid2 = Guid.Parse(stringGuid2); - HeroId id1 = new(guid1); - HeroId id2 = new(guid2); + HeroId id1 = HeroId.From(guid1); + HeroId id2 = HeroId.From(guid2); // Act var areEqual = id1 == id2; @@ -100,7 +100,7 @@ public void AddPower_ShouldRaisePowerLevelUpdatedEvent() { // Act var hero = Hero.Create("name", "alias"); - hero.Id = new HeroId(); + hero.Id = HeroId.From(Guid.NewGuid()); hero.UpdatePowers([new Power("Super-strength", 10)]); // Assert diff --git a/tests/Domain.UnitTests/Teams/MissionTests.cs b/tests/Domain.UnitTests/Teams/MissionTests.cs index c9211bee..c87f2f30 100644 --- a/tests/Domain.UnitTests/Teams/MissionTests.cs +++ b/tests/Domain.UnitTests/Teams/MissionTests.cs @@ -12,8 +12,8 @@ public void MissionId_ShouldBeComparable(string stringGuid1, string stringGuid2, // Arrange Guid guid1 = Guid.Parse(stringGuid1); Guid guid2 = Guid.Parse(stringGuid2); - MissionId id1 = new(guid1); - MissionId id2 = new(guid2); + MissionId id1 = MissionId.From(guid1); + MissionId id2 = MissionId.From(guid2); // Act var areEqual = id1 == id2; diff --git a/tests/Domain.UnitTests/Teams/TeamTests.cs b/tests/Domain.UnitTests/Teams/TeamTests.cs index 77134d2f..52566d53 100644 --- a/tests/Domain.UnitTests/Teams/TeamTests.cs +++ b/tests/Domain.UnitTests/Teams/TeamTests.cs @@ -13,8 +13,8 @@ public void TeamId_ShouldBeComparable(string stringGuid1, string stringGuid2, bo // Arrange Guid guid1 = Guid.Parse(stringGuid1); Guid guid2 = Guid.Parse(stringGuid2); - TeamId id1 = new(guid1); - TeamId id2 = new(guid2); + TeamId id1 = TeamId.From(guid1); + TeamId id2 = TeamId.From(guid2); // Act var areEqual = id1 == id2; diff --git a/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/UpdateHeroCommandTests.cs b/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/UpdateHeroCommandTests.cs index da46346a..21d2fb03 100644 --- a/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/UpdateHeroCommandTests.cs +++ b/tests/WebApi.IntegrationTests/Endpoints/Heroes/Commands/UpdateHeroCommandTests.cs @@ -54,7 +54,7 @@ public async Task Command_ShouldUpdateHero() public async Task Command_WhenHeroDoesNotExist_ShouldReturnNotFound() { // Arrange - var heroId = new HeroId(); + var heroId = HeroId.From(Guid.NewGuid()); var cmd = new UpdateHeroCommand( "foo", "bar",