Introduction to Sharpino and Event Sourcing

Sharpino is a lightweight, modern F# event sourcing framework designed to power robust, predictable, and highly scalable backends.

The Paradigm Shift

Traditional CRUD applications rely on structural state stored in a relational database, constantly overwritten as the system changes. Event Sourcing represents a paradigm shift: moving from tracking the current structural state to tracking the behavioral domain events that caused the state to change.

In Sharpino, the immutable sequence of events is the single source of truth. The current state is simply a projection or "fold" of those events over time. This approach guarantees an absolute audit trail and enables powerful temporal queries.

The Canonical Example: BlazorBookLibrary

While Sharpino has numerous examples in its primary repository, the best way to understand its power in a real-world setting is by exploring the canonical, production-ready example: blazorBookLibrary.

This project demonstrates all the advanced features discussed throughout this book:

  • Using Pure Aggregates with specific Value Object IDs.
  • Eliminating bidirectional relationships in favor of unidirectional flow and Materialized Detail Views.
  • High-performance, distributed L1/L2 caching using SQL and backplanes.
  • A fully asynchronous architecture capable of robust web-scale workloads.
  • Seamless integration between an F# domain backend and a C# Blazor frontend.

This book will guide you through the principles of building event-sourced systems using Sharpino, leveraging the architectural blueprints provided by blazorBookLibrary.

The Domain Model & Pure Aggregates

In event-sourced systems, the Aggregate is the primary boundary of consistency. It receives commands, applies business rules, and emits events.

Ditching "Contexts"

In earlier versions of Sharpino, there was a concept known as a "Context" — essentially an event-sourced object that did not have a specific ID, assuming a single global instance.

Contexts are now deprecated.

To streamline the architecture, Sharpino now advocates using Pure Aggregates for everything. Every event-sourced entity must have a proper Id.

If your domain requires a "singleton" or single-instance object (like a global configuration or a master catalog), you achieve this by simply using an Aggregate with a constant Id. You then ensure at application startup that the instance exists (creating it automatically if it doesn't). This allows all optimizations, caching, and features to be developed exclusively for Aggregates without needing to support a separate "Context" construct.

Combating Primitive Obsession

Aggregates require an ID, often represented by a standard Guid. However, relying heavily on raw Guids leads to "Primitive Obsession," where the compiler cannot distinguish between a Book Guid and an Author Guid.

Taking inspiration from the blazorBookLibrary, Sharpino encourages defining specific domain-relevant Value Objects for IDs. By wrapping the Guid in a strongly typed construct (e.g., BookId, AuthorId), you leverage the F# type system to guarantee compile-time safety and prevent accidental cross-pollination of identifiers across different aggregate types.

Events and Commands

The heartbeat of Sharpino relies on two fundamental concepts: Commands (the intent to change state) and Events (the immutable record of that change).

Events

Events represent facts that have already occurred. Because they form the historical ledger of the system, they must be immutable and safely serializable. When an Aggregate processes a Command, it yields a list of Events. These events are appended to the Event Store and then processed to derive the Aggregate's new state.

Commands

Commands are functions that encapsulate business intent. They receive the current state of an Aggregate, along with any necessary parameters, validate the business rules, and return a Result. If successful, the Result contains the Events to be committed. Note that for aggregates we need to use AggregateCommand from Sharpino.Core instead of Command.

The De-emphasis of the "Undoer"

Previously, Sharpino supported an "Undoer" mechanism for Commands—an automatic way to generate reverse actions to rollback a command.

In modern implementations, the use of the "Undoer" is heavily de-emphasized. In practice, it is almost always set to None.

Relying on generic "undo" logic often breaks domain semantics. Instead, compensating actions or state reversions should be modeled explicitly as their own domain Commands (e.g., instead of undoing a PayOrder command, issue an explicit RefundOrder command). This keeps the domain model clear, explicit, and intention-revealing.

In-Memory Projections and Detail Views

When modeling relationships between Aggregates in Event Sourced systems, developers often face the challenge of querying and associating data spread across multiple streams.

The Complexity of Mutual References

It is tempting to model bidirectional (mutual) references between aggregates to easily navigate relationships (e.g., a Student holds a list of Course IDs, and a Course holds a list of Student IDs).

However, bidirectional references should be avoided.

They introduce significant complexity because they potentially involve updating more objects at once. Crucially, they heavily increase the complexity when an object supports deletion, requiring intricate reference counting to ensure safe deletion without leaving orphaned or broken links.

The Unidirectional Approach

Sharpino advocates for a unidirectional design. Instead of mutual links, you designate a single aggregate as the source of truth for the relationship.

Detail Views (Materialized Projections)

To compensate for the lack of bidirectional links when querying, Sharpino utilizes Detail Views (in-memory materialized projections).

A Detail View listens to the events of multiple aggregates and continuously reconstructs the composite relationships in memory. This allows the application (and the UI) to easily query composite objects and navigate relations in any direction without tangling the underlying write-side domain models.

graph TD
    subgraph Bidirectional["Bidirectional (Avoid)"]
        A[Student Aggregate] <--> B[Course Aggregate]
        style A stroke:#f66,stroke-width:2px;
        style B stroke:#f66,stroke-width:2px;
    end

    subgraph Unidirectional["Unidirectional (Recommended)"]
        C[Enrollment Aggregate] --> D[Student Aggregate]
        C --> E[Course Aggregate]
        
        F[Student Courses Detail] -. "Materialized View" .-> C
    end

Advanced Caching Architecture

Reconstituting an aggregate state from a long stream of events can be costly if done for every command or read request. Sharpino implements an advanced, multi-tiered caching architecture to handle this, ensuring both blazing-fast reads and strict consistency across distributed nodes.

The Refreshable Details Cache

Detail views are implemented using a Refreshable<'A> or RefreshableAsync<'A> interface. Because they depend on Aggregate states, Sharpino tracks these dependencies. The Detail Cache received a massive boost by utilizing async, task-based refreshable details.

When an Aggregate produces a new event, the system immediately and asynchronously triggers a refresh (RefreshDependentDetails) on all Details that depend on that specific Aggregate ID. This ensures the high-performance read models never drift from the Event Store.

L1, L2, and the Message Backplane

To support horizontal scaling, Sharpino instruments its cache layers with an L2 Cache and a Message Backplane.

  • L1 Cache (In-Memory): A localized, ultra-fast cache holding reconstructed Aggregates and active Detail closures.
  • L2 Cache (SQL/Azure SQL): A distributed cache layer that shares easily serialized data across nodes.
  • Message Backplane: To prevent nodes from serving stale L1 data, Sharpino uses a backplane (Azure Service Bus or MQTT). When Node A updates an aggregate, it broadcasts an invalidation message over the backplane. Node B receives this, instantly invalidates its stale L1 cache, and automatically triggers its local RefreshDependentDetails process.

Synchronization Flow

graph TD
    subgraph Node_A_Writer
        A_App[App Command] --> A_AC[AggregateCache3]
        A_AC --> A_EventStore[Database / EventStore]
        A_AC -. "1. Update Local" .-> A_L1[L1 Cache]
        A_AC -- "2. Publish Message" --> ASB((Azure Service Bus / MQTT<br/>Backplane))
    end

    subgraph Node_B_Reader
        ASB -- "3. Receive Message" --> B_AC[AggregateCache3]
        B_AC -. "4. Invalidate/Remove" .-> B_L1_Agg[Aggregate L1 Cache]
        B_AC -- "5. Trigger Refresh" --> B_DC[DetailsCache]
        B_DC -. "6. Recompute View" .-> B_L1_Det[statesDetails L1 Cache]
    end

    subgraph External
        L2[(Azure SQL L2 Cache)]
        A_AC -. "Optional Sync" .-> L2
        B_AC -. "Optional Request" .-> L2
    end

The Storage & State Layer (Async First)

The persistence mechanism in Sharpino is responsible for saving events and snapshotting aggregate states. As modern web applications demand high throughput and non-blocking I/O, Sharpino's storage layer has been comprehensively upgraded to an asynchronous-first design.

Asynchronous Event Store

Every critical member of the Event Store—whether it is retrieving events, fetching snapshots, or committing new sequences—now has a dedicated, Task-based Async version. This guarantees that your domain operations do not block threads while waiting on the underlying database (such as PostgreSQL).

StateView Enhancements

The StateView component manages the read-side projections of the Aggregate state.

Recent enhancements to the StateView ensure that it supports multiple asynchronous views of the aggregate state. These async views fully mimic the behavior of their non-async counterparts but provide the critical addition of Cancellation Token support.

By utilizing Cancellation Tokens, long-running state reconstruction queries can be safely aborted if a client disconnects or a timeout occurs, significantly improving the stability and resource management of the web application.

Application Layer & Frontend Integration

One of the great strengths of Sharpino is that while the core domain and event-sourcing mechanics are built using strict functional F#, it is incredibly easy to integrate with a robust C# frontend.

C# and F# Interoperability

Integrating a C# (Blazor) frontend with an F# Sharpino backend is highly feasible and extremely powerful. The integration friction is exceptionally low.

Most integration tasks simply involve establishing thin mapping layers. For example, converting standard F# list types emitted by the detail views into standard C# List<T> or IEnumerable<T> structures that Blazor components (like QuickGrid) can comfortably consume.

Architecture Blueprint: BlazorBookLibrary

The blazorBookLibrary project serves as the definitive blueprint for this architecture. It cleanly separates the Interactive Blazor Server/WASM frontend from the strict F# CQRS domain.

graph TB
    subgraph "Frontend (Blazor Web App)"
        UI["Razor Components (.razor)<br/>(Interactive Server/WASM)"]
        CLIENT["Blazor Client Project<br/>(Shared UI Logic)"]
    end

    subgraph "Application Layer (F# Services)"
        SVC["Domain Services<br/>(BookService, AuthorService, etc.)"]
        EXT["External API Services<br/>(GoogleBooks, Gemini Embeddings)"]
    end

    subgraph "Domain Layer (Sharpino + F#)"
        CMD["Commands & Events"]
        AGG["Aggregates<br/>(Book, Author, User, Loan)"]
        VIEWER["State Viewers<br/>(Materialized Views)"]
    end

    subgraph "Infrastructure & Data"
        PGE["PostgreSQL (Event Store)<br/>(Sharpino.PgStorage)"]
        PGV["PostgreSQL (Vector DB)<br/>(pgvector extension)"]
    end

    %% Relationships
    UI --> SVC
    CLIENT -.-> UI
    SVC --> CMD
    SVC --> VIEWER
    SVC --> EXT
    CMD --> AGG
    AGG --> PGE
    SVC --> PGV
    EXT -- "Vectorized Data" --> PGV

By leveraging this separation of concerns, developers get the best of both worlds: the rich ecosystem and UI capabilities of C# Blazor, underpinned by the bulletproof reliability and testability of an F# event-sourced domain.

Security, Privacy, and GDPR

Event Sourcing presents a unique challenge for privacy regulations like GDPR, particularly the "Right to be Forgotten." Because the Event Store is an immutable ledger, you cannot simply execute a DELETE statement to remove someone's personal data.

The Golden Rule of PII

The primary recommendation is simple: Personally Identifiable Information (PII) should not be kept in the Event Store.

Whenever possible, keep PII in a separate, mutable store (like standard Identity tables) and only reference non-identifiable GUIDs in your immutable Event Store.

Event Ghosting and Scrambling

In scenarios where sensitive information has inadvertently entered the event stream, Sharpino provides advanced GDPR functions to "ghost" or scramble the eventual private data.

Because the event stream must remain replayable to reach the current valid state, Sharpino supports replacing specific events with "ghosted" versions.

The critical requirement for safe replacement: The replacement event must be chosen so that reprocessing the stream leaves the system behavior completely unaltered. When the existing stored events are applied to a state that is "ghosted", they must still return an Ok (No error). Likewise, the newly introduced ghosted events must also yield an Ok. This guarantees the integrity of the stream is preserved, the application does not crash during replay, and the sensitive PII is permanently removed from the ledger.

Refactoring and Testing

Event-sourced systems possess unique characteristics that require specialized approaches to both testing and code refactoring.

Refactoring Aggregates and Events

Because the sequence of events is immutable, changing the shape of an event or the structure of an aggregate requires careful versioning. In Sharpino, you can utilize the built-in snapshotting mechanisms to baseline your aggregates, making it easier to transition between structural versions of your data.

When breaking changes to the event schema are strictly required, you can create upcasters or utilize Sharpino's event replacement features (similar to those used for GDPR ghosting) to map old event versions to new structures seamlessly during replay.

Testing the Domain

Sharpino's pure functional approach makes testing Aggregates exceptionally straightforward. You do not need complex mock frameworks or database setups to test core business logic.

  • Testing Commands: You simply initialize a starting State, pass it to a Command function along with test parameters, and assert that the returned Result contains the exact Expected Events.
  • Testing Events: You take a starting State, apply an Event, and assert that the resulting State matches your expectations.
  • Testing Details: You can emit a sequence of events across multiple mock aggregates and verify that the Materialized Views correctly reconstruct the composite models.

By keeping the domain logic pure and free of I/O, the majority of your test suite can run at memory speed, ensuring your application remains highly reliable as it scales.