Events System
This document describes autospec’s event-driven architecture using kelindar/event, a high-performance in-process event dispatcher for Go.
Table of Contents
Overview
autospec uses an event-driven architecture to decouple command execution from cross-cutting concerns like notifications, logging, and metrics. Instead of commands directly calling notification handlers, they emit events that subscribers handle independently.
Before (direct coupling):
func runSpecify(cmd *cobra.Command, args []string) error {
startTime := time.Now()
// ... command logic ...
duration := time.Since(startTime)
notifHandler.OnCommandComplete("specify", err == nil, duration) // Tight coupling
return err
}
After (event-driven):
func runSpecify(cmd *cobra.Command, args []string) error {
return lifecycle.Run("specify", func() error {
// ... command logic ...
return nil
})
}
Why kelindar/event
We chose kelindar/event over a custom implementation for:
| Feature | Benefit |
|---|---|
| 4-10x faster than channels | High throughput for future event-heavy scenarios |
| Zero allocations | No GC pressure, consistent performance |
| Zero dependencies | Only Go stdlib, aligns with our minimal-deps policy |
| Type-safe generics | Compile-time safety for event handlers |
| Goroutine-per-subscriber | Non-blocking async dispatch by default |
| Battle-tested | Production-ready, handles edge cases |
Event Interface
Events must implement the Type() uint32 method:
type Event interface {
Type() uint32
}
This allows the dispatcher to route events efficiently to interested subscribers.
Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ CLI Command │ │ Lifecycle │ │ Event Bus │
│ (specify) │────▶│ Manager │────▶│ (Dispatcher) │
└─────────────────┘ └─────────────────┘ └────────┬────────┘
│
┌───────────────────────────────┼───────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Notification │ │ Logger │ │ Metrics │
│ Subscriber │ │ Subscriber │ │ Subscriber │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Components
| Component | Location | Responsibility |
|---|---|---|
| Event Types | internal/events/types.go |
Event struct definitions and type constants |
| Event Bus | internal/events/bus.go |
Global dispatcher instance and helpers |
| Lifecycle Manager | internal/lifecycle/run.go |
Wraps command execution, emits events |
| Notification Subscriber | internal/notify/subscriber.go |
Handles sound/visual notifications |
Event Types
Core Events
package events
// Event type constants (uint32 for kelindar/event compatibility)
const (
TypeCommandComplete uint32 = iota + 1
TypeStageComplete
)
// CommandCompleteEvent is emitted when a CLI command finishes execution.
type CommandCompleteEvent struct {
Name string // Command name (e.g., "specify", "plan")
Success bool // Whether command succeeded
Duration time.Duration // Execution time
Error error // Error if failed, nil otherwise
}
func (e CommandCompleteEvent) Type() uint32 { return TypeCommandComplete }
// StageCompleteEvent is emitted when a workflow stage finishes.
type StageCompleteEvent struct {
Name string // Stage name (e.g., "specify", "plan")
Success bool // Whether stage succeeded
Duration time.Duration // Execution time
}
func (e StageCompleteEvent) Type() uint32 { return TypeStageComplete }
Adding New Event Types
- Add a new constant to the
constblock - Define a struct with relevant fields
- Implement
Type() uint32returning your constant - Document when the event is emitted
const (
// ... existing ...
TypeValidationFailed uint32 = iota + 1
)
// ValidationFailedEvent is emitted when artifact validation fails.
type ValidationFailedEvent struct {
Artifact string // Which artifact failed (e.g., "spec.yaml")
Errors []string // Validation error messages
}
func (e ValidationFailedEvent) Type() uint32 { return TypeValidationFailed }
Usage Patterns
Global Dispatcher (Default)
For most use cases, use the global dispatcher via package-level functions:
package events
import "github.com/kelindar/event"
// Global dispatcher instance
var bus = event.NewDispatcher()
// Subscribe registers a handler for a specific event type.
// Returns an unsubscribe function that MUST be deferred.
func Subscribe[T event.Event](handler func(T)) func() {
return event.Subscribe(bus, handler)
}
// Publish emits an event to all registered subscribers.
func Publish[T event.Event](e T) {
event.Publish(bus, e)
}
Subscribing to Events
Subscribers register handlers that receive events asynchronously:
package notify
import "autospec/internal/events"
// Subscribe registers the notification handler with the event bus.
// Must be called during application initialization.
func (h *Handler) Subscribe() func() {
return events.Subscribe(func(e events.CommandCompleteEvent) {
h.onCommandComplete(e)
})
}
func (h *Handler) onCommandComplete(e events.CommandCompleteEvent) {
if e.Success {
h.playSound("success")
} else {
h.playSound("failure")
}
h.showNotification(e.Name, e.Success, e.Duration)
}
Publishing Events
The lifecycle manager publishes events automatically:
package lifecycle
import (
"time"
"autospec/internal/events"
)
// Run wraps command execution with event emission.
func Run(name string, fn func() error) error {
start := time.Now()
err := fn()
duration := time.Since(start)
events.Publish(events.CommandCompleteEvent{
Name: name,
Success: err == nil,
Duration: duration,
Error: err,
})
return err
}
CLI Command Integration
Commands use the lifecycle wrapper:
func newSpecifyCmd() *cobra.Command {
return &cobra.Command{
Use: "specify",
Short: "Generate feature specification",
RunE: func(cmd *cobra.Command, args []string) error {
return lifecycle.Run("specify", func() error {
// Command implementation
return orch.ExecuteSpecify(ctx)
})
},
}
}
Application Initialization
Set up subscriptions at startup:
func main() {
// Create notification handler
notifHandler := notify.NewHandler(cfg.Notifications)
// Subscribe to events (returns unsubscribe func)
unsubscribe := notifHandler.Subscribe()
defer unsubscribe()
// Run CLI
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
Testing
Testing Event Emission
Verify events are published correctly:
func TestLifecycleRunEmitsEvent(t *testing.T) {
t.Parallel()
var received events.CommandCompleteEvent
var wg sync.WaitGroup
wg.Add(1)
unsubscribe := events.Subscribe(func(e events.CommandCompleteEvent) {
received = e
wg.Done()
})
defer unsubscribe()
err := lifecycle.Run("test-cmd", func() error {
return nil
})
wg.Wait()
assert.NoError(t, err)
assert.Equal(t, "test-cmd", received.Name)
assert.True(t, received.Success)
assert.Greater(t, received.Duration, time.Duration(0))
}
Testing Subscribers
Test handlers in isolation:
func TestNotificationHandlerOnCommandComplete(t *testing.T) {
tests := map[string]struct {
event events.CommandCompleteEvent
wantSound string
}{
"success plays success sound": {
event: events.CommandCompleteEvent{Success: true},
wantSound: "success",
},
"failure plays failure sound": {
event: events.CommandCompleteEvent{Success: false},
wantSound: "failure",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
mock := &mockSoundPlayer{}
h := &Handler{player: mock}
h.onCommandComplete(tt.event)
assert.Equal(t, tt.wantSound, mock.lastPlayed)
})
}
}
Race Condition Testing
Always run event tests with the race detector:
go test -race ./internal/events/...
go test -race ./internal/lifecycle/...
Best Practices
Do
- Defer unsubscribe calls - Prevents goroutine leaks
- Keep handlers fast - Each subscriber runs in its own goroutine, but slow handlers can accumulate
- Use typed events - Leverage generics for compile-time safety
- Test with -race - Event systems are prone to race conditions
- Document event emission - Comment when/why each event is published
Don’t
- Don’t block in handlers - Use separate goroutines for slow operations
- Don’t panic in handlers - Recover and log errors instead
- Don’t rely on event order - Subscribers run concurrently
- Don’t store mutable state in events - Events should be immutable snapshots
- Don’t create circular dependencies - Events flow one direction
Error Handling in Subscribers
func (h *Handler) onCommandComplete(e events.CommandCompleteEvent) {
defer func() {
if r := recover(); r != nil {
h.logger.Error("panic in event handler", "panic", r)
}
}()
if err := h.sendNotification(e); err != nil {
h.logger.Warn("notification failed", "error", err)
// Don't propagate - subscriber failures shouldn't affect other subscribers
}
}
Future Extensions
The event system enables future capabilities without modifying commands:
- Metrics collection - Subscribe to track command durations
- Audit logging - Subscribe to log all command executions
- Progress reporting - Add progress events for long-running operations
- Plugin system - External subscribers via IPC/RPC