EVENT DESIGN
VietGara — Multi-Tenant SaaS Platform for Auto Garage Management & Maintenance
Version 1.8 — October 12, 2026 · Cloud: Google Cloud Platform (GCP) · Backend: Go
1. Introduction
This document specifies the design of event-driven communication between VietGara modules and services, using Apache Kafka (ADR-004) combined with the Outbox Pattern to guarantee no event is lost if a service fails between writing data and publishing the event.
2. Outbox Pattern — The No-Lost-Event Guarantee Mechanism
The problem: if a service writes business data to the database and then publishes an event right after via two separate operations, the service could crash in between — resulting in data that was saved but an event that was never sent (or vice versa). The Outbox Pattern solves this by writing the event to an outbox_events table within the SAME transaction as the business data, after which a background process (the Outbox Relay) reads this table and publishes to Kafka, marking it as sent.
BEGIN; SELECT set_config('app.current_garage_id', $garage, true), set_config('app.current_user_id', $user, true); INSERT INTO settlements (...) VALUES (...); INSERT INTO outbox_events (id, event_type, garage_id, user_id, payload, status, created_at) VALUES (gen_random_uuid(), 'settlement.created', $garage, $user, '{...}', 'pending', now());COMMIT;
-- Outbox Relay (background goroutine, every OUTBOX_RELAY_INTERVAL = 2 s), one transaction per event:SELECT ... FROM outbox_events WHERE id = $1 AND status = 'pending' FOR UPDATE SKIP LOCKED; -> produce to Kafka topic <prefix>.<context>.events, key = garage_id (else user_id) -> UPDATE outbox_events SET status = 'published', published_at = now() WHERE id = $1;-- On failure: attempts = attempts + 1, last_error = ..., retried on the next tick.sequenceDiagram
participant UC as Use case
participant DB as PostgreSQL
participant RL as Outbox relay
participant K as Kafka
participant C as Consumer (group)
UC->>DB: business rows + outbox_events (one transaction)
loop every 2 s
RL->>DB: pending events (FOR UPDATE SKIP LOCKED)
RL->>K: produce (key = garage/user)
RL->>DB: mark published
end
K->>C: deliver
C->>DB: INSERT processed_events (consumer, event_id) + handler writes (one transaction)
alt already processed
C-->>K: skip, commit offset
else handler fails
C->>C: retry with backoff (5 attempts)
C->>K: park on <topic>.dlq
end
- Guarantees at-least-once delivery: consumers process idempotently —
processed_events (consumer, event_id)is inserted in the same transaction as the handler’s writes, so a redelivered event is skipped. - Without
KAFKA_BROKERS(unit tests, single-process development), the relay hands events to an in-process dispatcher after commit, through the same subscriber interface. - The Outbox Relay can be implemented via periodic polling (simple, sufficient at the current scale) or upgraded to Debezium/CDC (Change Data Capture) if lower latency is needed at larger scale.
3. Naming Convention & Event Structure
- Event name: <bounded_context>.<past_tense_event>, e.g. settlement.created, repair_order.closed, payment.confirmed.
- Kafka topic: one topic per bounded context,
<prefix>.<context>.events(prefixvietgaraby default):vietgara.identity.events,vietgara.tenant.events,vietgara.license.events,vietgara.customer.events, and so on. The record key is the garage id (else the user id), so events of one tenant are ordered within a partition. Each consumer service has its own consumer group; each topic has a dead-letter topic<topic>.dlq. The record headerevent_typecarries the event name. - Event payload: JSON, always including a standard envelope.
{ "event_id": "uuid", "event_type": "settlement.created", "occurred_at": "2026-07-12T01:40:00Z", "garage_id": "uuid", // omitted for user-level events "user_id": "uuid", // the user concerned (owner, invitee, …) "version": 1, "data": { ... payload specific to each event type ... }}- Versioning: a version field is included in the envelope; breaking payload changes must bump the version, and consumers must support reading both versions in parallel during the transition period.
4. Event Catalog
| Event | Producer | Main Consumer | Description | Release |
|---|---|---|---|---|
| user.email_verified | Identity | Tenant/Garage, License, Notification | Create the owner’s default roles; start the 14-day trial subscription; send a registration-confirmation e-mail (1.5) | 1.0 |
| garage.created | Tenant/Garage | Reporting (later) | A garage was created (default staff groups are created synchronously) | 1.0 |
| staff.invited | Tenant/Garage | Notification (planned, not Release 1.5) | An existing user was invited to a garage (the invitation e-mail is sent directly after commit in Release 1.x) | 1.0 |
| staff.joined | Tenant/Garage | Notification (planned, not Release 1.5) | An invitee accepted and became a member | 1.0 |
| staff.removed | Tenant/Garage | Notification (planned, not Release 1.5) | A member was removed from a garage | 1.0 |
| entitlement.updated | License | Split-out services (cache refresh), Reporting — no consumer yet | The owner’s plan or status changed (the monolith invalidates its Redis cache directly) | 1.1 |
| subscription.expiring_soon | License | Notification | Renewal reminder 7 days before the period ends, e-mailed with the platform’s payment instructions and the amount to renew (FR-LIC-04, FR-LIC-07) | 1.1 |
| subscription.payment_submitted | License | Notification | An owner submitted a plan payment request (FR-LIC-07); { request_id, user_id, plan_code, amount_due, transferred_amount, reference, amount_mismatch }. The platform admins are e-mailed | 5.2 |
| subscription.payment_approved | License | Notification, Reporting (planned) | A platform admin approved the request; the plan was applied in the same transaction; the owner is e-mailed | 5.2 |
| subscription.payment_rejected | License | Notification | A request was rejected with a reason; the owner is e-mailed | 5.2 |
| subscription.plan_changed | License | Reporting (planned, no consumer yet) | The owner’s plan changed at approval: { subscription_id, user_id, from_plan_id, plan_id, period_start, period_end } (FR-LIC-02) | 5.2 |
| subscription.credit_changed | License | Reporting (planned, no consumer yet) | Account credit was earned, applied or adjusted (FR-LIC-09) | 5.2 |
| subscription.payment_review_reminder | License | Notification | Emitted daily by the License job while requests wait for review: { waiting, oldest_submitted_at }; the platform admins are e-mailed | 5.2 |
| subscription.status_changed | License | Notification (e-mails the owner when to is past_due or suspended); Reporting (planned, no consumer yet) | trial/active → past_due (read-only) → suspended, or reactivated: { subscription_id, user_id, from, to, current_period_end }; a lapse also carries plan_code, amount_due, the bank details and grace_period_end | 1.1 / 5.2 |
| quote.approved | Quote & Pricing | Repair Order (indirectly via the convert API) | At least one line of a quote was approved, by the customer (public link) or a staff member on their behalf (manual mode / override, FR-QUO-03) | 1.2 |
| quote.rejected | Quote & Pricing | Notification (planned, not Release 1.5), no consumer yet | Every line of a quote was rejected | 1.2 |
| repair_order.created | Repair Order | Inventory | A repair order was created (from a quote or ad hoc). Inventory creates the order’s draft stock issue, which reserves its part lines. Payload: repair_order_id, repair_order_number, garage_id, part_lines[] with line_id, item_ref_id (the part id), quantity, unit_id and entered_quantity | 7.0 |
| repair_order.started | Repair Order | Inventory | The first move of a repair order out of open. Inventory posts the order’s stock issue when the garage’s stock_issue_mode is on_start (FR-INV-20); same payload as repair_order.created | 7.0 |
| repair_order.closed | Repair Order | Inventory | Repair order closed, ready for settlement (settlement creation itself is a direct request, not an event consumer, FR-BIL-01). Inventory posts the order’s stock issue, which deducts the part lines (FR-INV-02, FR-INV-20); same payload as repair_order.created | 1.3 (lines and posting in 7.0) |
| repair_order.lines_changed | Repair Order | Inventory | A part line of an order was added, changed or removed (Release 7.3). Inventory brings the order’s draft stock issue in line and rechecks the shortage; a posted or cancelled issue is left alone. Same payload as repair_order.created (the part lines as they are now) | 7.3 |
| repair_order.cancelled | Repair Order | Inventory | A repair order was cancelled before work started (Release 7.3). Inventory cancels its draft stock issue, which releases the reservation; same payload as repair_order.created | 7.3 |
| stock_issue.shortage_changed | Inventory | Repair Order | A draft stock issue became unable, or able again, to be covered by the stock on hand (Release 7.3, FR-INV-15). Payload: repair_order_id, garage_id, short. The Repair Order module reads the current answer from Inventory when it handles the event (so a late or repeated event converges) and sets or clears the order’s waiting-on-parts mark | 7.3 |
| stock.issued | Inventory | Reporting (planned, no consumer yet) | A stock issue of a repair order was posted: stock_issue_id, garage_id, repair_order_id and lines[] (part_id, quantity, base unit) | 1.3 (published from 7.0) |
| settlement.created | Billing & Settlement | E-Invoice Service, Receivables — no consumer yet | A settlement was created; { settlement_id, garage_id, repair_order_id } | 1.4 |
| payment_voucher.issued | Billing & Settlement | Notification, Reporting (planned) | A payment voucher (phiếu thu) was issued for a recorded payment. Not published in Release 5.0: the issue is in the audit log as settlement.payment_recorded | planned (with its first consumer) |
| payment_voucher.voided | Billing & Settlement | Receivables, Reporting (planned) | A payment voucher was voided; settlement totals recalculated. Not published in Release 5.0: the void is in the audit log as settlement.payment_voided | planned (13.0) |
| customer.linked / customer.unlinked | Customer & Vehicle | Customer BFF, Notification | A customer profile was linked to or unlinked from a user, after the user’s consent. Not published in Release 5.1: the change is in the audit log as customer.link_confirmed / customer.link_ended | planned (with its first consumer) |
| supplier.linked / supplier.unlinked | Supplier | Notification | A supplier was linked to or unlinked from a user (supplier portal) | 17.2 |
| stock_count.completed / stock.adjusted | Inventory | Reporting, Notification (planned) | A stock count was completed, or stock was written off or returned. Not published in Release 7.1: these changes are audit entries (see below) until a consumer needs them | planned (with its first consumer) |
| support_grant.requested / approved / revoked | Tenant | Notification | Lifecycle of a read-only support grant; the owner is notified | 8.2 |
| stock.low | Inventory | Notification | A part crossed below its threshold (once per crossing) | 10.1 |
| booking.requested / confirmed / cancelled / converted | Booking | Notification, Customer BFF | Booking lifecycle; converted carries the new quote or repair order id | 12.0-12.2 |
| receivable.overdue | Receivables | Notification | A receivable passed a configured reminder threshold | 13.0 |
| stock_transfer.shipped / received | Inventory | Inventory (the receiving garage) | A transfer between two garages of an owner was shipped or received | 15.3 |
| invoice.issued | E-Invoice Service | Notification Service | Send an invoice notification to the customer | 16.1 |
| invoice.issue_failed | E-Invoice Service | Notification Service | The provider failed or timed out after the retries; alert the accountant. The settlement is not rolled back | 16.1 |
| payment.confirmed | Payment Gateway Adapter | Receivables, Billing & Settlement | Update the receivable’s collected status; Billing issues the payment voucher | 16.0 |
| claim.status_changed | Insurance | Notification Service, Receivables | Update the claim status | 17.0 |
| repair_order.progress_updated | Repair Order | Notification Service, Technician BFF | Real-time progress update for the Customer App | 11.0 |
Audit log entries of Release 5.0 (audit_log, written in the business transaction, Security Architecture Section 7): settlement.created, settlement.payment_recorded (entity settlement_payment, before and after carry the settlement’s paid_amount and status, and after.payment the voucher number, amount, method and payer) and settlement.payment_voided (the same, with the void reason). The actor is the signed-in user.
Audit log entries added by Release 5.1 (same rules; the user behind a link is never written, only who acted):
| Action | Entity | Before / after |
|---|---|---|
customer.link_requested | customer_user_link | — / customer, status, channel. Actor: the staff member |
customer.link_confirmed | customer_user_link | status requested / linked with channel otp (staff entered the code), app (the user accepted; actor: the user) or auto_email (sign-in match; actor: the user) |
customer.link_ended | customer_user_link | requested or linked / unlinked, by staff, by the user, or because the e-mail changed hands |
customer.link_answered | customer_user_link | requested / declined; actor: the user |
settlement.billing_updated | settlement | the settlement’s amounts, billing_tax_profile_id and invoice_requested before and after |
vehicle.corrected | vehicle | the identity (plate, make, model, year, VIN, engine number) before / after, plus reason |
vehicle.owner_changed | vehicle | customer_id before / after, plus reason |
Audit log entries added by Release 7.0 (same rules; these are audit entries, not events, so no consumer reads them):
| Action | Entity | Before / after |
|---|---|---|
goods_receipt.completed | goods_receipt | draft / completed, with the number and the line count |
goods_receipt.cancelled | goods_receipt | the status before (draft or completed) / cancelled; a completed receipt is reversed in the same transaction |
stock_issue.posted | stock_issue | draft / posted, with the number, the repair order id and the line count; the actor is the user who posted it, or, for a close or a start, the user whose change caused it |
Audit log entries added by Release 7.1 (same rules; audit entries, not events):
| Action | Entity | Before / after |
|---|---|---|
stock_count.completed | stock_count | draft / completed, with the number and the line count; the adjustments are the stock transactions with ref_type stock_count |
stock_count.cancelled | stock_count | the status before (draft or completed) / cancelled; a completed count is reversed in the same transaction |
stock_count.merged | stock_count | the merged count’s number and how many drafts were absorbed; each absorbed draft is cancelled with the note “Merged into {number}” |
write_off.created / write_off.cancelled | write_off | none / completed, and completed / cancelled |
supplier_return.created / supplier_return.cancelled | supplier_return | none / completed, and completed / cancelled, with the credit amount |
repair_order.part_returned | repair_order_part_return | none / the returned quantity and the reason |
5. A Typical Saga: Settlement → Invoice → Payment
flowchart TD ro([repair_order.closed]) -. "ready for settlement, not auto-triggered" .-> acc acc["Garage Accountant<br/>POST .../settlements (FR-BIL-01)"] --> bil["Billing & Settlement<br/>create settlement"] bil --> sc([settlement.created]) sc --> rec["Receivables<br/>create receivable"] sc --> ei["E-Invoice Service<br/>issue invoice"] ei -->|success| ii([invoice.issued]) --> nt["Notification<br/>invoice e-mail/SMS"] ei -->|provider error| fail([invoice.issue_failed]) --> alert["Notification<br/>alert the accountant"] fail -. "settlement is NOT rolled back" .- bil
Design note: a settlement is not rolled back if the invoice-issuance step fails — the two operations are independently valid states, linked only via events. This is a Choreography-based Saga (no central orchestrator) rather than an Orchestration-based Saga, which fits well because the steps do not need to roll back one another — each failure mode has its own compensating action, see Section 6. Only the first two steps (repair_order.closed as a readiness signal, and the Accountant’s direct request creating the settlement and publishing settlement.created) are built as of Release 1.4; Receivables and E-Invoice Service are Phase 2+ and do not consume the event yet.
6. Error Handling & Compensating Actions
| Failure Scenario | Compensating Action |
|---|---|
| Invoice issuance fails (error from the provider) | Mark invoice.status = ‘failed’, create a task reminding the accountant to handle it manually, auto-retry up to 3 times with backoff |
| Duplicate event or payment webhook received | The consumer’s processed_events (consumer, event_id) insert conflicts, so the handler is skipped; webhooks additionally use the provider’s transaction id as an idempotency key |
| Consumer downtime when an event is published | Kafka retains the records; the consumer group resumes from its last committed offset when it comes back (topic retention ≥ 7 days) |
| Handler keeps failing | Retried up to 5 times with doubling backoff, then produced to <topic>.dlq with the error and consumer group in headers; the DLQ is monitored and replayed after the fix |
| Prolonged Outbox Relay downtime | Alerting fires when outbox_events backlog exceeds a configured time threshold (NFR-OB-02) |