Skip to content

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 SQL

Generated 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) error
func (uc *EntitlementUseCase) ConsumeTransaction(ctx context.Context, ownerID string) error

3.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_end
unusedDays = 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 0
creditApplied= min(credit + credit_balance, plan.price)
amountDue = plan.price - creditApplied
creditSurplus= max(credit + credit_balance - plan.price, 0) // becomes account credit

Approval 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 modeSubscription statusEffect
Fulltrial, activeEverything allowed
ReadOnlypast_dueGet*, List*, Lookup* allowed; writes fail with 403 SUBSCRIPTION_READ_ONLY
Lockedsuspended, cancelledEvery 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:

LimitChecked whenUsage counted
max_garagesCreating a garage, reactivating oneActive garages of the owner
max_staffInviting staff, reactivating a memberDistinct non-owners with an active membership or a pending invitation, across the owner’s garages (owner_usage())
max_transactions_per_dayCreating 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 approvalRefused 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)