-
Notifications
You must be signed in to change notification settings - Fork 0
feat(phase-02a): packet 2 — shared kernel core #6
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
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
db230ae
feat(shared-kernel): Phase 02a packet 2 — shared kernel core
cemililik 377b34b
docs(decisions,architecture): Packet 2 follow-up — ADR-0023 Amendment…
cemililik 7c9133a
fix(shared-kernel): packet 2 review — blocker + major findings
cemililik a1ad5fb
docs(standards,decisions): packet 2 review follow-up — corpus sync
cemililik 188d33a
fix(shared-kernel): packet 2 PR #6 review-2 — defensive guards + cast…
cemililik 6b3a4aa
fix(shared-kernel): packet 2 PR #6 review-3 — defensive ctors + modul…
cemililik cc4435c
fix(shared-kernel): packet 2 PR #6 review-4 — init-path guards + per-…
cemililik b82acc2
docs(standards): packet 2 PR #6 review-5 — Types-table CourseId examp…
cemililik File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
127 changes: 127 additions & 0 deletions
127
backend/src/LearnStack.SharedKernel/Domain/AuditableEntity.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 => 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; | ||
| } | ||
|
|
||
| // 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)); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
23
backend/src/LearnStack.SharedKernel/Domain/IDomainEvent.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
13
backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } |
45 changes: 45 additions & 0 deletions
45
backend/src/LearnStack.SharedKernel/Identifiers/FixedGuidFactory.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
16
backend/src/LearnStack.SharedKernel/Identifiers/IAggregateRoot.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| { | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.