.NET SDK
Hapnd .NET SDK
Section titled “Hapnd .NET SDK”Official .NET SDK for Hapnd — the event sourcing platform built for engineers. Append events, query aggregate state, subscribe to real-time projection updates, and build event-sourced systems with a fluent type-safe API, built-in resilience via Polly, ASP.NET Core dependency injection, and distributed tracing support.
Installation
Section titled “Installation”dotnet add package Hapnd.SdkRequires .NET 10.0.
Quick Start
Section titled “Quick Start”using Hapnd.Client;
var hapnd = new HapndClient("hpnd_your_api_key");
// Append an eventvar result = await hapnd.Aggregate("order_001") .Append(new OrderCreated("customer_123"));
Console.WriteLine($"EventId: {result.EventId}, Version: {result.Version}");
// Check if state was computed by a bound reducerif (result.HasState){ var state = result.State<OrderState>(); Console.WriteLine($"Order total: {state.Total}");}Creating a Client
Section titled “Creating a Client”Direct Instantiation
Section titled “Direct Instantiation”// Minimal — uses shared static HttpClient with connection poolingvar hapnd = new HapndClient("hpnd_your_api_key");
// With full configurationvar hapnd = new HapndClient(new HapndClientOptions{ ApiKey = "hpnd_your_api_key", BaseUrl = "https://hapnd-api.lightestnight.workers.dev", Timeout = TimeSpan.FromSeconds(60), Resilience = new HapndResilienceOptions { MaxRetryAttempts = 5 }});The client is thread-safe and should be used as a singleton. It uses a shared static HttpClient with SocketsHttpHandler and a 2-minute pooled connection lifetime to respect DNS changes while maintaining connection reuse.
Dependency Injection
Section titled “Dependency Injection”// Simple formservices.AddHapnd("hpnd_your_api_key");
// Full configurationservices.AddHapnd(options =>{ options.ApiKey = configuration["Hapnd:ApiKey"]; options.Timeout = TimeSpan.FromSeconds(60); options.Resilience = new HapndResilienceOptions { MaxRetryAttempts = 5, EnableCircuitBreaker = true };});Then inject IHapndClient anywhere in your application:
public class OrderService(IHapndClient hapnd){ public async Task PlaceOrder(string customerId) { var result = await hapnd.Aggregate($"order_{Guid.NewGuid()}") .Append(new OrderPlaced(customerId)); }}The DI registration configures IHapndClient as a singleton with a named HttpClient via IHttpClientFactory for proper HTTP connection lifecycle management.
Configuration Options
Section titled “Configuration Options”| Option | Type | Default | Description |
|---|---|---|---|
ApiKey | string | (required) | Your Hapnd API key |
BaseUrl | string | https://hapnd-api.lightestnight.workers.dev | API base URL |
Timeout | TimeSpan | 30 seconds | HTTP request timeout |
Resilience | HapndResilienceOptions? | Enabled with defaults | Retry and circuit breaker config. Set to null to disable. |
Appending Events
Section titled “Appending Events”Single Event
Section titled “Single Event”var result = await hapnd.Aggregate("order_123") .Append(new ItemAdded("Widget", 25.00m));
Console.WriteLine($"EventId: {result.EventId}");Console.WriteLine($"AggregateId: {result.AggregateId}");Console.WriteLine($"AggregateType: {result.AggregateType}");Console.WriteLine($"Version: {result.Version}");Console.WriteLine($"Timestamp: {result.Timestamp}");Console.WriteLine($"HasState: {result.HasState}");AppendResult properties:
| Property | Type | Description |
|---|---|---|
EventId | string | Unique identifier for the appended event |
AggregateId | string | The aggregate this event was appended to |
AggregateType | string | The type of the aggregate |
Version | int | Version of the aggregate after this event |
Timestamp | DateTimeOffset | Server-assigned timestamp |
HasState | bool | Whether computed state is available (reducer bound) |
State<T>() | T? | Deserialize the computed state to the specified type |
Batch Append
Section titled “Batch Append”var result = await hapnd.Aggregate("order_123") .AppendMany([ new ItemAdded("Widget", 25.00m), new ItemAdded("Gadget", 15.00m) ]);
Console.WriteLine($"Appended {result.Events.Count} events (versions {result.StartVersion}-{result.EndVersion})");All events in a batch are stored atomically — either all succeed or none do.
AppendManyResult properties:
| Property | Type | Description |
|---|---|---|
AggregateId | string | The aggregate these events were appended to |
AggregateType | string | The type of the aggregate |
StartVersion | int | Version after the first event in the batch |
EndVersion | int | Version after the last event in the batch |
Events | IReadOnlyList<AppendedEvent> | Individual results for each appended event |
HasState | bool | Whether computed state is available |
State<T>() | T? | Deserialize the computed state after all events |
Event Type Resolution
Section titled “Event Type Resolution”By default, the event type is derived from the class name:
// Event type: "OrderCreated"await hapnd.Aggregate("order_123").Append(new OrderCreated("customer_456"));Override with the [EventType] attribute:
[EventType("order_placed")]public class OrderPlaced{ public string CustomerId { get; init; }}
// Event type: "order_placed"await hapnd.Aggregate("order_123").Append(new OrderPlaced { CustomerId = "customer_456" });Aggregates
Section titled “Aggregates”Aggregate Type Inference
Section titled “Aggregate Type Inference”The SDK infers the aggregate type from the ID using the {type}_{id} or {type}-{id} convention:
| Aggregate ID | Inferred Type |
|---|---|
order_123 | order |
cart-abc | cart |
inventory_sku_42 | inventory |
Override when your IDs don’t follow this convention:
await hapnd.Aggregate("ORD-2024-00123") .WithAggregateType("order") .Append(new OrderPlaced { CustomerId = "customer_456" });Optimistic Concurrency
Section titled “Optimistic Concurrency”Use .ExpectVersion() to prevent lost updates when multiple processes modify the same aggregate:
// Read current statevar current = await hapnd.Aggregate("order_123").GetState<OrderState>();
try{ // Write with version check await hapnd.Aggregate("order_123") .ExpectVersion(current.Version) .Append(new ItemAdded("Widget", 25.00m));}catch (HapndConcurrencyException ex){ Console.WriteLine($"Expected version {ex.ExpectedVersion}, actual {ex.ActualVersion}"); // Reload state and retry}Use ExpectVersion(0) to assert the aggregate does not yet exist:
await hapnd.Aggregate("order_new") .ExpectVersion(0) .Append(new OrderCreated("customer_789"));Querying State
Section titled “Querying State”var aggregate = await hapnd.Aggregate("order_123").GetState<OrderState>();
if (aggregate is not null){ Console.WriteLine($"Version: {aggregate.Version}"); Console.WriteLine($"Total: {aggregate.State.Total}"); Console.WriteLine($"Last Modified: {aggregate.LastModified}");}Returns AggregateState<TState>? — null if the aggregate does not exist or no reducer is bound.
AggregateState<T> properties:
| Property | Type | Description |
|---|---|---|
AggregateId | string | The aggregate identifier |
AggregateType | string | The type of the aggregate |
Version | int | Current version of the aggregate |
State | T | The computed state value |
LastModified | DateTimeOffset | Timestamp of the last event applied |
Distributed Tracing
Section titled “Distributed Tracing”Correlation IDs
Section titled “Correlation IDs”Group related operations across services:
var correlationId = Guid.NewGuid().ToString();
await hapnd.Aggregate("order_123") .WithCorrelation(correlationId) .Append(new OrderCreated("customer_456"));
await hapnd.Aggregate("inventory_widget") .WithCorrelation(correlationId) .Append(new StockReserved("order_123", 1));Causation IDs
Section titled “Causation IDs”Track direct cause-and-effect between events:
var orderResult = await hapnd.Aggregate("order_123") .Append(new OrderCreated("customer_456"));
// This event was caused by the OrderCreated eventawait hapnd.Aggregate("notification_123") .WithCausation(orderResult.EventId) .Append(new OrderConfirmationSent("customer_456"));Metadata
Section titled “Metadata”Attach arbitrary context to events:
await hapnd.Aggregate("order_123") .WithMetadata(new { UserId = "user_456", IpAddress = "192.168.1.1", UserAgent = "MyApp/1.0" }) .Append(new OrderCreated("customer_456"));Fluent Builder
Section titled “Fluent Builder”All configuration methods return the builder (IAggregateBuilder) for chaining. Terminal methods execute the API call.
Configuration methods: WithAggregateType, ExpectVersion, WithCorrelation, WithCausation, WithMetadata
Terminal methods: Append, AppendMany, GetState<T>
var result = await hapnd.Aggregate("order_123") .WithAggregateType("order") .ExpectVersion(3) .WithCorrelation("corr_abc") .WithCausation("evt_previous") .WithMetadata(new { UserId = "user_456", Source = "web" }) .Append(new ItemAdded("Widget", 25.00m));Writing Reducers
Section titled “Writing Reducers”Reducers compute aggregate state synchronously on every event append. Bind a reducer to an aggregate type, and the response to every event append includes the freshly computed state.
The Contracts Package
Section titled “The Contracts Package”dotnet add package Hapnd.Projections.ContractsImplementing a Reducer
Section titled “Implementing a Reducer”using Hapnd.Projections.Contracts;
public record OrderState{ public decimal Total { get; init; } public List<string> Items { get; init; } = []; public int ItemCount { get; init; } public string? CustomerId { get; init; }}
public class OrderReducer : IReducer<OrderState>{ public OrderState Apply(OrderState? state, Event @event) { state ??= new OrderState();
return @event.Type switch { "OrderCreated" => state with { CustomerId = @event.GetData<OrderCreatedData>()?.CustomerId }, "ItemAdded" => ApplyItemAdded(state, @event.GetData<ItemAddedData>()), _ => state }; }
private static OrderState ApplyItemAdded(OrderState state, ItemAddedData data) { return state with { Total = state.Total + data.Price, Items = state.Items.Append(data.Item).ToList(), ItemCount = state.ItemCount + 1 }; }}
public record OrderCreatedData(string? CustomerId);public record ItemAddedData(string Item, decimal Price);Reducer Rules
Section titled “Reducer Rules”- Pure function — no side effects, no I/O, no network calls
- No mutation — return a new state instance, do not modify the input
- Idempotent — applying the same event twice produces the same result
- Handle unknown events — return the current state unchanged for unrecognised event types
- Roslyn static analysis enforces these constraints at compile time, blocking dangerous namespaces (System.IO, System.Net, System.Reflection, System.Diagnostics, System.Threading)
The Event Record
Section titled “The Event Record”| Property | Type | Description |
|---|---|---|
Id | string | Unique event identifier |
TenantId | string | Tenant identifier for multi-tenancy |
AggregateId | string | Aggregate this event belongs to |
Type | string | Event type discriminator |
Data | object | Event payload |
Timestamp | long | Event timestamp (Unix milliseconds) |
Metadata | Dictionary<string, object>? | Optional metadata |
Use @event.GetData<T>() for strongly-typed deserialization:
var data = @event.GetData<ItemAddedData>();Console.WriteLine($"Added {data.Item} for {data.Price:C}");Use @event.TryGetData<T>() for safe deserialization that returns default on failure.
Multi-Reducer DLLs
Section titled “Multi-Reducer DLLs”A single DLL can contain multiple IReducer<T> implementations. The platform auto-discovers all implementations at compile time. The aggregate type is inferred by stripping the “Reducer” suffix from the class name and lowercasing: OrderReducer → order, MailboxReducer → mailbox.
Override with the [AggregateType] attribute:
[AggregateType("user-account")]public class UserReducer : IReducer<UserState>{ public UserState Apply(UserState? state, Event @event) { state ??= new UserState(); return @event.Type switch { "UserRegistered" => state with { Email = @event.GetData<UserRegisteredData>().Email }, _ => state }; }}Deploying
Section titled “Deploying”# Authenticatenpx @hapnd/cli login sk_live_your_key
# Upload and compilenpx @hapnd/cli deploy
# Bind the reducer to an aggregate typenpx @hapnd/cli bind order red_abc123
# Check compilation statusnpx @hapnd/cli status red_abc123Writing Projections
Section titled “Writing Projections”Projections compute state asynchronously across all aggregates. Implement IProjection<TState> instead of IReducer<TState>:
using Hapnd.Projections.Contracts;
public record DashboardState{ public int TotalOrders { get; init; } public decimal Revenue { get; init; }}
public class DashboardProjection : IProjection<DashboardState>{ public DashboardState Apply(DashboardState? state, Event @event) { state ??= new DashboardState();
return @event.Type switch { "OrderCreated" => state with { TotalOrders = state.TotalOrders + 1 }, "ItemAdded" => state with { Revenue = state.Revenue + @event.GetData<ItemAddedData>().Price }, _ => state }; }}Projections use the same Apply method signature as reducers but are processed asynchronously. On upload, Hapnd automatically catches up on all historical events, then processes new events as they arrive.
Real-Time Subscriptions
Section titled “Real-Time Subscriptions”Basic Usage
Section titled “Basic Usage”var subscription = hapnd.Subscriptions() .OnStateChanged<OrderState>(async (update, ct) => { Console.WriteLine($"Order {update.AggregateId} updated to version {update.Version}"); Console.WriteLine($"Total: {update.State.Total}"); }) .OnError(async (error, ct) => { Console.WriteLine($"Stream {error.ProjectionId} failed: {error.Exception.Message}"); error.Action.Reconnect(); }) .Subscribe();
// Later, graceful shutdown:await subscription.DisposeAsync();Projection Attribute
Section titled “Projection Attribute”Decorate your state class with [Projection] to bind it to a projection ID:
[Projection("proj_orders")]public class OrderState{ public decimal Total { get; set; } public List<string> Items { get; set; } = []; public int ItemCount { get; set; }}
// Projection ID resolved automatically from the attributehapnd.Subscriptions() .OnStateChanged<OrderState>(async (update, ct) => { Console.WriteLine(update.State.Total); }) .OnError(async (error, ct) => error.Action.Reconnect()) .Subscribe();If [Projection] is missing, OnStateChanged<T>() throws HapndValidationException. Use the explicit ID overload when the attribute is not present:
hapnd.Subscriptions() .OnStateChanged<OrderState>("proj_orders", async (update, ct) => { Console.WriteLine(update.State.Total); }) .OnError(async (error, ct) => error.Action.Reconnect()) .Subscribe();Compiler-Enforced Error Handling
Section titled “Compiler-Enforced Error Handling”The SDK uses a type-state pattern to enforce error handler registration at compile time:
HapndSubscriptionBuilder → .OnError() → ConfiguredSubscriptionBuilder(no Subscribe method) (has Subscribe method)// Compiles: error handler is registered before Subscribehapnd.Subscriptions() .OnStateChanged<OrderState>(async (update, ct) => { }) .OnError(async (error, ct) => error.Action.Reconnect()) .Subscribe(); // ConfiguredSubscriptionBuilder has Subscribe()
// Does NOT compile: HapndSubscriptionBuilder has no Subscribe methodhapnd.Subscriptions() .OnStateChanged<OrderState>(async (update, ct) => { }) .Subscribe(); // Compile error!Error Handling
Section titled “Error Handling”StreamError properties:
| Property | Type | Description |
|---|---|---|
ProjectionId | string | The projection whose handler failed |
Sequence | long | Last successfully processed sequence number |
Exception | Exception | The exception thrown by the handler |
Action | StreamErrorAction | Call exactly one method to signal how to proceed |
Three recovery actions:
| Action | Effect |
|---|---|
Action.Reconnect() | Resume the stream from the last good sequence with backoff |
Action.Stop() | Stop this stream only; other streams continue |
Action.Shutdown() | Tear down the entire subscription, stopping all streams |
Safeguards:
- If the error handler itself throws, the failing stream stops automatically
- If no action is called within 30 seconds, the stream stops automatically
- Sequence advances only after the handler completes successfully — on reconnect, the server resends from the last acknowledged position
Multiple Projections
Section titled “Multiple Projections”Register multiple projections in a single subscription. Each gets its own WebSocket connection:
hapnd.Subscriptions() .OnStateChanged<OrderState>(async (update, ct) => { Console.WriteLine($"Order: {update.State.Total}"); }) .OnStateChanged<InventoryState>(async (update, ct) => { Console.WriteLine($"Inventory: {update.State.StockLevel}"); }) .OnError(async (error, ct) => { Console.WriteLine($"Stream {error.ProjectionId} failed"); error.Action.Reconnect(); }) .Subscribe();DI Integration with IHostedService
Section titled “DI Integration with IHostedService”Use .AddSubscriptions() to register subscriptions that start and stop with the application:
services.AddHapnd(options =>{ options.ApiKey = configuration["Hapnd:ApiKey"];}).AddSubscriptions((subs, sp) => subs.OnStateChanged<OrderState>(async (update, ct) => { await using var scope = sp.CreateAsyncScope(); var dashboard = scope.ServiceProvider.GetRequiredService<IDashboardService>(); await dashboard.Update(update.State); }) .OnError(async (error, ct) => { var logger = sp.GetRequiredService<ILogger<Program>>(); logger.LogError(error.Exception, "Stream {ProjectionId} failed", error.ProjectionId); error.Action.Reconnect(); }));ProjectionUpdate Properties
Section titled “ProjectionUpdate Properties”| Property | Type | Description |
|---|---|---|
ProjectionId | string | The projection that produced this update |
AggregateId | string | The aggregate whose state changed |
AggregateType | string | The aggregate type |
Version | int | Aggregate version after this change |
Sequence | long | Server-assigned sequence number for resumption |
State | TState | The computed state |
TriggeredBy | string | Event ID that triggered this state change |
Timestamp | DateTimeOffset | Server timestamp of the state change |
Resilience
Section titled “Resilience”Exponential backoff with jitter via Polly. Automatically retries transient failures:
Retried: Network errors, timeouts, 5xx server errors, 429 (rate limit), 408 (request timeout)
Not retried: 4xx client errors, 409 (concurrency conflict), cancellation
Defaults: 3 attempts, 500ms initial delay, 10s max delay
Circuit Breaker
Section titled “Circuit Breaker”Prevents cascading failures by temporarily stopping requests when error rates are high.
States: Closed (normal) → Open (rejecting requests) → Half-Open (testing recovery)
Defaults: Opens after 50% failure rate over 60 seconds with minimum 10 requests, stays open for 30 seconds before transitioning to half-open.
Configuration
Section titled “Configuration”services.AddHapnd(options =>{ options.ApiKey = "hpnd_your_api_key"; options.Resilience = new HapndResilienceOptions { MaxRetryAttempts = 5, RetryDelay = TimeSpan.FromMilliseconds(200), MaxRetryDelay = TimeSpan.FromSeconds(5), EnableCircuitBreaker = true, CircuitBreakerDuration = TimeSpan.FromSeconds(30), CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(60), CircuitBreakerFailureRatio = 0.5, CircuitBreakerMinimumThroughput = 10 };});Disabling
Section titled “Disabling”options.Resilience = null;Error Handling
Section titled “Error Handling”try{ await hapnd.Aggregate("order_123") .ExpectVersion(5) .Append(new ItemAdded("Widget", 25.00m));}catch (HapndValidationException ex){ // Client-side validation failure (before any network request) Console.WriteLine($"Validation: {ex.Message}");}catch (HapndConcurrencyException ex){ // 409 — version mismatch Console.WriteLine($"Expected {ex.ExpectedVersion}, actual {ex.ActualVersion}");}catch (HapndAggregateTypeMismatchException ex){ // 400 — aggregate type conflict Console.WriteLine($"Expected type '{ex.ExpectedType}', actual '{ex.ActualType}'");}catch (HapndApiException ex){ // Server returned an error (varies by status code) Console.WriteLine($"API error {ex.StatusCode}: {ex.Message}");}catch (HapndNetworkException ex){ // Network failure (DNS, connection refused, timeout) Console.WriteLine($"Network error: {ex.Message}");}| Exception | HTTP Status | Description |
|---|---|---|
HapndValidationException | N/A | Client-side validation failure before request |
HapndConcurrencyException | 409 | Optimistic concurrency version mismatch |
HapndAggregateTypeMismatchException | 400 | Aggregate type conflicts with existing events |
HapndApiException | Varies | Server returned an error response |
HapndNetworkException | N/A | Network-level failure (DNS, timeout, etc.) |
API Endpoints
Section titled “API Endpoints”| Endpoint | Method | SDK Method |
|---|---|---|
/events | POST | Append |
/events/batch | POST | AppendMany |
/aggregate-types/{type}/{id}/state | GET | GetState<T> |
/projections/{id}/stream | WebSocket | Subscriptions |
Full Example
Section titled “Full Example”using Hapnd.Client;
public record OrderState{ public decimal Total { get; init; } public List<string> Items { get; init; } = []; public int ItemCount { get; init; } public string? CustomerId { get; init; }}
public record OrderCreated(string CustomerId);public record ItemAdded(string Item, decimal Price);
public class OrderService(IHapndClient hapnd){ public async Task<string> CreateOrder(string customerId) { var orderId = $"order_{Guid.NewGuid()}";
var result = await hapnd.Aggregate(orderId) .ExpectVersion(0) .WithCorrelation(Guid.NewGuid().ToString()) .Append(new OrderCreated(customerId));
Console.WriteLine($"Order {orderId} created at version {result.Version}"); return orderId; }
public async Task AddItem(string orderId, string item, decimal price, int expectedVersion) { try { await hapnd.Aggregate(orderId) .ExpectVersion(expectedVersion) .Append(new ItemAdded(item, price)); } catch (HapndConcurrencyException) { var current = await hapnd.Aggregate(orderId).GetState<OrderState>(); if (current is not null) { await hapnd.Aggregate(orderId) .ExpectVersion(current.Version) .Append(new ItemAdded(item, price)); } } }
public async Task<OrderState?> GetOrder(string orderId) { var aggregate = await hapnd.Aggregate(orderId).GetState<OrderState>(); return aggregate?.State; }}
// DI setupvar builder = WebApplication.CreateBuilder(args);
builder.Services.AddHapnd(options =>{ options.ApiKey = builder.Configuration["Hapnd:ApiKey"]!;}).AddSubscriptions((subs, sp) => subs.OnStateChanged<OrderState>("proj_orders", async (update, ct) => { var logger = sp.GetRequiredService<ILogger<Program>>(); logger.LogInformation("Order {AggregateId} updated: {Total}", update.AggregateId, update.State.Total); }) .OnError(async (error, ct) => { var logger = sp.GetRequiredService<ILogger<Program>>(); logger.LogError(error.Exception, "Stream {ProjectionId} failed", error.ProjectionId); error.Action.Reconnect(); }));
builder.Services.AddScoped<OrderService>();License & Links
Section titled “License & Links”Licensed under Apache 2.0.