Go MQTT Vending Backend Β· Lesson 5A Β· 15 minutes
Add the backend message-processing step
Section 5 validates incoming bytes. In 5A, add the application code that turns a valid heartbeat into a small, queryable backend state.
π― One skill: apply a validated messageπ§ Separate device time from receive timeπ§ͺ In-memory lab
β Start with the repository as it is
The current cmd/backend/main.go receives mqtt.Message values from internal/mqtt and logs their topic and payload. The current codebase does not yet contain internal/deviceview, Store, Service, or the methods in this lesson.
Section 5A in teaching/course-source/05a-backend-message-processing.md is an implementation assignment: create those files and replace the raw-message log path with contract validation and application behavior. Every code block below is a complete file, including imports and wiring. The new code is the lesson's target, not code that already exists.
πΊοΈ First, see what the files do
β
Existing files used by this lesson
internal/contracts/topic.go defines TopicName, TopicFilter, Kind, concrete-topic builders, exact and fleet-wide subscription builders, and ParseTopic. Concrete publish names and subscription filters use distinct types.
internal/contracts/message.go defines the payload contracts and their topic/body validation methods.
internal/contracts/decode.go decodes and validates a payload against that parsed topic.
internal/mqtt/client.go defines mqtt.Message and copies received topic and payload into the inbox.
cmd/backend/main.go owns startup and the receive loop; today it logs received payloads.
π οΈ Files this lesson adds or changes
- Create
internal/deviceview/store.go for an in-memory latest-heartbeat view.
- Create
internal/deviceview/service.go to validate messages and apply their current use cases.
- Edit
cmd/backend/main.go to create the service and delegate inbox messages to it.
Store, Service, RecordHeartbeat, and the other device-view methods are new lesson code. They are not present in the checkout before you implement this section.
Existing receive loop
topic + bytes
β
Existing contracts
parse + validate
β
New service
choose application behavior
β
New store
latest heartbeat view
Validation is a trust boundary, not a business action. The service applies the meaning of a valid message. The store owns the resulting view. Keeping these roles separate lets Section 6 move service work to workers without mixing application state with Paho callback code.
ποΈ Then, create `internal/deviceview/store.go`
File overview: this file owns one latest heartbeat snapshot per device. It keeps the map in process memory, so a backend restart clears the view.
`NewStore` summary: initializes the empty map. A query before the first heartbeat can report βnot seen yetβ rather than inventing an offline record.
`RecordHeartbeat` summary: parses device-reported time, copies optional temperature, and replaces the snapshot only when the report is newer.
`GetStatus` summary: returns the snapshot, plus whether the backend received it within the requested freshness window.
π§© Complete file
package deviceview
import (
"fmt"
"sync"
"time"
"example.com/mqtt5-learning/internal/contracts"
)
type Snapshot struct {
DeviceID string
MessageID string
ReportedAt time.Time
ReceivedAt time.Time
Temperature float64
TemperatureSet bool
}
type Status struct {
Snapshot Snapshot
HeartbeatFresh bool
}
type Store struct {
mu sync.RWMutex
latest map[string]Snapshot
}
func NewStore() *Store {
return &Store{latest: make(map[string]Snapshot)}
}
func (s *Store) RecordHeartbeat(message contracts.Heartbeat, receivedAt time.Time) (bool, error) {
reportedAt, err := time.Parse(time.RFC3339, message.OccurredAt)
if err != nil {
// Decode already validates this timestamp. Keep the store defensive too.
return false, fmt.Errorf("parse heartbeat timestamp: %w", err)
}
snapshot := Snapshot{
DeviceID: message.DeviceID,
MessageID: message.MessageID,
ReportedAt: reportedAt,
ReceivedAt: receivedAt,
}
if message.Temperature != nil {
snapshot.Temperature = *message.Temperature
snapshot.TemperatureSet = true
}
s.mu.Lock()
defer s.mu.Unlock()
previous, exists := s.latest[message.DeviceID]
if exists && !reportedAt.After(previous.ReportedAt) {
return false, nil
}
s.latest[message.DeviceID] = snapshot
return true, nil
}
func (s *Store) GetStatus(deviceID string, now time.Time, freshFor time.Duration) (Status, bool) {
s.mu.RLock()
snapshot, exists := s.latest[deviceID]
s.mu.RUnlock()
if !exists {
return Status{}, false
}
fresh := freshFor > 0 && !now.Before(snapshot.ReceivedAt) && now.Sub(snapshot.ReceivedAt) <= freshFor
return Status{Snapshot: snapshot, HeartbeatFresh: fresh}, true
}
π Details: how the store methods work
NewStore: the map starts empty; the second return from GetStatus is false until a heartbeat is recorded.
RecordHeartbeat: contract decoding already checks OccurredAt, but the store parses it defensively too. ReportedAt orders observations; backend-supplied ReceivedAt measures freshness.
- Optional temperature: the store keeps temperature as a value and uses
TemperatureSet to distinguish a missing reading from a reported zero. The map snapshot copy is therefore independent without pointer-copy logic.
- Locking: use an exclusive lock while comparing and replacing the map entry. Section 6 will allow multiple workers to call the service. A duplicate or older timestamp returns
false, nil and leaves the newer view unchanged.
GetStatus: the read lock protects the map lookup. Freshness requires a positive window, a receive time not in the future, and an age no greater than that window.
Ordering is not durable deduplication. Equal timestamps count as not newer, and process restart removes all snapshots.
π§ Next, create `internal/deviceview/service.go`
File overview: this file receives the existing transport-neutral mqtt.Message, validates it with contracts, applies the behavior assigned to its message kind, and logs a safe outcome. It does not own a Paho client or publish MQTT messages.
`NewService` summary: checks that the store, logger, and positive freshness window are configured before message handling begins.
`HandleMessage` summary: checks cancellation, parses the route, captures one backend receive time, then validates and handles the message according to its kind.
`Status` summary: asks the store for the latest view using the service clock and configured freshness window.
π§© Complete file
package deviceview
import (
"context"
"fmt"
"log/slog"
"time"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/mqtt"
)
type Service struct {
store *Store
logger *slog.Logger
freshFor time.Duration
now func() time.Time
}
func NewService(store *Store, logger *slog.Logger, freshFor time.Duration) (*Service, error) {
if store == nil {
return nil, fmt.Errorf("device-view store is required")
}
if logger == nil {
return nil, fmt.Errorf("logger is required")
}
if freshFor <= 0 {
return nil, fmt.Errorf("heartbeat freshness window must be positive")
}
return &Service{store: store, logger: logger, freshFor: freshFor, now: time.Now}, nil
}
func (s *Service) HandleMessage(ctx context.Context, message mqtt.Message) error {
if err := ctx.Err(); err != nil {
return err
}
topic, err := contracts.ParseTopic(message.Topic)
if err != nil {
return fmt.Errorf("parse topic: %w", err)
}
receivedAt := s.now().UTC()
switch topic.Kind {
case contracts.KindTelemetry:
heartbeat, err := contracts.Decode[contracts.Heartbeat](message.Payload, topic, receivedAt)
if err != nil {
return fmt.Errorf("validate heartbeat: %w", err)
}
updated, err := s.store.RecordHeartbeat(heartbeat, receivedAt)
if err != nil {
return fmt.Errorf("record heartbeat: %w", err)
}
if !updated {
s.logger.Info("older or duplicate heartbeat ignored",
"device_id", heartbeat.DeviceID,
"message_id", heartbeat.MessageID,
)
return nil
}
s.logger.Info("heartbeat recorded",
"device_id", heartbeat.DeviceID,
"message_id", heartbeat.MessageID,
"received_at", receivedAt,
)
return nil
case contracts.KindSales:
report, err := contracts.Decode[contracts.SalesReport](message.Payload, topic, receivedAt)
if err != nil {
return fmt.Errorf("validate sales report: %w", err)
}
// Validation is not durable admission; Section 13 stores reports.
s.logger.Info("sales report validated; persistence not implemented",
"device_id", report.DeviceID,
"report_id", report.ReportID,
"item_count", len(report.Items),
)
return nil
case contracts.KindResponses:
result, err := contracts.Decode[contracts.CommandResult](message.Payload, topic, receivedAt)
if err != nil {
return fmt.Errorf("validate command result: %w", err)
}
// Section 7 adds pending-command and correlation matching.
s.logger.Info("command result validated; matching not implemented",
"device_id", result.DeviceID,
"command_id", result.CommandID,
"status", result.Status,
)
return nil
default:
return fmt.Errorf("device-view service does not handle topic kind %q", topic.Kind)
}
}
func (s *Service) Status(deviceID string) (Status, bool) {
return s.store.GetStatus(deviceID, s.now().UTC(), s.freshFor)
}
π Details: how the service methods work
NewService: rejects missing dependencies and invalid policy at startup, not later as a nil-pointer failure. Its freshness window is application policy, separate from MQTT Keep Alive and Session Expiry.
HandleMessage: returns immediately for cancelled work or an invalid topic. It captures backend receive time once and reuses it in validation, storage, and logs.
- Telemetry branch: decodes a heartbeat, asks the store to advance its snapshot, and logs either βrecordedβ or βolder or duplicate ignored.β Invalid input never reaches the store.
- Sales and response branches: decode and log identifiers only. Sales are not persisted here, and command responses are not correlated here. Those later outcomes must not be implied by a successful decode.
Status: returns backend state without querying the device. A fresh heartbeat means recent valid application traffic, not a live broker connection or healthy vending hardware.
π Finally, edit `cmd/backend/main.go`
File overview: this existing file loads configuration, starts the MQTT client, owns the inbox receive loop, and periodically publishes the lab's ping command. The edited version keeps those responsibilities and delegates inbound messages to the new service instead of printing raw payloads.
run summary: constructs one store and service before MQTT startup, then passes each received message to handleMessage.
handleMessage summary: keeps the select loop focused on receiving events by delegating application behavior to Service.
π§© Complete edited file
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"example.com/mqtt5-learning/internal/config"
"example.com/mqtt5-learning/internal/deviceview"
"example.com/mqtt5-learning/internal/mqtt"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
cfg, err := config.Load(os.Args[1:])
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Keep one shared in-memory view for the lifetime of this backend process.
store := deviceview.NewStore()
deviceView, err := deviceview.NewService(store, slog.Default(), 90*time.Second)
if err != nil {
return fmt.Errorf("create device-view service: %w", err)
}
telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
if err != nil { return err }
responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
if err != nil { return err }
commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands)
if err != nil { return err }
inbox := make(chan mqtt.Message, 16)
manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter}, inbox)
if err != nil {
return fmt.Errorf("failed to start MQTT client: %w", err)
}
defer mqtt.Stop(manager)
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
runID := time.Now().UnixNano()
sequence := 0
for {
select {
case <-ctx.Done():
log.Print("shutting down")
return nil
case message := <-inbox:
if err := handleMessage(ctx, deviceView, message); err != nil {
slog.Error("message handling failed", "topic", message.Topic, "error", err)
}
case <-ticker.C:
sequence++
id := fmt.Sprintf("ping-%d-%d", runID, sequence)
payload, err := json.Marshal(contracts.Command{
SchemaVersion: contracts.SchemaVersion,
MessageID: id,
DeviceID: cfg.MQTT.DeviceID,
OccurredAt: time.Now().UTC().Format(time.RFC3339),
CommandID: id,
Command: "ping",
})
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
if err := mqtt.Publish(ctx, manager, commandTopic, payload); err != nil {
log.Printf("command publication failed or outcome uncertain: %v", err)
}
}
}
}
func handleMessage(ctx context.Context, deviceView *deviceview.Service, message mqtt.Message) error {
return deviceView.HandleMessage(ctx, message)
}
π Details: why the integration is placed here
- Construct the store once before the loop. Creating it per message would discard prior snapshots.
- The 90-second freshness window is the course example policy; it should be chosen relative to the heartbeat interval and expected network delay.
- The service handles inbound telemetry and response messages. The existing ticker and outbound ping remain intact.
- Errors are logged with the topic and error, while the raw payload is no longer printed. Invalid or mismatched input therefore does not leak into a general log line.
- The MQTT callback copies into the bounded inbox and returns before this receive loop applies the use case. Under automatic acknowledgement, a queue drop or later application error does not reverse an acknowledgement already sent. Section 6 studies that loss boundary and adds explicit overload handling.
π§ͺ Check your model
π οΈ Your tiny practice
Predict the final store contents for these arrivals:
- The device reports 12:00:05; the backend receives it at 12:00:06.
- The device reports 12:00:02; the backend receives it at 12:00:09.
Which snapshot remains? At 12:00:09, is that snapshot fresh under a 90-second window? Explain why the later arrival does not move device-observation order backward.
ποΈ Build and run the vertical slice
After saving the new internal/deviceview files and updating cmd/backend/main.go, compile the real programs and start the isolated broker. Run these from the repository root:
go test ./...
go build -o /tmp/mqtt5-lab-backend ./cmd/backend
go build -o /tmp/mqtt5-lab-simulator ./cmd/simulator
docker compose -p mqtt5-learning up -d broker
docker compose -p mqtt5-learning ps
go test ./... runs the tests that exist; it does not prove every device-view branch is covered. The two builds catch compile and link errors in the actual processes. Check that the project-named broker is running before connecting clients.
Start each binary in its own terminal:
/tmp/mqtt5-lab-simulator -username simulator -client-id lab-simulator-device-001
/tmp/mqtt5-lab-backend -username backend -client-id lab-backend
Within a few seconds, expect backend log entries for recorded heartbeats with device and message IDs. The backend also sends a ping every ten seconds; the simulator replies, and the backend validates that response. This confirms a real MQTT message reached the application service. It does not establish command correlation, which Lesson 7 adds.
Stop both clients with Ctrl+C, then run docker compose -p mqtt5-learning stop broker. The device view is in memory and disappears when the backend exits. The lesson's temporary deviceView.Status diagnostic can show the snapshot and freshness flag; no HTTP status endpoint exists yet. Ask me a follow-up if a build error or log result is unclear.
β
Takeaway
The existing code receives MQTT messages; the new service validates and applies them; the new store holds the latest heartbeat view. The implementation is complete above, but its state is RAM-only and a fresh heartbeat does not prove device health.
Ask me a follow-up if you want to trace one message through the code line by line.