Lesson 10 · The repo in practice · your services
notification & the publish link
Your two services, connected by one subject — conversationmgmt publishes, notification consumes, and the domain logic doesn't care which bus carried it.
Your win: read notification's subscriber, trace the Notification.Created
link from conversationmgmt to notification, and explain why the business logic is transport-agnostic.
The subscriber: Notification.Created
notification's core NATS subscriber is the mirror image of Lesson 9 — same shape, its own tuning:
func (i *PushNotificationEvent) StartSubscribe() error {
opts := nats.Option{ JetStreamOptions: []nats.JSSubOption{
nats.ManualAck(),
nats.Bind(StreamNotification, DurableNotification),
nats.DeliverSubject(DeliverNotification),
nats.MaxDeliver(3), nats.AckWait(90*time.Second), nats.MaxAckPending(30),
}}
return i.nats.QueueSubscribe(SubjectNotificationCreated, QueueNotification, opts, i.HandleMessage)
}
Unlike conversationmgmt, notification owns the notification stream
(it's declared in fink/streams/notificationmgmt.go, Lesson 3) — so it's both the stream
owner and a consumer of it. Its AckWait is 90s (sending a push can be slow).
The link: convo publishes → notification consumes
Here's the whole reason both are your services — they're connected by one subject. When a chat message needs a push, conversationmgmt publishes (Lesson 6's real example):
That's a complete cross-service event flow you own end to end: conversationmgmt decides "a push is
needed," puts a NatsCreateNotificationRequest on Notification.Created, and
notification — which has no idea conversationmgmt exists — picks it up and sends the Firebase push.
Total decoupling through one subject.
The handler is transport-agnostic
proto.Unmarshal(data, ¬ification) // protocol: bytes → the request
switch method {
case "push_notification":
i.notificationSubscriber.ProcessPushNotification(ctx, ¬ification) // ← the DOMAIN logic
}
return false, err // retry=false → drop on error (no redelivery)
HandleMessage actually does: unmarshal, dispatch, done. The real work —
ProcessPushNotification — lives in internal/notification/subscribers/, a
transport-agnostic domain handler. And here's the payoff: the same handler
is reused by the Kafka transport too (per the service's own CLAUDE.md). So the
NATS/Kafka choice is just which shell wraps the same business logic — which is exactly what
makes the NATS→Kafka migration (Lesson 12) a transport swap, not a rewrite.
HandleMessage returns retry=false even on error. By Lesson 8's
logic, that means a failed push is acked and dropped, not redelivered — a
deliberate call: a malformed or un-sendable notification shouldn't loop forever hammering FCM.
(conversationmgmt's subscribers, by contrast, return retry=true so a transient DB
failure retries.) The retry bool is a per-handler policy decision, and these two of
your services chose differently — for good reasons.
notification's transport & domain split
The service's own guide explains the transport-agnostic subscribers/ reused by NATS
and Kafka — the design behind the migration.
→ in-repo internal/notification/CLAUDE.md ("Event flows") ·
internal/notification/transports/nats/push_notification.go
→ the convo publisher …/conversation/infrastructure/nats/notification.go
Check yourself (from memory)
Q1. How are conversationmgmt and notification connected?
TracedPublishes to
Notification.Created; notification's QueueSubscribe picks it up. Total
decoupling.
Q2. Why is ProcessPushNotification not inside the NATS transport?
subscribers/, reused by NATS and Kafka — which makes migration a swap.
Q3. notification's handler returns retry=false on error. What does that do?
retry=false → handleMsg acks (drops). A
deliberate choice so a bad push doesn't loop. (convo's subs use true to retry.)
PushNotificationEvent.StartSubscribe
(push_notification.go:30-47) = QueueSubscribe(Notification.Created, QueueNotification),
Bind(StreamNotification, DurableNotification), DeliverSubject, MaxDeliver 3, AckWait 90s. notification
owns the notification stream (via fink). The link:
conversationmgmt TracedPublishes Notification.Created →
notification consumes → ProcessPushNotification → FCM push. Total decoupling via one
subject. Transport-agnostic: HandleMessage = unmarshal + dispatch only;
the domain logic (ProcessPushNotification in subscribers/) is reused
by NATS AND Kafka → migration = swap the shell. Delivery choice: notification
returns retry=false (drop failed pushes, don't loop); convo returns true
(retry transient failures).Notification.Created event; notification consumes it and sends the Firebase
push — the two are fully decoupled through one subject. And the business logic sits in a
transport-agnostic handler reused by both NATS and Kafka, so the transport is just a thin shell — the
reason we can migrate a flow from NATS to Kafka without touching the domain code."
retry policy
choice if the drop-vs-redeliver contrast is interesting.
1. In-repo: internal/notification/transports/nats/push_notification.go:30-82, internal/notification/CLAUDE.md, …/conversation/infrastructure/nats/notification.go.