Skip to content

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 (prefix vietgara by 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 header event_type carries 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

EventProducerMain ConsumerDescriptionRelease
user.email_verifiedIdentityTenant/Garage, License, NotificationCreate the owner’s default roles; start the 14-day trial subscription; send a registration-confirmation e-mail (1.5)1.0
garage.createdTenant/GarageReporting (later)A garage was created (default staff groups are created synchronously)1.0
staff.invitedTenant/GarageNotification (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.joinedTenant/GarageNotification (planned, not Release 1.5)An invitee accepted and became a member1.0
staff.removedTenant/GarageNotification (planned, not Release 1.5)A member was removed from a garage1.0
entitlement.updatedLicenseSplit-out services (cache refresh), Reporting — no consumer yetThe owner’s plan or status changed (the monolith invalidates its Redis cache directly)1.1
subscription.expiring_soonLicenseNotificationRenewal 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_submittedLicenseNotificationAn 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-mailed5.2
subscription.payment_approvedLicenseNotification, Reporting (planned)A platform admin approved the request; the plan was applied in the same transaction; the owner is e-mailed5.2
subscription.payment_rejectedLicenseNotificationA request was rejected with a reason; the owner is e-mailed5.2
subscription.plan_changedLicenseReporting (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_changedLicenseReporting (planned, no consumer yet)Account credit was earned, applied or adjusted (FR-LIC-09)5.2
subscription.payment_review_reminderLicenseNotificationEmitted daily by the License job while requests wait for review: { waiting, oldest_submitted_at }; the platform admins are e-mailed5.2
subscription.status_changedLicenseNotification (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_end1.1 / 5.2
quote.approvedQuote & PricingRepair 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.rejectedQuote & PricingNotification (planned, not Release 1.5), no consumer yetEvery line of a quote was rejected1.2
repair_order.createdRepair OrderInventoryA 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_quantity7.0
repair_order.startedRepair OrderInventoryThe 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.created7.0
repair_order.closedRepair OrderInventoryRepair 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.created1.3 (lines and posting in 7.0)
repair_order.lines_changedRepair OrderInventoryA 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.cancelledRepair OrderInventoryA 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.created7.3
stock_issue.shortage_changedInventoryRepair OrderA 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 mark7.3
stock.issuedInventoryReporting (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.createdBilling & SettlementE-Invoice Service, Receivables — no consumer yetA settlement was created; { settlement_id, garage_id, repair_order_id }1.4
payment_voucher.issuedBilling & SettlementNotification, 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_recordedplanned (with its first consumer)
payment_voucher.voidedBilling & SettlementReceivables, 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_voidedplanned (13.0)
customer.linked / customer.unlinkedCustomer & VehicleCustomer BFF, NotificationA 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_endedplanned (with its first consumer)
supplier.linked / supplier.unlinkedSupplierNotificationA supplier was linked to or unlinked from a user (supplier portal)17.2
stock_count.completed / stock.adjustedInventoryReporting, 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 themplanned (with its first consumer)
support_grant.requested / approved / revokedTenantNotificationLifecycle of a read-only support grant; the owner is notified8.2
stock.lowInventoryNotificationA part crossed below its threshold (once per crossing)10.1
booking.requested / confirmed / cancelled / convertedBookingNotification, Customer BFFBooking lifecycle; converted carries the new quote or repair order id12.0-12.2
receivable.overdueReceivablesNotificationA receivable passed a configured reminder threshold13.0
stock_transfer.shipped / receivedInventoryInventory (the receiving garage)A transfer between two garages of an owner was shipped or received15.3
invoice.issuedE-Invoice ServiceNotification ServiceSend an invoice notification to the customer16.1
invoice.issue_failedE-Invoice ServiceNotification ServiceThe provider failed or timed out after the retries; alert the accountant. The settlement is not rolled back16.1
payment.confirmedPayment Gateway AdapterReceivables, Billing & SettlementUpdate the receivable’s collected status; Billing issues the payment voucher16.0
claim.status_changedInsuranceNotification Service, ReceivablesUpdate the claim status17.0
repair_order.progress_updatedRepair OrderNotification Service, Technician BFFReal-time progress update for the Customer App11.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):

ActionEntityBefore / after
customer.link_requestedcustomer_user_link— / customer, status, channel. Actor: the staff member
customer.link_confirmedcustomer_user_linkstatus 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_endedcustomer_user_linkrequested or linked / unlinked, by staff, by the user, or because the e-mail changed hands
customer.link_answeredcustomer_user_linkrequested / declined; actor: the user
settlement.billing_updatedsettlementthe settlement’s amounts, billing_tax_profile_id and invoice_requested before and after
vehicle.correctedvehiclethe identity (plate, make, model, year, VIN, engine number) before / after, plus reason
vehicle.owner_changedvehiclecustomer_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):

ActionEntityBefore / after
goods_receipt.completedgoods_receiptdraft / completed, with the number and the line count
goods_receipt.cancelledgoods_receiptthe status before (draft or completed) / cancelled; a completed receipt is reversed in the same transaction
stock_issue.postedstock_issuedraft / 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):

ActionEntityBefore / after
stock_count.completedstock_countdraft / completed, with the number and the line count; the adjustments are the stock transactions with ref_type stock_count
stock_count.cancelledstock_countthe status before (draft or completed) / cancelled; a completed count is reversed in the same transaction
stock_count.mergedstock_countthe 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.cancelledwrite_offnone / completed, and completed / cancelled
supplier_return.created / supplier_return.cancelledsupplier_returnnone / completed, and completed / cancelled, with the credit amount
repair_order.part_returnedrepair_order_part_returnnone / 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 ScenarioCompensating 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 receivedThe 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 publishedKafka retains the records; the consumer group resumes from its last committed offset when it comes back (topic retention ≥ 7 days)
Handler keeps failingRetried 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 downtimeAlerting fires when outbox_events backlog exceeds a configured time threshold (NFR-OB-02)