zoobzio December 8, 2025 Edit this page

Overview

Event handling in Go often means choosing between tight coupling or heavyweight infrastructure.

Capitan offers a third path: in-process event coordination that stays out of your way.

// Define your types
type Order struct {
    ID    string
    Total float64
    Items int
}

// Define a signal
orderCreated := capitan.NewSignal("order.created", "New order placed")

// Define unique keys
orderID := capitan.NewStringKey("order_id")
orderKey := capitan.NewKey[Order]("order", "myapp.Order")

// Hook a listener
capitan.Hook(orderCreated, func(ctx context.Context, e *capitan.Event) {
    id, ok := orderID.From(e)
    if !ok {
        return
    }
    order, ok := orderKey.From(e)
    if !ok {
        return
    }
    fmt.Printf("Order %s: $%.2f (%d items)\n", id, order.Total, order.Items)
})

// Emit an event
capitan.Emit(ctx, orderCreated,
    orderID.Field("ORDER-123"),
    orderKey.Field(Order{ID: "ORDER-123", Total: 99.99, Items: 3}),
)

Type-safe, zero dependencies, async by default.

Architecture

┌─────────────────────────────────────────────────────────┐
│                      Capitan                            │
│                                                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │   Signal A  │  │   Signal B  │  │   Signal C  │      │
│  │   Worker    │  │   Worker    │  │   Worker    │      │
│  │  [buffer]   │  │  [buffer]   │  │  [buffer]   │      │
│  │      │      │  │      │      │  │      │      │      │
│  │  Listeners  │  │  Listeners  │  │  Listeners  │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
│                                                         │
│  ┌─────────────────────────────────────────────────┐    │
│  │              Observers (cross-cutting)          │    │
│  └─────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

Each signal maintains its own buffered channel and worker goroutine. Listeners subscribe to specific signals. Observers span all signals for cross-cutting concerns like logging or metrics.

Philosophy

Capitan draws inspiration from Go's slog library: a package-level singleton that enables cross-cutting concerns without explicit wiring. Any package in your application can emit events or observe them. This creates a unified event stream that flows through your entire system.

// In your order service
capitan.Emit(ctx, orderCreated, orderID.Field(id))

// In your notification service
capitan.Hook(orderCreated, sendConfirmationEmail)

// In your analytics service
capitan.Observe(recordMetrics)

Three independent packages, zero explicit dependencies between them, one coherent event flow.

Capabilities

A unified event stream opens possibilities:

aperture - OpenTelemetry observability from capitan events.

herald - Message broker bridge for distributed capitan events.

ago - Workflow orchestration with capitan event choreography. (Experimental)

Persistence - Store events to a database for audit trails, debugging, and replay. Re-emit historical events through the same signal infrastructure.

Capitan provides the coordination layer. What you build on top is up to you.

Priorities

Type Safety

Fields are typed at compile time. No runtime type assertions, no stringly-typed maps, no interface{} gymnastics.

orderID := capitan.NewStringKey("order_id")
total := capitan.NewFloat64Key("total")

// Type-safe field creation
capitan.Emit(ctx, signal, orderID.Field("ORD-123"), total.Field(99.99))

// Type-safe extraction
id, ok := orderID.From(event)  // Returns (string, bool)

Custom types work the same way via generics:

var orderKey = capitan.NewKey[OrderInfo]("order", "myapp.OrderInfo")
order, ok := orderKey.From(event)  // Returns (OrderInfo, bool)

Reliability

Events queue asynchronously with backpressure. Each signal gets its own worker goroutine, created lazily on first emission. Slow listeners on one signal cannot affect others.

Listener panics are recovered automatically. One misbehaving handler won't crash your system or prevent other listeners from running.

Graceful shutdown drains all pending events before exit:

capitan.Shutdown()  // Waits for queues to empty

Performance

  • Lazy initialization - Workers spawn only when needed
  • Event pooling - Reduces allocations via sync.Pool
  • Per-signal isolation - No contention between different event types
  • Minimal locking - Read locks on hot paths, write locks only for registration

Events are pooled and reused. Workers process their queues independently. The system scales with your signal count, not your event volume.