Kafka & Avro Messaging
Shared event schemas, Kafka topic catalog, and producer/consumer configuration patterns.
Messaging Architecture
The Arya Banking platform uses Apache Kafka with Confluent Avro for all asynchronous inter-service communication. This ensures type safety and schema compatibility across the ecosystem via the Confluent Schema Registry.
All schemas are defined in the kafka module of arya-banking-common (src/main/avro, namespace org.arya.banking.common.avro) and auto-compiled into Java classes by the avro-maven-plugin during generate-sources.
Topic Catalog
Topic names are centralized in KafkaConstants (org.arya.banking.common.constants.kafka):
| Constant | Topic | Schema | Current Producer | Consumers |
|---|---|---|---|---|
USER_CREATE_EVENT | user.create.event | UserCreateEvent | auth-service (UserEventProducer) | auth-service (UserUpdateEventListener) |
AUDIT_EVENT | audit.event | AuditEvent | — (defined, not yet sent) | None yet |
USER_UPDATE_EVENT | user.update.event | OutboxKafkaEvent (wrapping UserCreateEvent) | user-service (outbox relay) | auth-service (UserUpdateEventListener) |
AUTH_FAILED_EVENT | auth.failed.event | LoginFailedEvent | auth-service (UserEventProducer) | user-service (UserEventListeners) |
| (per-event) | arya-user-svc-usr-update | OutboxKafkaEvent | user-service (outbox relay) | auth-service |
The platform now has active producers and consumers. Auth Service produces UserCreateEvent and LoginFailedEvent, and consumes UserCreateEvent from the User Service outbox topic. User Service consumes LoginFailedEvent for account locking logic.
Avro Schemas
1. Audit Event (AuditEvent.avsc)
Tracks system-wide actions for auditing purposes.
- Topic:
audit.event - Fields:
actionType,targetTable,targetId,userId,changeType,details(all strings)
2. User Create Event (UserCreateEvent.avsc)
Triggered when a new user finishes the registration flow.
- Topic:
user.create.event/user.update.event(via outbox envelope) - Fields:
userId(string),status(string),isEmailVerified(boolean, defaultfalse),isContactVerified(boolean, defaultfalse),metadata(EventMetadata)
3. Login Failed Event (LoginFailedEvent.avsc)
Published when authentication fails, enabling downstream services to track and act on failed login attempts.
- Topic:
auth.failed.event - Fields:
userId(string),isLockUser(boolean),metadata(EventMetadata)
4. User Lock Event (UserLockEvent.avsc)
Published when a user account is locked/unlocked.
- Topic:
user.lock.event - Fields:
userId(string),isLocked(boolean),metadata(EventMetadata)
5. Event Metadata (EventMetadata.avsc)
Standardized metadata attached to all events for tracing and causality.
- Fields:
correlationId(string — tracks request across services),eventId(string — unique event identifier),causationId(string, optional — ID of the event that caused this one)
6. Outbox Kafka Event (OutboxKafkaEvent.avsc)
Envelope used by the outbox pattern relay (arya-banking-outbox-service) to publish pending events to Kafka.
- Topic: per-event (
event.topic— e.g.arya-user-svc-usr-update) - Fields:
aggregateId(string),eventType(string),payload(string — serialized payload, e.g. JSON ofUserCreateEvent)
Kafka Configuration
The kafka module provides a pre-configured KafkaConfiguration class (@ConditionalOnProperty("spring.kafka.bootstrap-servers")) that wires producers and consumers:
- Producer:
StringSerializerkey + ConfluentKafkaAvroSerializervalue; readsspring.kafka.bootstrap-serversandspring.kafka.properties.schema.registry.url. - Consumer factory:
kafkaListerFactory(groupId)helper returning aConcurrentKafkaListenerContainerFactorywithStringDeserializer+KafkaAvroDeserializer,AUTO_OFFSET_RESET=earliest,SPECIFIC_AVRO_READER=true, and the given consumer group. - Confluent Cloud (
kafka:2.0.1+): when SASL / Schema Registry basic-auth properties are set, they are forwarded automatically —security.protocol(SASL_SSL),sasl.mechanism+sasl.jaas.config, andbasic.auth.credentials.source(USER_INFO) +basic.auth.user.info. Empty values are skipped, so local PLAINTEXT setups keep working unchanged. Localdocker-compose.yamlfiles were removed in2.0.1; Confluent Cloud is now the canonical broker.
Producer Pattern
Use the shared KafkaTemplate<String, Object> for sending events — it is pre-wired with the KafkaAvroSerializer:
kafkaTemplate.send("user.create.event", aggregateId, event);
Consumer Pattern
To consume events, services use the kafkaListerFactory(groupId) helper:
@Bean
public ConcurrentKafkaListenerContainerFactory<String, UserCreateEvent> userEventFactory(
KafkaConfiguration kafkaConfiguration) {
return kafkaConfiguration.kafkaListerFactory("your-service-group");
}
Reusing the Shared Producer & Consumer
The kafka module's KafkaConfiguration beans are registered for every service that has the kafka module on the classpath and sets spring.kafka.bootstrap-servers — do not define a second producer or consumer stack in that case:
- Producer: inject the pre-wired
KafkaTemplate<String, Object>(or the outbox starter'sOutboxEventProducer) directly. OneProducerFactoryper JVM is enough; the outbox starter reuses this same factory to build its typedKafkaTemplate<String,OutboxKafkaEvent>. - Consumer: create one
ConcurrentKafkaListenerContainerFactoryper consumer group viakafkaListerFactory("<groupId>")— this is a cheap factory wrapper, not a second connection.
If the Beans Are Not Present
The kafka module's KafkaConfiguration is inactive when spring.kafka.bootstrap-servers is not set. In that case, wire the serializer classes manually (KafkaConstants provides the fully-qualified names):
@Configuration
public class KafkaFallbackConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Value("${spring.kafka.properties.schema.registry.url}")
private String schemaRegistryUrl;
@Bean
public ProducerFactory<?, ?> producerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put("schema.registry.url", schemaRegistryUrl);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class);
return new DefaultKafkaProducerFactory<>(props);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<?, ?> listenerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, "your-service-group");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, KafkaAvroDeserializer.class);
props.put(KafkaAvroDeserializerConfig.SPECIFIC_AVRO_READER_CONFIG, true);
props.put("schema.registry.url", schemaRegistryUrl);
ConcurrentKafkaListenerContainerFactory<?, ?> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(props));
return factory;
}
}
Define a setting bean only once per application context — multiple ProducerFactory/KafkaTemplate beans for the same cluster waste connections and can confuse @ConditionalOnMissingBean logic. Prefer reusing the shared beans over re-defining them.
Key Configuration Properties
spring.kafka.bootstrap-servers— Confluent Cloud (e.g.pkc-41p56.asia-south1.gcp.confluent.cloud:9092); legacy local:localhost:9092/kafka:29092spring.kafka.properties.schema.registry.url— Confluent Schema Registry (e.g.https://psrc-81q7m7.asia-south1.gcp.confluent.cloud); legacy local:http://localhost:8081spring.kafka.properties.security.protocol—SASL_SSLon Confluent Cloud,PLAINTEXTlocally (default)spring.kafka.properties.sasl.mechanism/sasl.jaas.config—PLAIN+PlainLoginModulewith Vault-injected${confluent.kafka.api.key:secret}spring.kafka.properties.basic.auth.credentials.source/basic.auth.user.info—USER_INFO+${confluent.kafka.schema.key:secret}for Schema Registry auth- Service-level Vault keys:
confluent.kafka.api.key/secret,confluent.kafka.schema.key/secret(resolved from Vault viaspring-cloud-starter-vault-config)
Central defaults live in arya-banking-configs/application.yml (Config Server). Services only declare the confluent.* Vault placeholders in their local application.yaml — broker URL, SASL, and Schema Registry auth are inherited.
Correlation ID & Event Context Utilities
The kafka module provides thread-local utilities for distributed tracing and event causality tracking across the microservice ecosystem.
CorrelationIdContext
Thread-local holder for the correlation ID that flows through the entire request chain (gateway → services → Kafka).
// Set at entry point (e.g., Gateway filter or Feign interceptor)
CorrelationIdContext.set(correlationId);
// Retrieve anywhere in the call stack
String correlationId = CorrelationIdContext.get();
Integration: The API Gateway's CorrelationIdGlobalFilter extracts/generates X-Correlation-ID and sets it in MDC and thread-local context.
EventContext
Holds both the correlation ID and the current event ID during event processing (consumer side).
// In @KafkaListener, before processing
EventContext.setEventContext(
event.getMetadata().getCorrelationId().toString(),
event.getMetadata().getEventId().toString()
);
// Later in the call chain
String correlationId = EventContext.getCorrelationId();
String causedEventId = EventContext.getCausedEventId();
// Cleanup after processing
EventContext.remove();
EventMetadataFactory
Standardized factory for creating EventMetadata Avro records with proper correlation/causation linkage.
// For new events (no parent cause)
EventMetadata metadata = EventMetadataFactory.newEventMetadata();
// -> correlationId from EventContext, new eventId, causationId = null
// For events caused by another event (e.g., in saga/compensation)
EventMetadata metadata = EventMetadataFactory.causedByMetadata();
// -> correlationId from EventContext, new eventId, causationId = current event's eventId
GsonParser
Thread-safe JSON-Avro parsing helper that handles CharSequence (Avro's string type) correctly, avoiding "Interfaces can't be instantiated!" errors from Gson.
// Avro SpecificRecord -> JSON
String json = GsonParser.toJson(userCreateEvent);
// JSON -> Avro SpecificRecord
UserCreateEvent event = GsonParser.fromJson(json, UserCreateEvent.class);
// For generic types
List<UserCreateEvent> events = GsonParser.fromJson(json, new TypeToken<List<UserCreateEvent>>(){}.getType());
AOP Aspects
The kafka module also includes Spring AOP aspects for cross-cutting concerns in Kafka consumer methods.
EventContextAop — Automatic ThreadLocal Cleanup
Problem: EventContext uses ThreadLocal<String> for CORRELATION_ID and CAUSED_EVENT_ID. Spring Kafka uses a thread pool — when a @KafkaListener method completes, the thread returns to the pool with stale ThreadLocal values. This causes cross-message context pollution: the next message processed by the same thread inherits the previous message's correlation ID and event ID.
Solution: An @After aspect that automatically clears EventContext.remove() after every @KafkaListener method execution:
@Aspect
@Component
public class EventContextAop {
@After("@annotation(org.springframework.kafka.annotation.KafkaListener)")
public void clearEventContext() {
EventContext.remove();
}
}
Configuration requirements:
spring-boot-starter-aopdependency in your service'spom.xml@EnableAspectJAutoProxyon your Spring Boot application class@ComponentScancoveringorg.arya.banking.common.aop(already included if you scanorg.arya.banking.common)
Impact: All @KafkaListener methods across every service get automatic ThreadLocal cleanup — no manual try-finally blocks needed. This eliminates the root cause of context pollution in pooled-thread Kafka consumers.
The EventContextAop is the first production AOP aspect shipped in the platform. Future aspects (audit logging, authorization, metrics) will follow the same pattern in org.arya.banking.common.aop.
Topic Constants
Always use KafkaConstants for topic names to avoid typos:
KafkaConstants.AUDIT_EVENT->"audit.event"KafkaConstants.USER_CREATE_EVENT->"user.create.event"KafkaConstants.USER_UPDATE_EVENT->"user.update.event"KafkaConstants.AUTH_FAILED_EVENT->"auth.failed.event"
Ensure the Confluent Schema Registry URL plus SASL (security.protocol=SASL_SSL) and Schema Registry basic-auth (USER_INFO) are correctly configured — Avro serialisation fails without them. On Confluent Cloud these come from Config Server + Vault; locally use PLAINTEXT with no JAAS config.