diff --git a/README.md b/README.md index 3562e294..937dc627 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ This is a template for creating a new project using [Clean Architecture](https:/ ## ✨ Features +- 🎯 Domain Driven Design Patterns + - [Super Hero Domain](./docs/domain.md) + - AggregateRoot + - Entity + - ValueObject + - DomainEvent - ⚖️ EditorConfig - comes with the [SSW.EditorConfig](https://github.com/SSWConsulting/SSW.EditorConfig) - Maintain consistent coding styles for individual developers or teams of developers working on the same project using different IDEs - as per [ssw.com.au/rules/consistent-code-style/](https://ssw.com.au/rules/consistent-code-style/) diff --git a/docs/adr/20240404-use-domain-driven-design-tactical-patterns.md b/docs/adr/20240404-use-domain-driven-design-tactical-patterns.md new file mode 100644 index 00000000..9c2ae1be --- /dev/null +++ b/docs/adr/20240404-use-domain-driven-design-tactical-patterns.md @@ -0,0 +1,43 @@ +# Use Domain-Driven Design Tactical Patterns + +- Status: Accepted +- Deciders: Daniel Mackay, Matt Goldman, Matt Wicks, Luke Parker, Chris Clement +- Date: 2024-04-04 +- Tags: ddd + +Technical Story: https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/283 + + +## Context and Problem Statement + +The current Clean Architecture framework relies on an anemic domain model, which simplifies initial development but increasingly hampers our ability to handle the complex interactions and business logic inherent in our domain. By incorporating Domain-Driven Design (DDD), projects with non-trivial logic can better accommodate complex workflows and business rule integrations without compromising maintainability or scalability. + +We would like to default to using DDD in the template and provide a good example of building applications in that manner. + + +## Considered Options + +1. Anemic Domain Model +2. Rich Domain Model with DDD + +## Decision Outcome + +Chosen option: "Option 2 - Rich Domain Model with DDD", because it helps set developers up for success when building complex applications. It's easier to go from a rich domain model to an anemic domain model than the other way around. + +### Consequences + +- Need to create a new Domain model to show the usefulness of DDD. This will require most layers to be rebuilt. + +## Pros and Cons of the Options + +### Option 1 - Anemic Domain Model + +- ✅ Simplier for trivial applications +- ❌ Difficult to upgrade to use DDD patterns + +### Option 2 - Rich Domain Model with DDD + +- ✅ Easy to migrate to an anemic domain model if needed +- ✅ More flexible for complex applications +- ❌ Overkill for trivial applications +- ❌ More complex to understand diff --git a/docs/database.png b/docs/database.png new file mode 100644 index 00000000..6e1af6bf Binary files /dev/null and b/docs/database.png differ diff --git a/docs/domain.md b/docs/domain.md new file mode 100644 index 00000000..ef1e3a8a --- /dev/null +++ b/docs/domain.md @@ -0,0 +1,49 @@ +# SuperHero Domain Model + +- `Hero` - Aggregate +- `Team` - Aggregate +- `Power` - ValueObject +- `Mission` - Entity +- `HeroPowerUpdated` - Domain Event (updates the TotalPowerLevel on the SuperHeroTeam) + +```mermaid +classDiagram + class Hero { + string Name + string Alias + int Strength + Power[] Powers + void AddPower() + void RemovePower() + } + + class Power { + string Name + string Strength + } + + class Team { + string Name + int TotalStrength + enum TeamStatus + Mission[] Missions + void AddHero() + void RemoveHero() + void ExecuteMission() + void CompleteCurrentMission() + } + + class Mission { + int MissionId + string Description + enum MissionStatus + void Complete() + } + + Hero --> Power: has many + Team --> Hero: has many + Team --> Mission: has many +``` +## Database Schema + +![SuperHero Database Schema](./database.png) diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index d03c77f3..3468d998 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -18,4 +18,4 @@ - \ No newline at end of file + diff --git a/src/Domain/Common/Base/AggregateRoot.cs b/src/Domain/Common/Base/AggregateRoot.cs new file mode 100644 index 00000000..5dafe5e9 --- /dev/null +++ b/src/Domain/Common/Base/AggregateRoot.cs @@ -0,0 +1,41 @@ +using SSW.CleanArchitecture.Domain.Common.Interfaces; +using System.ComponentModel.DataAnnotations.Schema; + +namespace SSW.CleanArchitecture.Domain.Common.Base; + +/// +/// Cluster of objects treated as a single unit. +/// Can contain entities, value objects, and other aggregates. +/// Enforce business rules (i.e. invariants) +/// Can be created externally. +/// Can raise domain events. +/// Represent a transactional boundary (i.e. all changes are saved or none are saved) +/// +public abstract class AggregateRoot : Entity, IAggregateRoot +{ + private readonly List _domainEvents = []; + + [NotMapped] + public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); + + public void AddDomainEvent(DomainEvent domainEvent) => _domainEvents.Add(domainEvent); + + public void RemoveDomainEvent(DomainEvent domainEvent) => _domainEvents.Remove(domainEvent); + + public void ClearDomainEvents() => _domainEvents.Clear(); +} + +// TODO: Delete this once TodoItems are removed +public abstract class BaseEntity : Entity +{ + private readonly List _domainEvents = []; + + [NotMapped] + public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); + + public void AddDomainEvent(DomainEvent domainEvent) => _domainEvents.Add(domainEvent); + + public void RemoveDomainEvent(DomainEvent domainEvent) => _domainEvents.Remove(domainEvent); + + public void ClearDomainEvents() => _domainEvents.Clear(); +} \ No newline at end of file diff --git a/src/Domain/Common/Base/AuditableEntity.cs b/src/Domain/Common/Base/AuditableEntity.cs index 8be069b6..d9b3d616 100644 --- a/src/Domain/Common/Base/AuditableEntity.cs +++ b/src/Domain/Common/Base/AuditableEntity.cs @@ -2,10 +2,25 @@ namespace SSW.CleanArchitecture.Domain.Common.Base; +/// +/// Tracks creation and modification of an entity. +/// public abstract class AuditableEntity : IAuditableEntity { - public DateTimeOffset CreatedAt { get; set; } - public string? CreatedBy { get; set; } - public DateTimeOffset? UpdatedAt { get; set; } - public string? UpdatedBy { get; set; } -} + public DateTimeOffset CreatedAt { get; private set; } + public string? CreatedBy { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public string? UpdatedBy { get; private set; } + + public void SetCreated(DateTimeOffset createdAt, string? createdBy) + { + CreatedAt = createdAt; + CreatedBy = createdBy; + } + + public void SetUpdated(DateTimeOffset updatedAt, string? updatedBy) + { + UpdatedAt = updatedAt; + UpdatedBy = updatedBy; + } +} \ No newline at end of file diff --git a/src/Domain/Common/Base/BaseEntity.cs b/src/Domain/Common/Base/BaseEntity.cs deleted file mode 100644 index 47f22b8c..00000000 --- a/src/Domain/Common/Base/BaseEntity.cs +++ /dev/null @@ -1,20 +0,0 @@ -using SSW.CleanArchitecture.Domain.Common.Interfaces; -using System.ComponentModel.DataAnnotations.Schema; - -namespace SSW.CleanArchitecture.Domain.Common.Base; - -public abstract class BaseEntity : AuditableEntity, IDomainEvents -{ - private readonly List _domainEvents = new(); - - public TId Id { get; set; } = default!; - - [NotMapped] - public IReadOnlyList DomainEvents => _domainEvents.AsReadOnly(); - - public void AddDomainEvent(DomainEvent domainEvent) => _domainEvents.Add(domainEvent); - - public void RemoveDomainEvent(DomainEvent domainEvent) => _domainEvents.Remove(domainEvent); - - public void ClearDomainEvents() => _domainEvents.Clear(); -} diff --git a/src/Domain/Common/Base/DomainEvent.cs b/src/Domain/Common/Base/DomainEvent.cs index 794ff82f..6b4c6881 100644 --- a/src/Domain/Common/Base/DomainEvent.cs +++ b/src/Domain/Common/Base/DomainEvent.cs @@ -2,4 +2,7 @@ namespace SSW.CleanArchitecture.Domain.Common.Base; -public record DomainEvent : INotification; +/// +/// Can be raised by an AggregateRoot to notify subscribers of a domain event. +/// +public record DomainEvent : INotification; \ No newline at end of file diff --git a/src/Domain/Common/Base/Entity.cs b/src/Domain/Common/Base/Entity.cs new file mode 100644 index 00000000..34b093a5 --- /dev/null +++ b/src/Domain/Common/Base/Entity.cs @@ -0,0 +1,11 @@ +namespace SSW.CleanArchitecture.Domain.Common.Base; + +/// +/// Entities have an ID and a lifecycle (i.e. created, modified, and deleted) +/// They can be created within the domain, but not externally. +/// Enforce business rules (i.e. invariants) +/// +public abstract class Entity : AuditableEntity +{ + public TId Id { get; set; } = default!; +} \ No newline at end of file diff --git a/src/Domain/Common/Constants.cs b/src/Domain/Common/Constants.cs new file mode 100644 index 00000000..e60385cc --- /dev/null +++ b/src/Domain/Common/Constants.cs @@ -0,0 +1,7 @@ +namespace SSW.CleanArchitecture.Domain.Common; + +public static class Constants +{ + public const int DefaultNameMaxLength = 100; + public const int DefaultDescriptionMaxLength = 500; +} \ No newline at end of file diff --git a/src/Domain/Common/Interfaces/IAggregateRoot.cs b/src/Domain/Common/Interfaces/IAggregateRoot.cs new file mode 100644 index 00000000..104b2995 --- /dev/null +++ b/src/Domain/Common/Interfaces/IAggregateRoot.cs @@ -0,0 +1,14 @@ +using SSW.CleanArchitecture.Domain.Common.Base; + +namespace SSW.CleanArchitecture.Domain.Common.Interfaces; + +public interface IAggregateRoot +{ + public IReadOnlyList DomainEvents { get; } + + public void AddDomainEvent(DomainEvent domainEvent); + + public void RemoveDomainEvent(DomainEvent domainEvent); + + public void ClearDomainEvents(); +} \ No newline at end of file diff --git a/src/Domain/Common/Interfaces/IAuditableEntity.cs b/src/Domain/Common/Interfaces/IAuditableEntity.cs index 08c82a53..acb7cafc 100644 --- a/src/Domain/Common/Interfaces/IAuditableEntity.cs +++ b/src/Domain/Common/Interfaces/IAuditableEntity.cs @@ -2,8 +2,12 @@ public interface IAuditableEntity { - public DateTimeOffset CreatedAt { get; set; } - public string? CreatedBy { get; set; } // TODO: String as userId? (https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/76) - public DateTimeOffset? UpdatedAt { get; set; } - public string? UpdatedBy { get; set; } // TODO: String as userId? (https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/76) + public DateTimeOffset CreatedAt { get; } + public string? CreatedBy { get; } // TODO: String as userId? (https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/76) + public DateTimeOffset? UpdatedAt { get; } + public string? UpdatedBy { get; } // TODO: String as userId? (https://github.com/SSWConsulting/SSW.CleanArchitecture/issues/76) + + public void SetCreated(DateTimeOffset createdAt, string? createdBy); + + public void SetUpdated(DateTimeOffset updatedAt, string? updatedBy); } \ No newline at end of file diff --git a/src/Domain/Common/Interfaces/IDomainEvents.cs b/src/Domain/Common/Interfaces/IDomainEvents.cs deleted file mode 100644 index c7d66376..00000000 --- a/src/Domain/Common/Interfaces/IDomainEvents.cs +++ /dev/null @@ -1,14 +0,0 @@ -using SSW.CleanArchitecture.Domain.Common.Base; - -namespace SSW.CleanArchitecture.Domain.Common.Interfaces; - -public interface IDomainEvents -{ - IReadOnlyList DomainEvents { get; } - - void AddDomainEvent(DomainEvent domainEvent); - - void RemoveDomainEvent(DomainEvent domainEvent); - - void ClearDomainEvents(); -} \ No newline at end of file diff --git a/src/Domain/Common/Interfaces/IValueObject.cs b/src/Domain/Common/Interfaces/IValueObject.cs new file mode 100644 index 00000000..f784c9d2 --- /dev/null +++ b/src/Domain/Common/Interfaces/IValueObject.cs @@ -0,0 +1,12 @@ +namespace SSW.CleanArchitecture.Domain.Common.Interfaces; + +/// +/// Marker interface. +/// Value objects do not have identity. +/// They are immutable. +/// Compared by using their attributes or properties. +/// Generally need context perhaps from a parent. +/// Improve ubiquitous language. +/// Help to eliminate primitive obsession. +/// +public interface IValueObject; \ No newline at end of file diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj index a7c271b5..4e14fa3a 100644 --- a/src/Domain/Domain.csproj +++ b/src/Domain/Domain.csproj @@ -7,6 +7,7 @@ enable + diff --git a/src/Domain/Heroes/Hero.cs b/src/Domain/Heroes/Hero.cs new file mode 100644 index 00000000..a607515e --- /dev/null +++ b/src/Domain/Heroes/Hero.cs @@ -0,0 +1,62 @@ +using Ardalis.GuardClauses; +using SSW.CleanArchitecture.Domain.Common; +using SSW.CleanArchitecture.Domain.Common.Base; + +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 class Hero : AggregateRoot +{ + private readonly List _powers = []; + public string Name { get; private set; } = null!; + public string Alias { get; private set; } = null!; + public int PowerLevel { get; private set; } + public IEnumerable Powers => _powers.AsReadOnly(); + + public static Hero Create(string name, string alias) + { + Guard.Against.NullOrWhiteSpace(name); + Guard.Against.StringTooLong(name, Constants.DefaultNameMaxLength); + + Guard.Against.NullOrWhiteSpace(alias); + Guard.Against.StringTooLong(alias, Constants.DefaultNameMaxLength); + + var hero = new Hero { Id = new HeroId(Guid.NewGuid()), Name = name, Alias = alias, }; + + return hero; + } + + public void AddPower(Power power) + { + Guard.Against.Null(power); + + if (!_powers.Contains(power)) + { + _powers.Add(power); + } + + PowerLevel += power.PowerLevel; + AddDomainEvent(new PowerLevelUpdatedEvent(this)); + } + + public void RemovePower(string powerName) + { + Guard.Against.NullOrWhiteSpace(powerName, nameof(powerName)); + + var power = Powers.FirstOrDefault(p => p.Name == powerName); + if (power is null) + { + return; + } + + if (_powers.Contains(power)) + { + _powers.Remove(power); + } + + PowerLevel -= power.PowerLevel; + AddDomainEvent(new PowerLevelUpdatedEvent(this)); + } +} \ No newline at end of file diff --git a/src/Domain/Heroes/Power.cs b/src/Domain/Heroes/Power.cs new file mode 100644 index 00000000..83d6de67 --- /dev/null +++ b/src/Domain/Heroes/Power.cs @@ -0,0 +1,20 @@ +using Ardalis.GuardClauses; +using SSW.CleanArchitecture.Domain.Common; +using SSW.CleanArchitecture.Domain.Common.Interfaces; + +namespace SSW.CleanArchitecture.Domain.Heroes; + +public record Power : IValueObject +{ + // Private setters needed for EF + public string Name { get; private set; } + + // Private setters needed for EF + public int PowerLevel { get; private set; } + + public Power(string name, int powerLevel) + { + Name = Guard.Against.StringTooLong(name, Constants.DefaultNameMaxLength); + PowerLevel = Guard.Against.OutOfRange(powerLevel, nameof(PowerLevel), 1, 10); + } +} \ No newline at end of file diff --git a/src/Domain/Heroes/PowerLevelUpdatedEvent.cs b/src/Domain/Heroes/PowerLevelUpdatedEvent.cs new file mode 100644 index 00000000..972b98da --- /dev/null +++ b/src/Domain/Heroes/PowerLevelUpdatedEvent.cs @@ -0,0 +1,20 @@ +using Ardalis.GuardClauses; +using SSW.CleanArchitecture.Domain.Common.Base; + +namespace SSW.CleanArchitecture.Domain.Heroes; + +public record PowerLevelUpdatedEvent : DomainEvent +{ + public HeroId Id { get; } + public string Name { get; } + public int PowerLevel { get; } + + public PowerLevelUpdatedEvent(Hero hero) + { + Guard.Against.Null(hero); + + Id = hero.Id; + Name = hero.Name; + PowerLevel = hero.PowerLevel; + } +} \ No newline at end of file diff --git a/src/Domain/Teams/Mission.cs b/src/Domain/Teams/Mission.cs new file mode 100644 index 00000000..78d25943 --- /dev/null +++ b/src/Domain/Teams/Mission.cs @@ -0,0 +1,35 @@ +using Ardalis.GuardClauses; +using SSW.CleanArchitecture.Domain.Common; +using SSW.CleanArchitecture.Domain.Common.Base; + +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); + +public class Mission : Entity +{ + public string Description { get; private set; } = null!; + + public MissionStatus Status { get; private set; } + + private Mission() { } // Needed for EF Core + + // NOTE: Internal so that missions can only be created by the aggregate + internal static Mission Create(string description) + { + Guard.Against.NullOrWhiteSpace(description); + Guard.Against.StringTooLong(description, Constants.DefaultDescriptionMaxLength); + return new Mission { Description = description, Status = MissionStatus.InProgress }; + } + + internal void Complete() + { + if (Status == MissionStatus.Complete) + { + throw new InvalidOperationException("Mission is already completed"); + } + + Status = MissionStatus.Complete; + } +} \ No newline at end of file diff --git a/src/Domain/Teams/MissionStatus.cs b/src/Domain/Teams/MissionStatus.cs new file mode 100644 index 00000000..d955c555 --- /dev/null +++ b/src/Domain/Teams/MissionStatus.cs @@ -0,0 +1,7 @@ +namespace SSW.CleanArchitecture.Domain.Teams; + +public enum MissionStatus +{ + InProgress = 1, + Complete = 2 +} \ No newline at end of file diff --git a/src/Domain/Teams/Team.cs b/src/Domain/Teams/Team.cs new file mode 100644 index 00000000..bd3a99fc --- /dev/null +++ b/src/Domain/Teams/Team.cs @@ -0,0 +1,81 @@ +using Ardalis.GuardClauses; +using SSW.CleanArchitecture.Domain.Common.Base; +using SSW.CleanArchitecture.Domain.Heroes; + +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 TeamId(Guid Value); + +public class Team : AggregateRoot +{ + public string Name { get; private set; } = null!; + public int TotalPowerLevel { get; private set; } + public TeamStatus Status { get; private set; } + + private readonly List _missions = []; + public IEnumerable Missions => _missions.AsReadOnly(); + private Mission? CurrentMission => _missions.FirstOrDefault(m => m.Status == MissionStatus.InProgress); + + private readonly List _heroes = []; + public IEnumerable Heroes => _heroes.AsReadOnly(); + + + private Team() { } + + public static Team Create(string name) + { + Guard.Against.NullOrWhiteSpace(name); + + var team = new Team { Id = new TeamId(Guid.NewGuid()), Name = name, Status = TeamStatus.Available }; + + return team; + } + + public void AddHero(Hero hero) + { + Guard.Against.Null(hero, nameof(hero)); + _heroes.Add(hero); + TotalPowerLevel += hero.PowerLevel; + } + + public void RemoveHero(Hero hero) + { + Guard.Against.Null(hero, nameof(hero)); + if (_heroes.Contains(hero)) + { + _heroes.Remove(hero); + TotalPowerLevel -= hero.PowerLevel; + } + } + + public void ExecuteMission(string description) + { + Guard.Against.NullOrWhiteSpace(description, nameof(description)); + + if (Status != TeamStatus.Available) + { + throw new InvalidOperationException("The team is currently not available for a new mission."); + } + + var mission = Mission.Create(description); + _missions.Add(mission); + Status = TeamStatus.OnMission; + } + + public void CompleteCurrentMission() + { + if (Status != TeamStatus.OnMission) + { + throw new InvalidOperationException("The team is currently not on a mission."); + } + + if (CurrentMission is null) + { + throw new InvalidOperationException("There is no mission in progress."); + } + + CurrentMission.Complete(); + Status = TeamStatus.Available; + } +} \ No newline at end of file diff --git a/src/Domain/Teams/TeamStatus.cs b/src/Domain/Teams/TeamStatus.cs new file mode 100644 index 00000000..dce67c09 --- /dev/null +++ b/src/Domain/Teams/TeamStatus.cs @@ -0,0 +1,7 @@ +namespace SSW.CleanArchitecture.Domain.Teams; + +public enum TeamStatus +{ + Available = 1, + OnMission = 2 +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/ApplicationDbContext.cs b/src/Infrastructure/Persistence/ApplicationDbContext.cs index 5474ad7c..3242c923 100644 --- a/src/Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/Infrastructure/Persistence/ApplicationDbContext.cs @@ -1,5 +1,9 @@ using Microsoft.EntityFrameworkCore; using SSW.CleanArchitecture.Application.Common.Interfaces; +using SSW.CleanArchitecture.Domain.Common.Base; +using SSW.CleanArchitecture.Domain.Common.Interfaces; +using SSW.CleanArchitecture.Domain.Heroes; +using SSW.CleanArchitecture.Domain.Teams; using SSW.CleanArchitecture.Domain.TodoItems; using SSW.CleanArchitecture.Infrastructure.Persistence.Interceptors; using System.Reflection; @@ -14,6 +18,10 @@ public class ApplicationDbContext( { public DbSet TodoItems => Set(); + public DbSet Heroes => AggregateRootSet(); + + public DbSet Teams => AggregateRootSet(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); @@ -26,4 +34,6 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) // Order of the interceptors is important optionsBuilder.AddInterceptors(saveChangesInterceptor, dispatchDomainEventsInterceptor); } + + private DbSet AggregateRootSet() where T : class, IAggregateRoot => Set(); } \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs b/src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs new file mode 100644 index 00000000..a2825905 --- /dev/null +++ b/src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SSW.CleanArchitecture.Domain.Common; +using SSW.CleanArchitecture.Domain.Heroes; + +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() + .HasMaxLength(Constants.DefaultNameMaxLength); + + builder.Property(t => t.Alias) + .IsRequired() + .HasMaxLength(Constants.DefaultNameMaxLength); + + // This is to highlight that we can also serialise to JSON for simple content instead of adding a new table + builder.OwnsMany(t => t.Powers, b => b.ToJson()); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configuration/MissionConfiguration.cs b/src/Infrastructure/Persistence/Configuration/MissionConfiguration.cs new file mode 100644 index 00000000..b679068d --- /dev/null +++ b/src/Infrastructure/Persistence/Configuration/MissionConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SSW.CleanArchitecture.Domain.Common; +using SSW.CleanArchitecture.Domain.Teams; + +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() + .HasMaxLength(Constants.DefaultDescriptionMaxLength); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configuration/TeamConfiguration.cs b/src/Infrastructure/Persistence/Configuration/TeamConfiguration.cs new file mode 100644 index 00000000..52fb300f --- /dev/null +++ b/src/Infrastructure/Persistence/Configuration/TeamConfiguration.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SSW.CleanArchitecture.Domain.Common; +using SSW.CleanArchitecture.Domain.Teams; + +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() + .HasMaxLength(Constants.DefaultNameMaxLength); + + // TODO: Check this works + builder.HasMany(t => t.Missions) + .WithOne() + //.HasForeignKey(m => m.Id) + .IsRequired(); + + // TODO: Check this works + builder.HasMany(t => t.Heroes) + .WithOne() + //.HasForeignKey(m => m.Id) + .IsRequired(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Interceptors/DispatchDomainEventsInterceptor.cs b/src/Infrastructure/Persistence/Interceptors/DispatchDomainEventsInterceptor.cs index 650e61b2..c33429cb 100644 --- a/src/Infrastructure/Persistence/Interceptors/DispatchDomainEventsInterceptor.cs +++ b/src/Infrastructure/Persistence/Interceptors/DispatchDomainEventsInterceptor.cs @@ -27,7 +27,7 @@ public async Task DispatchDomainEvents(DbContext? context) return; var entities = context.ChangeTracker - .Entries() + .Entries() .Where(e => e.Entity.DomainEvents.Any()) .Select(e => e.Entity) .ToList(); @@ -41,4 +41,4 @@ public async Task DispatchDomainEvents(DbContext? context) foreach (var domainEvent in domainEvents) await mediator.Publish(domainEvent); } -} +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Interceptors/EntitySaveChangesInterceptor.cs b/src/Infrastructure/Persistence/Interceptors/EntitySaveChangesInterceptor.cs index 1261d099..48fce803 100644 --- a/src/Infrastructure/Persistence/Interceptors/EntitySaveChangesInterceptor.cs +++ b/src/Infrastructure/Persistence/Interceptors/EntitySaveChangesInterceptor.cs @@ -31,14 +31,12 @@ public void UpdateEntities(DbContext? context) foreach (var entry in context.ChangeTracker.Entries()) if (entry.State is EntityState.Added) { - entry.Entity.CreatedAt = timeProvider.GetUtcNow(); - entry.Entity.CreatedBy = currentUserService.UserId; + entry.Entity.SetCreated(timeProvider.GetUtcNow(), currentUserService.UserId); } else if (entry.State is EntityState.Added or EntityState.Modified || entry.HasChangedOwnedEntities()) { - entry.Entity.UpdatedAt = timeProvider.GetUtcNow(); - entry.Entity.UpdatedBy = currentUserService.UserId; + entry.Entity.SetUpdated(timeProvider.GetUtcNow(), currentUserService.UserId); } } } diff --git a/src/Infrastructure/Persistence/Migrations/20240415010506_Heroes.Designer.cs b/src/Infrastructure/Persistence/Migrations/20240415010506_Heroes.Designer.cs new file mode 100644 index 00000000..11202258 --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20240415010506_Heroes.Designer.cs @@ -0,0 +1,231 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using SSW.CleanArchitecture.Infrastructure.Persistence; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240415010506_Heroes")] + partial class Heroes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Heroes.Hero", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PowerLevel") + .HasColumnType("int"); + + b.Property("TeamId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("Heroes"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Mission", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeamId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("Mission"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Team", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalPowerLevel") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.TodoItems.TodoItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Done") + .HasColumnType("bit"); + + b.Property("Note") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("int"); + + b.Property("Reminder") + .HasColumnType("datetime2"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TodoItems"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Heroes.Hero", b => + { + b.HasOne("SSW.CleanArchitecture.Domain.Teams.Team", null) + .WithMany("Heroes") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SSW.CleanArchitecture.Domain.Heroes.Power", "Powers", b1 => + { + b1.Property("HeroId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("PowerLevel") + .HasColumnType("int"); + + b1.HasKey("HeroId", "Id"); + + b1.ToTable("Heroes"); + + b1.ToJson("Powers"); + + b1.WithOwner() + .HasForeignKey("HeroId"); + }); + + b.Navigation("Powers"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Mission", b => + { + b.HasOne("SSW.CleanArchitecture.Domain.Teams.Team", null) + .WithMany("Missions") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Team", b => + { + b.Navigation("Heroes"); + + b.Navigation("Missions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/20240415010506_Heroes.cs b/src/Infrastructure/Persistence/Migrations/20240415010506_Heroes.cs new file mode 100644 index 00000000..14d918cd --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20240415010506_Heroes.cs @@ -0,0 +1,106 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + /// + public partial class Heroes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Teams", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + TotalPowerLevel = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Teams", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Heroes", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Alias = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + PowerLevel = table.Column(type: "int", nullable: false), + TeamId = table.Column(type: "uniqueidentifier", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + Powers = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Heroes", x => x.Id); + table.ForeignKey( + name: "FK_Heroes_Teams_TeamId", + column: x => x.TeamId, + principalTable: "Teams", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Mission", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Status = table.Column(type: "int", nullable: false), + TeamId = table.Column(type: "uniqueidentifier", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + CreatedBy = table.Column(type: "nvarchar(max)", nullable: true), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: true), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Mission", x => x.Id); + table.ForeignKey( + name: "FK_Mission_Teams_TeamId", + column: x => x.TeamId, + principalTable: "Teams", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Heroes_TeamId", + table: "Heroes", + column: "TeamId"); + + migrationBuilder.CreateIndex( + name: "IX_Mission_TeamId", + table: "Mission", + column: "TeamId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Heroes"); + + migrationBuilder.DropTable( + name: "Mission"); + + migrationBuilder.DropTable( + name: "Teams"); + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 95c367c7..33f17c1e 100644 --- a/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -17,12 +17,120 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.3") + .HasAnnotation("ProductVersion", "8.0.3") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Domain.Entities.TodoItem", b => + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Heroes.Hero", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PowerLevel") + .HasColumnType("int"); + + b.Property("TeamId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("Heroes"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Mission", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeamId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("Mission"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Team", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalPowerLevel") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.TodoItems.TodoItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -61,6 +169,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("TodoItems"); }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Heroes.Hero", b => + { + b.HasOne("SSW.CleanArchitecture.Domain.Teams.Team", null) + .WithMany("Heroes") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("SSW.CleanArchitecture.Domain.Heroes.Power", "Powers", b1 => + { + b1.Property("HeroId") + .HasColumnType("uniqueidentifier"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b1.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b1.Property("PowerLevel") + .HasColumnType("int"); + + b1.HasKey("HeroId", "Id"); + + b1.ToTable("Heroes"); + + b1.ToJson("Powers"); + + b1.WithOwner() + .HasForeignKey("HeroId"); + }); + + b.Navigation("Powers"); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Mission", b => + { + b.HasOne("SSW.CleanArchitecture.Domain.Teams.Team", null) + .WithMany("Missions") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SSW.CleanArchitecture.Domain.Teams.Team", b => + { + b.Navigation("Heroes"); + + b.Navigation("Missions"); + }); #pragma warning restore 612, 618 } } diff --git a/tests/Architecture.Tests/Common/IsEnumRule.cs b/tests/Architecture.Tests/Common/IsEnumRule.cs new file mode 100644 index 00000000..61adaf56 --- /dev/null +++ b/tests/Architecture.Tests/Common/IsEnumRule.cs @@ -0,0 +1,9 @@ +using Mono.Cecil; +using NetArchTest.Rules; + +namespace SSW.CleanArchitecture.Architecture.UnitTests.Common; + +public class IsEnumRule : ICustomRule +{ + public bool MeetsRule(TypeDefinition type) => type.IsEnum; +} \ No newline at end of file diff --git a/tests/Architecture.Tests/Common/IsNotEnumRule.cs b/tests/Architecture.Tests/Common/IsNotEnumRule.cs new file mode 100644 index 00000000..37a60521 --- /dev/null +++ b/tests/Architecture.Tests/Common/IsNotEnumRule.cs @@ -0,0 +1,9 @@ +using Mono.Cecil; +using NetArchTest.Rules; + +namespace SSW.CleanArchitecture.Architecture.UnitTests.Common; + +public class IsNotEnumRule : ICustomRule +{ + public bool MeetsRule(TypeDefinition type) => !type.IsEnum; +} \ No newline at end of file diff --git a/tests/Architecture.Tests/Common/TestResultExtensions.cs b/tests/Architecture.Tests/Common/TestResultExtensions.cs new file mode 100644 index 00000000..a4170b23 --- /dev/null +++ b/tests/Architecture.Tests/Common/TestResultExtensions.cs @@ -0,0 +1,18 @@ +using NetArchTest.Rules; +using Xunit.Abstractions; + +namespace SSW.CleanArchitecture.Architecture.UnitTests.Common; + +public static class TestResultExtensions +{ + public static void DumpFailingTypes(this TestResult result, ITestOutputHelper outputHelper) + { + if (result.IsSuccessful) + return; + + outputHelper.WriteLine("Failing Types:"); + + foreach (var type in result.FailingTypes) + outputHelper.WriteLine(type.FullName); + } +} \ No newline at end of file diff --git a/tests/Architecture.Tests/DatabaseEntitiesTest.cs b/tests/Architecture.Tests/DatabaseEntitiesTest.cs index 3a76bed9..d41db8fd 100644 --- a/tests/Architecture.Tests/DatabaseEntitiesTest.cs +++ b/tests/Architecture.Tests/DatabaseEntitiesTest.cs @@ -1,13 +1,24 @@ using FluentAssertions; using Microsoft.EntityFrameworkCore; using NetArchTest.Rules; +using SSW.CleanArchitecture.Architecture.UnitTests.Common; using SSW.CleanArchitecture.Domain.Common.Base; +using SSW.CleanArchitecture.Domain.Common.Interfaces; using SSW.CleanArchitecture.Infrastructure; +using Xunit.Abstractions; namespace SSW.CleanArchitecture.Architecture.UnitTests; public class DatabaseEntities { + private readonly ITestOutputHelper _outputHelper; + + public DatabaseEntities(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + } + + // // TODO: Fix this test [Fact] public void Entities_ShouldInheritsBaseComponent() { @@ -25,9 +36,12 @@ public void Entities_ShouldInheritsBaseComponent() .That() .HaveName(entityTypes) .Should() - .Inherit(typeof(BaseEntity<>)); + .Inherit(typeof(BaseEntity<>)) + .Or() + .Inherit(typeof(IAggregateRoot)) + ; - result.GetTypes().Count().Should().BePositive(); result.GetResult().IsSuccessful.Should().BeTrue(); + result.GetResult().DumpFailingTypes(_outputHelper); } } \ No newline at end of file diff --git a/tests/Architecture.Tests/DomainModelTests.cs b/tests/Architecture.Tests/DomainModelTests.cs new file mode 100644 index 00000000..7f1be804 --- /dev/null +++ b/tests/Architecture.Tests/DomainModelTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using NetArchTest.Rules; +using SSW.CleanArchitecture.Architecture.UnitTests.Common; +using SSW.CleanArchitecture.Domain.Common.Base; +using SSW.CleanArchitecture.Domain.Common.Interfaces; +using Xunit.Abstractions; + +namespace SSW.CleanArchitecture.Architecture.UnitTests; + +public class DomainModelTests +{ + private readonly ITestOutputHelper _outputHelper; + + public DomainModelTests(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + } + + [Fact] + public void DomainModel_ShouldInheritsBaseClasses() + { + // Arrange + var domainModels = Types.InAssembly(typeof(AggregateRoot<>).Assembly) + .That() + .DoNotResideInNamespaceContaining("Common") + .And().DoNotHaveNameEndingWith("Id") + .And().DoNotHaveNameEndingWith("Spec") + .And().MeetCustomRule(new IsNotEnumRule()); + + // Act + var result = domainModels + .Should() + .Inherit(typeof(AggregateRoot<>)) + .Or().Inherit(typeof(Entity<>)) + .Or().Inherit(typeof(BaseEntity<>)) + .Or().Inherit(typeof(DomainEvent)) + .Or().ImplementInterface(typeof(IValueObject)); + + // Assert + result.GetResult().IsSuccessful.Should().BeTrue(); + result.GetResult().DumpFailingTypes(_outputHelper); + } +} \ No newline at end of file