Common Interview Coding Patterns/Lesson 11
In progress
Spring Boot: Async Queue + Callback — Send Money Journey
advancedspring-bootkafkaasync
Async Queue + Callback Pattern — Send Money Journey
Your Flow
You described the exact production flow:
- Customer inputs amount → clicks Next → sees draft transaction (preview, fee, FX rate)
- Customer clicks Submit → saved to queue → 202 Accepted → shows transaction journey
- System runs AML Scan → Forter check
- Calls fund pull API
- Calls third-party remittance API
- Waits for callback from third-party
- Sends success email + push notification
This is a classic async orchestration pattern. Let's implement it correctly.
Architecture
[Mobile/Web] --POST /submit--> [API Gateway]
|
[Remittance Service]
|
save to DB (PENDING) + publish to queue
|
return 202 Accepted + txnId
|
[Kafka Consumer / Worker Thread]
AML Scan
Forter Check
Fund Pull
Third-party Call
Save status (CALLBACK_PENDING)
|
[Callback Endpoint] <--- Third-party provider
set COMPLETED
send email
push notification
1. Submit Endpoint (Fast Path — Must Return in < 200ms)
@RestController
@RequestMapping("/api/v2/remittance")
@Slf4j
public class RemittanceSubmitController {
private final TransactionRepository txnRepo;
private final KafkaTemplate<String, RemittanceEvent> kafka;
private final IdempotencyService idempotencyService;
@PostMapping("/submit")
public ResponseEntity<SubmitResponse> submit(
@RequestHeader("X-Idempotency-Key") String idempotencyKey,
@RequestBody RemittanceRequestV2 request,
Authentication auth) {
String customerId = auth.getName();
// 1. Check idempotency (Redis first)
Optional<SubmitResponse> cached = idempotencyService.get(idempotencyKey);
if (cached.isPresent()) {
log.info("[SUBMIT] Duplicate request idempotencyKey={} — returning cached response", idempotencyKey);
return ResponseEntity.accepted().body(cached.get());
}
// 2. Validate + create transaction in DB (PENDING)
Transaction txn = Transaction.builder()
.id(UUID.randomUUID().toString())
.customerId(customerId)
.amount(request.getAmount())
.currency(request.getCurrency())
.beneficiaryId(request.getBeneficiaryId())
.status(TxnStatus.PENDING)
.idempotencyKey(idempotencyKey)
.createdAt(Instant.now())
.build();
txnRepo.save(txn);
// 3. Publish to Kafka (async processing starts here)
kafka.send("remittance.submit", txn.getId(),
RemittanceEvent.of(txn.getId(), EventType.SUBMITTED));
// 4. Cache the response for idempotency
SubmitResponse response = new SubmitResponse(txn.getId(), "/api/v2/remittance/" + txn.getId() + "/journey");
idempotencyService.set(idempotencyKey, response, Duration.ofHours(24));
log.info("[SUBMIT] Accepted txnId={} customerId={}", txn.getId(), customerId);
return ResponseEntity.accepted()
.header("Location", response.journeyUrl())
.body(response);
}
}
2. Kafka Consumer (Async Processing)
@Component
@Slf4j
public class RemittanceProcessingConsumer {
private final RemittanceOrchestrator orchestrator;
@KafkaListener(
topics = "remittance.submit",
groupId = "remittance-processor",
concurrency = "5" // 5 consumer threads
)
public void process(ConsumerRecord<String, RemittanceEvent> record) {
String txnId = record.key();
MDC.put("txnId", txnId); // structured logging for all downstream calls
try {
log.info("[CONSUMER] Processing txnId={}", txnId);
orchestrator.process(txnId);
} catch (Exception ex) {
log.error("[CONSUMER] Failed to process txnId={}", txnId, ex);
// Let Kafka retry (throw to trigger requeue) or send to DLQ
throw ex; // Spring Kafka will retry based on RetryTopicConfiguration
} finally {
MDC.clear();
}
}
}
Retry configuration:
@Bean
public RetryTopicConfiguration retryTopicConfig(KafkaTemplate<String, RemittanceEvent> template) {
return RetryTopicConfigurationBuilder
.newInstance()
.fixedBackOff(5_000) // 5s between retries
.maxAttempts(3)
.includeTopic("remittance.submit")
.dltSuffix(".DLT") // dead letter topic
.create(template);
}
3. Transaction Journey API (Polling / SSE)
The mobile app shows the customer a live journey. Two approaches:
Option A: Polling (Simpler)
@GetMapping("/{txnId}/journey")
public ResponseEntity<JourneyResponse> getJourney(@PathVariable String txnId,
Authentication auth) {
Transaction txn = txnRepo.findByIdAndCustomerId(txnId, auth.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
List<TxnStatusHistory> history = historyRepo.findByTxnIdOrderByOccurredAt(txnId);
return ResponseEntity.ok(JourneyResponse.of(txn, history));
}
Mobile polls every 3–5 seconds until status is COMPLETED or FAILED.
Option B: Server-Sent Events (Better UX)
@GetMapping(value = "/{txnId}/journey/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamJourney(@PathVariable String txnId) {
SseEmitter emitter = new SseEmitter(30_000L); // 30s timeout
journeyStreamService.subscribe(txnId, emitter);
return emitter;
}
4. Callback Endpoint (Third-Party Signals Completion)
@RestController
@RequestMapping("/internal/callback/remittance")
@Slf4j
public class RemittanceCallbackController {
private final RemittanceOrchestrator orchestrator;
private final CallbackAuthenticator auth;
@PostMapping
public ResponseEntity<Void> callback(
@RequestHeader("X-Provider-Signature") String signature,
@RequestBody CallbackPayload payload) {
// Always verify provider signature
if (!auth.verify(payload, signature)) {
log.warn("[CALLBACK] Invalid signature providerRef={}", payload.getProviderRef());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
log.info("[CALLBACK] Received providerRef={} status={}", payload.getProviderRef(), payload.getStatus());
orchestrator.handleCallback(payload.getInternalTxnId(), payload);
return ResponseEntity.ok().build();
}
}
5. Notification (Email + Push)
@Service
public class NotificationService {
private final EmailClient emailClient;
private final PushNotificationClient pushClient;
@Async("notificationExecutor") // don't block the callback response
public void sendSuccessEmail(Transaction txn) {
try {
emailClient.send(EmailRequest.builder()
.to(txn.getCustomerEmail())
.subject("Your transfer is complete!")
.template("remittance-success")
.params(Map.of("amount", txn.getAmount(), "beneficiary", txn.getBeneficiaryName()))
.build());
} catch (Exception e) {
log.error("[NOTIFY] Email failed txnId={}", txn.getId(), e);
// Log and swallow — notification failure must not fail the transaction
}
}
@Async("notificationExecutor")
public void pushMobileNotification(Transaction txn) {
pushClient.send(PushRequest.of(txn.getCustomerId(),
"Transfer Complete", "Your RM " + txn.getAmount() + " has been sent."));
}
}
Key rule: notification failures must never propagate back to the saga orchestrator.
Sequence Diagram
Customer → POST /submit → 202 Accepted + txnId
Customer ← Journey URL (polling starts)
[Background]
Queue Consumer
→ AML Scan (PENDING → AML_SCANNING → FORTER_CHECKING)
→ Forter Check (→ FUND_PULLING)
→ Fund Pull (→ REMITTANCE_IN_PROGRESS)
→ Third-party Call (→ CALLBACK_PENDING)
Third-party → POST /internal/callback/remittance
→ COMPLETED
→ Email sent async
→ Push notification sent async
Customer sees journey: "Submitted → AML Passed → Fund Deducted → Sent → Complete"
Interview Talking Points
- "Submit returns 202 immediately after saving to DB and publishing to Kafka. The consumer handles all async steps."
- "We set
CALLBACK_PENDINGafter the third-party submission. The callback endpoint updates toCOMPLETED. This decouples our success state from their response latency." - "Notifications are
@Async— they never block the callback response. Failure of a notification is logged and swallowed." - "We persist every status transition to
txn_status_historyso the customer-facing journey screen always has accurate, timestamped steps." - "Idempotency key on submit prevents double charges if the mobile app retries the 202."
JavaLabRunner.java
Loading...
All classes must be named JavaLabRunner
Output
Click Run to execute the code.