Skip to content
Merged
6 changes: 6 additions & 0 deletions backend/Directory.Packages.props
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,12 @@
<PackageVersion Include="MediatR" Version="12.4.1" />
<PackageVersion Include="FluentValidation" Version="11.10.0" />

<!-- Strongly-typed ID + value object source generator (ADR-0023).
Referenced via PrivateAssets="all" in every project that hosts
[ValueObject<>] declarations (SharedKernel for cross-cutting VOs,
each Modules.<X>.Domain for aggregate-root IDs). -->
<PackageVersion Include="Vogen" Version="7.0.0" />

<!-- Test stack -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="xunit" Version="2.9.2" />
Expand Down
127 changes: 127 additions & 0 deletions backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
using LearnStack.SharedKernel.Identifiers;
using LearnStack.SharedKernel.Persistence;
using LearnStack.SharedKernel.Time;

namespace LearnStack.SharedKernel.Domain;

/// <summary>
/// Mutable aggregate base. Carries the audit columns every tenant-owned
/// table mirrors (<c>CreatedAt</c> / <c>CreatedBy</c> / <c>UpdatedAt</c> /
/// <c>UpdatedBy</c> / <c>DeletedAt</c> / <c>DeletedBy</c> / <c>Version</c>)
/// and implements <see cref="ISoftDelete"/> + <see cref="IOptimisticConcurrency"/>
/// so the EF global-query-filter and concurrency-token wiring picks them up
/// uniformly. Audit columns are populated by <see cref="MarkCreated"/> /
/// <see cref="MarkUpdated"/> / <see cref="SoftDelete"/>, which command
/// handlers call via the <see cref="IClock"/> they already inject.
/// </summary>
public abstract class AuditableEntity<TId>
: Entity<TId>, ISoftDelete, IOptimisticConcurrency
where TId : struct, IStronglyTypedId<Guid>
{
protected AuditableEntity(TId id)
: base(id)
{
}

// EF Core / ORM materialization ctor.
protected AuditableEntity()
{
}

public DateTimeOffset CreatedAt { get; protected set; }

public UserId CreatedBy { get; protected set; }

public DateTimeOffset? UpdatedAt { get; protected set; }

public UserId? UpdatedBy { get; protected set; }

public DateTimeOffset? DeletedAt { get; protected set; }

public UserId? DeletedBy { get; protected set; }

public uint Version { get; protected set; }

/// <summary>
/// Convenience projection of <see cref="DeletedAt"/> for in-process
/// callers (aggregate methods, application services, mappers). EF
/// global query filters should gate on <see cref="DeletedAt"/> directly
/// (<c>e =&gt; e.DeletedAt == null</c>) — <see cref="IsDeleted"/> is a
/// computed CLR property and is NOT guaranteed to translate to SQL by
/// EF Core's expression translator. Packet 7 wires the filters
/// accordingly.
/// </summary>
public bool IsDeleted => DeletedAt.HasValue;

/// <summary>
/// Stamps <see cref="CreatedAt"/> / <see cref="CreatedBy"/> on first
/// persist. Throws when the aggregate already has a non-default
/// <see cref="CreatedAt"/> — audit-trail integrity rules out silent
/// overwrites.
/// </summary>
public void MarkCreated(DateTimeOffset at, UserId by)
{
EnsureValidAuditInput(at, by);

if (CreatedAt != default)
{
throw new InvalidOperationException(
"MarkCreated has already been called on this aggregate; the created-at / created-by columns are immutable after first stamp.");
}

CreatedAt = at;
CreatedBy = by;
}

/// <summary>
/// Stamps <see cref="UpdatedAt"/> / <see cref="UpdatedBy"/>. Called by
/// aggregate methods that mutate state; the audit pipeline reads these
/// values when writing the audit entry.
/// </summary>
public void MarkUpdated(DateTimeOffset at, UserId by)
{
EnsureValidAuditInput(at, by);

UpdatedAt = at;
UpdatedBy = by;
}

/// <summary>
/// Marks the entity as soft-deleted. Also bumps
/// <see cref="UpdatedAt"/> / <see cref="UpdatedBy"/> so the
/// "last touched at" timestamp is monotonic — replication / sync /
/// reporting jobs that scan on <c>UpdatedAt</c> see soft-deletes
/// without keying off <c>DeletedAt</c> separately. The audit row still
/// classifies the action as a delete via its own operation type.
/// </summary>
public void SoftDelete(DateTimeOffset at, UserId by)
{
EnsureValidAuditInput(at, by);

DeletedAt = at;
DeletedBy = by;
UpdatedAt = at;
UpdatedBy = by;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Audit metadata must always be meaningful: the default timestamp
// (0001-01-01) and the default UserId (Guid.Empty) are programmer-error
// sentinels rather than legitimate audit values. Fail loud at the call
// site rather than persisting them.
private static void EnsureValidAuditInput(DateTimeOffset at, UserId by)
{
if (at == default)
{
throw new ArgumentException(
"Audit timestamp must be a meaningful instant, not default(DateTimeOffset). Pass the value from IClock.UtcNow.",
nameof(at));
}

if (by.Value == Guid.Empty)
{
throw new ArgumentException(
"Audit actor must be a real UserId, not default(UserId). Pass the resolved ITenantContext.UserId.",
nameof(by));
}
}
}
31 changes: 31 additions & 0 deletions backend/src/LearnStack.SharedKernel/Domain/DomainEvent.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
namespace LearnStack.SharedKernel.Domain;

/// <summary>
/// Base record for in-process domain events. Concrete events derive from
/// this and add their own payload fields:
/// <code>
/// public sealed record CourseEnrolled(CourseId CourseId, UserId UserId) : DomainEvent;
///
/// // raised from inside an aggregate method:
/// RaiseDomainEvent(new CourseEnrolled(Id, learnerId)
/// {
/// EventId = guids.NewUuidV7(),
/// OccurredAt = clock.UtcNow,
/// });
/// </code>
/// </summary>
/// <remarks>
/// <see cref="EventId"/> and <see cref="OccurredAt"/> are <c>required init</c>:
/// every event MUST be stamped with the aggregate's injected
/// <c>IGuidFactory</c> / <c>IClock</c>. Defaulting these to
/// <c>Guid.CreateVersion7()</c> / <c>DateTimeOffset.UtcNow</c> would
/// silently bypass the deterministic-test abstractions every command
/// handler already threads in — which is the entire reason <c>IClock</c>
/// and <c>IGuidFactory</c> exist (Standards 02 § Time).
/// </remarks>
public abstract record DomainEvent : IDomainEvent
{
public required Guid EventId { get; init; }

public required DateTimeOffset OccurredAt { get; init; }
}
110 changes: 110 additions & 0 deletions backend/src/LearnStack.SharedKernel/Domain/Entity.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
using System.Collections.ObjectModel;
using LearnStack.SharedKernel.Identifiers;

namespace LearnStack.SharedKernel.Domain;

/// <summary>
/// Append-only / audit aggregate base. Carries identity and raises
/// in-process <see cref="IDomainEvent"/>s; does <em>not</em> carry the
/// <c>CreatedAt</c> / <c>UpdatedAt</c> audit columns — those belong to
/// mutable aggregates and live on <see cref="AuditableEntity{TId}"/>.
/// </summary>
/// <remarks>
/// <para>
/// The audit subsystem's own <c>AuditEntry</c> aggregate inherits this base
/// directly, never <see cref="AuditableEntity{TId}"/>, because audit rows
/// are immutable once written. Architecture test
/// <c>AuditEntry_Inherits_Entity_Not_AuditableEntity</c> guards that rule.
/// </para>
/// <para>
/// Equality contract: identity-based, with three guards every aggregate
/// inherits — transient entities (<see cref="Id"/> equal to
/// <c>default(TId)</c>) are never equal to each other (reference equality
/// is the only match), runtime-type mismatches are never equal even when
/// the underlying ID matches, and the hash code partitions transient
/// instances apart so EF Core's change-tracker identity map and any
/// <c>HashSet</c> in a collection navigation behave correctly.
/// </para>
/// <para>
/// Domain-event collection state is lazily allocated: the backing
/// <c>List</c> and its <see cref="ReadOnlyCollection{T}"/> view are only
/// created on first raise or first read. EF Core materialises every loaded
/// aggregate through the parameterless ctor on read paths; the lazy
/// approach keeps materialisation allocation-free for the common
/// query case (paginated reads, projections), and pays the allocation only
/// when an aggregate actually raises events (command paths).
/// </para>
/// </remarks>
public abstract class Entity<TId> : IHasId<TId>, IHasDomainEvents
where TId : struct, IStronglyTypedId<Guid>
{
private List<IDomainEvent>? _domainEvents;
private ReadOnlyCollection<IDomainEvent>? _domainEventsView;

protected Entity(TId id)
{
Id = id;
}

// EF Core / ORM materialization ctor.
protected Entity()
{
}

public TId Id { get; protected init; }

/// <summary>
/// In-process domain events raised since the last <see cref="ClearDomainEvents"/>.
/// Returns a cached <see cref="ReadOnlyCollection{T}"/> wrapper rather
/// than the backing <c>List</c> directly so callers cannot downcast
/// and mutate the collection out from under the aggregate. Both the
/// backing list and the wrapper are lazily allocated on first
/// access / first raise.
/// </summary>
public IReadOnlyCollection<IDomainEvent> DomainEvents =>
_domainEventsView ??= (_domainEvents ??= []).AsReadOnly();

protected void RaiseDomainEvent(IDomainEvent domainEvent)
{
ArgumentNullException.ThrowIfNull(domainEvent);
(_domainEvents ??= []).Add(domainEvent);
}

public void ClearDomainEvents() => _domainEvents?.Clear();

public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other)
{
return false;
}

if (ReferenceEquals(this, other))
{
return true;
}

// Cross-type guard: a Course and a hypothetical CourseDraft that both
// inherit Entity<CourseId> and share an Id value are still different
// runtime types and must not compare equal.
if (GetType() != other.GetType())
{
return false;
}

// Transient guard: two newly-constructed aggregates carry default(TId)
// until SaveChangesAsync stamps them. They must never collapse into
// each other in EF's change tracker or in a HashSet-backed navigation.
if (Id.Equals(default(TId)) || other.Id.Equals(default(TId)))
{
return false;
}

return Id.Equals(other.Id);
}

public override int GetHashCode() =>
Id.Equals(default(TId))
? base.GetHashCode()
: HashCode.Combine(GetType(), Id);
}
23 changes: 23 additions & 0 deletions backend/src/LearnStack.SharedKernel/Domain/IDomainEvent.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
using MediatR;

namespace LearnStack.SharedKernel.Domain;

/// <summary>
/// In-process domain event raised by an aggregate method. Dispatched
/// in-process by MediatR — the cross-module integration-event path
/// (outbox + Dapr pub/sub) is a different mechanism per
/// <see href="../../../../docs/decisions/0010-cross-module-communication.md">ADR-0010</see>.
/// </summary>
public interface IDomainEvent : INotification
{
/// <summary>
/// Unique event identifier. UUIDv7 so insertion-order matches occurrence
/// order when an event is persisted for replay or debugging.
/// </summary>
Guid EventId { get; }

/// <summary>
/// UTC instant the event was raised.
/// </summary>
DateTimeOffset OccurredAt { get; }
}
13 changes: 13 additions & 0 deletions backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
namespace LearnStack.SharedKernel.Domain;

/// <summary>
/// Marker every entity that raises <see cref="IDomainEvent"/> implements.
/// The unit-of-work walks tracked entities, drains the events, and hands
/// them to MediatR's in-process publisher on commit.
/// </summary>
public interface IHasDomainEvents
{
IReadOnlyCollection<IDomainEvent> DomainEvents { get; }

void ClearDomainEvents();
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
namespace LearnStack.SharedKernel.Identifiers;

/// <summary>
/// Deterministic <see cref="IGuidFactory"/> for tests. Returns the supplied
/// sequence in order; throws <see cref="InvalidOperationException"/> once
/// the sequence is exhausted so tests fail loud rather than silently
/// reusing a default value.
/// </summary>
/// <remarks>
/// <see cref="NewUuidV7"/> and <see cref="NewUuidV4"/> draw from the
/// <em>same</em> queue — the fixture does not synthesise version-7 / -4
/// shapes from the supplied <see cref="Guid"/>s. Callers that need to
/// assert <c>guid.Version == 7</c> (or 4) seed the queue with
/// version-appropriate values (e.g. <c>Guid.CreateVersion7()</c> minted
/// at test setup) so the fixture's output is the exact <see cref="Guid"/>
/// the test passes in. This keeps the fixture trivial; the production
/// <see cref="SystemGuidFactory"/> is where the version contract is
/// enforced.
/// </remarks>
public sealed class FixedGuidFactory : IGuidFactory
{
private readonly Queue<Guid> _sequence;

public FixedGuidFactory(params Guid[] sequence)
{
ArgumentNullException.ThrowIfNull(sequence);
_sequence = new Queue<Guid>(sequence);
}

public Guid NewUuidV7() => Dequeue();

public Guid NewUuidV4() => Dequeue();

private Guid Dequeue()
{
if (_sequence.Count == 0)
{
throw new InvalidOperationException(
"FixedGuidFactory sequence exhausted. Construct with enough GUIDs " +
"for the test, or switch to a different fixture.");
}

return _sequence.Dequeue();
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
namespace LearnStack.SharedKernel.Identifiers;

/// <summary>
/// Marker for the root entity of an aggregate. Repositories accept and
/// return only aggregate roots; entities inside an aggregate are reached
/// through the root.
/// </summary>
/// <typeparam name="TId">
/// The aggregate's strongly-typed identifier — per ADR-0023 every aggregate
/// root carries an <see cref="IStronglyTypedId{TKey}"/>-shaped Vogen-emitted
/// ID over <see cref="Guid"/>.
/// </typeparam>
public interface IAggregateRoot<out TId> : IHasId<TId>
where TId : struct, IStronglyTypedId<Guid>
{
}
Loading
Loading