Definition
- Events are contracts – a published event creates a dependency just like a REST API; consumers couple to its fields and meaning.
- Raw changelog danger – publishing every table INSERT/UPDATE/DELETE exposes persistence internals and makes schema evolution painful.
- Domain facts – publish events that represent meaningful business occurrences like
TransferCompletedorAccountFrozen. - Versioned contracts – treat event schemas with the same discipline as API contracts, using Avro or Protobuf with a Schema Registry.
- Outbox pattern – use CDC on a curated outbox table, not on business tables directly.
- Guiding question – does another component legitimately need this fact, and am I willing to support it as a contract?
Imagine every time National Bank of India's transfer department scribbled anything in its internal notebook: a draft figure, a corrected typo, an intermediate status, it photocopied that page and mailed it to every other department. The other departments would start depending on those scribbles, so the transfer team could never tidy its notebook without breaking everyone. The right approach is that the department only sends an official announcement when something meaningful happens, like "a transfer completed," on a standardized form. The notebook stays private; only deliberate domain facts become announcements.
Understanding the Concepts
Why is publishing every database change tempting but wrong?
The tempting idea is to wire CDC (Change Data Capture) onto every table and publish every insert, update, and delete as a public event. It feels decoupled because the producer does not call anyone, but a published event is a contract. The instant a consumer reads fields from your event, those fields become an agreement you must honor.
package com.bank.events;
// WRONG: event that mirrors the database table structure
public class AccountRowChangedEvent {
private String operationType; // INSERT, UPDATE, DELETE
private String accountId;
private String accountNumber;
private String internalStatus; // DRAFT, VALIDATING, RISK_CHECKED
private String customerIdFk; // internal foreign key
private String denormBalance; // denormalized column
// Exposes every internal column as a public contract!
}
Problems with raw changelog events:
1. Internal columns (internalStatus, customerIdFk) exposed publicly
2. Consumers depend on column names you want to rename
3. Schema migration = breaking change for all consumers
4. One transfer touches 5 rows = 15 events (noisy)
5. Intermediate states (DRAFT, VALIDATING) leak internal state machine
What are the concrete harms?
Exposing internals, painful schema evolution, semantic noise, and tight coupling are the four direct harms.
Harm 1: Exposure of internals
Internal staging states (DRAFT, VALIDATING, RISK_CHECKED) visible
Consumers build logic on states you need to change
Harm 2: Painful schema evolution
Rename account_number -> acc_num?
Every consumer breaks, even though this is YOUR internal table
The freedom to evolve your own data model (a key benefit of
database-per-service) is gone
Harm 3: Semantic noise
Consumer receives:
INSERT account_row (status=DRAFT)
UPDATE account_row (status=VALIDATING)
UPDATE account_row (status=RISK_CHECKED)
UPDATE account_row (status=COMPLETED)
INSERT ledger_entry
Must reverse-engineer: "oh, a transfer completed"
Harm 4: Tight coupling
The whole point of events was loose coupling
Raw changelog events deliver the opposite
What should you publish instead?
Publish events that represent meaningful domain facts: things that happened in the business that other components legitimately care about.
package com.bank.events;
import java.math.BigDecimal;
import java.time.Instant;
// CORRECT: deliberate domain-fact event
public class TransferCompletedEvent {
private String transferId; // business identifier
private String fromAccountId; // SAV2001
private String toAccountId; // SAV2002
private BigDecimal amount; // 50000.00
private String currency; // INR
private Instant completedAt; // when it finished
// Only curated fields consumers actually need
// No internal states, no FK columns, no denormalized data
}
package com.bank.transfer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TransferService {
private final AccountRepository accountRepo;
private final OutboxRepository outbox;
public TransferService(AccountRepository accountRepo,
OutboxRepository outbox) {
this.accountRepo = accountRepo;
this.outbox = outbox;
}
@Transactional
public void transfer(String from, String to, BigDecimal amount) {
// Business logic touches multiple rows/tables internally
accountRepo.debit(from, amount);
accountRepo.credit(to, amount);
accountRepo.updateStatus(from, "COMPLETED");
// ONE curated domain-fact event, not N row-change events
outbox.save(new OutboxEvent(
"TransferCompleted",
new TransferCompletedEvent(
"TXF-" + System.currentTimeMillis(),
from, to, amount, "INR")
));
}
}
Raw changelog: 5 rows changed = 15 events (noisy, leaky)
Domain facts: 1 business operation = 1 TransferCompleted event (clean)
How does the outbox pattern work with CDC?
CDC and the transactional outbox are excellent mechanisms, but you point them at an outbox table that you fill with deliberately designed domain-fact events, not at your business tables directly.
package com.bank.outbox;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
private String eventId;
private String eventType; // "TransferCompleted"
private String payload; // JSON of the curated event
private Instant createdAt;
private boolean published;
// CDC reads from this table, not from business tables
// Publisher marks published=true after sending to Kafka
}
Outbox pattern flow:
1. Business logic writes to business tables
2. In the SAME transaction, writes curated event to outbox table
3. CDC (Debezium) reads outbox table and publishes to Kafka
4. CDC publishes YOUR chosen events, not raw table mutations
5. Internal schema changes do not affect the outbox contract
How should event schemas be versioned?
Treat event schemas with the same discipline as API contracts. Use Avro or Protobuf with a Schema Registry to enforce backward compatibility.
Event versioning rules:
Safe changes (backward compatible):
- Add a new optional field
- Add a new event type
- Add a default value to an existing field
Breaking changes (require new version):
- Remove a field
- Rename a field
- Change a field's type
- Change the meaning of a field
Schema Registry enforces compatibility:
- BACKWARD: new schema can read old data
- FORWARD: old schema can read new data
- FULL: both directions
How do domain-fact events compare to raw changelog events?
| Aspect | Raw Changelog | Domain-Fact Events |
|---|---|---|
| Granularity | Per-row mutation | Per-business-operation |
| Schema | Database columns | Curated business fields |
| Coupling | Tight (internals exposed) | Loose (API-like contract) |
| Schema evolution | Painful (breaks consumers) | Free (internal schema private) |
| Noise | High (intermediate states) | Low (meaningful facts) |
| Consumer complexity | Must reverse-engineer meaning | Meaning is explicit |
| CDC target | Business tables | Outbox table |
What is the guiding question for any proposed event?
Before publishing any event, ask: does another component legitimately need to know this business fact, and am I willing to support this as a versioned contract? If not, it should not be a public event.
package com.bank.events;
// Decision framework
public class EventDecision {
/*
* Publish as event:
* TransferCompleted -> notification service needs it
* AccountFrozen -> fraud service needs it
* LoanApproved -> document service needs it
*
* Keep private:
* Transfer status: DRAFT -> VALIDATING (internal state)
* Ledger entry created (implementation detail)
* Balance column updated (persistence internals)
* Risk score recalculated (intermediate computation)
*/
}
What are common mistakes?
The most common mistake is wiring CDC directly onto business tables. Another is not versioning event schemas.
WRONG: CDC on business tables
Debezium watches accounts table -> every UPDATE becomes an event
Consumer depends on column names -> schema evolution blocked
CORRECT: CDC on outbox table
Business writes curated event to outbox table
Debezium watches outbox -> publishes YOUR chosen events
Internal schema changes do not affect consumers
Visual Overview
WRONG: CDC on Business Tables
┌───────────────┐ CDC ┌─────────┐ ┌──────────┐
│ Business Table│ ──────────> │ Kafka │ ──> │ Consumer │
│ (internals) │ every row │ │ │ (coupled │
│ exposed │ mutation │ │ │ to cols)│
└───────────────┘ └─────────┘ └──────────┘
CORRECT: CDC on Outbox Table
┌───────────────┐ same TX ┌──────────────┐
│ Business Table│ ────────> │ Outbox Table │
│ (private) │ │ (curated │
└───────────────┘ │ domain facts│
└──────┬───────┘
│ CDC
▼
┌─────────┐ ┌──────────┐
│ Kafka │ ──> │ Consumer │
│ │ │ (reads │
│ │ │ clean │
└─────────┘ │ events) │
└──────────┘
Key Takeaway
A service should not publish an event for every database change, because a published event is a contract exactly like a public API. The moment a consumer reads fields from your event, those fields become an agreement you must honor. If your events are just table rows streamed out, then your database schema has silently become your public API, coupling every consumer to your persistence internals.
The right approach is to publish events that represent meaningful domain facts: TransferCompleted, AccountFrozen, LoanApproved. These are deliberately designed, named in business terms, carry only the curated fields consumers need, and are versioned as contracts with Avro or Protobuf. One business operation that touches five rows produces one clean event, not fifteen row-change events. CDC and the outbox pattern are excellent, but you point them at an outbox table filled with curated events, not at business tables directly.
At National Bank of India, the transfer moves through internal states like DRAFT, VALIDATING, and RISK_CHECKED. Publishing every mutation would have exposed the internal state machine and consumers would have started depending on states the team needed to change. Instead, only a single TransferCompleted event with account, beneficiary, and amount is published, keeping the internal schema private and free to evolve.
Remember one sentence: events are contracts just like APIs, so publish deliberate, versioned domain facts through an outbox, never a raw changelog of every table mutation.
---