GUIDES & REFERENCE

Documentation#

A high-performance event bus for Go with deterministic rules, cached decisions, and optional Jev-powered event routing.

GoEventBus combines a bounded MPMC in-memory event bus with fan-out, ordered and batch handlers, middleware, lifecycle hooks, dead-letter handling, 12 official broker providers, and an optional decision layer for choosing which event type should execute.

01Register02Subscribe03Publish

Installation#

Terminal
go get github.com/Protocol-Lattice/GoEventBus

GoEventBus currently targets Go 1.23+.

Quick start#

Plain event bus#

Use the direct path when the event type is already known.

Go
package main

import (
    "context"
    "fmt"
    "log"

    GoEventBus "github.com/Protocol-Lattice/GoEventBus"
)

type UserCreated struct {
    ID string
}

func main() {
    dispatcher := GoEventBus.Dispatcher{}

    dispatcher.Register("user.created", func(
        ctx context.Context,
        ev GoEventBus.Event,
    ) (GoEventBus.Result, error) {
        payload := ev.Data.(UserCreated)
        fmt.Println("user created:", payload.ID)
        return GoEventBus.Result{Message: "ok"}, nil
    })

    store := GoEventBus.NewEventStore(
        &dispatcher,
        1<<16,
        GoEventBus.DropOldest,
    )
    store.Async = true

    if err := store.Subscribe(context.Background(), GoEventBus.Event{
        ID:         "evt-1",
        Projection: "user.created",
        Data:       UserCreated{ID: "u-42"},
    }); err != nil {
        log.Fatal(err)
    }

    store.Publish()

    if err := store.Drain(context.Background()); err != nil {
        log.Fatal(err)
    }
}

Projection may be any comparable value for local dispatch, including a string or a typed struct.

Events & handlers#

Go
type Event struct {
    ID         string
    Projection interface{}
    Data       any
    Args       map[string]any // deprecated
}

Prefer Data for payloads. Args remains for backwards compatibility.

Handlers use:

Go
type HandlerFunc func(
    context.Context,
    Event,
) (Result, error)

Register handlers with a Dispatcher:

Go
dispatcher := GoEventBus.Dispatcher{}

dispatcher.Register(
    "order.created",
    handleBilling,
    handleAnalytics,
)

Calling Register repeatedly for the same projection appends handlers rather than replacing them.

Why GoEventBus?#

Most events do not need model inference. Some do.

GoEventBus keeps those paths separate:

  • Known event type → enqueue it directly.
  • Obvious rule → route deterministically.
  • Repeated decision → reuse the cached choice.
  • Ambiguous input → ask Jev to choose from a bounded candidate set.
  • Execution → always stays inside GoEventBus.

That gives you intelligent routing without putting an LLM call in the Publish() hot path.

Features#

  • Bounded MPMC ring buffer built with atomics and cache-line padding.
  • Rules → cache → Jev routing for optional intelligent event selection.
  • Typed or string projections for local dispatch.
  • Fan-out handlers with independent execution.
  • Ordered handlers with FIFO delivery per ordering key.
  • Batch handlers for bulk writes and high-throughput pipelines.
  • Sync and async dispatch with a fixed worker pool.
  • Middleware for per-handler cross-cutting behavior.
  • Lifecycle hooks with OnBefore, OnAfter, and OnError.
  • Back-pressure policies: DropOldest, Block, or ReturnError.
  • Dead-letter queue with replay support and panic recovery.
  • 12 official broker providers: Redis Streams, RabbitMQ, NATS JetStream, Kafka, AWS SQS, AWS SNS, Google Cloud Pub/Sub, Azure Service Bus, Apache Pulsar, MQTT, PostgreSQL, and NSQ.
  • Transactions with local buffering and transport-aware commit to local handlers or the configured broker.
  • Scheduling with absolute and relative timers.
  • Metrics for published, processed, and failed events.
  • Pluggable decision cache through the DecisionCache interface.

Fan-out#

Multiple handlers may subscribe to the same projection.

Go
dispatcher.Register(
    "order.placed",
    auditLogger,
    inventoryReducer,
    notificationSender,
)

Each handler is independent. One handler returning an error does not prevent the other fan-out handlers from running.

In async mode, each regular handler invocation becomes its own worker-pool item.

Ordered Handlers#

Use ordered handlers when events for the same entity must be processed sequentially while unrelated entities can still run concurrently.

Go
type OrderEvent struct {
    OrderID  string
    Sequence int
}

store.RegisterOrdered(
    "order.event",
    func(ev GoEventBus.Event) string {
        return ev.Data.(OrderEvent).OrderID
    },
    func(
        ctx context.Context,
        ev GoEventBus.Event,
    ) (GoEventBus.Result, error) {
        return applyOrderEvent(ctx, ev.Data.(OrderEvent))
    },
)

For a given ordering key, async delivery preserves FIFO order.

Different keys remain concurrent.

Batch Handlers#

Batch handlers collect pending events for a projection during a Publish cycle and deliver them in chunks.

Go
store.RegisterBatch(
    "metrics.recorded",
    100,
    func(
        ctx context.Context,
        events []GoEventBus.Event,
    ) ([]GoEventBus.Result, error) {
        return nil, writeMetricsBatch(ctx, events)
    },
)

Regular and batch handlers may coexist on the same projection.

Multiple batch handlers may also be registered for one projection; each receives the full chunk independently.

Middleware is not applied to batch handlers. Lifecycle hooks are.

Middleware#

Middleware wraps regular handlers.

Go
store.Use(func(next GoEventBus.HandlerFunc) GoEventBus.HandlerFunc {
    return func(
        ctx context.Context,
        ev GoEventBus.Event,
    ) (GoEventBus.Result, error) {
        started := time.Now()
        result, err := next(ctx, ev)
        log.Printf(
            "projection=%v duration=%s err=%v",
            ev.Projection,
            time.Since(started),
            err,
        )
        return result, err
    }
})

Middleware is applied independently to every handler in a fan-out.

Lifecycle Hooks#

Use hooks for observability without changing handler logic.

Go
store.OnBefore(func(ctx context.Context, ev GoEventBus.Event) {
    metrics.Inc("handler.started")
})

store.OnAfter(func(
    ctx context.Context,
    ev GoEventBus.Event,
    result GoEventBus.Result,
    err error,
) {
    metrics.Inc("handler.finished")
})

store.OnError(func(
    ctx context.Context,
    ev GoEventBus.Event,
    err error,
) {
    log.Printf("event=%s err=%v", ev.ID, err)
})

For batch handlers, hooks are emitted per event.

Back-pressure#

Choose how Subscribe behaves when the ring buffer is full.

PolicyBehavior
DropOldestEvict the oldest queued event and accept the new one
BlockWait for capacity while respecting the caller context
ReturnErrorReturn ErrBufferFull immediately

Example:

Go
store := GoEventBus.NewEventStore(
    &dispatcher,
    1<<14,
    GoEventBus.Block,
)

ctx, cancel := context.WithTimeout(
    context.Background(),
    50*time.Millisecond,
)
defer cancel()

if err := store.Subscribe(ctx, event); err != nil {
    log.Println("enqueue failed:", err)
}

Async & shutdown#

Enable worker-pool dispatch with:

Go
store.Async = true

The store uses a fixed worker pool sized to runtime.NumCPU().

Publish() submits regular, ordered, and batch work to that pool.

Call Drain or Close when shutting down:

Go
ctx, cancel := context.WithTimeout(
    context.Background(),
    5*time.Second,
)
defer cancel()

if err := store.Drain(ctx); err != nil {
    log.Println("drain failed:", err)
}

Once shutdown begins, new subscriptions return ErrEventStoreClosed.

Rules, cache & Jev#

GoEventBus can choose an event type before enqueueing it.

The recommended pipeline is:

Flow
rules → cache → Jev → cache write → DecideAndSubscribe → local queue or configured broker

This keeps the fast path deterministic and only calls Jev when local logic cannot resolve the event type.

1. Define candidates#

Go
type HouseWasSold struct{}

candidates := []GoEventBus.EventCandidate{
    {
        Key:         "user_created",
        Projection:  "user.created",
        Description: "A new user account was created",
    },
    {
        Key:         "house_sold",
        Projection:  HouseWasSold{},
        Description: "A property sale was completed",
    },
    {
        Key:         "order_cancelled",
        Projection:  "order.cancelled",
        Description: "An existing order should be cancelled",
    },
}

Key is the stable identifier exposed to the decision layer. Projection is the real GoEventBus dispatcher key.

That separation lets Jev choose between simple string keys while the event bus can still dispatch to typed projections.

2. Add deterministic rules#

Rules run first. The first matching rule wins.

Go
rules := []GoEventBus.EventRule{
    {
        Name:   "explicit-cancel",
        Choice: "order_cancelled",
        Match: func(
            _ context.Context,
            state any,
            _ []GoEventBus.EventCandidate,
        ) bool {
            input, ok := state.(map[string]any)
            return ok && input["action"] == "cancel"
        },
    },
}

Rules are intentionally evaluated before cache. Adding a new rule can therefore override an older cached model decision immediately.

3. Add a decision cache#

Go
cache := GoEventBus.NewMemoryDecisionCache(5 * time.Minute)

The built-in cache is goroutine-safe and lazily expires entries.

The default cache key is a SHA-256 hash of:

  • the JSON-serializable decision state,
  • candidate keys,
  • candidate descriptions.

Candidate ordering does not change the key. Projection values are deliberately excluded because typed projections may not be JSON-serializable.

Need a different strategy? Supply RuleCacheSelector.CacheKey.

4. Use Jev as the fallback#

Go
jev := &GoEventBus.JevSelector{
    APIKey: os.Getenv("OPENROUTER_API_KEY"),
}

The default model is:

Flow
typesafe/jev-1.13

The adapter calls OpenRouter's Decisions API using the Go standard library, so the Jev integration adds no SDK dependency.

5. Compose the router#

Go
selector := &GoEventBus.RuleCacheSelector{
    Rules:    rules,
    Cache:    cache,
    Fallback: jev,
}

Then route and deliver:

Go
state := map[string]any{
    "message": "The property at 1 Main St was sold for $500000",
}

decision, err := store.DecideAndSubscribe(
    context.Background(),
    selector,
    state,
    GoEventBus.Event{
        ID: "evt-2",
    },
    candidates,
)
if err != nil {
    log.Fatal(err)
}

fmt.Printf(
    "choice=%s confidence=%.2f probabilities=%v\n",
    decision.Choice,
    decision.Confidence,
    decision.Probabilities,
)

store.Publish()

With a normal three-argument NewEventStore, DecideAndSubscribe enqueues locally and Publish() dispatches the event. With WithRedis(...) or WithRabbitMQ(...), the same call publishes the selected event to the configured broker instead.

\n

If a rule matches, Jev is never called. If the same decision state is already cached, Jev is never called. Only a cache miss reaches the model.

Cache behavior#

DecisionCache is intentionally small:

Go
type DecisionCache interface {
    Get(context.Context, string) (EventDecision, bool, error)
    Set(context.Context, string, EventDecision) error
}

That makes it straightforward to implement Redis, distributed, persistent, or application-specific caches.

Cache failures are fail-open by default. Routing continues through the fallback selector because cache is treated as an optimization.

Set:

Go
StrictCache: true

when cache failures should fail the routing call instead.

External providers#

GoEventBus exposes one application-facing broker API across twelve official transports. Configure exactly one provider on NewEventStore, then use the same DecideAndSubscribe, PublishToProvider, Consume, and shutdown lifecycle regardless of the broker.

Go
store := GoEventBus.NewEventStore(
    &dispatcher,
    1024,
    GoEventBus.Block,
    GoEventBus.WithSQS(sqsConfig), // swap only this option
)

Every official provider supports the shared EventCodec abstraction. JSONCodec is the default.

Broker capability matrix#

BrokerOptionSuccess acknowledgementFailure / retryOrdering / grouping
Redis StreamsWithRedisXACK after handler successPending entry remains for retryConsumer groups; stream order
RabbitMQWithRabbitMQManual Ack after successNack + requeueTopic routing; queue ordering
NATS JetStreamWithNATSJetStreamExplicit AckNak for redeliveryDurable consumers; queue groups
Apache KafkaWithKafkaOffset commit after successUncommitted offset is redeliveredPartitions; consumer groups
AWS SQSWithSQSDelete after successVisibility timeout makes failed messages visible againStandard or FIFO queues
AWS SNSWithSNSPublish acceptanceSubscriber transport owns retryTopics; FIFO when configured
GCP Pub/SubWithGCPPubSubAck after successNack on decode/handler failureSubscriptions; optional ordering keys
Azure Service BusWithAzureServiceBusComplete in PeekLock modeAbandon on failureQueues or topic subscriptions
Apache PulsarWithPulsarAck after successNack for redeliveryShared / Exclusive / Failover / KeyShared
MQTTWithMQTTProtocol QoS acknowledgementHandler errors cannot be translated to broker Nack after callback deliveryTopics; QoS 0/1/2
PostgreSQLWithPostgresDelete row after successAttempts increment + delayed availabilityPersistent table; SKIP LOCKED
NSQWithNSQFinish after handler successHandler error triggers requeueTopics + channels

AWS SQS#

Go
store := GoEventBus.NewEventStore(
    &dispatcher, 1024, GoEventBus.Block,
    GoEventBus.WithSQS(GoEventBus.SQSProviderConfig{
        Client:   sqsClient,
        QueueURL: queueURL,
    }),
)

SQS uses long polling. Messages are deleted only after the EventStore accepts and processes them successfully. FIFO queues can set FIFO, MessageGroupID, and MessageDeduplicationID.

AWS SNS#

Go
sqsSubscriber, _ := GoEventBus.NewSQSProvider(sqsConfig)

store := GoEventBus.NewEventStore(
    &dispatcher, 1024, GoEventBus.Block,
    GoEventBus.WithSNS(GoEventBus.SNSProviderConfig{
        Client:     snsClient,
        TopicARN:   topicARN,
        Subscriber: sqsSubscriber,
    }),
)

SNS is publish-oriented, not a queue. Consume therefore delegates to a supported subscriber transport such as SQS instead of inventing queue semantics.

Google Cloud Pub/Sub#

Go
GoEventBus.WithGCPPubSub(GoEventBus.GCPPubSubProviderConfig{
    Client:         pubsubClient,
    Topic:          "events",
    Subscription:   "billing",
    EnableOrdering: true,
})

Successful handling calls Ack. Decode or handler failures call Nack. When ordering is enabled, the projection is the default ordering key.

Azure Service Bus#

Go
GoEventBus.WithAzureServiceBus(GoEventBus.AzureServiceBusProviderConfig{
    Client: sbClient,
    Queue:  "billing",
})

Queue consumers use PeekLock processing: successful messages are completed, while failures are abandoned for retry. For pub/sub, configure Topic and Subscription instead of Queue.

Apache Pulsar#

Go
GoEventBus.WithPulsar(GoEventBus.PulsarProviderConfig{
    Client:       pulsarClient,
    Topic:        "persistent://public/default/events",
    Subscription: "billing",
})

The default subscription type is Shared. Configure SubscriptionType for Exclusive, Failover, or KeyShared behavior. Handler failures are Nacked for redelivery.

MQTT#

Go
GoEventBus.WithMQTT(GoEventBus.MQTTProviderConfig{
    Options:     mqttOptions,
    TopicPrefix: "events/",
    QoS:         1,
})

QoS 0, 1, and 2 are supported. Automatic reconnect and persistent-session behavior are configured with Paho client options. Injected clients remain caller-owned; clients created from Options are owned by the provider.

PostgreSQL#

Go
GoEventBus.WithPostgres(GoEventBus.PostgresProviderConfig{
    ConnectionString: os.Getenv("DATABASE_URL"),
    Table:            "goeventbus_events",
})

The PostgreSQL provider implements a persistent table-backed queue. Consumers claim rows with FOR UPDATE SKIP LOCKED; success deletes the row and failure increments attempts and moves available_at forward for retry. An injected pool remains caller-owned.

NSQ#

Go
GoEventBus.WithNSQ(GoEventBus.NSQProviderConfig{
    NSQDAddress: "127.0.0.1:4150",
    Topic:       "events",
    Channel:     "billing",
})

NSQ uses topics and channels. The provider returns handler errors to go-nsq, allowing the client to requeue failed messages according to NSQ retry behavior.

Redis Streams#

Go
store := GoEventBus.NewEventStore(
    &dispatcher,
    1024,
    GoEventBus.Block,
    GoEventBus.WithRedis(GoEventBus.RedisProviderConfig{
        Client:   redisClient,
        Stream:   "events",
        Group:    "billing",
        Consumer: "billing-1",
        StartID:  "0",
    }),
)

Redis Streams uses consumer groups. Messages are acknowledged with XACK only after successful handling. Failed messages remain pending so the same consumer can retry them on a later consume cycle.

The injected Redis client remains application-owned and is not closed by the provider. Stream retention can be controlled with MaxLen, and Block / Count tune read behavior.

RabbitMQ#

Go
store := GoEventBus.NewEventStore(
    &dispatcher,
    1024,
    GoEventBus.Block,
    GoEventBus.WithRabbitMQ(GoEventBus.RabbitMQProviderConfig{
        URL:        "amqp://guest:guest@localhost:5672/",
        Exchange:   "events",
        Queue:      "billing",
        BindingKey: "order.*",
        Consumer:   "billing-1",
    }),
)

RabbitMQ publishes to a durable topic exchange and waits for publisher confirms. Consumers use manual acknowledgements: successful processing calls Ack, while decode or handler failures Nack and requeue the delivery.

The provider owns its AMQP connection and publisher channel. PrefetchCount controls the number of unacknowledged deliveries per consumer.

NATS JetStream#

Go
store := GoEventBus.NewEventStore(
    &dispatcher,
    1024,
    GoEventBus.Block,
    GoEventBus.WithNATSJetStream(GoEventBus.NATSJetStreamProviderConfig{
        Conn:          nc,
        SubjectPrefix: "events.",
        Subject:       "events.>",
        Durable:       "billing",
        Queue:         "billing-workers",
    }),
)

JetStream publishes each event to SubjectPrefix + Projection. Durable consumers and queue groups are supported. Messages use explicit acknowledgements: Ack after successful handling and Nak after decode or handler failure for redelivery.

The injected NATS connection remains application-owned. GoEventBus closes only the subscription it creates.

Apache Kafka#

Go
store := GoEventBus.NewEventStore(
    &dispatcher,
    1024,
    GoEventBus.Block,
    GoEventBus.WithKafka(GoEventBus.KafkaProviderConfig{
        Brokers: []string{"localhost:9092"},
        Topic:   "events",
        Group:   "billing",
    }),
)

Kafka uses the event projection as the default message key, so events with the same projection stay ordered within a partition. Consumer groups are supported, and offsets are committed synchronously only after successful event handling.

Handler or decode failures leave the offset uncommitted for redelivery according to Kafka consumer-group semantics. An optional fixed partition can be used when no consumer group is configured.

Dead letter queue#

Attach a DLQ to retain handler failures and recovered panics.

Go
store.DLQ = GoEventBus.NewDeadLetterQueue()

Inspect failures:

Go
for _, dead := range store.DLQ.Entries() {
    log.Printf(
        "event=%s attempts=%d err=%v",
        dead.Event.ID,
        dead.Attempts,
        dead.Err,
    )
}

Replay:

Go
if err := store.DLQ.Replay(ctx, store); err != nil {
    log.Println("replay failed:", err)
}

Handler panics are recovered and converted into errors so a single handler cannot kill the worker pool.

Transactions#

Transactions are local event buffers, not database-style atomic transactions.

Direct transactions#

Go
tx := store.BeginTransaction()

tx.Publish(GoEventBus.Event{
    ID:         "evt-1",
    Projection: "order.created",
    Data:       order,
})

tx.Publish(GoEventBus.Event{
    ID:         "evt-2",
    Projection: "invoice.created",
    Data:       invoice,
})

if err := tx.Commit(ctx); err != nil {
    tx.Rollback()
    log.Fatal(err)
}

Jev-aware transactions#

Use DecideAndPublish when a transaction entry needs the same Rules / Cache / Jev selection pipeline as DecideAndSubscribe.

Go
tx := store.BeginTransaction()

decision, err := tx.DecideAndPublish(
    ctx,
    selector,
    map[string]any{
        "message": "Create an invoice for order 42",
    },
    GoEventBus.Event{
        ID:   "evt-3",
        Data: invoice,
    },
    []GoEventBus.EventCandidate{
        {
            Key:         "order_created",
            Projection:  "order.created",
            Description: "A new order should be created",
        },
        {
            Key:         "invoice_created",
            Projection:  "invoice.created",
            Description: "An invoice should be created",
        },
    },
)
if err != nil {
    tx.Rollback()
    log.Fatal(err)
}

fmt.Println("selected:", decision.Choice)

// No handler has run yet.
if err := tx.Commit(ctx); err != nil {
    tx.Rollback()
    log.Fatal(err)
}
01Rules / Cache / Jev02DecideAndPublish03Transaction buffer04Commit

DecideAndPublish resolves the event projection immediately and buffers only the selected event. Routing failures do not mutate the transaction, and Rollback discards intelligently selected events just like direct events.

Local Commit stops at the first handler error. Broker-backed Commit publishes events in transaction order, stops at the first provider error, and removes only the prefix already confirmed by the provider. Retrying the transaction therefore starts from the first unpublished event instead of publishing the confirmed prefix again.

Transactions are not database-style atomic transactions: handler side effects or broker publishes that already succeeded cannot be undone.

Rollback discards only events still buffered locally and never rewinds the shared EventStore ring.

Scheduling#

Direct scheduling#

Schedule a known event at a specific time:

Go
timer := store.Schedule(
    ctx,
    time.Now().Add(10*time.Second),
    event,
)

Or after a duration:

Go
timer := store.ScheduleAfter(
    ctx,
    30*time.Second,
    event,
)

Jev-aware scheduling#

Use the same Rules / Cache / Jev selector pipeline when the event type must be chosen before scheduling.

Go
decision, timer, err := store.DecideAndSchedule(
    ctx,
    time.Now().Add(10*time.Second),
    selector,
    state,
    GoEventBus.Event{
        ID:   "evt-scheduled-1",
        Data: payload,
    },
    candidates,
)
if err != nil {
    log.Fatal(err)
}

fmt.Println("scheduled:", decision.Choice)

Or after a duration:

Go
decision, timer, err := store.DecideAndScheduleAfter(
    ctx,
    30*time.Second,
    selector,
    state,
    GoEventBus.Event{
        ID:   "evt-scheduled-2",
        Data: payload,
    },
    candidates,
)
if err != nil {
    log.Fatal(err)
}
01Rules / Cache / Jev02Selected event03Timer04Handler

The selector runs when scheduling is requested, not when the timer fires. Selection errors are returned immediately and no timer is created. A successfully scheduled timer therefore contains an already-resolved event.

The returned *time.Timer may be stopped before it fires. Past times and non-positive durations execute immediately and return nil.

Metrics#

Go
published, processed, failures := store.Metrics()

fmt.Printf(
    "published=%d processed=%d errors=%d\n",
    published,
    processed,
    failures,
)

These counters cover event execution. Decision-layer metrics such as cache hit rate or Jev latency are intentionally not baked into the core API yet.

API reference#

Event routing#

Go
type EventCandidate struct {
    Key         string
    Projection  interface{}
    Description string
}

type EventDecision struct {
    Choice        string
    Projection    interface{}
    Confidence    float64
    Probabilities map[string]float64
    Model         string
    RequestID     string
}

type EventSelector interface {
    SelectEvent(
        context.Context,
        any,
        []EventCandidate,
    ) (EventDecision, error)
}

Main implementations:

  • JevSelector
  • RuleCacheSelector
  • MemoryDecisionCache

EventStore#

Go
func NewEventStore(
    dispatcher *Dispatcher,
    bufferSize uint64,
    policy OverrunPolicy,
    options ...EventStoreOption,
) *EventStore

bufferSize must be a non-zero power of two.

Important methods:

MethodPurpose
SubscribeEnqueue a known event
DecideAndSubscribeSelect a candidate and enqueue locally or publish to the configured broker
PublishDispatch pending events
RegisterOrderedPreserve FIFO per ordering key
RegisterBatchProcess projection events in chunks
ConsumeFeed configured or explicit provider events into the local store
PublishToProviderPublish a known event through the provider configured on NewEventStore
UseRegister middleware
OnBefore / OnAfter / OnErrorRegister lifecycle hooks
MetricsRead event counters
Schedule / ScheduleAfterSchedule known events
DecideAndSchedule / DecideAndScheduleAfterSelect with Rules/Cache/Jev, then schedule the resolved event
BeginTransactionBuffer events until commit; local stores execute regular handlers, provider-backed stores publish to the configured broker
Drain / CloseStop accepting events and finish in-flight work

Examples#

The repository includes runnable examples for both the classic event-bus path and the intelligent routing layer.

ExampleWhat it demonstrates
routing_rulesDeterministic first-match routing with EventRule; no model or cache required
routing_cacheReusing a previous decision so the fallback selector is called only once
routing_jevFull rules → cache → Jev → GoEventBus routing with OpenRouter
transaction_jevRules/Cache/Jev selection buffered until commit, then dispatched locally or published through the configured provider
schedule_jevRules/Cache/Jev event selection before delayed scheduling
hello_worldMinimal direct Subscribe → Publish flow
middlewareHandler middleware and lifecycle behavior
goroutines-subscribe-publisherConcurrent producers and publishing
drop_oldestDropOldest back-pressure
return_errorReturnError back-pressure
handler_timeoutHandler context timeout
publisher_timeoutBlocking publisher timeout
fasthttpHTTP integration

Run the new routing examples directly:

Terminal
go run ./examples/routing_rules
go run ./examples/routing_cache
OPENROUTER_API_KEY=... go run ./examples/routing_jev
OPENROUTER_API_KEY=... go run ./examples/transaction_jev
OPENROUTER_API_KEY=... go run ./examples/schedule_jev

See examples/README.md for a compact guide to all examples.

Benchmarks#

Repository benchmarks on Apple M-series:

Terminal
go test -bench . -benchtime=3s
BenchmarkIterationsns/op
BenchmarkSubscribe27,080,37640.37
BenchmarkSubscribeParallel26,418,99938.42
BenchmarkPublish295,661,4643.91
BenchmarkPublishAfterPrefill252,943,5264.59
BenchmarkSubscribeLargePayload1,613,017771.5
BenchmarkPublishLargePayload296,434,2253.91
BenchmarkEventStore_Async2,816,988436.5
BenchmarkEventStore_Sync2,638,519428.5
BenchmarkFastHTTPSync6,275,112163.8
BenchmarkFastHTTPAsync1,954,884662.0
BenchmarkFastHTTPParallel4,489,274262.3

The intelligent routing path is intentionally outside these core dispatch benchmarks because rules, cache, and Jev have very different latency profiles.

Architecture#

GoEventBus keeps decision-making and execution separate.

Flow
Decision layer                    Execution layer

rules ─┐
       ├─► choice ───────────────► EventStore
cache ─┤                           ├─ ring buffer
       │                           ├─ back-pressure
Jev ───┘                           ├─ worker pool
                                   ├─ ordering
                                   ├─ batching
                                   ├─ middleware/hooks
                                   └─ DLQ

This separation means:

  • the event bus does not depend on Jev,
  • deterministic workloads do not pay model latency,
  • model failures do not change core dispatch semantics,
  • alternative selectors can implement the same EventSelector interface,
  • alternative caches can implement DecisionCache,
  • application code can mix direct and intelligent routing in the same store.

Contributing#

Issues and pull requests are welcome.

Run the test suite with:

Terminal
go test -race ./...

Integration tests:

Terminal
go test -race -tags=integration -timeout=5m ./...

Quality checks used by CI include go vet and staticcheck.

License#

Distributed under the MIT License. See LICENSE.