LOW-LEVEL DESIGN
VietGara — Multi-Tenant SaaS Platform for Auto Garage Management & Maintenance
Version 1.2 — October 9, 2026 · Cloud: Google Cloud Platform (GCP) · Backend: Go
1. Introduction
The Low-Level Design (LLD) presents module/class/function-level design for the four services with the most complex business logic in VietGara: License Management (enforcement logic), Billing & Settlement (Saga/Outbox), Repair Order (offline sync), and Identity/Access (RBAC + RLS middleware). The remaining services are less complex and follow the same package layout pattern described in Section 2.
2. Standard Package Structure
The Phase 1 modular monolith (vietgara-backend) keeps one package tree per bounded context; a module split into its own service later takes its tree with it:
vietgara-backend/ cmd/server/ -> composition root: config, modules, interceptors, gateway, workers internal/<module>/ -> identity, tenant, license, customer (…) domain/ -> pure business types and rules (no framework or database imports) usecase/ -> business logic; depends on small interfaces (ports) repository/ -> Postgres/Redis implementations of the ports handler/ -> gRPC servers, per-RPC Policies, event subscriptions internal/middleware/ -> Auth, EmailVerified, PlatformAdmin, Garage interceptors internal/platform/ -> db (tx + RLS scope), access (permissions), outbox, kafka, mail, storage, audit, httpapi (grpc-gateway), auth (JWT) pkg/apperror, pkg/converter -> error model shared by every module db/migrations/ -> golang-migrate SQLGenerated gRPC and gateway code comes from the vietgara-proto module (apis-go/). Principle: the domain layer never imports framework/database packages (Clean Architecture / Hexagonal), so business logic is unit-tested without a database; modules talk to each other only through use-case interfaces or events.
3. LLD — License Management
3.1. Main Types
A subscription belongs to the owner and covers all of its garages; plan limits bound the whole user.
type Subscription struct { ID string UserID string // owner (one subscription per user) PlanID string Plan *Plan Status SubscriptionStatus // trial | active | past_due | suspended | cancelled TrialEndAt *time.Time CurrentPeriodStart time.Time CurrentPeriodEnd time.Time GracePeriodEnd *time.Time // set once the period has expired AutoRenew bool}
type Entitlements struct { // cached in Redis per user UserID string Status SubscriptionStatus PlanCode string Features []Feature MaxGarages int MaxStaff int MaxTransactionsPerDay int CurrentPeriodEnd time.Time}
// Used by the Garage interceptor for every garage-scoped request.type LicenseChecker interface { LicenseMode(ctx context.Context, garageID string) (LicenseMode, error) // Full | ReadOnly | Locked}
// Used by Tenant (garages, staff) and, from Release 1.2, by the document modules.func (uc *EntitlementUseCase) CheckLimit(ctx context.Context, ownerID string, limit LimitType, current int) errorfunc (uc *EntitlementUseCase) ConsumeTransaction(ctx context.Context, ownerID string) error3.2. Subscription Lifecycle and Expiry Job
stateDiagram-v2 [*] --> trial: user.email_verified (14 days) trial --> active: payment approved / admin extension trial --> past_due: trial ends (grace period starts) active --> past_due: period ends past_due --> active: payment approved / extended past_due --> suspended: grace period ends suspended --> active: payment approved / extended active --> active: plan change or renewal approved
The plan changes only in the approval transaction of a payment request (Section 3.3); the expiry job never changes a plan. It runs every LICENSE_EXPIRY_JOB_INTERVAL:
func (j *ExpiryJob) Run(ctx context.Context) error { // 1. Subscriptions ending within 7 days and not yet reminded -> subscription.expiring_soon // 2. Expired subscriptions: active/trial -> past_due (grace period starts, read-only); // past_due whose grace period ended -> suspended (locked) // 3. Each change: save, invalidate the user's entitlement cache, // publish subscription.status_changed through the outbox (a lapse carries the amount // to pay, the bank details and the end of the grace period for the owner's e-mail) // 4. Once a day: requests still SUBMITTED -> subscription.payment_review_reminder}3.3. Plan Payment Requests (FR-LIC-07..09)
stateDiagram-v2 [*] --> awaiting_payment: StartPlanPayment (quote, reference) awaiting_payment --> awaiting_payment: replaced by a new request awaiting_payment --> submitted: SubmitPlanPayment (evidence) awaiting_payment --> cancelled: owner cancels submitted --> cancelled: owner cancels before review submitted --> approved: admin approves submitted --> rejected: admin rejects (reason) rejected --> [*] approved --> [*]
One request is open per owner (awaiting_payment or submitted). The quote is computed when the request starts and honoured at approval:
periodDays = days between current_period_start and current_period_endunusedDays = whole days from now to current_period_end (0 when expired or after a trial's end)credit = floor(unusedDays / periodDays * period_paid_amount) // trial: paid 0creditApplied= min(credit + credit_balance, plan.price)amountDue = plan.price - creditAppliedcreditSurplus= max(credit + credit_balance - plan.price, 0) // becomes account creditApproval runs in one database transaction under the subscription lock (LockByID):
func (uc *PaymentReviewUseCase) Approve(ctx context.Context, requestID, adminID, note string) (*Subscription, error) { return uc.tx.WithTransaction(ctx, func(tx Tx) (*Subscription, error) { req := uc.requests.LockSubmitted(tx, requestID) // REQUEST_NOT_OPEN otherwise sub := uc.subscriptions.LockByID(tx, req.SubscriptionID) uc.limits.Validate(sub.OwnerUsage(), req.Plan) // PLAN_LIMIT_REACHED again if req.AmountMismatch && note == "" { return nil, ErrMismatchNoteRequired } sub.Apply(req.Plan, req.Kind, req.QuotedAmount, now) // new period from approval; renew extends from the old end; // PeriodPaidAmount = the plan price (credit included) for the next proration uc.credit.Record(tx, req) // applied / earned entries; balance updated req.MarkApproved(adminID, note, now) uc.entitlements.Replace(tx, sub) // table; cache invalidated after commit uc.outbox.Publish(tx, "subscription.payment_approved", "subscription.plan_changed") uc.audit.Record(tx, "subscription.plan_payment_approved", req) return sub, nil })}A renewal of the current plan before the period ends sets the new period from the old current_period_end; a change, or a renewal after expiry, starts the new period at approval, clears the grace period and reactivates a past-due or suspended subscription. The unused days of a trial are not credited (paid 0).
3.4. Enforcement
Enforcement runs inside the backend’s Garage interceptor (not at the gateway), after the garage access check, reading the owner’s entitlements from Redis (loaded from Postgres on a miss):
| License mode | Subscription status | Effect |
|---|---|---|
| Full | trial, active | Everything allowed |
| ReadOnly | past_due | Get*, List*, Lookup* allowed; writes fail with 403 SUBSCRIPTION_READ_ONLY |
| Locked | suspended, cancelled | Every garage-scoped call fails with 402 SUBSCRIPTION_SUSPENDED |
Subscription and billing RPCs are exempt (SkipLicense) so an owner can always renew. If the license check itself fails (cache and database unavailable), the request is let through and a warning is logged: licensing must not take the API down.
Plan limits are checked where usage grows:
| Limit | Checked when | Usage counted |
|---|---|---|
max_garages | Creating a garage, reactivating one | Active garages of the owner |
max_staff | Inviting staff, reactivating a member | Distinct non-owners with an active membership or a pending invitation, across the owner’s garages (owner_usage()) |
max_transactions_per_day | Creating a quote, repair order or settlement (Release 1.2+) | Redis counter per owner and day (Asia/Ho_Chi_Minh) |
| Plan change (up or down) | Starting a payment request and again at approval | Refused with PLAN_LIMIT_REACHED while current usage (garages, staff) exceeds the new plan |
4. LLD — Billing & Settlement Service (Saga/Outbox)
4.1. Create-Settlement Use Case (transaction + outbox in a single DB transaction)
func (uc *CreateSettlementUseCase) Execute(ctx context.Context, req CreateSettlementRequest) (*Settlement, error) { return uc.txManager.WithTransaction(ctx, func(tx Tx) (*Settlement, error) { settlement := domain.NewSettlement(req.RepairOrderID, req.GarageID) settlement.TotalAmount = req.TotalAmount // single payer since Release 5.0: the customer pays the whole total
if err := uc.settlementRepo.Save(tx, settlement); err != nil { return nil, err // rolls back the outbox event too if this fails }
event := NewOutboxEvent("settlement.created", settlement) if err := uc.outboxRepo.Save(tx, event); err != nil { return nil, err } return settlement, nil // COMMIT: settlement + outbox event in the same transaction })}4.2. Allocation Engine — Customer/Insurer Split Algorithm (removed in Release 5.0, returns in Phase 17)
func (e *AllocationEngine) Allocate(total Money, coverage []InsuranceCoverageLine) Allocation { insurerAmount := Money(0) for _, line := range coverage { insurerAmount += min(line.CoveredAmount, line.LineTotal) } insurerAmount = min(insurerAmount, total) // never exceeds the settlement total return Allocation{ InsurerAmount: insurerAmount, CustomerAmount: total - insurerAmount, }}4.3. Outbox Relay Worker
func (r *Relay) Run(ctx context.Context) { ticker := time.NewTicker(r.interval) // OUTBOX_RELAY_INTERVAL, 2s by default for { r.RelayPending(ctx) select { case <-ctx.Done(): return; case <-ticker.C: } }}
func (r *Relay) RelayPending(ctx context.Context) (int, error) { ids := r.pendingIDs(ctx) // oldest pending events, bounded batch for _, id := range ids { // One transaction per event: SELECT … FOR UPDATE SKIP LOCKED (several replicas // can relay concurrently), publish to Kafka topic <prefix>.<context>.events // keyed by garage_id (else user_id), then mark it published. if _, err := r.deliver(ctx, id); err != nil { r.recordFailure(ctx, id, err) // attempts++, last_error; retried next tick } } return len(ids), nil}Consumers wrap each handler with Processor.Wrap, which inserts (consumer, event_id) into processed_events in the handler’s transaction; a duplicate delivery finds the row and is skipped.
5. LLD — Repair Order Service (Offline Sync for the Technician App)
5.1. Conflict Resolution Strategy
type SyncRequest struct { RepairOrderLineID uuid.UUID NewStatus string ClientUpdatedAt time.Time // when the technician updated on their phone (possibly offline) ClientVersion int // the last version the technician saw while online}
func (uc *SyncRepairOrderUseCase) Apply(ctx context.Context, req SyncRequest) (SyncResult, error) { current := uc.repo.GetLine(ctx, req.RepairOrderLineID) if req.ClientVersion < current.Version { // conflict: the server changed after the client went offline if isTerminalStatus(current.Status) { return SyncResult{Conflict: true, ServerState: current}, nil // technician must review in-app } // intermediate status: prefer the "more advanced" status (in_progress > not_started) } current.Status = mergeStatus(current.Status, req.NewStatus) current.Version++ uc.repo.Save(ctx, current) return SyncResult{Conflict: false, ServerState: current}, nil}5.2. Client-Side Sync Queue (Flutter, design summary)
- Every action performed while offline is written to a local SQLite queue (a pending_sync table) with a timestamp and the last known version.
- Once connectivity returns: actions are sent in chronological order; a Conflict response is handled by showing the technician a prompt to choose which state to keep.
- Photos (before/after) are saved locally first and uploaded in the background once connectivity is available, without blocking the main workflow.
6. LLD — Identity & Access (RBAC + RLS)
6.1. Interceptor Chain
Every gRPC call — and every REST call, which grpc-gateway turns into an in-process gRPC call — passes through the same chain. What each RPC requires is declared once per module in a Policies table (full method name → policy); an RPC without a policy is rejected (NO_POLICY).
flowchart LR req[Request] --> auth["Auth<br/>verify JWT unless Public"] auth --> ev["EmailVerified<br/>unless AllowUnverified"] ev --> pa["PlatformAdmin<br/>if PlatformAdmin"] pa --> g["Garage<br/>BEGIN; set_config scope;<br/>access + license check"] g --> h[Handler] h --> c[COMMIT / ROLLBACK]
// Declared by each module, e.g. internal/customer/handler.var Policies = middleware.Policies{ customerv1.CustomerService_ListCustomers_FullMethodName: middleware.GarageMethod(access.Requires(access.CustomerRead)), customerv1.CustomerService_CreateCustomer_FullMethodName: middleware.GarageMethod(access.Requires(access.CustomerWrite)),}6.2. Garage Interceptor
func Garage(tx Transactor, reader access.Reader, license LicenseChecker) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) { policy := policyFromContext(ctx) scope := db.UserScope(UserID(ctx)) // app.current_user_id if policy.Garage != nil { scope.GarageID = req.(GarageScoped).GetGarageId() // app.current_garage_id } var resp any err := tx.WithTransaction(ctx, scope, func(ctx context.Context) error { if scope.GarageID != "" { acc, err := reader.GarageAccess(ctx, scope.GarageID, scope.UserID) // owner? active member? permissions if err != nil { return denied(err) } // GARAGE_ACCESS_DENIED if err := acc.Check(*policy.Garage); err != nil { return denied(err) } // permission, OWNER_ONLY, GARAGE_INACTIVE if err := enforceLicense(ctx, license, scope.GarageID, info.FullMethod); err != nil { return err } ctx = access.WithAccess(ctx, acc) } var err error resp, err = next(ctx, req) return err }) return resp, apperror.ToGRPCError(err) }}WithTransaction runs SELECT set_config('app.current_garage_id', $1, true), set_config('app.current_user_id', $2, true) at the start of the transaction; nested calls join the outer transaction and switch the scope temporarily when a use case must act on another garage of the same owner. Because the settings are transaction-local, a pooled connection never carries a tenant into the next request.
6.3. Access Model
erDiagram
USER ||--o{ ROLE : "owns (shared by all its garages)"
ROLE ||--o{ ROLE_PERMISSION : grants
GARAGE ||--o{ STAFF_GROUP : has
ROLE ||--o{ STAFF_GROUP : "assigned to"
STAFF_GROUP ||--o{ STAFF_GROUP_MEMBER : contains
GARAGE_STAFF ||--o{ STAFF_GROUP_MEMBER : "belongs to"
GarageAccess returns: whether the caller owns the garage (owners hold every permission), whether it is an active member, whether the garage is active, and the union of the permissions of the member’s groups’ roles, passed through access.Normalize (catalogue order, unknown values dropped, and each <module>.write completed with its <module>.read). Role writes are normalized the same way. The token never carries garages or roles, so removing a member or editing a role takes effect on the next request.
6.4. OTP Verification
sequenceDiagram
participant C as Client
participant I as Identity
participant R as Redis
participant M as Mail
C->>I: SendEmailVerificationOtp
I->>R: GET otp:email_verification:<user>
alt within the 30 s resend window
I-->>C: 429 OTP_RESEND_TOO_SOON (+ running challenge)
else
I->>R: SET code hash, expires 2 min, attempts 0
I->>M: send code (user language)
I-->>C: {expiresAt, resendAvailableAt}
end
C->>I: VerifyEmail(code, refreshToken)
I->>R: compare, attempts + 1 (5 max → OTP_TOO_MANY_ATTEMPTS)
I->>I: set email_verified_at, publish user.email_verified
I-->>C: new session (token with email_verified = true)