This document serves as a general architectural reference for any solution built on F#, Sharpino (event sourcing), Blazor, and PostgreSQL. Use it as a blueprint when creating or extending features.
All concrete code examples in this document are drawn from the blazorBookLibrary reference implementation. When starting from an empty repository, an agentic assistant should consult this repository for working examples of every pattern described here. Note: the current state changed a little bit as the solution moved toward "slices" project structure. The architecture is still the same but organized differently.
| URL | https://github.com/tonyx/blazorBookLibrary |
| Purpose | Full working implementation of every pattern in this blueprint |
| When to use | Browse this repo whenever a pattern description alone is insufficient — check a real aggregate, migration, service, or Razor page for a concrete, compilable example |
Important
If you are an AI assistant bootstrapping a new solution from scratch, treat the reference repository as your primary source of truth for implementation details. The blueprints sections below describe the patterns; the repository contains working code for each one.
Solution.sln
├── Solution.Shared/ # Domain aggregates, shared interfaces, details composition
│ ├── Domain/ # Aggregate definitions (event-sourced objects)
│ ├── Details/Details.fs # Composed read-model types (joining multiple aggregates)
│ ├── Services/ # Service interfaces (IXxxService)
│ ├── Commons.fs # Value objects, IDs, shared domain types
│ └── Resources/ # Localization .resx files
│
├── Solution.Server/ # F# server-side backend
│ ├── Domain/ # Commands and Events per aggregate
│ │ └── Xxx/
│ │ ├── Commands.fs
│ │ └── Events.fs
│ ├── Services/ # Service implementations
│ │ ├── XxxService.fs
│ │ └── DetailsService.fs # Cross-aggregate read-model service
│ ├── Details.fs # RefreshableXxxDetails wrappers (for DetailsCache)
│ ├── db/
│ │ └── migrations/ # SQL migration files (one per aggregate)
│ └── ...
│
├── Solution/ # Blazor WebAssembly / Server UI
│ ├── Components/Pages/ # Razor pages
│ ├── appsettings.json # Feature flags and configuration
│ └── Program.cs # DI registration
│
└── Solution.Tests/ # Test project
All domain primitives are wrapped in single-case discriminated unions defined in Shared/Commons.fs. This technique eliminates primitive obsession and enforces compile-time type safety — a BookId can never be accidentally passed where a UserId is expected.
Without value objects:
// BAD — all Guids look the same to the compiler:
let addLoan (bookId: Guid) (userId: Guid) (loanId: Guid) = ...
addLoan loanId userId bookId // compiles silently, wrong at runtime!With value objects:
// GOOD — the compiler catches transposed arguments:
let addLoan (bookId: BookId) (userId: UserId) (loanId: LoanId) = ...
addLoan loanId userId bookId // ❌ compile errorEvery aggregate has a dedicated ID type. All follow the same four-line pattern:
type BookId =
| BookId of Guid
with
static member New() = BookId(Guid.NewGuid())
member this.Value =
match this with
| BookId v -> vID types in this solution:
| Type | Wraps | Used by aggregate |
|---|---|---|
BookId |
Guid |
Book |
AuthorId |
Guid |
Author |
UserId |
Guid |
User |
LoanId |
Guid |
Loan |
ReservationId |
Guid |
Reservation |
ReviewId |
Guid |
Review |
EditorId |
Guid |
Editor |
IsbnRegistryId |
Guid |
IsbnRegistry |
MailQueueId |
Guid |
MailQueue |
Rule: When you add a new aggregate, also add a corresponding
NewAggIdtype toCommons.fsbefore defining the aggregate record.
Simple string wrappers enforce semantic intent:
type Title =
| Title of string
with
static member New(title: string) = Title(title)
member this.Value =
match this with
| Title v -> v
type AuthorName =
| AuthorName of string
with
static member New(name: string) = AuthorName(name)
member this.Value =
match this with
| AuthorName v -> vSome string value objects carry validation logic and multiple cases to represent valid/invalid/empty states:
type Isbn =
| Isbn of string // valid ISBN
| InvalidIsbn of string // saved but flagged as invalid
| EmptyIsbn // absent
with
static member IsValid (isbn: string) = ... // checksum validation
static member New (isbn: string) =
if Isbn.IsValid(isbn) then Ok (Isbn isbn)
else Error "Invalid ISBN"
static member NewInvalid (isbn: string) = InvalidIsbn isbn
static member NewEmpty () = EmptyIsbn
member this.Value =
match this with
| Isbn v | InvalidIsbn v -> v
| EmptyIsbn -> ""
member this.IsValidIsbn =
match this with
| Isbn _ -> true
| _ -> falseThe same multi-case pattern is used for:
PhoneNumber/InvalidPhoneNumber/EmptyPhoneNumber(regex validation)FiscalCode/InvalidFiscalCode/EmptyFiscalCode(Italian CF checksum)Isbn/InvalidIsbn/EmptyIsbn(ISBN-10 and ISBN-13 checksums)Isni/InvalidIsni/EmptyIsni(ISNI checksum)Name/EmptyName(whitespace guard)
Rule: For any string field that has a well-defined validity rule, prefer a multi-case DU over
Option<string>orstring. TheInvalid*case lets data be stored even when invalid (e.g., user-entered data awaiting correction), while still being distinguishable from aValid*case at the type level.
Closed sets of domain values are also value objects:
type Availability =
| Circulating
| ReferenceOnly
| Unspecified
with
static member AllCases () = [ Circulating; ReferenceOnly; Unspecified ]
static member FromString (s: string) = ...
type ApprovalStatus =
| Pending
| Approved of DateTime // carries the approval timestamp
| Rejected of DateTime
type LoanStatus =
| InProgress
| Returned of DateTime
type ReservationStatus =
| Pending
| LoanedEnumerations that carry data (like Approved of DateTime) are richer than C# enums — the timestamp is part of the type, not a separate field.
Some value objects encapsulate domain behaviour:
type TimeSlot =
{
Start: DateTime
End: DateTime
}
with
static member New (start: DateTime) (endTime: DateTime) = ...
member this.IsFutureOf (dateNow: DateTime) = this.Start > dateNow
member this.Overlaps (other: TimeSlot) =
this.Start < other.End && other.Start < this.End
member this.Shift (dateTime: DateTime) =
{ this with Start = dateTime; End = dateTime + (this.End - this.Start) }
type Sealed = // tracks seal/unseal lifecycle of an aggregate
{
DateTime: DateTime
Sealed: bool
}
with
member this.IsSealed (dateNow: DateTime) = this.Sealed
member this.Seal (dateTime: DateTime) = { this with Sealed = true; DateTime = dateTime }
member this.Unseal (dateTime: DateTime) = { this with Sealed = false; DateTime = dateTime }Whenever the underlying primitive is needed (e.g., passing to the event store or PostgreSQL), always use .Value:
// Reading an aggregate from the store:
let! book = bookViewerAsync (Some ct) bookId.Value // bookId.Value : Guid
// Constructing an ID from a raw Guid (e.g., from URL param):
let bookId = BookId.NewBookId(guid) // Sharpino-generated factory
// or:
let bookId = BookId guid // direct wrapping
// Constructing from a user-supplied string Guid:
if Guid.TryParse(userIdStr, out var userGuid) then
let userId = UserId.NewUserId(userGuid)Rule: Never pass raw
Guidorstringvalues across service or aggregate boundaries. Always wrap into the appropriate value object as early as possible (e.g., at the Razor page boundary when parsing URL parameters).
Each aggregate (also called: event-sourced object, domain object, stream root) lives in the Shared project so it can be referenced by both the Server and Client.
Every aggregate must declare:
static member StorageName = "_Xxx"— base name used to compose PostgreSQL table and function namesstatic member Version = "_01"— version prefix used in table and function namesstatic member SnapshotsInterval = N— how many events between snapshotsmember this.Id = this.XxxId.Value— Guid-based identitymember this.Serialize/static member Deserialize— JSON round-trip using sharedjsonOptions
namespace MyDomain
type Review =
{
ReviewId: ReviewId
BookId: BookId
UserId: UserId
Comment: string
Hidden: bool
ApprovalStatus: ApprovalStatus
}
with
static member SnapshotsInterval = 50
static member StorageName = "_Review" // → tables: events_01_Review, snapshots_01_Review
static member Version = "_01"
member this.Id = this.ReviewId.Value
member this.Serialize = JsonSerializer.Serialize(this, jsonOptions)
static member Deserialize json = ...
// domain methods returning Result<'T, string>
member this.Approve dateTime = { this with ApprovalStatus = Approved dateTime } |> OkRule: All mutable state transitions return
Result<Aggregate, string>, never throw.
Each aggregate has exactly one migration file. The file name format is:
YYYYMMDDHHMMSS_create_AggregateName.sql
The table and function names are composed from Version + StorageName of the aggregate:
| Aggregate static member | Example value |
|---|---|
Version |
"_01" |
StorageName |
"_Review" |
| Resulting table prefix | _01_Review |
Tables created:
events_01_Review— append-only event logsnapshots_01_Review— periodic aggregate state snapshotsaggregate_events_01_Review— links aggregate IDs to event IDs
Functions created:
insert_01_Review_event_and_return_id(event_in, aggregate_id)insert_md_01_Review_event_and_return_id(event_in, aggregate_id, distance, md)insert_01_Review_aggregate_event_and_return_id(event_in, aggregate_id)insert_md_01_Review_aggregate_event_and_return_id(event_in, aggregate_id, distance, md)
Rule: When adding a new aggregate, create both the F# type (with
StorageName+Version) and the corresponding migration that uses the same naming. They must be in sync.
Each aggregate gets its own folder in Server/Domain/ containing exactly two files:
Defines a discriminated union implementing AggregateCommand<Aggregate, Event>:
type ReviewCommand =
| Approve of DateTime
| Reject of DateTime
| Edit of string
| Hide
| Show
interface AggregateCommand<Review, ReviewEvent> with
member this.Execute (review: Review) =
match this with
| Approve dateTime ->
review.Approve dateTime
|> Result.map (fun r -> (r, [ReviewApproved dateTime]))
| ...
member this.Undoer = NoneDefines a discriminated union implementing AggregateEvent<Aggregate>:
type ReviewEvent =
| ReviewApproved of DateTime
| ReviewRejected of DateTime
| ReviewEdited of string
| ReviewHidden
| ReviewShown
interface AggregateEvent<Review> with
member this.Process (review: Review) =
match this with
| ReviewApproved dateTime -> review.Approve dateTime
| ...Rule: Each command produces a
(newState, events list)tuple. Each event replays deterministically on the aggregate.
Rule: Each event, in its
Processmethod, calls directly the aggregate's method that transforms the state and returns a result.
Service interfaces live in the Shared project and are consumed by both the Server implementation and the Blazor UI (via DI injection).
namespace MyApp.Shared.Services
type IReviewService =
abstract member AddReviewAsync: review:Review * ?ct:CancellationToken -> Task<Result<unit, string>>
abstract member ApproveReviewAsync: id:ReviewId * ?ct:CancellationToken -> Task<Result<unit, string>>
abstract member GetApprovedVisibleReviewsOfBookAsync: bookId:BookId * ?ct:CancellationToken -> Task<Result<List<Review * _>, string>>- Use
[<Optional; DefaultParameterValue(null)>] ?ct:CancellationTokenfor optional cancellation tokens - All return types are
Task<Result<'T, string>>— never throw, always wrap errors
Service implementations live in the Server project and receive:
IEventStore<string>— the Sharpino PostgreSQL event storeAggregateViewerAsync2<TAgg>— typed stream state reader (see below)- Optional: other service interfaces as collaborators
// Built during service construction — one viewer per aggregate type
let bookViewerAsync =
getAggregateStorageFreshStateViewerAsync<Book, BookEvent, string> eventStore
// Usage inside service methods:
let! book =
bookViewerAsync (Some ct) bookId.Value
|> TaskResult.map snd // snd = the aggregate state (fst = version)Rule: Always use
AggregateViewerAsync2<TAgg>(the async variant) and always call with(Some ct)for cancellation support.
When a business operation must update multiple aggregates atomically, use Sharpino's multi-command runners:
// Two aggregates updated in one transaction:
let! result =
runInitAndTwoAggregateCommandsMd<Book, BookEvent, User, UserEvent, string, Reservation>
book.Id
user.Id
eventStore
messageSenders
reservation // initial aggregate to persist (e.g. a new Reservation)
"" // metadata
addReservationToBookCommand
addReservationToUserCommand
// Delete one + update two:
let! result =
runDeleteAndTwoAggregateCommandsMd<Reservation, ReservationEvent, Book, BookEvent, User, UserEvent, string>
eventStore messageSenders ""
reservationId.Value book.Id user.Id
removeReservationFromBook removeReservationFromUser
(fun _ -> true)- Domain services (
BookService,LoanService,ReservationService,ReviewService,UserService, etc.) are as independent as possible from each other. - They may accept other services as constructor arguments only when orchestration is required (e.g.,
ReservationServiceacceptsIUserServiceto verify user limits and send emails). - They do not call
DetailsService.
The Details module in the Shared project defines read-model types that join data from multiple aggregates. These are pure data records, not event-sourced objects.
module Details =
// Joins User (aggregate) + ApplicationUser (ASP.NET Identity) + related aggregates
type UserDetails =
{
User: User
ApplicationUser: ApplicationUser
FutureReservations: List<Reservation * Book>
CurrentLoans: List<Loan * Book>
BooksAndReviews: List<Book * Review>
}
// Joins Book + Authors + CurrentLoan + ReservationsDetails + ApprovedReviews
type BookDetails =
{
Authors: List<Author>
Book: Book
CurrentLoan: Option<LoanDetails>
ReservationsDetails: List<ReservationDetails>
ApprovedVisibleReviews: List<ReviewDetails>
}Rule: Details types are read-only projections. They carry convenience members (e.g.,
member this.HasAnApprovedReviewOfBook) but contain no commands or state mutations.
The Server project wraps every XxxDetails type into a RefreshableXxxDetails record. These types:
- Hold a
Refresher: Option<CancellationToken> -> TaskResult<XxxDetails, string>function closure - Implement the
RefreshableAsync<RefreshableXxxDetails>interface (required byDetailsCache) - Enable cache invalidation and lazy refresh without rebuilding from scratch
namespace MyApp.Details
module Details =
type RefreshableBookDetails =
{
BookDetails: BookDetails
Refresher: Option<CancellationToken> -> TaskResult<BookDetails, string>
}
member this.RefreshAsync (ct: Option<CancellationToken>) =
taskResult {
let! bookDetails = this.Refresher ct
return { this with BookDetails = bookDetails }
}
interface RefreshableAsync<RefreshableBookDetails> with
member this.RefreshAsync ct = this.RefreshAsync ctOne RefreshableXxxDetails type exists per details type:
RefreshableUserDetails, RefreshableBookDetails, RefreshableAuthorDetails, RefreshableLoanDetails, RefreshableReservationDetails, RefreshableReviewDetails, …
DetailsService is the central read-model service. It:
- Depends on
ILoanService,IReservationService,IReviewService(and others) - Holds
AggregateViewerAsync2<TAgg>viewers for all aggregate types - Implements
IDetailsService(defined in Shared)
For each details type, the service defines a private GetRefreshableXxxDetailsAsync method:
member private this.GetRefreshableBookDetailsAsync(bookId: BookId, ?ct: CancellationToken) =
let ct = defaultArg ct CancellationToken.None
let detailsBuilder =
fun (ct: Option<CancellationToken>) ->
let ct = ct |> Option.defaultValue CancellationToken.None
let refresher =
fun (ct: Option<CancellationToken>) ->
taskResult {
let ct = ct |> Option.defaultValue CancellationToken.None
let! book = bookViewerAsync (Some ct) bookId.Value |> TaskResult.map snd
let! authors = ...
return { Book = book; Authors = authors; ... }
}
taskResult {
let! bookDetails = refresher (Some ct)
return
{ BookDetails = bookDetails; Refresher = refresher }
:> RefreshableAsync<RefreshableBookDetails>
,
// Cache dependency keys (invalidate cache if any of these aggregate IDs change):
bookId.Value :: (bookDetails.Authors |> List.map _.AuthorId.Value)
}
let key = DetailsCacheKey.OfType typeof<RefreshableBookDetails> bookId.Value
StateView.getRefreshableDetailsTaskResultAsync<RefreshableBookDetails> (fun ct -> detailsBuilder ct) key ctThe public GetBookDetailsAsync unwraps the refreshable wrapper:
member this.GetBookDetailsAsync(bookId, ?ct) =
taskResult {
---
## 11. Real-time UI Synchronization (SignalR + DetailsCache)
To keep the client-side Blazor UI synchronized with backend aggregate modifications in real-time, the solution uses a push-based mechanism combining **Sharpino's DetailsCache dependency tracking** with **ASP.NET Core SignalR**.
### Architectural Flow
1. **State Mutation**: A command modifies an aggregate (e.g. `Loan` is released).
2. **Cache Invalidation**: Sharpino's `DetailsCache` identifies which refreshable details (e.g. `RefreshableLoanDetails`) depend on that aggregate. It marks the cache entry as dirty and triggers a background refresh.
3. **Event Emitted**: When a refreshable detail is updated, the cache raises the `Sharpino.Cache.DetailsCache.Instance.OnDetailsRefreshed` event.
4. **SignalR Broadcast**: In `Program.cs`, the application subscribes to `OnDetailsRefreshed` and broadcasts specific events to all clients via a SignalR Hub (`LibraryHub`).
5. **UI Update**: Blazor pages listen to these SignalR events and invoke their local reload method, updating the view reactively.
### 1. Centralized Hub Event Mapping (`Program.cs`)
In the host application startup (`Program.cs`), we listen to `OnDetailsRefreshed` and map cache types to client-side SignalR messages:
```csharp
Sharpino.Cache.DetailsCache.Instance.OnDetailsRefreshed += (sender, args) =>
{
var (typeName, id) = args;
using (var scope = app.Services.CreateScope())
{
var hubContext = scope.ServiceProvider.GetRequiredService<IHubContext<BookLibrary.Hubs.LibraryHub>>();
if (typeName == "RefreshableTenantDetails")
{
hubContext.Clients.All.SendAsync("TenantTagsChanged");
hubContext.Clients.All.SendAsync("TenantListChanged");
}
else if (typeName == "RefreshableLoanDetails")
{
hubContext.Clients.All.SendAsync("LoanListChanged");
hubContext.Clients.All.SendAsync("LoanDetailsChanged", id);
}
else if (typeName == "RefreshableReservationDetails")
{
hubContext.Clients.All.SendAsync("ReservationListChanged");
hubContext.Clients.All.SendAsync("ReservationDetailsChanged", id);
}
else if (typeName == "RefreshableBookDetails")
{
hubContext.Clients.All.SendAsync("BookCatalogChanged");
hubContext.Clients.All.SendAsync("BookDetailsChanged", id);
}
else if (typeName == "RefreshableAuthorDetails")
{
hubContext.Clients.All.SendAsync("AuthorCatalogChanged");
hubContext.Clients.All.SendAsync("AuthorDetailsChanged", id);
}
else if (typeName == "RefreshableReviewDetails")
{
hubContext.Clients.All.SendAsync("ReviewsListChanged");
hubContext.Clients.All.SendAsync("ReviewDetailsChanged", id);
}
}
};Blazor pages connect to LibraryHub and registers handlers. We use two main refresh approaches depending on the view:
For grids or search pages, listen to the catalog list events:
hubConnection.On("LoanListChanged", async () =>
{
await LoadLoans();
await InvokeAsync(StateHasChanged);
});For detail views (e.g. BookView.razor), verify if the notification belongs to the active record to avoid unnecessary refreshes:
hubConnection.On<Guid>("BookDetailsChanged", async (id) =>
{
if (bookDetails != null && bookDetails.Book.BookId.Value == id)
{
await LoadBookDetails();
await LoadUserReservation();
await InvokeAsync(StateHasChanged);
}
});Feature flags and domain settings live under a named section:
{
"BooksLibrary": {
"ReviewSytemEnabled": true,
"TimeSlotLoanDurationInDays": 15,
"MaxReservationsPerUser": 20,
"MaxLoansPerUser": 10,
"EmailNotificationEnabled": true,
"FromEmail": "noreply@example.com",
"FromName": "My Library"
}
}Read in F# services via constructor injection:
let maxReservations = configuration.GetValue<int>("BooksLibrary:MaxReservationsPerUser", 3)Read in Blazor Razor pages via @inject IConfiguration Configuration:
var isEnabled = Configuration.GetValue<bool>("BooksLibrary:ReviewSytemEnabled");@inject IConfiguration Configuration
@code {
private bool isFeatureEnabled;
protected override void OnInitialized() {
isFeatureEnabled = Configuration.GetValue<bool>("Section:FeatureEnabled");
}
}For pages that should only be accessible when a feature flag is true, check early in OnInitializedAsync:
var featureEnabled = Configuration.GetValue<bool>("BooksLibrary:ReviewSytemEnabled");
if (!featureEnabled) {
isAllowed = false;
errorMessage = L["ReviewSystemDisabled"];
isLoading = false;
return;
}- Inject service interfaces (from
Shared/Services/), never concrete types - Use
@inject IDetailsService DetailsServicefor composed read-models - Use
@inject ILogger<PageName> Logger(notLogger<T>)
- All user-visible strings come from
IStringLocalizer<SharedResources> L - Add keys to all
.resxfiles:SharedResources.resx(default),.it-IT.resx,.en-US.resx
| Aggregate | Version |
StorageName |
Migration file |
|---|---|---|---|
Book |
_01 |
_Book |
..._create_Book.sql |
Author |
_01 |
_Author |
..._create_Author.sql |
Loan |
_01 |
_Loan |
..._create_loan.sql |
Reservation |
_01 |
_Reservation |
..._create_Reservation.sql |
Review |
_01 |
_Review |
..._create_review.sql |
User |
_01 |
_User |
..._create_user.sql |
Editor |
_01 |
_Editor |
..._create_Editor.sql |
IsbnRegistry |
_01 |
_IsbnRegistry |
..._create_isbn_registry.sql |
Rule: The PostgreSQL table
events_{Version}{StorageName}must always match the aggregate's static members. If you bumpVersion, create a new migration.
To enable semantic discovery, the system utilizes a Vector Database powered by the PostgreSQL pgvector extension. This database stores high-dimensional embeddings of book descriptions, allowing for meaning-based searches.
- EventStore: Source of truth for domain state and archival consistency. Stores the
EmbeddingDataIdreference. - Vector Database: A specialized projection for similarity search. Stores the raw vector data and is optimized for the
<=>(cosine distance) operator.
The vector database requires the pgvector extension.
Local Installation (Mac OS / Homebrew):
brew install pgvector
# In postgres:
# CREATE EXTENSION IF NOT EXISTS vector;Azure Instance:
Consult your Azure documentation for enabling the pgvector extension on Azure Database for PostgreSQL (usually available in the "Server parameters" or "Extensions" section).
The table and index for embeddings are defined in:
/blazorBookLibrary.Server/vectorDbSetup/create.sql
CREATE TABLE item_embeddings_projections (
id uuid PRIMARY KEY,
book_id uuid NOT NULL,
vector_data vector(1536),
model_name text,
last_updated_at timestamp
);
CREATE INDEX ON item_embeddings_projections
USING hnsw (vector_data vector_cosine_ops);The service handles the storage and retrieval of embeddings and performs similarity searches:
let sql = "SELECT book_id FROM item_embeddings_projections
ORDER BY vector_data <=> @vector_data::real[]::vector
LIMIT @limit"- Add
NewAggId(single-case DU wrappingGuid) toShared/Commons.fs - Add any new domain value objects to
Shared/Commons.fs(string wrappers, enumerations, composite value objects) - Create
Shared/Domain/NewAgg.fs— define the F# record using the new value object IDs, withStorageName,Version,SnapshotsInterval,Id,Serialize,Deserialize - Create
Server/Domain/NewAgg/Commands.fs—NewAggCommandDU implementingAggregateCommand<NewAgg, NewAggEvent> - Create
Server/Domain/NewAgg/Events.fs—NewAggEventDU implementingAggregateEvent<NewAgg> - Create
Server/db/migrations/TIMESTAMP_create_NewAgg.sql— tables and functions using{Version}{StorageName}naming - Create
Shared/Details/Details.fsadditions —NewAggDetailstype if cross-aggregate composition is needed - Create
Server/Details.fsaddition —RefreshableNewAggDetailswrapping the details type - Create
Shared/Services/INewAggService.fs— interface withTask<Result<...>>members - Create
Server/Services/NewAggService.fs— implementation usingAggregateViewerAsync2andrunXxxCommandsMdhelpers - Update
DetailsService.fsif the new aggregate participates in any details composition - Register the service in
Program.cs(DI container) - Add localization keys to all
.resxfiles for any new UI strings
When users must be removed from the system while preserving the integrity of historical event streams (e.g., loans, reviews, reservations), use the Anonymization (Ghosting) Pattern instead of physical deletion.
- Identity Layer: Clear PII from the ASP.NET Identity record (
ApplicationUser). SetUserNameandEmailto randomized strings (e.g.,ghosted_abc123), clear fields likeNome,Cognome, andCodiceFiscale, and permanently lock the account. - Domain Layer: The
Useraggregate remains in the event store. Other aggregates that reference theUserId(likeLoanorReview) remain valid and resolvable, ensuring historical consistency. - Execution: Use
IUserService.GhostUserAsyncto coordinate the identity anonymization and the domain ghosting event.