Definition
- Event Notification – the event carries minimal data (event ID, event type, aggregate ID, timestamp, and usually a link), and the consumer must call back to the producer to fetch the details it needs.
- Event-Carried State Transfer – the event includes the data consumers need directly, so consumers act without any callback to the producer.
- Fat event / god event – the anti-pattern of serializing the entire aggregate and broadcasting it just in case some consumer might want something, which leaks internals and bloats the topic.
- Trade-off – Event Notification gives smaller payloads and always-fresh data but adds runtime coupling; State Transfer gives consumer autonomy and resilience at the cost of larger payloads and snapshot staleness.
- Versioned schema – both styles create a contract that consumers depend on, so both must use a versioned schema like Avro or Protobuf.
When a transfer completes at National Bank of India, the transfer department can tell the SMS department in two ways. Event Notification is a short memo: "Transfer T-123 happened for account SAV2001 - call us if you need details." The SMS department phones back to get the amount and beneficiary. Event-Carried State Transfer is a fuller memo: "Transfer T-123: Rs.50000 to BENEF-77 from SAV2001" - now the SMS department acts immediately without phoning back. The fat-event anti-pattern is photocopying the department's entire internal file, every private note, risk score, and scratch calculation, and mailing it to everyone just in case.
Understanding the Concepts
What is Event Notification?
Event Notification keeps the event as small as possible. The payload is an announcement: event ID, event type, aggregate ID, timestamp, and optionally a link. The consumer reacts by calling back to the producer to fetch the details it needs.
package com.bank.event;
import java.time.Instant;
// Event Notification: tiny pointer
public record TransferNotification(
String eventId,
String eventType,
String accountId,
String detailsLink,
Instant occurredAt
) {}
// Example payload:
// {
// "eventId": "EVT-001",
// "eventType": "TRANSFER_COMPLETED",
// "accountId": "SAV2001",
// "detailsLink": "/api/v1/transfers/TXN-123",
// "occurredAt": "2025-01-15T10:30:00Z"
// }
// Payload size: ~180 bytes
package com.bank.consumer;
import com.bank.event.TransferNotification;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
@Component
public class NotificationConsumerWithCallback {
private final RestClient restClient;
public NotificationConsumerWithCallback(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://transfer-service").build();
}
@KafkaListener(topics = "transfer-notifications", groupId = "notification-svc")
public void onNotification(TransferNotification event) {
// Must call back to get the details
TransferDetails details = restClient.get()
.uri(event.detailsLink())
.retrieve()
.body(TransferDetails.class);
sendSms(event.accountId(),
"Transfer of " + details.amount() + " to " + details.beneficiary());
}
private void sendSms(String accountId, String message) {
// Send SMS
}
}
// Notification consumer receives tiny event → calls back for details // If Transfer Service is down during callback → consumer is stuck // Runtime coupling: consumer depends on producer being available
What is Event-Carried State Transfer?
Event-Carried State Transfer includes the data consumers need inside the event. The consumer acts without calling back to the producer.
package com.bank.event;
import java.math.BigDecimal;
import java.time.Instant;
// Event-Carried State Transfer: includes the data consumers need
public record TransferCompletedEvent(
String eventId,
String transferId,
String fromAccount,
String toAccount,
BigDecimal amount,
String currency,
Instant completedAt
) {}
// Example payload:
// {
// "eventId": "EVT-001",
// "transferId": "TXN-123",
// "fromAccount": "SAV2001",
// "toAccount": "SAV3002",
// "amount": 50000,
// "currency": "INR",
// "completedAt": "2025-01-15T10:30:00Z"
// }
// Payload size: ~250 bytes (larger, but self-contained)
package com.bank.consumer;
import com.bank.event.TransferCompletedEvent;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class NotificationConsumerAutonomous {
@KafkaListener(topics = "transfer-events", groupId = "notification-svc")
public void onTransferCompleted(TransferCompletedEvent event) {
// No callback needed! All data is in the event.
sendSms(event.fromAccount(),
"Transfer of " + event.amount() + " " + event.currency()
+ " to " + event.toAccount() + " completed.");
}
private void sendSms(String accountId, String message) {
// Send SMS to account holder
}
}
// Consumer acts immediately with no callback // If Transfer Service is down → consumer still works (autonomous) // Schema coupling: consumer depends on event schema at design time
What is the fat event / god event anti-pattern?
The fat event serializes the entire aggregate and broadcasts it. This leaks internals, bloats the topic, and couples every consumer to the full model.
package com.bank.event;
import java.math.BigDecimal;
import java.util.List;
// WRONG: fat event / god event
public record TransferGodEvent(
String eventId,
String transferId,
String fromAccount,
String toAccount,
BigDecimal amount,
BigDecimal fee,
String currency,
String internalLedgerRef, // Internal detail!
String riskBucket, // Internal detail!
BigDecimal senderBalance, // Sensitive!
BigDecimal receiverBalance, // Sensitive!
List<String> auditNotes, // Internal detail!
String approvedBy,
String complianceStatus,
String fraudScore
) {}
// Payload size: ~800 bytes
// Leaks: internalLedgerRef, riskBucket, balances, audit notes
// Every consumer now coupled to every field
// Problems with the god event:
// 1. Leaks internal model: internalLedgerRef, riskBucket
// 2. Bloats topic: 800 bytes vs 250 bytes per event
// 3. Couples consumers: renaming riskBucket breaks everyone
// 4. Obscures contract: nobody knows which fields matter
// 5. Security risk: exposes balances and audit notes
How is Event Notification different from State Transfer? (comparison table)
| Aspect | Event Notification | Event-Carried State Transfer |
|---|---|---|
| Payload | Tiny (ID, type, link) | Medium (curated fields) |
| Consumer action | Calls back for details | Acts immediately |
| Runtime coupling | Consumer depends on producer being up | Consumer is autonomous |
| Design-time coupling | Minimal schema | Consumer depends on event schema |
| Freshness | Always fresh (fetches current state) | Snapshot at emission time |
| Network load | Callback per event | No extra calls |
| Resilience | Fails if producer is down during callback | Works even if producer is down |
| Best for | Large/sensitive data, freshness critical | Autonomy, local read models, fan-out |
How do you decide between the two styles?
The decision is a trade-off between coupling and autonomy. Favor State Transfer when consumer autonomy matters, when callback load would be heavy, or when consumers maintain local read models.
// Decision framework:
// Use Event-Carried State Transfer when:
// - Many consumers need the same data (fan-out)
// - Consumers maintain local read models (CQRS)
// - Producer availability should not affect consumers
// - Example: transfer event → notification, statement, fraud all need amount
// Use Event Notification when:
// - Data is large (e.g., full PDF statement)
// - Data is sensitive (e.g., full KYC documents)
// - Freshness is critical (e.g., real-time balance check)
// - Few consumers need details (callback cost is low)
// Hybrid: carry common fields + include a link for rare detailed lookups
public record TransferEventHybrid(
String eventId,
String transferId,
String fromAccount,
String toAccount,
java.math.BigDecimal amount, // Commonly needed
String currency, // Commonly needed
String detailsLink // For rare full lookups
) {}
What are common mistakes? (wrong vs correct)
The most common mistake is broadcasting the entire aggregate as a god event instead of curating the fields.
// WRONG: god event - dump everything
kafka.send("transfer-events", accountId, godEvent);
// Consumers see: internalLedgerRef, riskBucket, balances
// Any internal rename breaks all consumers
// CORRECT: curated event with only needed fields
TransferCompletedEvent curated = new TransferCompletedEvent(
eventId, transferId, fromAccount, toAccount, amount, "INR", now);
kafka.send("transfer-events", fromAccount, curated);
// Consumers see: transferId, fromAccount, toAccount, amount, currency
// Internal changes have zero impact on consumers
Do both styles need versioned schemas?
Yes. Both Event Notification and State Transfer create contracts that consumers depend on. Both must use a versioned schema like Avro or Protobuf with a Schema Registry.
// Avro schema for Event-Carried State Transfer
{
"type": "record",
"name": "TransferCompleted",
"namespace": "com.bank.events",
"fields": [
{"name": "eventId", "type": "string"},
{"name": "transferId", "type": "string"},
{"name": "fromAccount", "type": "string"},
{"name": "toAccount", "type": "string"},
{"name": "amount", "type": "string"},
{"name": "currency", "type": "string", "default": "INR"}
]
}
// Adding "beneficiaryName" with a default → backward compatible
// Removing "transferId" → breaking → Schema Registry rejects
What should a senior engineer know?
A senior engineer understands that the choice is about which coupling you prefer. Event Notification couples consumers to the producer at runtime; State Transfer couples them to the producer's schema at design time. You minimize both by keeping events lean, versioned, and focused on the domain fact.
// Senior framing:
// Event Notification: runtime coupling (must call back)
// State Transfer: design-time coupling (must parse schema)
// God event: both couplings at maximum (worst of all worlds)
// The right answer is almost always State Transfer with curated fields
// It gives autonomy, resilience, and works for local read models
// Use Notification only when data is too large or sensitive to carry
Visual Overview
Event Notification
──────────────────
Transfer Svc Kafka Notification Svc
│ │ │
│── tiny event ───▶│ │
│ {id, type, link}│ │
│ │── deliver ──────▶│
│ │ │── callback: GET /transfers/TXN-123
│◀────────────────────────────────────│
│── details ─────────────────────────▶│
│ │ │── send SMS
│ │ │
Producer must be UP for callback
Event-Carried State Transfer
────────────────────────────
Transfer Svc Kafka Notification Svc
│ │ │
│── rich event ───▶│ │
│ {id, from, to, │ │
│ amount, currency} │
│ │── deliver ──────▶│
│ │ │── send SMS (no callback!)
│ │ │
Producer can be DOWN, consumer still works
Fat Event (Anti-Pattern)
────────────────────────
Transfer Svc Kafka All Consumers
│ │ │
│── EVERYTHING ───▶│ │
│ {id, from, to, │ │
│ amount, fee, │── deliver ──────▶│
│ ledgerRef, │ │
│ riskBucket, │ Consumers see │
│ balance, │ internal fields │
│ auditNotes...} │ they should not │
│ │ │
Leaks internals, bloats topic, couples everyone
Key Takeaway
Event Notification and Event-Carried State Transfer are two ways to shape an event, and the difference is what the consumer has to do next. Event Notification keeps the event tiny, just an event ID, aggregate ID, and a link, and the consumer calls back to the producer for details. The upside is small payloads and always-fresh data; the downside is runtime coupling because the consumer depends on the producer being up for the callback. Event-Carried State Transfer puts the data consumers need inside the event, so the consumer acts with no callback at all. The upside is autonomy and resilience; the downside is larger payloads and a snapshot that may be slightly stale by consumption time.
The fat event or god event is what happens when you over-apply State Transfer and dump the entire aggregate into the event just in case. At National Bank of India, an early version of the transfer event included the internal ledger reference, risk bucket, and sender's balance. When the team renamed riskBucket internally, it broke the fraud scoring consumer because that team had started depending on the field. The fix was to design the event around the meaningful domain fact, including only the fields consumers legitimately need, and treat the schema as a curated contract.
In practice, Event-Carried State Transfer with curated fields is the better default for event-driven systems, because it gives consumer autonomy, resilience, and the ability to maintain local read models. Use Event Notification only when the data is too large or sensitive to carry, or when freshness is so critical that a stale snapshot is unacceptable. A pragmatic hybrid carries the commonly needed fields and includes a link for the rare consumer that needs the full current state.
Remember one sentence: carry the data consumers need in the event so they act autonomously, but curate the fields deliberately and never broadcast the whole aggregate as a god event.
---