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

✨ Add Superhero Domain and DDD Patterns #277

Merged
merged 22 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from 16 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
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
41 changes: 41 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,41 @@
# Use Domain-Driven Design Tactical Patterns

- Status: Pending <!-- optional: draft | proposed | rejected | accepted | deprecated | … | superseded by [xxx](yyyymmdd-xxx.md) -->
- Deciders: {{ TBC }} <!-- optional: list everyone involved in the decision -->
christoment marked this conversation as resolved.
Show resolved Hide resolved
- Date: 2024-04-04 <!-- optional. YYYY-MM-DD when the decision was last updated. To customize the ordering without relying on Git creation dates and filenames -->
- Tags: ddd <!-- optional: space and/or comma-separated list of tags -->

Technical Story: {{ TECH STORY }} <!-- optional: description | ticket/issue URL -->
christoment marked this conversation as resolved.
Show resolved Hide resolved

## Context and Problem Statement

Currently, the template has is mostly geared towards building applications with an anemic Domain model. This is fine for simple applications, but not for complex applications with many business rules.

Instead, 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
Hona marked this conversation as resolved.
Show resolved Hide resolved
{
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 Strength { 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);
}

Strength += power.Strength;
AddDomainEvent(new StrengthUpdatedEvent(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);
}

Strength -= power.Strength;
AddDomainEvent(new StrengthUpdatedEvent(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 Strength { get; private set; }

public Power(string name, int strength)
{
Name = Guard.Against.StringTooLong(name, Constants.DefaultNameMaxLength);
Strength = Guard.Against.OutOfRange(strength, "Strength", 1, 10);
}
}
Loading