Stage 12 - Events with Kafka
Publish an event when an order is placed, and consume it - proving a queue handler is just another input adapter.
Prereqs: stage 11 complete · Time: ~50 min
By the end: place one order and watch two independent handlers react - an order-side confirmation and a notification email - from a single published event.
Why events
Goal: understand what a published event buys, and why a consumer is an input adapter.
Learn
When an order is placed, other parts of the system want to react: send a confirmation, start fulfilment, update a dashboard. You could call each of them directly from PlaceOrder - but then the order domain would import every domain that cares, and grow a new import every time another one does. That is the stage-08 coupling problem, back at a larger scale.
The decoupled answer is a published event. After the order is saved, the order domain announces a fact - an order was placed - to a broker, and returns. It does not know or care who listens. Consumers subscribe independently and react on their own time. Adding a new reaction adds a new consumer; the order domain never changes.
Two properties come with this:
- Decoupling. The publisher depends on nothing downstream. Reactions are added and removed without touching the code that emits the event.
- Asynchrony. The reaction runs after the response is sent, in a separate process, so a slow email never slows down checkout.
Now the structural claim that makes this fit everything you have already built. A Kafka consumer is a function:
func(msg []byte) errorIt receives raw transport bytes, deserializes them into a request, and calls an application service. That is exactly what an HTTP handler does - read the request body, decode JSON, call the service. The transport differs (a topic instead of a socket); the shape is identical. So a message consumer is a first-class input adapter, and it lives where every input adapter lives: presentation/. Not a special category - the same hexagon, a second kind of door.
The publisher side is the mirror image: an output port, an interface the application depends on, with a Kafka adapter behind it - the same pattern as the repository port and its Postgres adapter.
In production: an application can run many of these consumers across domains, with each consumer sitting beside its domain's HTTP handlers in presentation/message/event/. They remain ordinary input adapters; that is the point.
Exercise
If a consumer is just an input adapter, what part of the system does it call, and what must it not know?
Answer
It deserializes the message and calls an application service - the same entry point an HTTP handler calls. It must not know it is Kafka: no broker types, no topic names in its signature, no offset handling. Swap Kafka for SQS and only the wiring changes, not the handler. Transport stays at the edge.
Redpanda
Goal: run a Kafka-compatible broker locally and point config at it.
Learn
You need a broker to publish to and consume from. Redpanda speaks the Kafka protocol in a single container with no ZooKeeper and no JVM, which makes it ideal locally - the app talks to it with the same Kafka client libraries it would use against a real Kafka cluster. Add it beside Postgres in compose, and add one config knob for the broker address.
Build
1. Add the redpanda service to docker-compose.yml, under services: beside db:
redpanda:
image: redpandadata/redpanda:v24.2.18
container_name: minimart-redpanda
command:
- redpanda
- start
- --mode=dev-container
- --kafka-addr=PLAINTEXT://0.0.0.0:9092
- --advertise-kafka-addr=PLAINTEXT://localhost:9092
ports:
- "9092:9092"--mode=dev-container runs a single-node broker tuned for local development - no replication, no auth. --advertise-kafka-addr=PLAINTEXT://localhost:9092 is the address the broker hands back to clients, so a client on your host connects to localhost:9092.
2. Add the broker address to conf/environment.go (it belongs to the same config file you started in the Postgres in Docker section). Add strings to the import block:
// KafkaBrokers is the comma-separated list of Kafka bootstrap brokers.
func KafkaBrokers() []string {
return strings.Split(env("KAFKA_BROKERS", "localhost:9092"), ",")
}And the matching line in sample.env:
# Kafka bootstrap brokers (comma-separated). Redpanda speaks the Kafka protocol.
KAFKA_BROKERS="localhost:9092"3. Bring the broker up:
docker compose up -dIn production: the broker is a managed multi-node cluster, and KAFKA_BROKERS is a comma-separated list of bootstrap hosts. Production also adds SASL/SCRAM auth and TLS - omitted locally because Redpanda's dev mode runs unauthenticated. The app code does not change; only the client config grows.
Verify
docker compose psBoth minimart-db and minimart-redpanda show as running (Up). If your image includes the admin tools, you can also list topics - empty for now:
docker compose exec redpanda rpk topic list
# (no topics yet)Topics are created on first publish in the later sections, so an empty list here is expected.
Exercise
Why can the course use Redpanda while still teaching a Kafka-style architecture?
Answer
The application talks to the Kafka protocol, not to a Redpanda-specific API. Redpanda is the local broker choice; the architectural boundary is the producer and consumer ports around message delivery.
The producer
Goal: define the topics, the event payload, and the Publisher output port with a Kafka adapter behind it.
Learn
The application must emit events without depending on Kafka. So the publisher is an output port - a small interface the application imports - with a concrete Kafka implementation behind it. Exactly the repository pattern, one layer over.
Three pieces:
- Topics and consumer groups - named constants, so nothing spells a topic wrong.
- The event payload - a plain struct with JSON tags, a published contract like an HTTP response body: small, stable, changed only in backward-compatible ways.
- The
Publisherinterface +Producer- the port the app depends on, and the sarama/watermill adapter that satisfies it.
Build
1. Create pkg/kafka/topics.go:
// Package kafka is MiniMart's thin wrapper over sarama + watermill. Domains depend
// only on the small Publisher interface and the func(msg []byte) error consumer
// signature - never on sarama or watermill directly.
package kafka
// Topic is a Kafka topic name.
type Topic string
func (t Topic) String() string { return string(t) }
// Topics used by MiniMart.
const (
OrderPlacedTopic Topic = "order-placed"
)
// Consumer groups. Two different groups subscribed to the same topic EACH receive
// every message - that is how one published event fans out to several independent
// handlers. Here one OrderPlaced event drives both an order-side confirmation and
// a notification email.
const (
OrderConfirmationConsumer = "order-confirmation"
NotificationConsumer = "order-notification"
)Two consumer-group names on one topic is the setup for fan-out - you wire both in the worker section.
2. Create the event payload pkg/message/events.go:
// Package message holds the event payloads published across domains. An event is
// a published contract, like an HTTP response body: keep it small and stable, and
// change it only in backward-compatible ways.
package message
// OrderPlaced is emitted after an order is successfully saved. Consumers in other
// domains react to it without the order domain knowing they exist.
type OrderPlaced struct {
OrderID string `json:"order_id"`
UserID string `json:"user_id"`
TotalCents int64 `json:"total_cents"`
ItemCount int `json:"item_count"`
}3. Create pkg/kafka/producer.go - the port and its implementation:
package kafka
import (
"context"
"encoding/json"
"fmt"
"github.com/IBM/sarama"
"github.com/ThreeDotsLabs/watermill"
wkafka "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka"
"github.com/ThreeDotsLabs/watermill/message"
)
// Publisher is the output port the application depends on to emit events. The
// application imports this interface, not sarama - so a test can pass a fake.
type Publisher interface {
PublishMessage(ctx context.Context, topic Topic, key string, payload interface{}) error
}
// Producer is the Kafka implementation of Publisher.
type Producer struct {
pub message.Publisher
}
func NewProducer(brokers []string) (*Producer, error) {
cfg := wkafka.DefaultSaramaSyncPublisherConfig()
// V2_1_0_0 keeps the client on non-flexible protocol encodings, which every
// Kafka-compatible broker (including Redpanda in dev mode) accepts.
cfg.Version = sarama.V2_1_0_0
pub, err := wkafka.NewPublisher(
wkafka.PublisherConfig{
Brokers: brokers,
Marshaler: wkafka.NewWithPartitioningMarshaler(partitionByKey),
OverwriteSaramaConfig: cfg,
},
watermill.NewStdLogger(false, false),
)
if err != nil {
return nil, fmt.Errorf("new kafka publisher: %w", err)
}
return &Producer{pub: pub}, nil
}
// partitionByKey sends all messages sharing a key to the same partition, so events
// for one order stay ordered.
func partitionByKey(topic string, msg *message.Message) (string, error) {
return msg.Metadata.Get("key"), nil
}
func (p *Producer) PublishMessage(ctx context.Context, topic Topic, key string, payload interface{}) error {
var body []byte
switch v := payload.(type) {
case []byte:
body = v
default:
var err error
body, err = json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
}
msg := message.NewMessage(watermill.NewUUID(), body)
msg.SetContext(ctx)
msg.Metadata.Set("key", key)
if err := p.pub.Publish(topic.String(), msg); err != nil {
return fmt.Errorf("publish to %s: %w", topic, err)
}
return nil
}
func (p *Producer) Close() error { return p.pub.Close() }The key (the order ID) routes all of one order's messages to the same partition, so they stay ordered. PublishMessage accepts structs for normal JSON events and raw []byte for dead-letter forwarding, where the original payload must not be JSON-encoded a second time. The important line is the interface: the application will depend on Publisher, so a test hands it a fake and never touches a broker (stage 14).
Verify
go mod tidy # resolves sarama + watermill; updates go.sum
go build ./pkg/...The pkg/kafka and pkg/message packages compile. Nothing calls PublishMessage yet
- the order service starts publishing in the Publish on place section.
Exercise
Why is the publisher an output port instead of a direct Kafka call from the order service?
Answer
The use case needs to announce that an order was placed; Kafka is only one delivery mechanism. A publisher port lets the application express that intent while tests use a fake and infrastructure owns the transport details.
Publish on place
Goal: emit an OrderPlaced event after an order is successfully saved.
Learn
The order service gains a third dependency: the kafka.Publisher port. Like the repository and the contracts registry, it is an interface, injected - so a test passes a fake and the service never touches a real broker.
Publishing happens after the order is committed, and it is best-effort: the order already exists, so a broker hiccup must not fail a request. The service logs the failure and returns success. This is a deliberate trade-off - you will see its stronger alternative below.
Build
1. Add the publisher field to order/application/service.go; NewService gains a third parameter:
package application
import (
"minimart/order/domain"
"minimart/pkg/kafka"
"minimart/pkg/module/contracts"
)
// Service is the order application layer. It depends on the order repository port,
// on other domains through the contracts.Modules INTERFACE, and on the Kafka
// Publisher INTERFACE - never on concrete infrastructure. Depending on interfaces
// is what lets tests pass fakes for all three.
type Service struct {
repo domain.Repository
modules contracts.Modules
publisher kafka.Publisher
}
func NewService(repo domain.Repository, modules contracts.Modules, publisher kafka.Publisher) *Service {
return &Service{repo: repo, modules: modules, publisher: publisher}
}2. Add the publish call at the end of order/application/place_order.go, after the save:
package application
import (
"context"
"fmt"
"log"
"minimart/order/domain"
"minimart/pkg/kafka"
"minimart/pkg/message"
)
// PlaceOrderRequest is the input to PlaceOrder. It carries NO prices or names:
// those are authoritative from the catalog, fetched below. The client only says
// which product and how many.
type PlaceOrderRequest struct {
UserID string
Items []PlaceOrderItem
}
type PlaceOrderItem struct {
ProductID string
Quantity int
}
// PlaceOrder resolves each product's real price from the catalog (through the
// published contract), builds and saves the order, then publishes an OrderPlaced
// event for other domains to react to.
func (s *Service) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (domain.Order, error) {
if len(req.Items) == 0 {
return domain.Order{}, domain.ErrNoItems
}
items := make([]domain.Item, 0, len(req.Items))
for _, it := range req.Items {
if it.Quantity <= 0 {
return domain.Order{}, domain.ErrInvalidQuantity
}
p, err := s.modules.GetProduct(ctx, it.ProductID)
if err != nil {
return domain.Order{}, fmt.Errorf("resolve product %s: %w", it.ProductID, err)
}
items = append(items, domain.Item{
ProductID: p.ID,
Name: p.Name,
UnitPriceCents: p.PriceCents,
Quantity: it.Quantity,
})
}
order, err := domain.NewOrder(newID(), req.UserID, items)
if err != nil {
return domain.Order{}, err
}
if err := s.repo.Save(ctx, order); err != nil {
return domain.Order{}, err
}
// The order is committed. Publishing is best-effort: a broker hiccup must not
// fail a request whose order already exists, so we log and continue. (A system
// needing an at-least-once guarantee writes the event to a transactional outbox
// in the same DB transaction and relays it separately.)
if err := s.publisher.PublishMessage(ctx, kafka.OrderPlacedTopic, order.ID, message.OrderPlaced{
OrderID: order.ID,
UserID: order.UserID,
TotalCents: order.TotalCents,
ItemCount: len(order.Items),
}); err != nil {
log.Printf("order %s: publish %s failed: %v", order.ID, kafka.OrderPlacedTopic, err)
}
return order, nil
}The order ID is the partition key (second argument), keeping one order's events ordered. Everything before the save is unchanged from stage 10.
In production: best-effort publish can drop an event if the broker is down at exactly the wrong moment. When the reaction must not be lost, use the transactional outbox: write the event into an outbox table inside the same transaction that saves the order, then a relay process reads the table and publishes - so the event and the order commit atomically. The publish call moves; the handler on the other side does not care which delivery path fed it.
Verify
go build ./order/...The order application layer compiles against the new three-argument NewService. The whole binary is temporarily red - the order module still calls NewService with two arguments; the worker section threads the publisher through. You build the consumers first.
Exercise
Why publish the event after repo.Save succeeds, not before?
Answer
An OrderPlaced event is a promise that the system accepted and saved the order. If you publish first and the save fails, other handlers may react to an order that does not exist. Stage 14 keeps this best-effort shape honest, and the next-step section points to an outbox when delivery must be stronger.
The consumer
Goal: write the input adapters that react to OrderPlaced - one in the order domain, one in a new notification domain.
Learn
A consumer handler is func(msg []byte) error: deserialize the JSON, call an application service, return. It knows nothing about Kafka - the same way an HTTP handler knows nothing about sockets. It lives in presentation/message/event/, beside the domain's HTTP handlers.
You write two consumers of the same event, to make fan-out concrete:
- The order domain consumes its own event (a normal pattern - a follow-up step like starting fulfilment).
- A new notification domain, worker-only: no HTTP, no contract, it exists purely to react.
Build
1. order/presentation/message/event/order_placed.go - the order-side handler:
// Package event holds the order domain's own message input adapters. Consuming an
// event your own domain published is a normal pattern - here it stands in for a
// follow-up step like starting fulfilment.
package event
import (
"encoding/json"
"log"
"minimart/pkg/message"
)
type Handler struct{}
func NewHandler() *Handler { return &Handler{} }
// OrderConfirmation reacts to the order-placed topic. It logs a confirmation; a
// real system might reserve stock or start fulfilment.
func (h *Handler) OrderConfirmation(msg []byte) error {
var event message.OrderPlaced
if err := json.Unmarshal(msg, &event); err != nil {
return err
}
log.Printf("[order] confirming order %s for user %s (%d cents, %d items)",
event.OrderID, event.UserID, event.TotalCents, event.ItemCount)
return nil
}2. order/presentation/message/workers.go - declares which handler subscribes to which topic and group:
package message
import (
"context"
"minimart/order/presentation/message/event"
"minimart/pkg/kafka"
"minimart/pkg/worker"
)
// Workers registers the order domain's Kafka consumers onto the worker.
func Workers(ctx context.Context, w *worker.Worker) {
h := event.NewHandler()
w.AttachKafka(ctx, kafka.OrderPlacedTopic, kafka.OrderConfirmationConsumer, h.OrderConfirmation)
}3. Now the notification domain. Its application service, notification/application/service.go:
// Package application is the notification domain's use-case layer. In a real
// system it would send email or push notifications; here it logs, standing in for
// any side effect a downstream domain performs in reaction to an event.
package application
import (
"context"
"log"
)
type Service struct{}
func NewService() *Service { return &Service{} }
func (s *Service) SendOrderConfirmation(ctx context.Context, orderID, userID string, totalCents int64) error {
log.Printf("[notification] emailing user %s: order %s confirmed, total %d cents",
userID, orderID, totalCents)
return nil
}4. Its input adapter, notification/presentation/message/event/order_placed.go - note it is structurally identical to the order one, and to an HTTP controller:
// Package event holds the notification domain's message input adapters. A handler
// here is structurally identical to an HTTP controller: it deserializes a transport
// payload (JSON bytes off a Kafka topic) into a request and calls the application
// service. It knows nothing about Kafka.
package event
import (
"context"
"encoding/json"
"minimart/notification/application"
"minimart/pkg/message"
)
type Handler struct {
svc *application.Service
}
func NewHandler(svc *application.Service) *Handler { return &Handler{svc: svc} }
// OrderPlaced is the input adapter for the order-placed topic.
func (h *Handler) OrderPlaced(msg []byte) error {
var event message.OrderPlaced
if err := json.Unmarshal(msg, &event); err != nil {
return err
}
return h.svc.SendOrderConfirmation(context.Background(), event.OrderID, event.UserID, event.TotalCents)
}5. Its workers.go - same topic, a different consumer group:
package message
import (
"context"
"minimart/notification/application"
"minimart/notification/presentation/message/event"
"minimart/pkg/kafka"
"minimart/pkg/worker"
)
// Workers registers the notification domain's Kafka consumers onto the worker.
func Workers(ctx context.Context, w *worker.Worker, svc *application.Service) {
h := event.NewHandler(svc)
w.AttachKafka(ctx, kafka.OrderPlacedTopic, kafka.NotificationConsumer, h.OrderPlaced)
}6. The module, notification/module.go - a worker-only domain:
package notification
import (
"context"
"net/http"
"minimart/notification/application"
"minimart/notification/presentation/message"
"minimart/pkg/module"
"minimart/pkg/module/contracts"
"minimart/pkg/worker"
)
// Module is a worker-only domain: it registers no HTTP routes and publishes no
// contract. It exists to react to events, demonstrating that a consumer is a
// first-class input adapter.
type Module struct {
svc *application.Service
}
var _ module.Module = (*Module)(nil)
func NewModule() *Module { return &Module{} }
func (m *Module) Name() string { return "notification" }
func (m *Module) Init(ctx context.Context) error {
m.svc = application.NewService()
return nil
}
func (m *Module) RegisterContracts(ctx context.Context, c *contracts.Contracts) error { return nil }
func (m *Module) RegisterHTTP(ctx context.Context, mux *http.ServeMux) error { return nil }
func (m *Module) RegisterWorkers(ctx context.Context, w *worker.Worker) error {
message.Workers(ctx, w, m.svc)
return nil
}RegisterHTTP is a no-op, RegisterWorkers does the work - the mirror of catalog, which registers HTTP and skips workers. Same interface, different doors.
Verify
go build ./order/presentation/message/event ./notification/application ./notification/presentation/message/eventThe pure handlers compile. The workers.go files and notification/module.go reference pkg/worker, which is created in the worker section, so those files compile with the full tree after the worker runtime exists.
Exercise
Why should an event handler know the message payload but not the Kafka client?
Answer
The handler is an input adapter for a business event. It should decode the event and call a use case. Subscription mechanics, consumer groups, retries, and broker configuration are runtime concerns that belong in the worker wiring.
The worker
Goal: run the consumers as a separate process, and watch one event drive two handlers.
Learn
The handlers from the consumer section need a runtime. Two pieces provide it:
pkg/kafka/consumer.go- subscribes a consumer group to a topic and calls a handler per message, with a dead-letter fallback.pkg/worker/worker.go- the worker-process equivalent of anhttp.ServeMux: modules attach handlers to it during a new boot phase,RegisterWorkers.
That new phase is added to the Module interface, and a cmd/worker.go boots the same modules the server does but runs phase 4b (workers) instead of 4a (HTTP). Because OrderPlacedTopic has two consumer groups, one published event is delivered to both - fan-out.
Build
1. pkg/kafka/consumer.go:
package kafka
import (
"context"
"fmt"
"github.com/IBM/sarama"
"github.com/ThreeDotsLabs/watermill"
wkafka "github.com/ThreeDotsLabs/watermill-kafka/v3/pkg/kafka"
)
// Consumer subscribes to topics and dispatches raw message bodies to handlers.
type Consumer struct {
brokers []string
dlq Publisher // optional; failed messages are routed here
logger watermill.LoggerAdapter
}
func NewConsumer(brokers []string, dlq Publisher) *Consumer {
return &Consumer{
brokers: brokers,
dlq: dlq,
logger: watermill.NewStdLogger(false, false),
}
}
// Subscribe joins consumerGroup on topic and calls handler for each message body.
// It blocks until ctx is cancelled. The handler signature - func(msg []byte) error
// - is deliberately the same shape as reading an HTTP request body: a queue message
// is just another input.
//
// A handler error routes the message to a dead-letter topic (<topic>.dlq) if a DLQ
// publisher is configured, then acknowledges it so one poison message cannot block
// the partition forever.
func (c *Consumer) Subscribe(ctx context.Context, topic Topic, consumerGroup string, handler func(msg []byte) error) error {
cfg := wkafka.DefaultSaramaSubscriberConfig()
// Same version pin as the producer - see producer.go.
cfg.Version = sarama.V2_1_0_0
cfg.Consumer.Offsets.Initial = sarama.OffsetOldest
sub, err := wkafka.NewSubscriber(
wkafka.SubscriberConfig{
Brokers: c.brokers,
Unmarshaler: wkafka.DefaultMarshaler{},
OverwriteSaramaConfig: cfg,
ConsumerGroup: consumerGroup,
},
c.logger,
)
if err != nil {
return fmt.Errorf("new kafka subscriber: %w", err)
}
defer sub.Close()
messages, err := sub.Subscribe(ctx, topic.String())
if err != nil {
return fmt.Errorf("subscribe %s: %w", topic, err)
}
for msg := range messages {
if err := handler(msg.Payload); err != nil {
c.logger.Error("handler failed; routing to DLQ", err, watermill.LogFields{
"topic": topic.String(), "group": consumerGroup,
})
if c.dlq != nil {
_ = c.dlq.PublishMessage(ctx, Topic(topic.String()+".dlq"), msg.Metadata.Get("key"), msg.Payload)
}
}
msg.Ack()
}
return nil
}The consumer group is the fan-out mechanism: each group gets its own copy of every message, so two groups on one topic means two deliveries.
2. pkg/worker/worker.go:
// Package worker runs a domain's Kafka consumers. It is the worker-process
// equivalent of an http.ServeMux: modules register handlers onto it during the
// RegisterWorkers boot phase, and each handler runs as one consumer-group loop.
package worker
import (
"context"
"log"
"sync"
"minimart/pkg/kafka"
)
type Worker struct {
consumer *kafka.Consumer
producer kafka.Publisher
wg sync.WaitGroup
}
func New(consumer *kafka.Consumer, producer kafka.Publisher) *Worker {
return &Worker{consumer: consumer, producer: producer}
}
// Producer exposes the shared publisher to modules that also emit events.
func (w *Worker) Producer() kafka.Publisher { return w.producer }
// AttachKafka registers one consumer: a handler for a topic under a consumer
// group. Each runs in its own goroutine until ctx is cancelled.
func (w *Worker) AttachKafka(ctx context.Context, topic kafka.Topic, group string, handler func(msg []byte) error) {
w.wg.Add(1)
go func() {
defer w.wg.Done()
if err := w.consumer.Subscribe(ctx, topic, group, handler); err != nil {
log.Printf("worker: %s/%s stopped: %v", topic, group, err)
}
}()
}
// Wait blocks until every consumer goroutine has exited.
func (w *Worker) Wait() { w.wg.Wait() }3. Add RegisterWorkers to the Module interface, pkg/module/module.go:
package module
import (
"context"
"net/http"
"minimart/pkg/module/contracts"
"minimart/pkg/worker"
)
// Module is one self-contained domain in the modular monolith.
//
// The boot sequence in cmd/ calls these methods in phases, for every module:
//
// 1. Init build the module's own internals (service, adapters).
// 2. RegisterContracts publish this module's contract into the shared registry.
// 3. (Verify) the boot fails if any contract slot is still nil.
// 4a. RegisterHTTP (server) wire this module's routes onto the mux.
// 4b. RegisterWorkers (worker) wire this module's Kafka consumers onto the worker.
//
// The server process runs phase 4a; the worker process runs phase 4b. No module
// may call another module before phase 3 completes.
type Module interface {
Name() string
Init(ctx context.Context) error
RegisterContracts(ctx context.Context, c *contracts.Contracts) error
RegisterHTTP(ctx context.Context, mux *http.ServeMux) error
RegisterWorkers(ctx context.Context, w *worker.Worker) error
}Every module now implements it: catalog's is a no-op (added next), notification's registers its consumer (the consumer section), and the order module registers its own - while also finally taking the Kafka publisher.
4. Add the no-op to catalog/module.go. Its Init and constructor are unchanged from the Swap the engine section; it only gains this method (and the minimart/pkg/worker import) to satisfy the wider interface:
// RegisterWorkers is a no-op: the catalog domain has no Kafka consumers.
func (m *Module) RegisterWorkers(ctx context.Context, w *worker.Worker) error { return nil }5. order/module.go - the order domain's final composition root:
package order
import (
"context"
"net/http"
"github.com/jackc/pgx/v5/pgxpool"
"minimart/order/application"
"minimart/order/infrastructure/postgres"
orderhttp "minimart/order/presentation/http"
ordermessage "minimart/order/presentation/message"
"minimart/pkg/kafka"
"minimart/pkg/module"
"minimart/pkg/module/contracts"
"minimart/pkg/worker"
)
// Module wires the order domain. It holds the shared contracts registry (to call
// other domains), the DB pools, and the Kafka publisher.
type Module struct {
registry *contracts.Contracts
readPool *pgxpool.Pool
writePool *pgxpool.Pool
publisher kafka.Publisher
svc *application.Service
handlers *orderhttp.Handlers
}
var _ module.Module = (*Module)(nil)
func NewModule(registry *contracts.Contracts, readPool, writePool *pgxpool.Pool, publisher kafka.Publisher) *Module {
return &Module{registry: registry, readPool: readPool, writePool: writePool, publisher: publisher}
}
func (m *Module) Name() string { return "order" }
func (m *Module) Init(ctx context.Context) error {
repo := postgres.NewOrderRepository(m.readPool, m.writePool)
m.svc = application.NewService(repo, m.registry, m.publisher)
m.handlers = orderhttp.NewHandlers(m.svc)
return nil
}
// RegisterContracts is a no-op: the order domain publishes no contract of its own.
func (m *Module) RegisterContracts(ctx context.Context, c *contracts.Contracts) error { return nil }
func (m *Module) RegisterHTTP(ctx context.Context, mux *http.ServeMux) error {
m.handlers.Register(mux)
return nil
}
// RegisterWorkers wires the order domain's Kafka consumers.
func (m *Module) RegisterWorkers(ctx context.Context, w *worker.Worker) error {
ordermessage.Workers(ctx, w)
return nil
}6. cmd/worker.go - the worker process. It is the server's boot with phase 4b in place of 4a:
package cmd
import (
"context"
"log"
"minimart/catalog"
"minimart/conf"
"minimart/notification"
"minimart/order"
kafkapkg "minimart/pkg/kafka"
"minimart/pkg/module"
"minimart/pkg/module/contracts"
"minimart/pkg/worker"
)
// StartWorker runs the background consumers. It boots the same modules as the
// server, but wires their Kafka handlers (RegisterWorkers) instead of HTTP routes,
// then blocks while the consumer goroutines run.
func StartWorker() error {
ctx := context.Background()
readPool, writePool, err := newPools(ctx)
if err != nil {
return err
}
defer writePool.Close()
defer readPool.Close()
producer, err := kafkapkg.NewProducer(conf.KafkaBrokers())
if err != nil {
return err
}
defer producer.Close()
// The producer doubles as the dead-letter publisher for failed messages.
consumer := kafkapkg.NewConsumer(conf.KafkaBrokers(), producer)
w := worker.New(consumer, producer)
registry := &contracts.Contracts{}
modules := []module.Module{
catalog.NewModule(readPool, writePool),
order.NewModule(registry, readPool, writePool, producer),
notification.NewModule(),
}
for _, m := range modules {
if err := m.Init(ctx); err != nil {
return err
}
}
for _, m := range modules {
if err := m.RegisterContracts(ctx, registry); err != nil {
return err
}
}
if err := registry.Verify(); err != nil {
return err
}
for _, m := range modules {
if err := m.RegisterWorkers(ctx, w); err != nil {
return err
}
}
log.Println("minimart worker started; consuming events")
w.Wait()
return nil
}The notification module joins the module list here (the server does not need it - it has no HTTP). Same four-phase boot, same Verify gate; only the final phase differs.
7. Give cmd/server.go the producer too. The HTTP path publishes OrderPlaced when an order is placed, so the server also builds a producer and passes it to order.NewModule. Add to StartServer (from the Swap the engine section), with the import kafkapkg "minimart/pkg/kafka":
// after building the pools:
producer, err := kafkapkg.NewProducer(conf.KafkaBrokers())
if err != nil {
return err
}
defer producer.Close()
// ...and pass it into the order module:
order.NewModule(registry, readPool, writePool, producer),Both processes now build a producer: the server publishes on order placement, the worker consumes (and reuses it as the dead-letter publisher).
Checkpoint
Bring up the stack, migrate, then run both processes. The dispatcher that turns these into go run . server / go run . worker arrives in stage 13, so for now use small temporary main.go files that call cmd.RunMigrate, cmd.StartWorker, or cmd.StartServer directly:
docker compose up -d
go run . # with main calling cmd.RunMigrate
go run . # terminal 1, with main calling cmd.StartWorker
go run . # terminal 2, with main calling cmd.StartServerIn a third terminal, create a product and place an order:
PID=$(curl -s -XPOST localhost:8080/v1/products \
-d '{"name":"Widget","price_cents":500,"stock":10}' \
| sed -n 's/.*"id":"\([a-f0-9]*\)".*/\1/p')
curl -s -XPOST localhost:8080/v1/orders \
-d "{\"user_id\":\"u1\",\"items\":[{\"product_id\":\"$PID\",\"quantity\":2}]}"Watch the worker terminal - one order, two log lines from two consumer groups:
[order] confirming order <order-id> for user u1 (1000 cents, 1 items)
[notification] emailing user u1: order <order-id> confirmed, total 1000 centsOne published event, two independent reactions. Adding a third is a third consumer group - the order domain never learns it exists.
What you learned
- A message consumer is an input adapter:
func(msg []byte) error, deserialize and call a service, ignorant of the transport. - The publisher is an output port; the application depends on the
Publisherinterface, with a Kafka adapter behind it. - One event fans out to many handlers via distinct consumer groups - decoupling the emitter from every reaction.
- The server and worker are the same modules booted through different final phases.
Exercise
Why do server and worker share module boot code but end in different registration phases?
Answer
They use the same domains, contracts, config, and infrastructure, but expose different edges. The server registers HTTP handlers; the worker registers message consumers. Sharing the early phases keeps construction consistent while the final phase selects the process role.