Understanding BSB
BSB (Better Service Base) is an event-driven microservices framework with a pluggable architecture. Learn the core concepts before you start building.
What is BSB?
BSB is a framework for building microservices that communicate through events. Instead of direct HTTP calls between services, every service publishes and subscribes to events through a central event bus.
The key insight: your business logic should not care about infrastructure. Whether your events go through an in-memory bus, RabbitMQ, or Kafka - your service code stays the same.
Core Principles
Event-Driven
Services communicate exclusively through typed events. No direct coupling between services - just event contracts.
Plugin Architecture
Everything is a plugin: configuration, logging, events, metrics, and your services. Swap implementations without changing code.
Container-First
Designed for Docker and Kubernetes. Mount plugin directories at runtime. Configure through environment variables.
Type-Safe
Define event schemas with BSB types and config schemas with AnyVali. Get compile-time type checking and runtime validation across all services.
How It Works
When BSB starts, it discovers and loads plugins, then orchestrates the service lifecycle:
Plugin Types
BSB has four types of plugins. Service plugins are where you write your business logic. The other three are core infrastructure plugins that power the framework:
- service-* - Your code goes here. Business logic, API handlers, workers - this is what you build.
- config-* - Loads configuration (YAML, Consul, etcd, etc.)
- events-* - Routes events between services (in-memory, RabbitMQ, Kafka, etc.)
- observable-* - Unified logging, metrics, and tracing (console, Graylog, OpenTelemetry, Pino, etc.)
Event Communication
Services communicate through three event patterns:
| Pattern | Description | Use Case |
|---|---|---|
emitEvent |
Fire-and-forget. First listener receives. | Background jobs, notifications |
emitEventAndReturn |
Request-response. Wait for a result. | Validation, data lookup, RPC-style calls |
emitBroadcast |
All listeners receive the event. | Cache invalidation, real-time updates |
Service Lifecycle
Every service plugin follows a predictable lifecycle:
- Constructor - Create class fields, generated service clients, pure helpers, and cheap in-memory objects. Do not open connections, register listeners, or start servers here.
- init() - Set up resources and register event handlers. Use this for database clients, event listeners, and dependencies that need the BSB context.
- run() - Start long-lived processing after every service has initialized: HTTP listeners, queue consumers, schedulers, and background loops.
- dispose() - Close what
init()orrun()opened.
Do not put everything in run(). If a service needs a generated client, create it in the constructor. If it needs to register event handlers or connect resources, do that in init(). Keep run() for actually starting the service.
Services can declare dependencies on other services using initBeforePlugins, initAfterPlugins, runBeforePlugins, and runAfterPlugins.
Type-Safe Events with Schemas
BSB uses schemas to define event contracts. This gives you autocomplete, type checking, and runtime validation:
// In init() - register listener. Trace comes FROM the caller.
async init(trace: Trace) {
await this.events.onReturnableEvent('user.validate',
async (callerTrace, data) => {
// callerTrace lets you see who called this event
return { valid: data.email.includes('@') };
}
);
}
// In run() - emit event. Pass YOUR trace so callers can track you.
async run(trace: Trace) {
const result = await this.events.emitEventAndReturn(
'user.validate',
trace, // pass startup trace for observability
{ email: 'user@example.com' },
5 // timeout seconds
);
}
// In Init() - register listener. Trace comes FROM the caller.
func (p *Plugin) Init(ctx context.Context) error {
p.events.OnReturnableEvent("user.validate",
func(ctx context.Context, data ValidateUserInput) (ValidateUserOutput, error) {
// ctx contains the caller's trace
return ValidateUserOutput{Valid: true}, nil
},
)
return nil
}
// In Run() - emit event. Pass YOUR trace so callers can track you.
func (p *Plugin) Run(ctx context.Context) error {
result, err := events.EmitAndReturn[ValidateUserOutput](
ctx, // pass startup context for observability
"user.validate",
ValidateUserInput{Email: "user@example.com"},
5*time.Second,
)
return err
}
# In init() - register listener. Trace comes FROM the caller.
async def init(self, trace: Trace):
@self.events.on_returnable_event("user.validate")
async def validate(caller_trace: Trace, data: ValidateUserInput):
# caller_trace lets you see who called this event
return ValidateUserOutput(valid="@" in data.email)
# In run() - emit event. Pass YOUR trace so callers can track you.
async def run(self, trace: Trace):
result = await self.events.emit_and_return(
"user.validate",
trace, # pass startup trace for observability
ValidateUserInput(email="user@example.com"),
timeout=5
)