Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ 364 Add Vogen for Strongly Typed IDs #441

Merged
merged 5 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/adr/20241118-produce-useful-sql-server-exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions docs/adr/20241118-replace-automapper-with-manual-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 46 additions & 0 deletions docs/adr/20241118-use-vogen-to-simplify-strongly-typed-ids.md
Original file line number Diff line number Diff line change
@@ -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 <!-- optional -->

- 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 <!-- optional -->

### 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
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal sealed class UpdateHeroCommandHandler(IApplicationDbContext dbContext)
{
public async Task<ErrorOr<Guid>> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ internal sealed class AddHeroToTeamCommandHandler(IApplicationDbContext dbContex
{
public async Task<ErrorOr<Success>> 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ internal sealed class CompleteMissionCommandHandler(IApplicationDbContext dbCont
{
public async Task<ErrorOr<Success>> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal sealed class ExecuteMissionCommandHandler(IApplicationDbContext dbConte
{
public async Task<ErrorOr<Success>> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public async Task<ErrorOr<TeamDto>> 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)
Expand Down
1 change: 1 addition & 0 deletions src/Domain/Domain.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
<PackageReference Include="Ardalis.Specification" Version="8.0.0" />
<PackageReference Include="ErrorOr" Version="2.0.1" />
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
<PackageReference Include="Vogen" Version="5.0.5" />
</ItemGroup>
</Project>
12 changes: 5 additions & 7 deletions src/Domain/Heroes/Hero.cs
Original file line number Diff line number Diff line change
@@ -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<Guid>]
public readonly partial struct HeroId;

public class Hero : AggregateRoot<HeroId>
{
Expand All @@ -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);

Expand Down
4 changes: 1 addition & 3 deletions src/Domain/Heroes/Power.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using SSW.CleanArchitecture.Domain.Common.Interfaces;

namespace SSW.CleanArchitecture.Domain.Heroes;
namespace SSW.CleanArchitecture.Domain.Heroes;

public record Power : IValueObject
{
Expand Down
8 changes: 4 additions & 4 deletions src/Domain/Teams/Mission.cs
Original file line number Diff line number Diff line change
@@ -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<Guid>]
public readonly partial struct MissionId;

public class Mission : Entity<MissionId>
{
Expand All @@ -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
};
Expand Down
11 changes: 5 additions & 6 deletions src/Domain/Teams/Team.cs
Original file line number Diff line number Diff line change
@@ -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<Guid>]
public readonly partial struct TeamId;

public class Team : AggregateRoot<TeamId>
{
Expand All @@ -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;
}
Expand Down
8 changes: 4 additions & 4 deletions src/Infrastructure/Persistence/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hero> Heroes => AggregateRootSet<Hero>();
Expand All @@ -26,8 +26,8 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura
{
base.ConfigureConventions(configurationBuilder);

configurationBuilder.Properties<string>()
.HaveMaxLength(256);
configurationBuilder.Properties<string>().HaveMaxLength(256);
configurationBuilder.RegisterAllInVogenEfCoreConverters();
}

private DbSet<T> AggregateRootSet<T>() where T : class, IAggregateRoot => Set<T>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,10 @@ namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration;

public class HeroConfiguration : IEntityTypeConfiguration<Hero>
{
// 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<Hero> 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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,10 @@ namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration;

public class MissionConfiguration : IEntityTypeConfiguration<Mission>
{
// 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<Mission> 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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,10 @@ namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration;

public class TeamConfiguration : IEntityTypeConfiguration<Team>
{
// 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<Team> 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();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using SSW.CleanArchitecture.Domain.Heroes;
using SSW.CleanArchitecture.Domain.Teams;
using Vogen;

namespace SSW.CleanArchitecture.Infrastructure.Persistence.Configuration;

[EfCoreConverter<TeamId>]
[EfCoreConverter<HeroId>]
[EfCoreConverter<MissionId>]
internal sealed partial class VogenEfCoreConverters;
9 changes: 7 additions & 2 deletions tests/Architecture.Tests/DomainTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<>);
Expand All @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions tests/Domain.UnitTests/Heroes/HeroTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/Domain.UnitTests/Teams/MissionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions tests/Domain.UnitTests/Teams/TeamTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down