Events
Services in Gas do not import each other. When one needs to tell others that
something happened, it emits an event on the gas.EventBus, and whoever cares
subscribes. The emitter does not know who is listening, so either side can be
replaced or removed without touching the other.
Every App and Worker has one bus. It is registered in the container as
*gas.EventBus, so a service takes it as a constructor parameter, and
app.EventBus() returns it too.
Declaring an event
Section titled “Declaring an event”An event is a type, not a value. Embedding gas.Event[Payload] binds the event
type to the payload its handlers receive:
// UserCreated is an event type. Embedding gas.Event binds it to its payload.type UserCreated struct { gas.Event[UserCreatedPayload]}
// UserCreatedPayload is what every UserCreated handler receives.type UserCreatedPayload struct { Email string}Subscriptions are keyed by the event type, so two events never collide, even when their payload types are identical. The payload type is inferred from the event type, so you name only the event, and a handler or an emit with the wrong payload type does not compile.
Subscribing
Section titled “Subscribing”// Billing provisions an account for every new user. It never imports the// users service; it only knows the event.type Billing struct { bus *gas.EventBus accounts []string mu sync.Mutex}
func NewBilling(bus *gas.EventBus) *Billing { return &Billing{bus: bus} }
func (s *Billing) Name() string { return "billing" }
func (s *Billing) Init(context.Context) error { // Owned by s, so closing billing removes the subscription. s.bus.SubscribeWithOwner[UserCreated](s, func(p UserCreatedPayload) { s.mu.Lock() defer s.mu.Unlock() s.accounts = append(s.accounts, p.Email) }) return nil}
func (s *Billing) Close(context.Context) error { return nil }SubscribeWithOwner[E](s, fn) records s as the subscription’s owner. When
s is closed with CloseService, every subscription it
owns is removed. A service should always subscribe this way, usually from its
Init, so that a restart
subscribes it again.
Subscribe[E](fn) registers a handler with no owner. Nothing removes it, so it
lives as long as the bus. It suits code outside any service, such as main or
a test.
bus.RemoveByService(s) is what the kill switch calls: it removes every
subscription s made with SubscribeWithOwner, across all event types, and
leaves ownerless ones in place.
Emitting
Section titled “Emitting”// Users emits UserCreated after it creates a user.type Users struct{ bus *gas.EventBus }
func NewUsers(bus *gas.EventBus) *Users { return &Users{bus: bus} }
func (s *Users) Name() string { return "users" }
func (s *Users) Init(context.Context) error { return nil }
func (s *Users) Close(context.Context) error { return nil }
func (s *Users) Create(email string) { // ... store the user ...
// Emit runs every handler on its own goroutine. Wait blocks until they // have all returned; drop it to move on without waiting. s.bus.Emit[UserCreated](UserCreatedPayload{Email: email}).Wait()}Emit[E](payload) runs every handler subscribed to E on its own goroutine
and returns a *sync.WaitGroup that completes when all of them have returned.
Call Wait() on it when the handlers must finish before you continue; drop it
to carry on immediately. The WaitGroup is never nil, and emitting an event
nobody subscribes to does nothing.
- Handlers run concurrently, and no ordering between them is guaranteed. A
handler that touches shared state must synchronize it, as
Billingdoes with its mutex. Emittakes a snapshot of the subscribers and runs the handlers without holding the bus lock, so a handler can subscribe or emit in turn. A subscription added during an emit does not receive that emit.- The bus is safe for concurrent use.
Panics in handlers
Section titled “Panics in handlers”A handler that panics is recovered. The panic does not take the process down,
does not keep the WaitGroup from completing, and does not stop the other
handlers. The stack is written to stderr, and on the bus of an App or Worker
the panic is also logged as event subscriber panic, with the event type and
the owning service, through the Worker’s logger: the registered gas.Logger,
or slog.Default() when none is registered.
A bus made with gas.NewEventBus() outside an App or Worker writes the stack
to stderr only.
System events
Section titled “System events”Gas emits these during the lifecycle. Subscribe to them like any other event;
the payload types are named after the event with a Payload suffix.
| Event | Payload fields | Emitted |
|---|---|---|
gas.SystemAllServicesInitialized |
none | During Start, once every singleton is built and initialized (and, in an App, the router is sealed and its handlers checked), before migrations run |
gas.SystemServiceClosed |
ServiceName |
By CloseService, after the service is closed |
gas.SystemServiceInitialized |
ServiceName |
By RestartService, after Init succeeds; not emitted at startup |
gas.SystemServerShuttingDown |
none | By App.Stop, before the HTTP server shuts down; App only |
gas.SystemShuttingDown |
none | When shutdown begins, before any service is closed; for both App and Worker |
gas.AppConfigUpdated |
Config, a copy of the bound gas.Config |
During App.Start, after the config is bound; App only |
Gas waits for every subscriber of a system event to return before it moves on, so a slow handler delays the step that emitted it. The order these occur in during startup and shutdown is on App lifecycle.
For shutdown work that must run in every process, subscribe to
SystemShuttingDown; SystemServerShuttingDown fires only in an App. For
releasing a service’s own resources, use its Close instead, which runs in
reverse dependency order.