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:

internal/notification/transports/nats/push_notification.go:30-47
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):

conversationmgmt notification PushNotificationForConversation PushNotificationEvent.HandleMessage TracedPublish(ctx, …, Notification.Created) ──────▶ QueueSubscribe(Notification.Created) (DataInMessage envelope: payload + tenant) → unmarshal → ProcessPushNotification → FCM push

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

push_notification.go:50-82 (HandleMessage, condensed)
proto.Unmarshal(data, &notification)                    // protocol: bytes → the request
switch method {
case "push_notification":
    i.notificationSubscriber.ProcessPushNotification(ctx, &notification)   // ← the DOMAIN logic
}
return false, err                                       // retry=false → drop on error (no redelivery)
The NATS transport is a thin shell Look at what 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.
A subtle delivery choice — notification drops on error Notice 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.
Backend use case Backend use case: this is the conversationmgmt to notification handoff and the best place to explain the transport-agnostic handler pattern.
Common mistake Common mistake: describing notification as only a subscriber and forgetting the cross-service publish link and migration story.
Read this next

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

In plain English Plain English: one owned service publishes the request and another owned service consumes it, with the business handler designed to outlive the transport choice.

Check yourself (from memory)

Q1. How are conversationmgmt and notification connected?

One subject: convo TracedPublishes to Notification.Created; notification's QueueSubscribe picks it up. Total decoupling.

Q2. Why is ProcessPushNotification not inside the NATS transport?

The transport is a thin shell (unmarshal + dispatch); the domain logic lives in 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=falsehandleMsg acks (drops). A deliberate choice so a bad push doesn't loop. (convo's subs use true to retry.)
Recall: notification's subscriber + the publish link + transport-agnostic.
subscriber + the link + the split, then reveal
Subscriber: 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).
🎯 Interview one-liner "Walk me through a cross-service event you own." → "conversationmgmt decides a push is needed and publishes a 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."
Next: the one flow that behaves differently — the activity-log firehose and the only pull consumer in the codebase. Ask me about the 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.