Messaging
RabbitMQ is a selective boundary in Lumie's backend, not the default way
modules collaborate. Most in-process follow-up work stays on Spring Modulith's
JDBC outbox and @ApplicationModuleListener; queue-backed messaging is mainly
owned by the exam module.
This page is a reference document for the current async contract.
Source Paths
| Path | Role |
|---|---|
libs/messaging/src/main/java/com/lumie/messaging/config/RabbitMqConstants.java | Shared AMQP names and routing keys |
app/src/main/resources/application.yaml | RabbitMQ connection and Modulith retry settings |
modules/exam/src/main/java/com/lumie/exam/adapter/out/config/RabbitMqConfig.java | RabbitTemplate, JSON message converter, listener-container behavior |
modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.java | Outbox-to-RabbitMQ bridge |
modules/exam/src/main/java/com/lumie/exam/adapter/in/messaging/* | RabbitMQ callback consumers |
modules/billing/src/main/java/com/lumie/billing/adapter/in/event/TenantCreatedListener.java | In-process Modulith event consumer example |
modules/staff/src/main/java/com/lumie/staff/adapter/in/event/OwnerRegisteredListener.java | In-process Modulith event consumer example |
modules/exam/src/main/java/com/lumie/exam/adapter/in/event/StudentRegisteredListener.java | In-process Modulith event consumer example |
Runtime Flow
Queue-Backed Contracts In Use Today
| Purpose | Producer | Message contract | Queue or routing | Consumer |
|---|---|---|---|---|
| OMR grading request | JobRequestForwarder.onOmrGradingRequested(...) | OmrGradingImageMessage | exchange lumie.commands, routing key grading.omr.request, queue grading.omr-request | grading worker |
| OMR grading callback | grading worker | OmrGradingCallbackRequest | queue grading.omr-callback | OmrGradingCallbackListener |
| Report generation request | JobRequestForwarder.onReportGenerationRequested(...) | ReportGenerationMessage | exchange lumie.commands, routing key report.generation.request, queue report.generation-request | report worker |
| Report generation callback | report worker | ReportCallbackRequest | queue report.generation-callback | ReportGenerationCallbackListener |
Example Message Bodies
These examples come directly from OmrGradingImageMessage, ReportGenerationMessage, OmrGradingCallbackRequest, ReportCallbackRequest, and lumie-worker/contracts/mq-schemas-v1.yaml.
OMR Request
{
"jobId": 341,
"examId": 15,
"tenantSlug": "acme",
"imageKey": "tmp/15/omr/20260614-01/page-1.png",
"imageIndex": 0,
"totalImages": 2,
"schemaVersion": 1
}
OMR Callback
{
"jobId": 341,
"examId": 15,
"tenantSlug": "acme",
"imageKey": "tmp/15/omr/20260614-01/page-1.png",
"imageIndex": 0,
"totalImages": 2,
"success": true,
"error": null,
"phoneNumber": "01012345678",
"totalScore": 92,
"grade": 1,
"results": [
{
"questionNumber": 1,
"studentAnswer": "3",
"correctAnswer": "3",
"score": 5,
"earnedScore": 5,
"questionType": "MULTIPLE_CHOICE"
}
]
}
Report Request
{
"jobId": 812,
"examId": 15,
"studentId": 101,
"tenantSlug": "acme",
"reportIndex": 0,
"totalReports": 2,
"schemaVersion": 1
}
Report Callback
{
"jobId": 812,
"examId": 15,
"studentId": 101,
"tenantSlug": "acme",
"reportIndex": 0,
"totalReports": 2,
"success": true,
"error": null,
"reportBytes": "<base64-pdf>"
}
Why The Exam Module Uses An Outbox
OmrGradingRequestedEvent and ReportGenerationRequestedEvent are published
inside the same transaction that persists the job row. Spring Modulith stores
them in public.event_publication, then JobRequestForwarder publishes to
RabbitMQ after commit.
That buys a specific guarantee:
- if the transaction rolls back, no message is sent
- if the transaction commits but the broker send fails, the publication stays incomplete and is retried on restart
It does not buy a two-phase commit with RabbitMQ. The guarantee is "job persisted and message will be retried," not "database and broker committed atomically in one XA transaction."
Listener Behavior And Failure Handling
RabbitMqConfig sets:
- JSON conversion for publishers and listeners
defaultRequeueRejected=falsemissingQueuesFatalconfigurable vialumie.rabbitmq.missing-queues-fatal
The important consequence is:
- listener exceptions do not tight-loop requeue in the same consumer thread
- the backend expects broker-side delivery-limit and DLQ policies to count
redeliveries and eventually route failures to
lumie.dlx
The exam callback listeners also:
- reject messages with blank or unknown
tenantSlug - resolve
tenantSlug -> tenantIdthroughTenantService - restore tenant context with
TenantContextHolder.withinContext(...) - log structured background-job fields for success and failure
In-Process Modulith Events That Do Not Leave The JVM
These are still asynchronous after commit, but they are not RabbitMQ messages:
| Event | Producer module | Consumer module | Purpose |
|---|---|---|---|
TenantCreatedEvent | tenant | billing | Provision initial trial subscription |
OwnerRegisteredEvent | auth | staff | Bootstrap OWNER staff row |
StudentRegisteredEvent | student | exam | Backfill previously unmatched exam results |
These events use the same event_publication durability mechanism as the
queue-backed exam events.
What Stays Synchronous Or HTTP-Based
- AI chat goes over HTTP to
chatbot-svc, which calls back into/internal/chatbot/**. OmrServiceClient.gradeOmrImage(...)is still a direct HTTP path for the one-image grading contract.ReportServiceClient.generateReport(...)is still a direct HTTP path for synchronous report generation.- Billing webhooks arrive over HTTP at
/internal/webhooks/toss/billing. - Normal module-to-module collaboration uses
libs/internal-api, not RabbitMQ.
Contract Drift Found During Review
RabbitMqConstantsstill definesGRADING_OMR_COMPLETED_QUEUE = "grading.omr-completed", but the current backend listeners and exam internal controller refer tograding.omr-callback.- The docs on this page follow the live consumer code
(
OmrGradingCallbackListener) because that is the active runtime contract inlumie-backend.
Verification Commands
cd /Users/bluemayne/Projects/Lumie/lumie-backend
./gradlew test
./gradlew :modules:exam:test
./gradlew :app:test
When you need stronger confidence in the durability path, also run:
./gradlew integrationTest
Expected success signals:
- Gradle exits with
BUILD SUCCESSFUL, and the exam callback listeners still deserialize the documented JSON bodies without wrapper envelopes. OmrGradingCallbackListenerstill bindsgrading.omr-callback,ReportGenerationCallbackListenerstill bindsreport.generation-callback, and the request publishers still use exchangelumie.commands.