@corbat-tech/coding-standards-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/assets/demo.gif +0 -0
- package/dist/agent.d.ts +53 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +629 -0
- package/dist/agent.js.map +1 -0
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +651 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/config.d.ts +73 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +105 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/profiles.d.ts +39 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +526 -0
- package/dist/profiles.js.map +1 -0
- package/dist/prompts-legacy.d.ts +25 -0
- package/dist/prompts-legacy.d.ts.map +1 -0
- package/dist/prompts-legacy.js +600 -0
- package/dist/prompts-legacy.js.map +1 -0
- package/dist/prompts-v2.d.ts +30 -0
- package/dist/prompts-v2.d.ts.map +1 -0
- package/dist/prompts-v2.js +310 -0
- package/dist/prompts-v2.js.map +1 -0
- package/dist/prompts.d.ts +30 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +310 -0
- package/dist/prompts.js.map +1 -0
- package/dist/resources.d.ts +18 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +95 -0
- package/dist/resources.js.map +1 -0
- package/dist/tools-legacy.d.ts +196 -0
- package/dist/tools-legacy.d.ts.map +1 -0
- package/dist/tools-legacy.js +1230 -0
- package/dist/tools-legacy.js.map +1 -0
- package/dist/tools-v2.d.ts +92 -0
- package/dist/tools-v2.d.ts.map +1 -0
- package/dist/tools-v2.js +410 -0
- package/dist/tools-v2.js.map +1 -0
- package/dist/tools.d.ts +92 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +410 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +3054 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +515 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/retry.d.ts +44 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +74 -0
- package/dist/utils/retry.js.map +1 -0
- package/package.json +79 -0
- package/profiles/README.md +199 -0
- package/profiles/custom/.gitkeep +2 -0
- package/profiles/templates/_template.yaml +159 -0
- package/profiles/templates/angular.yaml +494 -0
- package/profiles/templates/java-spring-backend.yaml +512 -0
- package/profiles/templates/minimal.yaml +102 -0
- package/profiles/templates/nodejs.yaml +338 -0
- package/profiles/templates/python.yaml +340 -0
- package/profiles/templates/react.yaml +331 -0
- package/profiles/templates/vue.yaml +598 -0
- package/standards/architecture/ddd.md +173 -0
- package/standards/architecture/hexagonal.md +97 -0
- package/standards/cicd/github-actions.md +567 -0
- package/standards/clean-code/naming.md +175 -0
- package/standards/clean-code/principles.md +179 -0
- package/standards/containerization/dockerfile.md +419 -0
- package/standards/database/selection-guide.md +443 -0
- package/standards/documentation/guidelines.md +189 -0
- package/standards/event-driven/domain-events.md +527 -0
- package/standards/kubernetes/deployment.md +518 -0
- package/standards/observability/guidelines.md +665 -0
- package/standards/project-setup/initialization-checklist.md +650 -0
- package/standards/spring-boot/best-practices.md +598 -0
- package/standards/testing/guidelines.md +559 -0
- package/standards/workflow/llm-development-workflow.md +542 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
# Event-Driven Architecture Guidelines
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
Event-driven architecture enables loose coupling between components through asynchronous messaging. This guide covers domain events, event publishing, and messaging patterns for Spring Boot applications.
|
|
6
|
+
|
|
7
|
+
## Domain Events
|
|
8
|
+
|
|
9
|
+
Domain events represent something significant that happened in the domain. They are named in the past tense and carry immutable data.
|
|
10
|
+
|
|
11
|
+
### Event Structure
|
|
12
|
+
|
|
13
|
+
```java
|
|
14
|
+
// Base domain event
|
|
15
|
+
public abstract class DomainEvent {
|
|
16
|
+
private final String eventId;
|
|
17
|
+
private final Instant occurredOn;
|
|
18
|
+
private final String aggregateId;
|
|
19
|
+
private final String aggregateType;
|
|
20
|
+
|
|
21
|
+
protected DomainEvent(String aggregateId, String aggregateType) {
|
|
22
|
+
this.eventId = UUID.randomUUID().toString();
|
|
23
|
+
this.occurredOn = Instant.now();
|
|
24
|
+
this.aggregateId = aggregateId;
|
|
25
|
+
this.aggregateType = aggregateType;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Getters...
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Concrete domain event
|
|
32
|
+
public class OrderCreatedEvent extends DomainEvent {
|
|
33
|
+
private final String customerId;
|
|
34
|
+
private final BigDecimal totalAmount;
|
|
35
|
+
private final List<OrderLineData> lines;
|
|
36
|
+
|
|
37
|
+
public OrderCreatedEvent(
|
|
38
|
+
String orderId,
|
|
39
|
+
String customerId,
|
|
40
|
+
BigDecimal totalAmount,
|
|
41
|
+
List<OrderLineData> lines) {
|
|
42
|
+
super(orderId, "Order");
|
|
43
|
+
this.customerId = customerId;
|
|
44
|
+
this.totalAmount = totalAmount;
|
|
45
|
+
this.lines = List.copyOf(lines);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Immutable record for nested data
|
|
49
|
+
public record OrderLineData(
|
|
50
|
+
String productId,
|
|
51
|
+
int quantity,
|
|
52
|
+
BigDecimal unitPrice
|
|
53
|
+
) {}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Event Naming Conventions
|
|
58
|
+
|
|
59
|
+
| Pattern | Examples |
|
|
60
|
+
|---------|----------|
|
|
61
|
+
| Past tense | `OrderCreatedEvent`, `PaymentReceivedEvent`, `OrderShippedEvent` |
|
|
62
|
+
| Aggregate + Action | `Order` + `Created` = `OrderCreatedEvent` |
|
|
63
|
+
| Descriptive | `CustomerEmailVerifiedEvent`, `InventoryReservedEvent` |
|
|
64
|
+
|
|
65
|
+
### Common Domain Events
|
|
66
|
+
|
|
67
|
+
```java
|
|
68
|
+
// Order domain events
|
|
69
|
+
public class OrderCreatedEvent extends DomainEvent { }
|
|
70
|
+
public class OrderConfirmedEvent extends DomainEvent { }
|
|
71
|
+
public class OrderShippedEvent extends DomainEvent { }
|
|
72
|
+
public class OrderCancelledEvent extends DomainEvent { }
|
|
73
|
+
public class OrderDeliveredEvent extends DomainEvent { }
|
|
74
|
+
|
|
75
|
+
// Payment domain events
|
|
76
|
+
public class PaymentInitiatedEvent extends DomainEvent { }
|
|
77
|
+
public class PaymentReceivedEvent extends DomainEvent { }
|
|
78
|
+
public class PaymentFailedEvent extends DomainEvent { }
|
|
79
|
+
public class RefundProcessedEvent extends DomainEvent { }
|
|
80
|
+
|
|
81
|
+
// Customer domain events
|
|
82
|
+
public class CustomerRegisteredEvent extends DomainEvent { }
|
|
83
|
+
public class CustomerEmailVerifiedEvent extends DomainEvent { }
|
|
84
|
+
public class CustomerAddressUpdatedEvent extends DomainEvent { }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Event Publishing
|
|
88
|
+
|
|
89
|
+
### Domain Event Publisher Interface
|
|
90
|
+
|
|
91
|
+
Define the interface in the domain layer (port):
|
|
92
|
+
|
|
93
|
+
```java
|
|
94
|
+
// Domain layer - port
|
|
95
|
+
public interface DomainEventPublisher {
|
|
96
|
+
void publish(DomainEvent event);
|
|
97
|
+
void publishAll(List<DomainEvent> events);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Aggregate Event Collection
|
|
102
|
+
|
|
103
|
+
Aggregates collect domain events during business operations:
|
|
104
|
+
|
|
105
|
+
```java
|
|
106
|
+
public abstract class AggregateRoot {
|
|
107
|
+
private final List<DomainEvent> domainEvents = new ArrayList<>();
|
|
108
|
+
|
|
109
|
+
protected void registerEvent(DomainEvent event) {
|
|
110
|
+
domainEvents.add(event);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public List<DomainEvent> getDomainEvents() {
|
|
114
|
+
return Collections.unmodifiableList(domainEvents);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public void clearDomainEvents() {
|
|
118
|
+
domainEvents.clear();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
public class Order extends AggregateRoot {
|
|
123
|
+
private OrderId id;
|
|
124
|
+
private OrderStatus status;
|
|
125
|
+
|
|
126
|
+
public static Order create(OrderId id, CustomerId customerId, List<OrderLine> lines) {
|
|
127
|
+
Order order = new Order();
|
|
128
|
+
order.id = id;
|
|
129
|
+
order.customerId = customerId;
|
|
130
|
+
order.lines = new ArrayList<>(lines);
|
|
131
|
+
order.status = OrderStatus.PENDING;
|
|
132
|
+
order.createdAt = Instant.now();
|
|
133
|
+
|
|
134
|
+
// Register domain event
|
|
135
|
+
order.registerEvent(new OrderCreatedEvent(
|
|
136
|
+
id.value(),
|
|
137
|
+
customerId.value(),
|
|
138
|
+
order.calculateTotal().amount(),
|
|
139
|
+
lines.stream().map(OrderLine::toEventData).toList()
|
|
140
|
+
));
|
|
141
|
+
|
|
142
|
+
return order;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public void confirm() {
|
|
146
|
+
if (status != OrderStatus.PENDING) {
|
|
147
|
+
throw new IllegalStateException("Order can only be confirmed when pending");
|
|
148
|
+
}
|
|
149
|
+
this.status = OrderStatus.CONFIRMED;
|
|
150
|
+
registerEvent(new OrderConfirmedEvent(id.value()));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public void ship(TrackingNumber trackingNumber) {
|
|
154
|
+
if (status != OrderStatus.CONFIRMED) {
|
|
155
|
+
throw new IllegalStateException("Order must be confirmed before shipping");
|
|
156
|
+
}
|
|
157
|
+
this.status = OrderStatus.SHIPPED;
|
|
158
|
+
this.trackingNumber = trackingNumber;
|
|
159
|
+
registerEvent(new OrderShippedEvent(id.value(), trackingNumber.value()));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Use Case Event Publishing
|
|
165
|
+
|
|
166
|
+
Publish events after the use case completes successfully:
|
|
167
|
+
|
|
168
|
+
```java
|
|
169
|
+
@Service
|
|
170
|
+
@RequiredArgsConstructor
|
|
171
|
+
@Transactional
|
|
172
|
+
public class PlaceOrderUseCase {
|
|
173
|
+
|
|
174
|
+
private final OrderRepository orderRepository;
|
|
175
|
+
private final DomainEventPublisher eventPublisher;
|
|
176
|
+
|
|
177
|
+
public OrderId execute(PlaceOrderCommand command) {
|
|
178
|
+
// Create the order (events are registered)
|
|
179
|
+
Order order = Order.create(
|
|
180
|
+
OrderId.generate(),
|
|
181
|
+
command.customerId(),
|
|
182
|
+
command.toOrderLines()
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Persist the order
|
|
186
|
+
orderRepository.save(order);
|
|
187
|
+
|
|
188
|
+
// Publish events after successful persistence
|
|
189
|
+
eventPublisher.publishAll(order.getDomainEvents());
|
|
190
|
+
order.clearDomainEvents();
|
|
191
|
+
|
|
192
|
+
return order.getId();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Messaging with Kafka
|
|
198
|
+
|
|
199
|
+
### Kafka Event Publisher Implementation
|
|
200
|
+
|
|
201
|
+
```java
|
|
202
|
+
@Component
|
|
203
|
+
@RequiredArgsConstructor
|
|
204
|
+
@Slf4j
|
|
205
|
+
public class KafkaDomainEventPublisher implements DomainEventPublisher {
|
|
206
|
+
|
|
207
|
+
private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
|
|
208
|
+
private final TopicResolver topicResolver;
|
|
209
|
+
|
|
210
|
+
@Override
|
|
211
|
+
public void publish(DomainEvent event) {
|
|
212
|
+
String topic = topicResolver.resolve(event);
|
|
213
|
+
String key = event.getAggregateId();
|
|
214
|
+
|
|
215
|
+
kafkaTemplate.send(topic, key, event)
|
|
216
|
+
.whenComplete((result, ex) -> {
|
|
217
|
+
if (ex != null) {
|
|
218
|
+
log.error("Failed to publish event {} to topic {}",
|
|
219
|
+
event.getClass().getSimpleName(), topic, ex);
|
|
220
|
+
} else {
|
|
221
|
+
log.debug("Published event {} to topic {} partition {}",
|
|
222
|
+
event.getClass().getSimpleName(), topic,
|
|
223
|
+
result.getRecordMetadata().partition());
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
@Override
|
|
229
|
+
public void publishAll(List<DomainEvent> events) {
|
|
230
|
+
events.forEach(this::publish);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Topic Naming Convention
|
|
236
|
+
|
|
237
|
+
Format: `{domain}.{aggregate}.{event-type}`
|
|
238
|
+
|
|
239
|
+
```java
|
|
240
|
+
@Component
|
|
241
|
+
public class TopicResolver {
|
|
242
|
+
|
|
243
|
+
public String resolve(DomainEvent event) {
|
|
244
|
+
String aggregateType = event.getAggregateType().toLowerCase();
|
|
245
|
+
String eventType = extractEventType(event.getClass().getSimpleName());
|
|
246
|
+
return String.format("%s.%s.%s", "orders", aggregateType, eventType);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private String extractEventType(String className) {
|
|
250
|
+
// OrderCreatedEvent -> created
|
|
251
|
+
return className
|
|
252
|
+
.replace("Event", "")
|
|
253
|
+
.replaceAll("([a-z])([A-Z])", "$1-$2")
|
|
254
|
+
.toLowerCase()
|
|
255
|
+
.substring(className.indexOf(className.replaceAll("^[A-Z][a-z]+", "").substring(0, 1)));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Example topics:
|
|
261
|
+
- `orders.order.created`
|
|
262
|
+
- `orders.order.confirmed`
|
|
263
|
+
- `orders.order.shipped`
|
|
264
|
+
- `payments.payment.received`
|
|
265
|
+
- `inventory.stock.reserved`
|
|
266
|
+
|
|
267
|
+
### Kafka Configuration
|
|
268
|
+
|
|
269
|
+
```java
|
|
270
|
+
@Configuration
|
|
271
|
+
public class KafkaProducerConfig {
|
|
272
|
+
|
|
273
|
+
@Bean
|
|
274
|
+
public ProducerFactory<String, DomainEvent> producerFactory(KafkaProperties kafkaProperties) {
|
|
275
|
+
Map<String, Object> props = new HashMap<>(kafkaProperties.buildProducerProperties());
|
|
276
|
+
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
|
|
277
|
+
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
|
|
278
|
+
props.put(ProducerConfig.ACKS_CONFIG, "all");
|
|
279
|
+
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
|
|
280
|
+
props.put(ProducerConfig.RETRIES_CONFIG, 3);
|
|
281
|
+
return new DefaultKafkaProducerFactory<>(props);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@Bean
|
|
285
|
+
public KafkaTemplate<String, DomainEvent> kafkaTemplate(
|
|
286
|
+
ProducerFactory<String, DomainEvent> producerFactory) {
|
|
287
|
+
KafkaTemplate<String, DomainEvent> template = new KafkaTemplate<>(producerFactory);
|
|
288
|
+
template.setObservationEnabled(true); // Enable tracing
|
|
289
|
+
return template;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Application Properties
|
|
295
|
+
|
|
296
|
+
```yaml
|
|
297
|
+
spring:
|
|
298
|
+
kafka:
|
|
299
|
+
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
|
|
300
|
+
producer:
|
|
301
|
+
acks: all
|
|
302
|
+
retries: 3
|
|
303
|
+
properties:
|
|
304
|
+
enable.idempotence: true
|
|
305
|
+
max.in.flight.requests.per.connection: 5
|
|
306
|
+
consumer:
|
|
307
|
+
group-id: ${spring.application.name}
|
|
308
|
+
auto-offset-reset: earliest
|
|
309
|
+
enable-auto-commit: false
|
|
310
|
+
properties:
|
|
311
|
+
isolation.level: read_committed
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Event Consumers
|
|
315
|
+
|
|
316
|
+
### Kafka Listener
|
|
317
|
+
|
|
318
|
+
```java
|
|
319
|
+
@Component
|
|
320
|
+
@RequiredArgsConstructor
|
|
321
|
+
@Slf4j
|
|
322
|
+
public class OrderEventListener {
|
|
323
|
+
|
|
324
|
+
private final NotificationService notificationService;
|
|
325
|
+
private final InventoryService inventoryService;
|
|
326
|
+
|
|
327
|
+
@KafkaListener(
|
|
328
|
+
topics = "orders.order.created",
|
|
329
|
+
groupId = "notification-service"
|
|
330
|
+
)
|
|
331
|
+
public void onOrderCreated(
|
|
332
|
+
@Payload OrderCreatedEvent event,
|
|
333
|
+
@Header(KafkaHeaders.RECEIVED_KEY) String key,
|
|
334
|
+
Acknowledgment ack) {
|
|
335
|
+
|
|
336
|
+
log.info("Received OrderCreatedEvent for order {}", event.getAggregateId());
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
// Process the event
|
|
340
|
+
notificationService.sendOrderConfirmation(event);
|
|
341
|
+
|
|
342
|
+
// Acknowledge successful processing
|
|
343
|
+
ack.acknowledge();
|
|
344
|
+
} catch (Exception e) {
|
|
345
|
+
log.error("Failed to process OrderCreatedEvent", e);
|
|
346
|
+
// Don't acknowledge - message will be redelivered
|
|
347
|
+
throw e;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
@KafkaListener(
|
|
352
|
+
topics = "orders.order.confirmed",
|
|
353
|
+
groupId = "inventory-service"
|
|
354
|
+
)
|
|
355
|
+
public void onOrderConfirmed(OrderConfirmedEvent event, Acknowledgment ack) {
|
|
356
|
+
log.info("Order {} confirmed, reserving inventory", event.getAggregateId());
|
|
357
|
+
inventoryService.reserveInventory(event.getAggregateId());
|
|
358
|
+
ack.acknowledge();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Consumer Configuration
|
|
364
|
+
|
|
365
|
+
```java
|
|
366
|
+
@Configuration
|
|
367
|
+
@EnableKafka
|
|
368
|
+
public class KafkaConsumerConfig {
|
|
369
|
+
|
|
370
|
+
@Bean
|
|
371
|
+
public ConsumerFactory<String, DomainEvent> consumerFactory(KafkaProperties kafkaProperties) {
|
|
372
|
+
Map<String, Object> props = new HashMap<>(kafkaProperties.buildConsumerProperties());
|
|
373
|
+
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
|
|
374
|
+
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
|
|
375
|
+
props.put(JsonDeserializer.TRUSTED_PACKAGES, "com.example.domain.event");
|
|
376
|
+
|
|
377
|
+
return new DefaultKafkaConsumerFactory<>(
|
|
378
|
+
props,
|
|
379
|
+
new StringDeserializer(),
|
|
380
|
+
new JsonDeserializer<>(DomainEvent.class)
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
@Bean
|
|
385
|
+
public ConcurrentKafkaListenerContainerFactory<String, DomainEvent> kafkaListenerContainerFactory(
|
|
386
|
+
ConsumerFactory<String, DomainEvent> consumerFactory) {
|
|
387
|
+
|
|
388
|
+
ConcurrentKafkaListenerContainerFactory<String, DomainEvent> factory =
|
|
389
|
+
new ConcurrentKafkaListenerContainerFactory<>();
|
|
390
|
+
|
|
391
|
+
factory.setConsumerFactory(consumerFactory);
|
|
392
|
+
factory.getContainerProperties().setAckMode(AckMode.MANUAL);
|
|
393
|
+
factory.getContainerProperties().setObservationEnabled(true);
|
|
394
|
+
|
|
395
|
+
return factory;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Transactional Outbox Pattern
|
|
401
|
+
|
|
402
|
+
For guaranteed event delivery, use the Transactional Outbox pattern:
|
|
403
|
+
|
|
404
|
+
### Outbox Entity
|
|
405
|
+
|
|
406
|
+
```java
|
|
407
|
+
@Entity
|
|
408
|
+
@Table(name = "outbox_events")
|
|
409
|
+
public class OutboxEvent {
|
|
410
|
+
@Id
|
|
411
|
+
private String id;
|
|
412
|
+
|
|
413
|
+
@Column(nullable = false)
|
|
414
|
+
private String aggregateType;
|
|
415
|
+
|
|
416
|
+
@Column(nullable = false)
|
|
417
|
+
private String aggregateId;
|
|
418
|
+
|
|
419
|
+
@Column(nullable = false)
|
|
420
|
+
private String eventType;
|
|
421
|
+
|
|
422
|
+
@Column(columnDefinition = "TEXT", nullable = false)
|
|
423
|
+
private String payload;
|
|
424
|
+
|
|
425
|
+
@Column(nullable = false)
|
|
426
|
+
private Instant createdAt;
|
|
427
|
+
|
|
428
|
+
@Column
|
|
429
|
+
private Instant processedAt;
|
|
430
|
+
|
|
431
|
+
// Factory method
|
|
432
|
+
public static OutboxEvent from(DomainEvent event, ObjectMapper objectMapper) {
|
|
433
|
+
OutboxEvent outbox = new OutboxEvent();
|
|
434
|
+
outbox.id = event.getEventId();
|
|
435
|
+
outbox.aggregateType = event.getAggregateType();
|
|
436
|
+
outbox.aggregateId = event.getAggregateId();
|
|
437
|
+
outbox.eventType = event.getClass().getSimpleName();
|
|
438
|
+
outbox.payload = objectMapper.writeValueAsString(event);
|
|
439
|
+
outbox.createdAt = Instant.now();
|
|
440
|
+
return outbox;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Outbox Publisher
|
|
446
|
+
|
|
447
|
+
```java
|
|
448
|
+
@Component
|
|
449
|
+
@RequiredArgsConstructor
|
|
450
|
+
public class OutboxEventPublisher implements DomainEventPublisher {
|
|
451
|
+
|
|
452
|
+
private final OutboxRepository outboxRepository;
|
|
453
|
+
private final ObjectMapper objectMapper;
|
|
454
|
+
|
|
455
|
+
@Override
|
|
456
|
+
@Transactional
|
|
457
|
+
public void publish(DomainEvent event) {
|
|
458
|
+
OutboxEvent outboxEvent = OutboxEvent.from(event, objectMapper);
|
|
459
|
+
outboxRepository.save(outboxEvent);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Outbox Processor (Scheduled)
|
|
465
|
+
|
|
466
|
+
```java
|
|
467
|
+
@Component
|
|
468
|
+
@RequiredArgsConstructor
|
|
469
|
+
@Slf4j
|
|
470
|
+
public class OutboxProcessor {
|
|
471
|
+
|
|
472
|
+
private final OutboxRepository outboxRepository;
|
|
473
|
+
private final KafkaTemplate<String, String> kafkaTemplate;
|
|
474
|
+
private final TopicResolver topicResolver;
|
|
475
|
+
|
|
476
|
+
@Scheduled(fixedDelayString = "${outbox.processing.interval:1000}")
|
|
477
|
+
@Transactional
|
|
478
|
+
public void processOutbox() {
|
|
479
|
+
List<OutboxEvent> events = outboxRepository.findUnprocessed(100);
|
|
480
|
+
|
|
481
|
+
for (OutboxEvent event : events) {
|
|
482
|
+
try {
|
|
483
|
+
String topic = topicResolver.resolve(event.getAggregateType(), event.getEventType());
|
|
484
|
+
kafkaTemplate.send(topic, event.getAggregateId(), event.getPayload()).get();
|
|
485
|
+
event.markAsProcessed();
|
|
486
|
+
outboxRepository.save(event);
|
|
487
|
+
} catch (Exception e) {
|
|
488
|
+
log.error("Failed to process outbox event {}", event.getId(), e);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
## Best Practices
|
|
496
|
+
|
|
497
|
+
1. **Events are immutable**: Never modify event data after creation
|
|
498
|
+
2. **Events are facts**: Name them in past tense (OrderCreated, not CreateOrder)
|
|
499
|
+
3. **Include enough context**: Events should be self-contained for consumers
|
|
500
|
+
4. **Version your events**: Plan for schema evolution
|
|
501
|
+
5. **Use idempotent consumers**: Handle duplicate deliveries gracefully
|
|
502
|
+
6. **Consider ordering**: Use partition keys for ordered delivery when needed
|
|
503
|
+
7. **Monitor event processing**: Track lag, errors, and processing time
|
|
504
|
+
8. **Handle failures gracefully**: Implement retry and dead letter queues
|
|
505
|
+
9. **Test event flows**: Include integration tests for event producers and consumers
|
|
506
|
+
10. **Document event schemas**: Maintain an event catalog for discovery
|
|
507
|
+
|
|
508
|
+
## Event Schema Evolution
|
|
509
|
+
|
|
510
|
+
When events need to change:
|
|
511
|
+
|
|
512
|
+
```java
|
|
513
|
+
// Version 1
|
|
514
|
+
public class OrderCreatedEventV1 extends DomainEvent {
|
|
515
|
+
private String customerId;
|
|
516
|
+
private BigDecimal total;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Version 2 - Add new field with default
|
|
520
|
+
public class OrderCreatedEventV2 extends DomainEvent {
|
|
521
|
+
private String customerId;
|
|
522
|
+
private BigDecimal total;
|
|
523
|
+
private String currency = "USD"; // New field with default
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Use schema registry (e.g., Confluent Schema Registry) for production systems.
|