Lifecycle hooks (middleware chain) for events, commands, and projections #118

Closed
opened 2026-02-23 09:55:43 +00:00 by ash · 0 comments
Owner

Summary

Add composable lifecycle hooks at three boundaries: event append, command dispatch, and projection processing. Middleware chain pattern — no event emitters, no reflection.

This makes cross-cutting concerns (metrics, tracing, validation, audit logging) first-class without bloating the core.

Decision: Three Hook Points Only

Hooks earn their place ONLY where interfaces alone are insufficient. After review:

YES — Event Append Hooks

store := sqlitestore.New(db,
    eskit.BeforeAppend(func(ctx context.Context, streamID string, events []Event) error {
        // validate, enrich metadata, inject correlation ID, authz
        return nil // or error to reject
    }),
    eskit.AfterAppend(func(ctx context.Context, streamID string, events []Event) {
        // metrics, cache invalidation, external notifications
    }),
)

YES — Command Dispatch Hooks

bus := command.NewRegister(store,
    eskit.BeforeCommand(func(ctx context.Context, cmd any) error {
        // auth, rate limit, validate, log
        return nil // or error to reject
    }),
    eskit.AfterCommand(func(ctx context.Context, cmd any, events []Event, err error) {
        // metrics, alerting, audit
    }),
)

This means WithCommandAudit (#114) becomes just another hook:

bus := command.NewRegister(store,
    commandlog.Hook(auditStore),  // just a hook, not special middleware
    WithMetrics(reg),
    WithRateLimit(100, time.Second),
)

YES — Projection Processing Hooks

projection := eskit.NewProjection(handler,
    eskit.OnProjectionError(func(ctx context.Context, event Event, err error) ErrorAction {
        return eskit.Skip // or Retry or Halt
    }),
    eskit.OnProjectionCaughtUp(func(ctx context.Context, position uint64) {
        // signal readiness, health check
    }),
)

NO — Places we deliberately exclude hooks

  • Inside the decider — Decide() IS the extension point. Wrapping it adds nothing.
  • Serialization — Codec interface covers this.
  • Store internals — SQL queries, NATS publishes are implementation details.

Implementation: Middleware Chain

Go-style functional options. Ordered. Multiple per point. Plain functions — testable, composable.

// Multiple hooks, executed in registration order
store := sqlitestore.New(db,
    eskit.BeforeAppend(validator),
    eskit.BeforeAppend(enricher),
    eskit.AfterAppend(notifier),
    eskit.AfterAppend(metrics),
)

Hook types

type BeforeAppendFunc func(ctx context.Context, streamID string, events []EventData) error
type AfterAppendFunc  func(ctx context.Context, streamID string, events []Event)
type BeforeCommandFunc func(ctx context.Context, cmd any) error
type AfterCommandFunc  func(ctx context.Context, cmd any, result CommandResult)
type ProjectionErrorFunc func(ctx context.Context, event Event, err error) ErrorAction
type ProjectionCaughtUpFunc func(ctx context.Context, position uint64)
  • Before hooks can reject (return error) — acts as a gate
  • After hooks are fire-and-forget — no error return, don't block the hot path
  • All hooks receive context for cancellation/deadline propagation

Why Not Event Emitters

  • Type-safe (no interface{} listeners)
  • Ordered execution (predictable)
  • Compile-time errors for wrong hook types
  • No goroutine leaks from forgotten listeners
  • Easy to test (just call the function)

Testing Requirements

  • Hook ordering tests (multiple hooks execute in registration order)
  • BeforeAppend rejection test (error stops append)
  • AfterAppend fire-and-forget test (panic in after-hook doesn't break append)
  • BeforeCommand rejection test (error stops dispatch)
  • Context propagation tests (timeout/cancellation flows through hooks)
  • Nil hook safety (skip nil hooks, don't panic)
  • Integration test: CommandAudit as a hook (#114)
  • Race safety: hooks called concurrently from multiple goroutines
  • Hook with no-op implementations (zero overhead when unused)

Benchmark Requirements

  • Benchmark zero hooks vs 1 vs 5 vs 10 hooks (overhead per hook)
  • Must be < 1μs overhead per hook call for no-op hooks
  • Zero allocations for the hook dispatch path
  • Compare: hooks vs manually wrapping the interface (hooks should be equal or faster)
  • Benchmark AfterAppend async vs sync execution

Build Order

Build BEFORE #114 (command persistence) — so command audit becomes a hook from day one rather than a special case retrofitted later.

## Summary Add composable lifecycle hooks at three boundaries: event append, command dispatch, and projection processing. Middleware chain pattern — no event emitters, no reflection. This makes cross-cutting concerns (metrics, tracing, validation, audit logging) first-class without bloating the core. ## Decision: Three Hook Points Only Hooks earn their place ONLY where interfaces alone are insufficient. After review: ### ✅ YES — Event Append Hooks ```go store := sqlitestore.New(db, eskit.BeforeAppend(func(ctx context.Context, streamID string, events []Event) error { // validate, enrich metadata, inject correlation ID, authz return nil // or error to reject }), eskit.AfterAppend(func(ctx context.Context, streamID string, events []Event) { // metrics, cache invalidation, external notifications }), ) ``` ### ✅ YES — Command Dispatch Hooks ```go bus := command.NewRegister(store, eskit.BeforeCommand(func(ctx context.Context, cmd any) error { // auth, rate limit, validate, log return nil // or error to reject }), eskit.AfterCommand(func(ctx context.Context, cmd any, events []Event, err error) { // metrics, alerting, audit }), ) ``` This means `WithCommandAudit` (#114) becomes just another hook: ```go bus := command.NewRegister(store, commandlog.Hook(auditStore), // just a hook, not special middleware WithMetrics(reg), WithRateLimit(100, time.Second), ) ``` ### ✅ YES — Projection Processing Hooks ```go projection := eskit.NewProjection(handler, eskit.OnProjectionError(func(ctx context.Context, event Event, err error) ErrorAction { return eskit.Skip // or Retry or Halt }), eskit.OnProjectionCaughtUp(func(ctx context.Context, position uint64) { // signal readiness, health check }), ) ``` ### ❌ NO — Places we deliberately exclude hooks - **Inside the decider** — Decide() IS the extension point. Wrapping it adds nothing. - **Serialization** — Codec interface covers this. - **Store internals** — SQL queries, NATS publishes are implementation details. ## Implementation: Middleware Chain Go-style functional options. Ordered. Multiple per point. Plain functions — testable, composable. ```go // Multiple hooks, executed in registration order store := sqlitestore.New(db, eskit.BeforeAppend(validator), eskit.BeforeAppend(enricher), eskit.AfterAppend(notifier), eskit.AfterAppend(metrics), ) ``` ### Hook types ```go type BeforeAppendFunc func(ctx context.Context, streamID string, events []EventData) error type AfterAppendFunc func(ctx context.Context, streamID string, events []Event) type BeforeCommandFunc func(ctx context.Context, cmd any) error type AfterCommandFunc func(ctx context.Context, cmd any, result CommandResult) type ProjectionErrorFunc func(ctx context.Context, event Event, err error) ErrorAction type ProjectionCaughtUpFunc func(ctx context.Context, position uint64) ``` - `Before` hooks can reject (return error) — acts as a gate - `After` hooks are fire-and-forget — no error return, don't block the hot path - All hooks receive context for cancellation/deadline propagation ## Why Not Event Emitters - Type-safe (no `interface{}` listeners) - Ordered execution (predictable) - Compile-time errors for wrong hook types - No goroutine leaks from forgotten listeners - Easy to test (just call the function) ## Testing Requirements - Hook ordering tests (multiple hooks execute in registration order) - BeforeAppend rejection test (error stops append) - AfterAppend fire-and-forget test (panic in after-hook doesn't break append) - BeforeCommand rejection test (error stops dispatch) - Context propagation tests (timeout/cancellation flows through hooks) - Nil hook safety (skip nil hooks, don't panic) - Integration test: CommandAudit as a hook (#114) - Race safety: hooks called concurrently from multiple goroutines - Hook with no-op implementations (zero overhead when unused) ## Benchmark Requirements - Benchmark zero hooks vs 1 vs 5 vs 10 hooks (overhead per hook) - Must be < 1μs overhead per hook call for no-op hooks - Zero allocations for the hook dispatch path - Compare: hooks vs manually wrapping the interface (hooks should be equal or faster) - Benchmark AfterAppend async vs sync execution ## Build Order Build BEFORE #114 (command persistence) — so command audit becomes a hook from day one rather than a special case retrofitted later.
ash closed this issue 2026-02-23 10:22:16 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ash/eskit#118
No description provided.