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
6 changes: 6 additions & 0 deletions backend/Directory.Packages.props
Original file line number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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();
}
}
16 changes: 16 additions & 0 deletions backend/src/LearnStack.SharedKernel/Identifiers/IAggregateRoot.cs
Original file line number Diff line number Diff 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