Skip to content
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2025-06-08

### Changed
- **BREAKING**: Upgraded target framework from .NET Standard 2.0 to .NET 8.0
- Modernized Guard class to use .NET 8 built-in methods (ArgumentNullException.ThrowIfNull, ArgumentException.ThrowIfNullOrEmpty, ArgumentException.ThrowIfNullOrWhiteSpace)
- Enhanced Entity<TId> class with IEquatable<Entity<TId>> implementation and improved equality comparisons using EqualityComparer<T>.Default
- Improved ValueObject class with IEquatable<ValueObject> implementation and System.HashCode for better hash code generation
- Modernized Result and Result<T> classes with C# 12 collection expressions, target-typed new expressions, and implicit conversion operators
- Updated AggregateRoot<TId> to use collection expressions and AsReadOnly() instead of ToImmutableList() for better performance
- Enhanced Guard methods with CallerArgumentExpression attributes for automatic parameter name detection
- Added NotNull attributes for better nullable reference types support

### Removed
- Dependency on System.Collections.Immutable package (no longer needed)

### Fixed
- Resolved CS8604 nullable reference warning in Guard.AgainstNullOrEmpty method
- Updated unit tests to match .NET 8 exception behavior (ArgumentNullException for null values in ThrowIfNullOrEmpty/ThrowIfNullOrWhiteSpace)

## [0.1.0] - 2025-06-08

### Added
Expand Down
10 changes: 3 additions & 7 deletions src/AggregateKit/AggregateKit.csproj
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<PackageId>AggregateKit</PackageId>
<Version>0.1.0</Version>
<Version>0.2.0</Version>
<Authors>ppilichowski</Authors>
<Company>ppilichowski</Company>
<Description>Lightweight building blocks for Domain-Driven Design in .NET</Description>
Expand All @@ -16,12 +16,8 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="System.Collections.Immutable" Version="9.0.5" />
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>

</Project>
10 changes: 8 additions & 2 deletions src/AggregateKit/AggregateRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ namespace AggregateKit
/// <typeparam name="TId">The type of the identity of the aggregate root.</typeparam>
public abstract class AggregateRoot<TId> : Entity<TId> where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = new List<IDomainEvent>();
private readonly List<IDomainEvent> _domainEvents = [];

/// <summary>
/// Gets the domain events that have been raised by this aggregate root.
/// </summary>
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.ToImmutableList();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

protected AggregateRoot(TId id) : base(id)
{
Expand All @@ -32,6 +32,7 @@ protected AggregateRoot()
/// <param name="domainEvent">The domain event to add.</param>
protected void AddDomainEvent(IDomainEvent domainEvent)
{
Guard.AgainstNull(domainEvent);
_domainEvents.Add(domainEvent);
}

Expand All @@ -43,5 +44,10 @@ public void ClearDomainEvents()
{
_domainEvents.Clear();
}

/// <summary>
/// Gets a value indicating whether this aggregate root has any domain events.
/// </summary>
public bool HasDomainEvents => _domainEvents.Count > 0;
}
}
37 changes: 18 additions & 19 deletions src/AggregateKit/Entity.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;

namespace AggregateKit
{
Expand All @@ -7,13 +8,15 @@
/// Entities are equality-comparable by identity, not by attributes.
/// </summary>
/// <typeparam name="TId">The type of the identity of the entity.</typeparam>
public abstract class Entity<TId> where TId : notnull
public abstract class Entity<TId> : IEquatable<Entity<TId>> where TId : notnull
{
public TId Id { get; protected set; } = default!;

protected Entity(TId id)
{
if (object.Equals(id, default(TId)))
ArgumentNullException.ThrowIfNull(id);

if (EqualityComparer<TId>.Default.Equals(id, default))
{
throw new ArgumentException("The ID cannot be the default value.", nameof(id));
}
Expand All @@ -22,34 +25,30 @@
}

// EF Core requires a parameterless constructor
protected Entity() {}
protected Entity() { }

Check warning on line 28 in src/AggregateKit/Entity.cs

View check run for this annotation

Codecov / codecov/patch

src/AggregateKit/Entity.cs#L28

Added line #L28 was not covered by tests

public override bool Equals(object? obj)
public bool Equals(Entity<TId>? other)
{
if (obj == null || obj.GetType() != GetType())
{
return false;
}

var other = (Entity<TId>)obj;
if (other is null) return false;
if (ReferenceEquals(this, other)) return true;
if (GetType() != other.GetType()) return false;

return Id.Equals(other.Id);
return EqualityComparer<TId>.Default.Equals(Id, other.Id);
}

public override bool Equals(object? obj)
{
return Equals(obj as Entity<TId>);
}

public override int GetHashCode()
{
return Id.GetHashCode();
return EqualityComparer<TId>.Default.GetHashCode(Id);
}

public static bool operator ==(Entity<TId>? left, Entity<TId>? right)
{
if (ReferenceEquals(left, null) && ReferenceEquals(right, null))
return true;

if (ReferenceEquals(left, null) || ReferenceEquals(right, null))
return false;

return left.Equals(right);
return EqualityComparer<Entity<TId>>.Default.Equals(left, right);
}

public static bool operator !=(Entity<TId>? left, Entity<TId>? right)
Expand Down
35 changes: 14 additions & 21 deletions src/AggregateKit/Guard.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;

namespace AggregateKit
{
Expand All @@ -15,12 +17,9 @@ public static class Guard
/// <param name="value">The value to check.</param>
/// <param name="parameterName">The name of the parameter.</param>
/// <exception cref="ArgumentNullException">Thrown when the value is null.</exception>
public static void AgainstNull(object? value, string parameterName)
public static void AgainstNull([NotNull] object? value, [CallerArgumentExpression(nameof(value))] string? parameterName = null)
{
if (value == null)
{
throw new ArgumentNullException(parameterName);
}
ArgumentNullException.ThrowIfNull(value, parameterName);
}

/// <summary>
Expand All @@ -29,12 +28,9 @@ public static void AgainstNull(object? value, string parameterName)
/// <param name="value">The string to check.</param>
/// <param name="parameterName">The name of the parameter.</param>
/// <exception cref="ArgumentException">Thrown when the string is null or empty.</exception>
public static void AgainstNullOrEmpty(string? value, string parameterName)
public static void AgainstNullOrEmpty([NotNull] string? value, [CallerArgumentExpression(nameof(value))] string? parameterName = null)
{
if (string.IsNullOrEmpty(value))
{
throw new ArgumentException("String cannot be null or empty.", parameterName);
}
ArgumentException.ThrowIfNullOrEmpty(value, parameterName);
}

/// <summary>
Expand All @@ -43,12 +39,9 @@ public static void AgainstNullOrEmpty(string? value, string parameterName)
/// <param name="value">The string to check.</param>
/// <param name="parameterName">The name of the parameter.</param>
/// <exception cref="ArgumentException">Thrown when the string is null, empty, or consists only of whitespace.</exception>
public static void AgainstNullOrWhiteSpace(string? value, string parameterName)
public static void AgainstNullOrWhiteSpace([NotNull] string? value, [CallerArgumentExpression(nameof(value))] string? parameterName = null)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("String cannot be null, empty, or consist only of whitespace.", parameterName);
}
ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName);
}

/// <summary>
Expand All @@ -58,9 +51,9 @@ public static void AgainstNullOrWhiteSpace(string? value, string parameterName)
/// <param name="value">The collection to check.</param>
/// <param name="parameterName">The name of the parameter.</param>
/// <exception cref="ArgumentException">Thrown when the collection is null or empty.</exception>
public static void AgainstNullOrEmpty<T>(IEnumerable<T>? value, string parameterName)
public static void AgainstNullOrEmpty<T>([NotNull] IEnumerable<T>? value, [CallerArgumentExpression(nameof(value))] string? parameterName = null)
{
AgainstNull(value, parameterName);
ArgumentNullException.ThrowIfNull(value, parameterName);

if (!value.Any())
{
Expand All @@ -69,13 +62,13 @@ public static void AgainstNullOrEmpty<T>(IEnumerable<T>? value, string parameter
}

/// <summary>
/// Ensures that the specified condition is true.
/// Ensures that the specified condition is false.
/// </summary>
/// <param name="condition">The condition to check.</param>
/// <param name="message">The exception message to use when the condition is false.</param>
/// <param name="message">The exception message to use when the condition is true.</param>
/// <param name="parameterName">The name of the parameter.</param>
/// <exception cref="ArgumentException">Thrown when the condition is false.</exception>
public static void AgainstCondition(bool condition, string message, string parameterName)
/// <exception cref="ArgumentException">Thrown when the condition is true.</exception>
public static void AgainstCondition(bool condition, string message, [CallerArgumentExpression(nameof(condition))] string? parameterName = null)
{
if (condition)
{
Expand Down
52 changes: 46 additions & 6 deletions src/AggregateKit/Result.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ namespace AggregateKit
/// </summary>
public class Result
{
protected readonly List<string> _errors = new List<string>();
protected readonly List<string> _errors = [];

/// <summary>
/// Gets a value indicating whether the operation was successful.
/// </summary>
public bool IsSuccess => !_errors.Any();
public bool IsSuccess => _errors.Count == 0;

/// <summary>
/// Gets a value indicating whether the operation failed.
Expand All @@ -35,7 +35,7 @@ protected Result() { }
/// Creates a new successful result.
/// </summary>
/// <returns>A successful result.</returns>
public static Result Success() => new Result();
public static Result Success() => new();

/// <summary>
/// Creates a new failed result with the specified error message.
Expand All @@ -44,6 +44,8 @@ protected Result() { }
/// <returns>A failed result.</returns>
public static Result Failure(string error)
{
ArgumentException.ThrowIfNullOrWhiteSpace(error);

var result = new Result();
result._errors.Add(error);
return result;
Expand All @@ -56,10 +58,25 @@ public static Result Failure(string error)
/// <returns>A failed result.</returns>
public static Result Failure(IEnumerable<string> errors)
{
ArgumentNullException.ThrowIfNull(errors);

var errorList = errors.ToList();
if (errorList.Count == 0)
{
throw new ArgumentException("At least one error must be provided.", nameof(errors));
}

var result = new Result();
result._errors.AddRange(errors);
result._errors.AddRange(errorList);
return result;
}

/// <summary>
/// Creates a new failed result with the specified error messages.
/// </summary>
/// <param name="errors">The error messages.</param>
/// <returns>A failed result.</returns>
public static Result Failure(params string[] errors) => Failure((IEnumerable<string>)errors);
}

/// <summary>
Expand Down Expand Up @@ -97,7 +114,7 @@ private Result() { }
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A successful result.</returns>
public static Result<T> Success(T value) => new Result<T>(value);
public static Result<T> Success(T value) => new(value);

/// <summary>
/// Creates a new failed result with the specified error message.
Expand All @@ -106,6 +123,8 @@ private Result() { }
/// <returns>A failed result.</returns>
public new static Result<T> Failure(string error)
{
ArgumentException.ThrowIfNullOrWhiteSpace(error);

var result = new Result<T>();
result._errors.Add(error);
return result;
Expand All @@ -118,9 +137,30 @@ private Result() { }
/// <returns>A failed result.</returns>
public new static Result<T> Failure(IEnumerable<string> errors)
{
ArgumentNullException.ThrowIfNull(errors);

var errorList = errors.ToList();
if (errorList.Count == 0)
{
throw new ArgumentException("At least one error must be provided.", nameof(errors));
}

var result = new Result<T>();
result._errors.AddRange(errors);
result._errors.AddRange(errorList);
return result;
}

/// <summary>
/// Creates a new failed result with the specified error messages.
/// </summary>
/// <param name="errors">The error messages.</param>
/// <returns>A failed result.</returns>
public new static Result<T> Failure(params string[] errors) => Failure((IEnumerable<string>)errors);

/// <summary>
/// Implicitly converts a value to a successful result.
/// </summary>
/// <param name="value">The value to convert.</param>
public static implicit operator Result<T>(T value) => Success(value);
}
}
Loading