Stage 7 · Master
Phase 16 — Kafka Integration
Event Driven Architecture
Decide, in writing, why payment-service publishes payment.completed instead of calling notification-service directly — and fix the topic, key, and envelope contract before either service touches Kafka.
A Captured Payment Today Has No Way to Tell Anyone
payment-service/internal/payment marks a payment captured inside its own transaction (the Payment module's transition guard) and stops there. notification-service already has a full asynchronous delivery pipeline — notification_jobs, a claim-safe worker pool, bounded retry, dead-lettering — built in Phase 14, but that pipeline has never received a single row from anything except a hand-typed psql INSERT in that phase's own verification steps. The gap is not a missing feature in either service; it is the absence of anything connecting them.
Closing that gap with a direct HTTP call from the payment webhook handler to notification-service's API is the obvious first idea, and the wrong one for the same reason Phase 14 gave for never sending emails inline from a request handler: it couples one system's request latency and availability to another system's. A slow or down notification-service would now make payment capture itself slow or fail, for a concern — telling someone their payment succeeded — that was never on the critical path of capturing the payment in the first place.
Decision: Publish payment.completed; Never Call notification-service Directly
The alternative is for payment-service to publish a fact — "payment completed" — to a durable log, and for any interested service, including notification-service, to consume it on its own schedule. This is worth writing down as a real decision record, not folding silently into the first lesson's code, because the next engineer who wonders "why isn't this just an HTTP call" deserves an answer that survives past this course.
# ADR 0009: payment-service Notifies via Kafka Events, Not Direct API Calls
## Status
Accepted
## Context
When a payment transitions to captured, at least one other service (notification-service)
needs to know. Two designs were considered:
1. payment-service's webhook handler calls notification-service's HTTP API synchronously
after committing the payment transition.
2. payment-service publishes a payment.completed event to Kafka; notification-service
consumes it independently.
## Decision
We adopt (2). payment-service never calls notification-service directly for this purpose.
## Consequences
- A notification-service outage or slow deploy no longer affects payment capture latency
or availability in any way.
- Delivery is at-least-once, not exactly-once: every consumer of this topic must handle
duplicate delivery of the same event_id safely. The Producers and Consumers lessons in
this module build the specific mechanisms (a transactional outbox, an idempotent inbox)
that make that safe.
- Other consumers can subscribe later (a future reporting service, an audit trail) without
payment-service changing a single line, since it publishes a fact about itself rather
than calling a specific service.
- This is strictly more moving parts than a direct call for the first consumer alone. The
decision is only worth it because notification-service is not expected to remain the
only consumer, and because the platform already has a Kafka broker (Phase 1) sitting idle.
| Approach | Failure isolation | Adding a second consumer later |
|---|---|---|
| Direct HTTP call from payment-service to notification-service | A notification-service outage or timeout becomes a payment-capture failure unless payment-service adds its own retry/circuit-breaking logic | Requires payment-service to add a second outbound call and reason about two dependencies instead of one |
| Publish payment.completed to Kafka | notification-service's availability has no effect on payment capture; the event simply waits on the topic | A new consumer group reads the same topic; payment-service changes nothing |
One Topic Per Event Type, Owned by the Service That Produces It
This platform's one existing topic, organization.smoke-test (Phase 1), already establishes the naming shape this module formalizes: <owning-domain>.<event-in-past-tense>. payment.completed is owned exclusively by payment-service — no other service is ever permitted to write to it, even though several may read it. Partitions are fixed at 6 for this topic: Lesson 4 explains exactly why 6, but the short version is that it sets the ceiling on how many consumer replicas can ever do useful work in parallel against it.
Every Event Is a Small, Versioned JSON Envelope
{
"event_id": "b7e6c2b4-3b7b-4b60-9a3b-1b6a9d9a9f01",
"event_type": "payment.completed",
"event_version": 1,
"org_id": "5b0a9e7e-1f8b-4a68-9b5b-6a2a9c9b8e33",
"aggregate_id": "1c2d3e4f-5678-90ab-cdef-1234567890ab",
"occurred_at": "2024-05-01T12:03:44Z",
"payload": {
"amount_paise": 250000,
"currency": "INR",
"provider_reference": "pay_Nx82"
}
}- event_id — a fresh uuid.New() per event, the durable identity every consumer's dedup table keys on, never reused even for a republished duplicate delivery.
- org_id — named after payment-service's own org_id column, not translated at the point of publication. Consumers translate it into their own vocabulary; the Consumers lesson shows exactly where.
- aggregate_id — the payment's id. Lesson 2 uses this, not org_id, as the Kafka partition key.
- payload — only the fields a consumer plausibly needs, never the full payments row. Adding a column to the payments table must not silently change this contract.
Kafka's own delivery guarantee here, combined with a producer that can crash between publishing and marking its own work done (Lesson 2), means the same event_id can legitimately arrive twice. Every consumer of payment.completed must treat processing the same event_id a second time as a normal, expected case — not an error path bolted on afterward.
Applied exercise
Design the contract for a second event type: payment.refunded
This lesson only designs payment.completed. Prove the naming and key rules generalize by designing a second, genuinely different event without copying this one's payload.
- Propose a topic name for a refund completing, following the <owning-domain>.<event-in-past-tense> rule.
- State what the partition key should be and justify it in terms of what ordering guarantee the platform actually needs for refunds.
- Design the payload fields, deliberately keeping it smaller than the full refund record.
- Name one plausible future consumer of this event besides notification-service, and explain why it would care.
Deliverable
A short written contract (topic, key, envelope fields, one named future consumer) for payment.refunded.
Completion checks
- The topic name follows the established <domain>.<past-tense> convention exactly.
- The partition key choice is justified by an ordering requirement, not asserted without reasoning.
- The payload does not simply copy every column from a refunds table.
Why is the payment.completed partition key the payment's own id (aggregate_id) rather than org_id?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.