Skip to content

Commit

Permalink
✨ Add Superhero Domain and DDD Patterns (#277)
Browse files Browse the repository at this point in the history
* Started on new hero domain model

* Tidied up base classes

* Updated readme

* Added migration for new Domain models

* Added DDD adr

* Tidied up current mission

* Updated domain model

* Added DB diagram

* Domain is easier to click

* Added descriptions to common base classes and interfaces

* Temporarily remove test

* Fix arch tests

* Removed unneeded interface

* Capture properties when event is raised

* Tidied up aggregate root interface

* Fixed arch tests

* Update src/Infrastructure/Persistence/Configuration/HeroConfiguration.cs

Co-authored-by: Chris Clement [SSW] <[email protected]>

* Configuration: Remove TODOs

* Renamed Strength to PowerLevel

* Update ADR

* Add TODO: to DB Context Configurations' marker

* Update database.png with new Heroes PowerLevel name

---------

Co-authored-by: Chris Clement [SSW] <[email protected]>
Co-authored-by: Chris <[email protected]>
  • Loading branch information
3 people committed Apr 15, 2024
1 parent 64aaca4 commit 23fa27e
Show file tree
Hide file tree
Showing 37 changed files with 1,149 additions and 55 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
43 changes: 43 additions & 0 deletions docs/adr/20240404-use-domain-driven-design-tactical-patterns.md
Original file line number Diff line number Diff line change
@@ -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 <!-- optional -->

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

### 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
Binary file added docs/database.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions docs/domain.md
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion src/Application/Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>
</Project>
41 changes: 41 additions & 0 deletions src/Domain/Common/Base/AggregateRoot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using SSW.CleanArchitecture.Domain.Common.Interfaces;
using System.ComponentModel.DataAnnotations.Schema;

namespace SSW.CleanArchitecture.Domain.Common.Base;

/// <summary>
/// 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)
/// </summary>
public abstract class AggregateRoot<TId> : Entity<TId>, IAggregateRoot
{
private readonly List<DomainEvent> _domainEvents = [];

[NotMapped]
public IReadOnlyList<DomainEvent> 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<TId> : Entity<TId>
{
private readonly List<DomainEvent> _domainEvents = [];

[NotMapped]
public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();

public void AddDomainEvent(DomainEvent domainEvent) => _domainEvents.Add(domainEvent);

public void RemoveDomainEvent(DomainEvent domainEvent) => _domainEvents.Remove(domainEvent);

public void ClearDomainEvents() => _domainEvents.Clear();
}
25 changes: 20 additions & 5 deletions src/Domain/Common/Base/AuditableEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,25 @@

namespace SSW.CleanArchitecture.Domain.Common.Base;

/// <summary>
/// Tracks creation and modification of an entity.
/// </summary>
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;
}
}
20 changes: 0 additions & 20 deletions src/Domain/Common/Base/BaseEntity.cs

This file was deleted.

5 changes: 4 additions & 1 deletion src/Domain/Common/Base/DomainEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@

namespace SSW.CleanArchitecture.Domain.Common.Base;

public record DomainEvent : INotification;
/// <summary>
/// Can be raised by an AggregateRoot to notify subscribers of a domain event.
/// </summary>
public record DomainEvent : INotification;
11 changes: 11 additions & 0 deletions src/Domain/Common/Base/Entity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SSW.CleanArchitecture.Domain.Common.Base;

/// <summary>
/// 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)
/// </summary>
public abstract class Entity<TId> : AuditableEntity
{
public TId Id { get; set; } = default!;
}
7 changes: 7 additions & 0 deletions src/Domain/Common/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SSW.CleanArchitecture.Domain.Common;

public static class Constants
{
public const int DefaultNameMaxLength = 100;
public const int DefaultDescriptionMaxLength = 500;
}
14 changes: 14 additions & 0 deletions src/Domain/Common/Interfaces/IAggregateRoot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using SSW.CleanArchitecture.Domain.Common.Base;

namespace SSW.CleanArchitecture.Domain.Common.Interfaces;

public interface IAggregateRoot
{
public IReadOnlyList<DomainEvent> DomainEvents { get; }

public void AddDomainEvent(DomainEvent domainEvent);

public void RemoveDomainEvent(DomainEvent domainEvent);

public void ClearDomainEvents();
}
12 changes: 8 additions & 4 deletions src/Domain/Common/Interfaces/IAuditableEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
14 changes: 0 additions & 14 deletions src/Domain/Common/Interfaces/IDomainEvents.cs

This file was deleted.

12 changes: 12 additions & 0 deletions src/Domain/Common/Interfaces/IValueObject.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace SSW.CleanArchitecture.Domain.Common.Interfaces;

/// <summary>
/// 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.
/// </summary>
public interface IValueObject;
1 change: 1 addition & 0 deletions src/Domain/Domain.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Ardalis.GuardClauses" Version="4.5.0" />
<PackageReference Include="Ardalis.Specification" Version="7.0.0" />
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
</ItemGroup>
Expand Down
62 changes: 62 additions & 0 deletions src/Domain/Heroes/Hero.cs
Original file line number Diff line number Diff line change
@@ -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<HeroId>
{
private readonly List<Power> _powers = [];
public string Name { get; private set; } = null!;
public string Alias { get; private set; } = null!;
public int PowerLevel { get; private set; }
public IEnumerable<Power> 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));
}
}
20 changes: 20 additions & 0 deletions src/Domain/Heroes/Power.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit 23fa27e

Please sign in to comment.