FSH.Framework.Core (package id) is the foundation every other module and building block depends on. It ships nothing runtime-active — no DI registrations, no middleware, no hosted services — just the shared abstractions every domain layer needs: entity bases, aggregate-root markers, the DomainEvent record, current-user / tenant interfaces, and the framework’s exception hierarchy. Around a few hundred lines of interface + abstract-class definitions.
What it ships
Entity bases
BaseEntity<TId>— abstract; carriesId, a private_domainEventslist, and a protectedAddDomainEvent(IDomainEvent). Every persisted entity inherits from this.AggregateRoot<TId>— extendsBaseEntity<TId>; a marker for aggregate roots. The kit’sSpecification<T>and audit interceptor key off this distinction.IEntity<TId>— empty marker interface for “this is a domain entity.”
Cross-cutting interfaces
IHasTenant—Guid? TenantId { get; }. Persistence’s global query filter auto-applies to anything implementing this.IAuditableEntity—CreatedAt,CreatedBy,UpdatedAt,UpdatedBy.AuditableEntitySaveChangesInterceptorfills them in automatically.ISoftDeletable—IsDeleted,DeletedOnUtc,DeletedBy. The named query filterSoftDeletefilters these out by default; opt out withIgnoreNamedQueryFilter("SoftDelete").IHasDomainEvents— implemented byBaseEntity; surfaces the domain-event list so theDomainEventsInterceptorcan dispatch them after SaveChanges.IGlobalEntity— opt-out of tenant isolation for platform-wide rows (plans, impersonation grants, outbox messages).
Domain events
DomainEvent is an abstract record:
public abstract record DomainEvent( Guid EventId, DateTimeOffset OccurredOnUtc, Guid? CorrelationId = null, Guid? TenantId = null) : IDomainEvent{ public static T Create<T>(Func<Guid, DateTimeOffset, T> factory) => factory(Guid.NewGuid(), DateTimeOffset.UtcNow);}Concrete events inherit it:
public sealed record ProductPriceChangedDomainEvent( Guid ProductId, Money OldPrice, Money NewPrice, Guid EventId, DateTimeOffset OccurredOnUtc) : DomainEvent(EventId, OccurredOnUtc);…and aggregates raise them inside their methods. The DomainEventsInterceptor dispatches via Mediator after SaveChangesAsync completes.
Current-user + request context
ICurrentUser— the request-time view of the authenticated user:GetUserId(),GetUserEmail(),GetTenant(),IsAuthenticated(),IsInRole(role),GetUserClaims().ICurrentUserInitializer— implemented by the Identity module; populatesICurrentUserfrom theClaimsPrincipal. The kit’sCurrentUserMiddlewarecalls this at the end of the request pipeline.IRequestContext— broader request abstraction beyond user identity.
Exception hierarchy
The kit’s global exception handler (in the Web block) turns these into ProblemDetails (RFC 9457):
| Type | Status | Use when |
|---|---|---|
CustomException(message, HttpStatusCode) | configurable | Generic domain failure; pick the status |
NotFoundException | 404 | Resource missing |
ForbiddenException | 403 | Authenticated but not authorised |
UnauthorizedException | 401 | Not authenticated |
All four are simple records; no message templating, no inner-exception chaining. Domain code throws them; the handler does the HTTP translation.
How modules consume Core
Every aggregate inherits one of the bases. Every persisted entity opts in or out of the cross-cutting interfaces:
// Example from Catalogpublic sealed class Product : AggregateRoot<Guid>, ISoftDeletable{ public Guid? TenantId { get; private set; } // satisfies IHasTenant via BaseEntity public bool IsDeleted { get; private set; } public DateTime? DeletedOnUtc { get; private set; } public string? DeletedBy { get; private set; }
public void ChangePrice(Money newPrice) { var old = Price; Price = newPrice; AddDomainEvent(DomainEvent.Create((id, when) => new ProductPriceChangedDomainEvent(Id, old, newPrice, id, when))); }}ICurrentUser is injected by constructor anywhere you need to attribute work to a user:
public sealed class StartImpersonationCommandHandler(IIdentityDbContext db, ICurrentUser current) : ICommandHandler<StartImpersonationCommand, ImpersonationResponse>{ public async ValueTask<ImpersonationResponse> Handle(StartImpersonationCommand cmd, CancellationToken ct) { var actor = current.GetUserId(); // never null inside an authenticated endpoint // ... }}How to extend
Add a new cross-cutting interface
If you want every entity to track, say, LastViewedAt, add IHasLastViewed to Core, add the auto-update logic to a new EF Core interceptor in Persistence, and implement the interface on the entities that care. The kit’s pattern is: cross-cutting capability = interface + interceptor.
Add a new exception type
Inherit CustomException, pass the right status code in the base constructor. Don’t bypass the handler — keep the handler’s RFC 9457 mapping as the single source of truth for HTTP error shape.
Gotchas
AddDomainEventisprotectedonBaseEntity. Domain events must be raised from inside the aggregate, not from a handler. That’s by design: it keeps invariants and event emission co-located.DomainEvent.Create<T>(factory)is the static helper that fills inEventIdandOccurredOnUtcautomatically. Use it; don’t construct events manually withGuid.NewGuid()scattered through code.CustomExceptionhas no inner exception. If you want to wrap a lower-level failure, catch it, log it, and throw aCustomExceptionwith a clean message — don’t pile inner exceptions into the response.
Critical files
src/BuildingBlocks/Core/Domain/BaseEntity.cssrc/BuildingBlocks/Core/Domain/AggregateRoot.cssrc/BuildingBlocks/Core/Domain/DomainEvent.cssrc/BuildingBlocks/Core/Context/ICurrentUser.cssrc/BuildingBlocks/Core/Exceptions/CustomException.cs
Related
- Persistence —
BaseDbContextand the interceptors that consume these interfaces. - Web —
CurrentUserMiddlewarepopulatesICurrentUser. - Architecture overview — modular monolith + Vertical Slice patterns.