Skip to content
fullstackhero

Reference

Core building block

The foundational types every module composes against — BaseEntity, AggregateRoot, DomainEvent, ICurrentUser, and the CustomException hierarchy.

views 0 Last updated

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; carries Id (protected setter), a private domain-event list surfaced as DomainEvents, a protected AddDomainEvent(IDomainEvent), and ClearDomainEvents(). Every persisted entity inherits from this.
  • AggregateRoot<TId> — extends BaseEntity<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 single TId Id { get; }.

Cross-cutting interfaces

  • IHasTenantstring 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 — BaseDbContext marks every entity IsMultiTenant() via Finbuckle unless it implements IGlobalEntity (see Persistence).
  • IAuditableEntityCreatedOnUtc, CreatedBy, LastModifiedOnUtc, LastModifiedBy (all DateTimeOffset-based). AuditableEntitySaveChangesInterceptor fills them in automatically.
  • ISoftDeletableIsDeleted, DeletedOnUtc, DeletedBy. The named query filter QueryFilters.SoftDelete hides deleted rows by default; opt out surgically with IgnoreQueryFilters([QueryFilters.SoftDelete]) (tenant scoping stays in force).
  • IHasDomainEvents — implemented by BaseEntity; surfaces the domain-event list so the DomainEventsInterceptor can 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:

src/BuildingBlocks/Core/Domain/DomainEvent.cs
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’s CurrentUserService; populates ICurrentUser from the ClaimsPrincipal (SetCurrentUser) or a raw id (SetCurrentUserId, used by Hangfire’s job activator). The kit’s CurrentUserMiddleware calls SetCurrentUser right after authentication, before the endpoint runs.
  • IRequestContext — request metadata without an ASP.NET Core dependency: IpAddress, UserAgent, ClientId (from the X-Client-Id header), Origin.

Exception hierarchy

The kit’s global exception handler (in the Web block) turns these into ProblemDetails (RFC 9457):

TypeStatusUse when
CustomException(message, errors, statusCode)configurable (default 500)Generic domain failure; pick the status
NotFoundException404Resource missing
ForbiddenException403Authenticated but not authorised
UnauthorizedException401Not 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

  • AddDomainEvent is protected on BaseEntity. 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 in EventId and OccurredOnUtc automatically. Use it; don’t construct events manually with Guid.NewGuid() scattered through code.
  • Inner exceptions never reach the response. CustomException accepts 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.cs
  • src/BuildingBlocks/Core/Domain/AggregateRoot.cs
  • src/BuildingBlocks/Core/Domain/DomainEvent.cs
  • src/BuildingBlocks/Core/Context/ICurrentUser.cs
  • src/BuildingBlocks/Core/Exceptions/CustomException.cs
  • PersistenceBaseDbContext and the interceptors that consume these interfaces.
  • WebCurrentUserMiddleware populates ICurrentUser.
  • Architecture overview — modular monolith + Vertical Slice patterns.