Skip to content

.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.

Terminal window
dotnet add package Hapnd.Sdk

Requires .NET 10.0.

using Hapnd.Client;
var hapnd = new HapndClient("hpnd_your_api_key");
// Append an event
var 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 reducer
if (result.HasState)
{
var state = result.State<OrderState>();
Console.WriteLine($"Order total: {state.Total}");
}
// Minimal — uses shared static HttpClient with connection pooling
var hapnd = new HapndClient("hpnd_your_api_key");
// With full configuration
var 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.

// Simple form
services.AddHapnd("hpnd_your_api_key");
// Full configuration
services.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.

OptionTypeDefaultDescription
ApiKeystring(required)Your Hapnd API key
BaseUrlstringhttps://hapnd-api.lightestnight.workers.devAPI base URL
TimeoutTimeSpan30 secondsHTTP request timeout
ResilienceHapndResilienceOptions?Enabled with defaultsRetry and circuit breaker config. Set to null to disable.
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:

PropertyTypeDescription
EventIdstringUnique identifier for the appended event
AggregateIdstringThe aggregate this event was appended to
AggregateTypestringThe type of the aggregate
VersionintVersion of the aggregate after this event
TimestampDateTimeOffsetServer-assigned timestamp
HasStateboolWhether computed state is available (reducer bound)
State<T>()T?Deserialize the computed state to the specified type
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:

PropertyTypeDescription
AggregateIdstringThe aggregate these events were appended to
AggregateTypestringThe type of the aggregate
StartVersionintVersion after the first event in the batch
EndVersionintVersion after the last event in the batch
EventsIReadOnlyList<AppendedEvent>Individual results for each appended event
HasStateboolWhether computed state is available
State<T>()T?Deserialize the computed state after all events

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" });

The SDK infers the aggregate type from the ID using the {type}_{id} or {type}-{id} convention:

Aggregate IDInferred Type
order_123order
cart-abccart
inventory_sku_42inventory

Override when your IDs don’t follow this convention:

await hapnd.Aggregate("ORD-2024-00123")
.WithAggregateType("order")
.Append(new OrderPlaced { CustomerId = "customer_456" });

Use .ExpectVersion() to prevent lost updates when multiple processes modify the same aggregate:

// Read current state
var 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"));
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:

PropertyTypeDescription
AggregateIdstringThe aggregate identifier
AggregateTypestringThe type of the aggregate
VersionintCurrent version of the aggregate
StateTThe computed state value
LastModifiedDateTimeOffsetTimestamp of the last event applied

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));

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 event
await hapnd.Aggregate("notification_123")
.WithCausation(orderResult.EventId)
.Append(new OrderConfirmationSent("customer_456"));

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"));

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));

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.

Terminal window
dotnet add package Hapnd.Projections.Contracts
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);
  • 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)
PropertyTypeDescription
IdstringUnique event identifier
TenantIdstringTenant identifier for multi-tenancy
AggregateIdstringAggregate this event belongs to
TypestringEvent type discriminator
DataobjectEvent payload
TimestamplongEvent timestamp (Unix milliseconds)
MetadataDictionary<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.

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: OrderReducerorder, MailboxReducermailbox.

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
};
}
}
Terminal window
# Authenticate
npx @hapnd/cli login sk_live_your_key
# Upload and compile
npx @hapnd/cli deploy
# Bind the reducer to an aggregate type
npx @hapnd/cli bind order red_abc123
# Check compilation status
npx @hapnd/cli status red_abc123

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.

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();

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 attribute
hapnd.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();

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 Subscribe
hapnd.Subscriptions()
.OnStateChanged<OrderState>(async (update, ct) => { })
.OnError(async (error, ct) => error.Action.Reconnect())
.Subscribe(); // ConfiguredSubscriptionBuilder has Subscribe()
// Does NOT compile: HapndSubscriptionBuilder has no Subscribe method
hapnd.Subscriptions()
.OnStateChanged<OrderState>(async (update, ct) => { })
.Subscribe(); // Compile error!

StreamError properties:

PropertyTypeDescription
ProjectionIdstringThe projection whose handler failed
SequencelongLast successfully processed sequence number
ExceptionExceptionThe exception thrown by the handler
ActionStreamErrorActionCall exactly one method to signal how to proceed

Three recovery actions:

ActionEffect
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

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();

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();
})
);
PropertyTypeDescription
ProjectionIdstringThe projection that produced this update
AggregateIdstringThe aggregate whose state changed
AggregateTypestringThe aggregate type
VersionintAggregate version after this change
SequencelongServer-assigned sequence number for resumption
StateTStateThe computed state
TriggeredBystringEvent ID that triggered this state change
TimestampDateTimeOffsetServer timestamp of the state change

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

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.

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
};
});
options.Resilience = null;
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}");
}
ExceptionHTTP StatusDescription
HapndValidationExceptionN/AClient-side validation failure before request
HapndConcurrencyException409Optimistic concurrency version mismatch
HapndAggregateTypeMismatchException400Aggregate type conflicts with existing events
HapndApiExceptionVariesServer returned an error response
HapndNetworkExceptionN/ANetwork-level failure (DNS, timeout, etc.)
EndpointMethodSDK Method
/eventsPOSTAppend
/events/batchPOSTAppendMany
/aggregate-types/{type}/{id}/stateGETGetState<T>
/projections/{id}/streamWebSocketSubscriptions
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 setup
var 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>();

Licensed under Apache 2.0.