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(protected setter), a private domain-event list surfaced asDomainEvents, a protectedAddDomainEvent(IDomainEvent), andClearDomainEvents(). Every persisted entity inherits from this.AggregateRoot<TId>— extendsBaseEntity<TId>; a semantic marker for aggregate roots. It adds no members today — it exists so the type system says “this is a consistency boundary” and gives you a place for aggregate-wide helpers later.IEntity<TId>— the minimal entity contract: a singleTId Id { get; }.
Cross-cutting interfaces
IHasTenant—string TenantId { get; }. A read-side convenience for entities that expose their tenant id as a real property. Note: tenant isolation itself does not key off this interface —BaseDbContextmarks every entityIsMultiTenant()via Finbuckle unless it implementsIGlobalEntity(see Persistence).IAuditableEntity—CreatedOnUtc,CreatedBy,LastModifiedOnUtc,LastModifiedBy(allDateTimeOffset-based).AuditableEntitySaveChangesInterceptorfills them in automatically.ISoftDeletable—IsDeleted,DeletedOnUtc,DeletedBy. The named query filterQueryFilters.SoftDeletehides deleted rows by default; opt out surgically withIgnoreQueryFilters([QueryFilters.SoftDelete])(tenant scoping stays in force).IHasDomainEvents— implemented byBaseEntity; surfaces the domain-event list so theDomainEventsInterceptorcan dispatch them after SaveChanges.IGlobalEntity— opt-out of tenant isolation for platform-wide rows (billing plans, impersonation grants, outbox/inbox messages).
Domain events
DomainEvent is an abstract record:
public abstract record DomainEvent( Guid EventId, DateTimeOffset OccurredOnUtc, string? CorrelationId = null, string? TenantId = null) : IDomainEvent{ public static T Create<T>(Func<Guid, DateTimeOffset, T> factory) where T : DomainEvent => factory(Guid.NewGuid(), DateTimeOffset.UtcNow);}Concrete events inherit it (this one is real, from Catalog):
public sealed record ProductPriceChangedDomainEvent( Guid ProductId, decimal OldPrice, decimal NewPrice, string Currency, 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:Name,GetUserId(),GetUserEmail(),GetTenant(),IsAuthenticated(),IsInRole(role),GetUserClaims().ICurrentUserInitializer— implemented by the Identity module’sCurrentUserService; populatesICurrentUserfrom theClaimsPrincipal(SetCurrentUser) or a raw id (SetCurrentUserId, used by Hangfire’s job activator). The kit’sCurrentUserMiddlewarecallsSetCurrentUserright after authentication, before the endpoint runs.IRequestContext— request metadata without an ASP.NET Core dependency:IpAddress,UserAgent,ClientId(from theX-Client-Idheader),Origin.
Exception hierarchy
The kit’s global exception handler (in the Web block) turns these into ProblemDetails (RFC 9457):
| Type | Status | Use when |
|---|---|---|
CustomException(message, errors, statusCode) | configurable (default 500) | Generic domain failure; pick the status |
NotFoundException | 404 | Resource missing |
ForbiddenException | 403 | Authenticated but not authorised |
UnauthorizedException | 401 | Not authenticated |
All four are plain exception classes. CustomException carries an ErrorMessages list (validation errors, business-rule details) and a StatusCode, and has overloads that accept an inner exception when you’re wrapping a lower-level failure. 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 Catalog (no TenantId property needed — Finbuckle adds the// tenant column and filter automatically via BaseDbContext)public sealed class Product : AggregateRoot<Guid>, ISoftDeletable{ public bool IsDeleted { get; private set; } public DateTimeOffset? DeletedOnUtc { get; private set; } public string? DeletedBy { get; private set; }
public void ChangePrice(Money newPrice) { decimal oldAmount = Price.Amount; Price = newPrice; AddDomainEvent(DomainEvent.Create((id, ts) => new ProductPriceChangedDomainEvent(Id, oldAmount, newPrice.Amount, newPrice.Currency, id, ts))); }}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.- Inner exceptions never reach the response.
CustomExceptionaccepts an inner exception for logging/diagnostics, but the global handler only maps the message,ErrorMessages, and status code into ProblemDetails — keep the public message clean and let logs carry the cause.
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.