How much data belongs in an event?
That question sits at the heart of every production event architecture decision I've made over five years. Get it right, and your consumers are happy, your system scales, and you sleep well knowing user data isn't leaking across topics. Get it wrong, and you're either republishing the entire user object every time a field changes, or your consumers are making database round-trips just to hydrate basic context.
The short answer is: enough to be useful, not enough to leak. But the practical answer requires understanding your domain, your consumers, and what you're trying to prevent.
Include fields consumers commonly need
When I'm deciding what goes into an event, I start by asking: what does the downstream service actually need to do its job without extra queries?
Consider an e-commerce order system. When an order is placed, you emit an OrderCreated event. Your fulfillment service needs the order ID, items, quantities, and shipping address. Your invoice service needs the same plus the customer's tax ID. Your recommendation engine needs order ID and product IDs. Your analytics pipeline needs timestamps, totals, and customer segments.
The trap is thinking you need to send the entire order object as stored in your database. You don't. You send the fields that answer the question each consumer is trying to solve:
interface OrderCreatedEvent {
orderId: string;
customerId: string;
createdAt: ISO8601;
totalAmount: number;
currency: string;
items: {
productId: string;
sku: string;
quantity: number;
unitPrice: number;
}[];
shippingAddress: {
country: string;
region: string;
postalCode: string;
};
// NOT the full customer object
// NOT order notes or internal comments
// NOT payment method details
}This event gives fulfillment, invoicing, and analytics what they need without forcing them to query the order service for basics. But notice what's not in there: the customer's email, phone, or payment card. Even though some services might want those, they shouldn't be embedded in a generic event broadcast to a topic with potentially hundreds of subscribers.
When you're uncertain, ask: can the consumer get this from a standard lookup? If yes, don't include it. If they need it urgently—like during order processing—include the minimal version (customer ID, not the full customer record).
Avoid personal data unless required
This is where security and practicality meet. In my experience, the moment you put personally identifiable information (PII) into a Kafka topic, you've inherited compliance obligations you didn't sign up for. And compliance gets expensive.
I worked on a ride-hailing platform where we initially included passenger phone numbers in RideAccepted events so drivers could call immediately. Seemed logical. Then the compliance team did the math: we now had to encrypt that topic, rotate keys, audit access, ensure backup retention matched our PII policies, and verify every consumer had data processing agreements. The driver service didn't actually need the passenger's phone in the event—it needed a communication token or a link to fetch it on-demand.
Here's a better pattern:
// Before (bad): Exposes PII to all subscribers
interface RideAcceptedBad {
rideId: string;
passengerId: string;
passengerPhone: string; // PII leak
driverLocation: { lat: number; lng: number };
}
// After (good): Link, not data
interface RideAcceptedGood {
rideId: string;
passengerId: string;
passengerCommunicationToken: string; // Token, not phone
driverLocation: { lat: number; lng: number };
}
// Driver calls a secured endpoint to fetch contact details
// Only the driver service can do this, not every subscriberThe same logic applies to: credit card numbers, bank accounts, social security numbers, medical histories, and any data that triggers GDPR, CCPA, or industry regulations. If you must include it, encrypt the topic and document your data processors. It's simpler to exclude it upfront.
Keep events stable and versioned
Here's what I see go wrong repeatedly: someone adds a field to an event "just in case," and six months later three different consumer teams are depending on it in three different ways. Then you want to remove it or rename it, and now you're coordinating with a dozen services.
Events are contracts. The moment you publish them, they're frozen. Treat them like API versions.
Every event should have a schema version and a clear deprecation path:
interface OrderCreatedEvent {
schemaVersion: number; // Always include this
eventId: string;
eventType: "order.created";
emittedAt: ISO8601;
orderId: string;
customerId: string;
totalAmount: number;
// New field added in v2? Mark it clearly
// @deprecated since schemaVersion 3, use orderStatus
legacyOrderState?: string;
}
// Producer side: emit with version
const event: OrderCreatedEvent = {
schemaVersion: 3,
eventId: crypto.randomUUID(),
eventType: "order.created",
// ... rest of fields
}
// Consumer side: handle versions
function handleOrderCreated(event: OrderCreatedEvent) {
if (event.schemaVersion === 1) {
return handleV1(event);
} else if (event.schemaVersion >= 2) {
return handleV2Plus(event);
}
}I also version the topic itself rather than mixing versions in one stream. order.created.v1 and order.created.v2 as separate topics means old consumers never break. New consumers subscribe to v2. When everyone has migrated, you retire v1. Clear and clean.
Large blobs go to storage, events carry links
This is the rule I apply when I'm unsure about size or sensitivity: if it's a large file, encrypted data, or something that changes frequently, it doesn't belong in the event. The event carries a reference.
Example: a user uploads a profile photo. Do you embed the image binary in a ProfilePhotoUpdated event? No. Do you send the image URL? No—that can change. Do you send the raw image as a base64 blob? Absolutely not.
You send a reference that lets the consumer fetch it if needed:
interface ProfilePhotoUpdatedEvent {
schemaVersion: 1;
userId: string;
photoStorageId: string; // A unique ID in S3, GCS, etc.
photoUrl?: string; // Computed URL, regenerated if needed
updatedAt: ISO8601;
// NOT the raw image bytes
// NOT base64 encoded data
}
// Consumer: fetch from storage if needed
async function handlePhotoUpdated(event: ProfilePhotoUpdatedEvent) {
const photoBuffer = await s3.getObject(event.photoStorageId);
// Process downstream
}This keeps events lean, lets you update storage without republishing, and decouples the event from blob lifecycle. It also means you can rotate credentials, change storage backends, or compress differently without touching Kafka.
Common mistakes
- Embedding entire objects: Sending the whole User, Order, or Invoice entity because "we might need it later." You won't, and when the schema changes, you break all consumers. Send fields, not objects.
- Assuming every consumer needs every field: One team needs customer segment for analytics, another needs shipping address for logistics. Don't bloat the event trying to please everyone. If a field is rarely used, let consumers query for it.
- Including PII without thinking through compliance: Once it's in Kafka, you've accepted responsibility for its security, retention, and access control across your organization. That's expensive. Exclude it unless genuinely required by the immediate consumer.
- Changing events without versioning: Adding or removing fields, renaming, or changing types without bumping the schema version breaks consumers silently. Version everything.
- Sending large attachments inline: Embedding PDFs, images, or JSON documents as base64 slows Kafka, wastes network, and makes storage harder. Move to object storage, send a reference.
Key takeaways
- Include only the fields consumers need to act without additional queries—nothing more, nothing less.
- Exclude personal data unless it's core to the event's immediate purpose; use tokens or references instead.
- Always version your event schema and maintain clear deprecation paths.
- Keep events small: large blobs belong in object storage, not Kafka. Events carry references.
- Think of events as published contracts; they're frozen the moment consumers depend on them.






Comments
Loading comments…