Skip to content

Events

Events are immutable records of things that happened in your system. They’re stored permanently in per-aggregate streams with monotonically increasing version numbers and a global position that orders events across all aggregates in your tenant. Events are the source of truth — everything else (aggregate state, projections, read models) is derived from them.

var result = await hapnd.Aggregate("order_123")
.Append(new OrderPlaced { CustomerId = "cust_456" });
Console.WriteLine($"Event {result.EventId} at version {result.Version}");
const result = await hapnd
.aggregate("order_123")
.append("OrderPlaced", { customerId: "cust_456" });
console.log(`Event ${result.eventId} at version ${result.version}`);

Append multiple events atomically — all succeed or none do. This is essential for maintaining consistency when a single action produces multiple events.

var result = await hapnd.Aggregate("order_123")
.AppendMany([
new ItemAdded { Item = "Widget", Price = 25.00m },
new ItemAdded { Item = "Gadget", Price = 15.00m }
]);
Console.WriteLine($"Versions {result.StartVersion} to {result.EndVersion}");
const result = await hapnd.aggregate("order_123").appendMany([
{ eventType: "ItemAdded", data: { item: "Widget", price: 25.0 } },
{ eventType: "ItemAdded", data: { item: "Gadget", price: 15.0 } },
]);
console.log(`Versions ${result.startVersion} to ${result.endVersion}`);

The event type is a string discriminator stored with each event. Your reducers and projections use it to determine how to process the event.

The SDK resolves the event type from the class name automatically:

// Event type: "OrderPlaced"
await hapnd.Aggregate("order_123").Append(new OrderPlaced { ... });
// Event type: "ItemAdded"
await hapnd.Aggregate("order_123").Append(new ItemAdded { ... });

Override with the [EventType] attribute when the class name doesn’t match your domain language:

using Hapnd.Client;
[EventType("item_added")]
public class AddItemToOrder
{
public string Item { get; init; }
public decimal Price { get; init; }
}
// Event type: "item_added"
await hapnd.Aggregate("order_123").Append(new AddItemToOrder { ... });

In TypeScript, the event type is always passed explicitly as the first argument:

await hapnd
.aggregate("order_123")
.append("ItemAdded", { item: "Widget", price: 25.0 });

Use ExpectVersion to prevent lost updates when multiple processes write to the same aggregate concurrently. The server rejects the append if the aggregate’s current version doesn’t match.

try
{
await hapnd.Aggregate("order_123")
.ExpectVersion(5)
.Append(new ItemAdded { Item = "Widget", Price = 25.00m });
}
catch (HapndConcurrencyException ex)
{
Console.WriteLine($"Expected version {ex.ExpectedVersion}, actual {ex.ActualVersion}");
// Reload state and retry with business logic
}
import { HapndConcurrencyError } from "@hapnd/client";
try {
await hapnd
.aggregate("order_123")
.expectVersion(5)
.append("ItemAdded", { item: "Widget", price: 25.0 });
} catch (err) {
if (err instanceof HapndConcurrencyError) {
console.log(`Expected ${err.expectedVersion}, actual ${err.actualVersion}`);
// Reload state and retry with business logic
}
}

Use ExpectVersion(0) (or expectVersion(0)) to assert the aggregate doesn’t exist yet — useful for creation commands.

Every event appended to Hapnd receives two sequence numbers:

  • Version — sequential within a single aggregate stream (1, 2, 3…). Used for optimistic concurrency.
  • Global position — sequential across all aggregates within your tenant. Used for cross-aggregate ordering.

The global position is assigned at write time and returned in append results:

var result = await hapnd.Aggregate("order_123")
.Append(new OrderPlaced { CustomerId = "cust_456" });
Console.WriteLine($"Version: {result.Version}"); // per-aggregate: 1
Console.WriteLine($"Global position: {result.GlobalPosition}"); // per-tenant: 4217
const result = await hapnd
.aggregate("order_123")
.append("OrderPlaced", { customerId: "cust_456" });
console.log(`Version: ${result.version}`); // per-aggregate: 1
console.log(`Global position: ${result.globalPosition}`); // per-tenant: 4217

When a projection spans multiple aggregates, the order events are processed in affects the final state. Without global ordering, two events from different aggregates that happen close together could be processed in either order depending on timing.

Hapnd assigns a single, sequentially increasing position to every event in your tenant. Projections replay events in this order during catchup, guaranteeing consistent state regardless of when the projection was deployed or restarted.

You don’t need to think about this. It’s handled automatically. But if you’re coming from a system where cross-aggregate ordering was a concern, it’s worth knowing that Hapnd solves it at the infrastructure level.

Group related operations across services. Typically propagated from an incoming HTTP request:

await hapnd.Aggregate("order_123")
.WithCorrelation(correlationId)
.Append(new ItemAdded { Item = "Widget" });
await hapnd
.aggregate("order_123")
.withCorrelation(correlationId)
.append("ItemAdded", { item: "Widget" });

Link events in a cause-and-effect chain. Set to the EventId of the event that triggered this operation:

var first = await hapnd.Aggregate("order_123")
.Append(new OrderPlaced { CustomerId = "cust_456" });
await hapnd.Aggregate("order_123")
.WithCausation(first.EventId)
.Append(new PaymentRequested { Amount = 50.00m });
const first = await hapnd
.aggregate("order_123")
.append("OrderPlaced", { customerId: "cust_456" });
await hapnd
.aggregate("order_123")
.withCausation(first.eventId)
.append("PaymentRequested", { amount: 50.0 });

Attach arbitrary context to events. Stored alongside the event but not part of the event data itself:

await hapnd.Aggregate("order_123")
.WithMetadata(new
{
UserId = "user_456",
IpAddress = "192.168.1.1",
UserAgent = "MyApp/1.0"
})
.Append(new OrderPlaced { CustomerId = "cust_456" });
await hapnd
.aggregate("order_123")
.withMetadata({
userId: "user_456",
ipAddress: "192.168.1.1",
userAgent: "MyApp/1.0",
})
.append("OrderPlaced", { customerId: "cust_456" });