Event-driven architectuur in Go helpt je systemen bouwen die schaalbaar, losgekoppeld en robuust zijn. In plaats van dat services elkaar direct aanroepen, publiceren ze events waar andere services op reageren. Dat maakt je architectuur flexibeler en bestendiger tegen uitval.
In deze gids leer je hoe je event-driven patterns toepast in Go. Van het ontwerp van events en de keuze van een message broker tot praktische code met NATS en Kafka, inclusief patterns als idempotency, retries en de transactional outbox.
Wat is event-driven architectuur?
Een event is een feit dat al gebeurd is: OrderPlaced, UserRegistered, PaymentFailed. De producer publiceert dit event naar een message broker. Consumers die geïnteresseerd zijn, abonneren zich en handelen af.
Het verschil met request/response is fundamenteel. Bij synchrone communicatie moet de aanroeper weten wie de ontvanger is en wachten op een antwoord. Bij events weet de producer niet wie er luistert en wacht nergens op.
Dat levert drie voordelen op: losse koppeling tussen services, natuurlijke schaalbaarheid omdat consumers onafhankelijk verwerken, en betere resilience omdat een trage of uitgevallen consumer de producer niet blokkeert.
Wanneer kies je voor events?
Events passen niet overal. Gebruik ze wanneer je werk asynchroon kunt afhandelen, wanneer meerdere systemen op hetzelfde feit moeten reageren (fan-out), of wanneer je audit trails en event sourcing wilt.
Blijf bij synchrone calls als je een direct antwoord nodig hebt, als de flow transactioneel en sterk consistent moet zijn, of als de complexiteit van een broker niet opweegt tegen de baten. Voor meer context over service design zie microservices in Go.
Events goed ontwerpen
Een event beschrijft iets wat is gebeurd, in verleden tijd. De naamgeving volgt die conventie: InvoiceSent, niet SendInvoice. Dat laatste is een command.
Houd events klein maar betekenisvol. Neem de essentiële velden op plus een ID, timestamp en versienummer. Dat laatste is cruciaal, omdat events over jaren blijven leven en je schema zal evolueren.
type OrderPlaced struct {
EventID string `json:"event_id"`
Version int `json:"version"`
OccurredAt time.Time `json:"occurred_at"`
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
Total int64 `json:"total_cents"`
Currency string `json:"currency"`
}
Serialiseer met JSON voor eenvoud of met Protobuf voor schema-evolutie en performance. Zie ook JSON handling in Go voor de details van encoding.
Een broker kiezen
Drie populaire keuzes in de Go-wereld:
- NATS: razendsnel, lichtgewicht en eenvoudig te draaien. JetStream voegt persistence en at-least-once delivery toe. Ideaal voor interne microservices.
- Kafka: de standaard voor high-throughput event streaming en event sourcing. Events blijven bewaard, dus je kunt ze opnieuw afspelen.
- RabbitMQ: een klassieke AMQP broker, sterk voor work queues met complexe routing.
Voor de voorbeelden hieronder pakken we NATS, omdat het lekker minimaal is en je snel op weg helpt.
Publisher in Go met NATS
Installeer de client: go get github.com/nats-io/nats.go. Een simpele publisher ziet er zo uit.
package events
import (
"context"
"encoding/json"
"github.com/nats-io/nats.go"
)
type Publisher struct {
nc *nats.Conn
}
func NewPublisher(url string) (*Publisher, error) {
nc, err := nats.Connect(url)
if err != nil {
return nil, err
}
return &Publisher{nc: nc}, nil
}
func (p *Publisher) Publish(ctx context.Context, subject string, event any) error {
data, err := json.Marshal(event)
if err != nil {
return err
}
return p.nc.Publish(subject, data)
}
func (p *Publisher) Close() { p.nc.Drain() }
Merk op dat we een interface-achtige signature hanteren. In echte code wil je Publisher abstraheren achter een interface, zodat je hem in tests kunt vervangen. Lees hier meer over in testing in Go.
Consumer met graceful shutdown
De consumer abonneert zich op een subject en verwerkt binnenkomende events. Belangrijk: gebruik het context package om netjes af te sluiten bij een SIGTERM.
func (c *Consumer) Run(ctx context.Context) error {
sub, err := c.nc.Subscribe("orders.placed", func(m *nats.Msg) {
var evt OrderPlaced
if err := json.Unmarshal(m.Data, &evt); err != nil {
c.log.Error("decode", "err", err)
return
}
if err := c.handle(ctx, evt); err != nil {
c.log.Error("handle", "event_id", evt.EventID, "err", err)
return
}
})
if err != nil {
return err
}
<-ctx.Done()
return sub.Drain()
}
Voor de finesse van context cancellation zie context package in Go. Voor een complete shutdown-flow verwijs ik naar HTTP servers bouwen in Go.
Idempotency: het belangrijkste detail
Bijna alle brokers leveren berichten at-least-once. Dat betekent dat een consumer hetzelfde event een keer extra kan zien. Als je handler niet idempotent is, krijg je dubbele facturen of mails.
De oplossing is een gededuppeerde consumer. Bewaar verwerkte event-IDs en sla dubbele over.
func (c *Consumer) handle(ctx context.Context, evt OrderPlaced) error {
seen, err := c.store.MarkProcessed(ctx, evt.EventID)
if err != nil {
return err
}
if seen {
return nil
}
return c.doWork(ctx, evt)
}
De MarkProcessed schrijft in dezelfde transactie als je business update, zodat het atomair is. Voor de basis van transacties zie database toegang in Go.
De transactional outbox
Een klassiek probleem: je update de database en publiceert een event. Crasht je service tussen die twee stappen, dan klopt je state niet meer met je events.
Het transactional outbox pattern lost dit op. Schrijf in dezelfde database-transactie een rij naar een outbox tabel. Een aparte worker leest die tabel en publiceert de events naar de broker.
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
"INSERT INTO orders (id, total) VALUES ($1, $2)",
id, total); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
"INSERT INTO outbox (id, subject, payload) VALUES ($1, $2, $3)",
evtID, "orders.placed", payload); err != nil {
return err
}
return tx.Commit()
Zo heb je gegarandeerd dat je event gepubliceerd wordt, precies één keer wanneer de order daadwerkelijk is vastgelegd. Meer over dit pattern vind je in de Microservices.io patterns library.
Retries en dead letter queues
Niet elke verwerking slaagt. Netwerkglitches, tijdelijke DB-druk, rate limits op externe APIs. Bouw daarom retries met exponentiële backoff in.
Lukt het na een aantal pogingen nog niet, parkeer het event dan in een dead letter queue (DLQ). Een mens of een ander proces kan het later onderzoeken, zonder dat de hoofdstroom blijft vastlopen.
for attempt := 0; attempt < 5; attempt++ {
err := c.doWork(ctx, evt)
if err == nil {
return nil
}
time.Sleep(time.Duration(1<<attempt) * time.Second)
}
return c.dlq.Publish(ctx, "orders.placed.dlq", evt)
Throughput met worker pools
Een enkele consumer goroutine is vaak genoeg. Heb je zware handlers, verdeel dan het werk over een worker pool zodat je CPU of I/O parallelisme benut. Combineer dit met bounded channels voor backpressure, zoals beschreven in worker pools en pipelines.
Let op ordering. Sommige use cases eisen dat events per aggregaat (bijvoorbeeld per OrderID) in volgorde verwerkt worden. Kafka partitioneert op sleutel, bij NATS JetStream gebruik je consumer groups met een partitie-functie.
Observability inbouwen
Event-driven systemen kunnen lastig te debuggen zijn. Je moet kunnen zien welk event waar binnenkwam, hoelang het bleef hangen en waar het faalde.
Minimaal wil je:
- Structured logs met
event_id,subject,attemptenduration_ms. - Metrics: aantal gepubliceerde events, consumer lag, retry count, DLQ grootte.
- Tracing met OpenTelemetry, zodat je een trace van producer tot consumer kunt volgen.
Zie logging en observability in Go voor een complete setup met slog en OpenTelemetry.
Testen van event-driven code
Abstraheer je publisher en consumer achter interfaces. In unit tests gebruik je een in-memory implementatie die events in een slice stopt, zodat je kunt asserten op wat er gepubliceerd is.
Voor integratietests start je een echte broker. Met testcontainers-go spin je in enkele regels een NATS of Kafka container op, draai je je tests en ruimt de container zichzelf weer op.
Veelgemaakte fouten
- Geen versionering in events. Over een jaar wil je een veld toevoegen en loop je vast.
- Commands verkleed als events.
SendEmailin de broker is een command, geen event. Noem hetOrderPlaceden laat een email-service reageren. - Synchrone gedachten in asynchrone code. Verwacht geen antwoord op een event. Heb je dat nodig, publiceer dan een vervolgevent.
- Geen idempotency. Eén keer duplicaat en je klant krijgt twee facturen.
- Alle services op één topic. Splits per domein en geef elk hun eigen consumer group.
Veelgestelde vragen
Wat is event-driven architectuur in Go?
Event-driven architectuur is een pattern waarbij services communiceren via events in plaats van directe calls. In Go combineer je dit vaak met goroutines, channels en een message broker zoals NATS of Kafka voor schaalbare, losgekoppelde systemen.
Wanneer kies je voor event-driven boven request/response?
Kies event-driven wanneer je asynchrone verwerking, losse koppeling of fan-out nodig hebt. Denk aan audit logging, notificaties of data pipelines. Voor directe antwoorden met sterke consistentie blijft synchrone communicatie vaak beter.
Welke message broker past het best bij Go?
NATS is lichtgewicht en ideaal voor interne services. Kafka past bij event sourcing en hoge doorvoer met replay. RabbitMQ werkt goed voor klassieke work queues. Alle drie hebben volwassen Go clients.
Hoe voorkom je dubbele verwerking van events?
Gebruik idempotente consumers door elk event een unieke ID te geven en verwerkte IDs bij te houden. Combineer dit met at-least-once delivery en transactionele outbox patterns om data consistent te houden.
Hoe test je event-driven code in Go?
Gebruik interfaces voor je publisher en subscriber zodat je ze in tests kunt mocken. Voor integratie tests start je een echte broker via testcontainers of een embedded NATS server.