Skip to content

ARCHITECTURE DECISION RECORDS

VietGara — Multi-Tenant SaaS Platform for Auto Garage Management & Maintenance

Version 1.5 — October 10, 2026 · Cloud: Google Cloud Platform (GCP) · Backend: Go

Introduction

This document consolidates all Architecture Decision Records (ADR) for the VietGara project — each ADR records one important architectural decision, the alternatives considered, a brief summary of pros/cons, the final decision, and the criteria behind it. Other documents in the technical documentation set (SAD, HLD, LLD, Database Design, Infrastructure Architecture, etc.) reference the relevant ADRs instead of repeating the analysis.

Status convention: Accepted (approved, currently applied) · Superseded (replaced by another ADR) · Proposed (under discussion, not yet finalized).

List of ADRs

ADR-001 — Overall Architecture Style: Microservices vs. Monolith vs. Modular Monolith

Status: Accepted

Context

Need to choose the overall backend architecture style for a multi-tenant SaaS platform, targeting 100–1,000 garages within the first 1–2 years, with a small engineering team in the early stage.

Options Considered

OptionProsCons
Traditional MonolithSimple to deploy, low operating cost initiallyCannot scale parts independently; a single bug can bring down the whole system; hard for multiple teams to work in parallel
Full Microservices from day oneIndependent scaling, fault isolation, suits multiple teamsHigh operating cost (K8s, observability); complex distributed transactions; requires a mature DevOps team
Modular Monolith (starting point) → gradually split into MicroservicesKeeps clear bounded-context boundaries in code, runs as a single runtime to reduce MVP-stage cost, splits gradually based on real loadRequires design discipline to avoid blurring module boundaries

Decision

Start with a Modular Monolith with clear bounded-context boundaries (Phase 1 MVP), gradually splitting into true Microservices (the integration services in Phases 16–17, the rest in Phase 15, HLD Section 6) based on load and the need for independent scaling.

Rationale / Decision Criteria

  • The target scale is eventually large enough to need microservices, but not yet at the MVP stage.
  • Reduces operating cost and shortens time-to-market while the team is still small.
  • Bounded-context boundaries are preserved in code — splitting services later is a controlled refactor, not a rewrite.

Consequences

  • All Go packages are organized by bounded context (see HLD, Section 5) from Phase 1, so splitting the runtime later does not require restructuring the code.

ADR-002 — Cloud Provider

Status: Accepted

Context

Choose the international cloud platform for infrastructure. The engineering team already has hands-on experience deploying GCP + Golang from a previous project.

Options Considered

OptionProsCons
Google Cloud Platform (GCP)Team already has hands-on experience (reduces risk and ramp-up time); GKE is the best-managed Kubernetes offering on the market (Google originated K8s); Cloud SQL, Pub/Sub, and Cloud Run integrate smoothly with a Go + microservices architectureSmaller local partner/consulting ecosystem in Vietnam than AWS; fewer niche managed services than AWS
AWSBroadest managed-service ecosystem, largest community, more regions near VietnamTeam has no hands-on experience → increases ramp-up time and early-stage operational risk
AzureSuitable if the company’s ecosystem is Microsoft-heavy (AD, .NET)No particular advantage for a Go + Kubernetes stack; team has no experience with it

Decision

Choose Google Cloud Platform (GCP), primary region asia-southeast1 (Singapore). Infrastructure uses GKE (Kubernetes), Cloud SQL for PostgreSQL, Memorystore (Redis), Cloud Storage, and Cloud Load Balancing. The message broker is Kafka (ADR-004).

Rationale / Decision Criteria

  • The team’s prior hands-on experience with GCP + Golang is the single most important decision criterion — it significantly reduces technical risk and time-to-launch compared to learning a new cloud.
  • GKE is a mature managed Kubernetes platform, naturally aligned with ADR-001 (Modular Monolith → Microservices on Kubernetes).
  • Lower initial operating cost since the team requires no retraining.

Consequences

  • All subsequent infrastructure ADRs (message broker, database, identity, etc.) are evaluated within the GCP ecosystem as the default managed-service option.

ADR-003 — Backend Programming Language

Status: Accepted

Context

Choose the primary language for all backend services. The team already has hands-on Golang experience from a previous project.

Options Considered

OptionProsCons
Go (Golang)Team already has hands-on experience; high performance, strong concurrency for a system with many services calling each other; statically compiled, lightweight containers, fast startup (fits GKE/Cloud Run)Business-oriented library ecosystem (e.g. ORMs, frameworks) is less “batteries-included” than Java/Node in some areas
Node.js / TypeScriptRich npm ecosystem, easier hiringTeam has no experience; weaker CPU-bound performance than Go; weaker runtime typing
Java / Spring BootMature enterprise ecosystem, many ready-made patternsTeam has no experience; heavier containers, slower startup than Go, more resource-intensive at the scale of many small services

Decision

Choose Go for all backend services (both the Phase 1 Modular Monolith and the services split out later).

Rationale / Decision Criteria

  • The team’s hands-on Go experience reduces ramp-up time and lowers the risk of code-quality issues in the early stage.
  • Go fits naturally with an architecture of many small services running on Kubernetes: lightweight containers, fast startup, low resource footprint — important when operating dozens of services on the same GKE cluster.
  • gRPC (ADR-006) has excellent Go code-gen support, leveraging both decisions at once.

ADR-004 — Message Broker for Asynchronous Communication

Status: Accepted (supersedes the choice of Google Cloud Pub/Sub)

Context

Choose the event bus technology for cross-module flows (e.g. user verified → trial subscription and default roles; settlement → stock issue → invoice → payment). Financial flows need at-least-once delivery, ordering per aggregate, replay for audits and reconciliation, and the same broker in local development, CI and production.

Options Considered

OptionProsCons
Google Cloud Pub/SubFully managed, integrates with GCP IAM, auto-scalesNo local server identical to production (emulator only); limited replay and ordering; ties the event layer to GCP
Apache Kafka (managed: Google Cloud Managed Service for Apache Kafka or Confluent Cloud; KRaft in Docker locally)Ordered partitions per key, long retention and replay, consumer groups, industry standard for financial audit trails; identical broker locally, in CI (in-memory fake) and in productionPartition/retention planning; managed offering costs more than Pub/Sub at low volume
NATS JetStream (self-managed on GKE)Lightweight, low latencySelf-managed; smaller ecosystem for audit and replay tooling

Decision

Use Apache Kafka, as a managed service on GCP in staging/production and a single KRaft broker in Docker Compose locally. Modules never talk to Kafka directly:

  • Publishing goes through the transactional outbox (outbox_events) written in the same database transaction as the change; a relay publishes pending rows to Kafka (franz-go client), keyed by garage/user so events of one tenant stay ordered.
  • Topics are per bounded context: <prefix>.<context>.events (e.g. vietgara.identity.events), each with a dead-letter topic <topic>.dlq.
  • Consuming uses one consumer group per service; handlers are idempotent through processed_events (event id + handler), retried with backoff, then parked on the DLQ.
  • Without KAFKA_BROKERS (unit tests, single-process development) events are dispatched in-process after commit through the same subscriber interface.

Rationale / Decision Criteria

  • Replay and per-key ordering matter for the financial flows of Phases 1–2 and the reconciliation reports (FR-REC-04).
  • The same broker everywhere removes emulator-only behaviour from tests and local development.
  • The outbox + subscriber abstraction keeps business code independent of the broker (NFR-SC-04).

Consequences

  • Event Design and Deployment/Infrastructure documents describe topics, the DLQ and the managed Kafka cluster.
  • Integration tests use an in-memory Kafka (kfake); Docker Compose runs apache/kafka in KRaft mode.

ADR-005 — Multi-Tenancy Model

Status: Accepted

Context

Determine the data isolation unit (tenant) and how to isolate data between garages in a shared database, consistent with the one-user–many-garages model (branch = garage) agreed in the Functional Requirements Document.

Options Considered

OptionProsCons
Pooled (row-level, tenant_id = garage_id)Lowest infrastructure cost, easy to operate a single database for many garages, easy to upgrade all at onceRisk of cross-tenant leakage if application logic has a bug; requires tight control at the database layer
Silo (separate schema/DB per garage)Strongest isolation, suitable for large enterprise garages with their own compliance needsInfrastructure cost grows linearly with the number of garages; schema migration is more complex
Hybrid (Pooled by default + Silo for premium tiers)Balances cost for the majority of small/medium garages while still meeting the isolation needs of large garagesMore operationally complex due to supporting two models in parallel

Decision

Use a Hybrid model: the tenant unit is garage_id (one garage = one tenant, not user_id); Pooled + PostgreSQL Row-Level Security by default; Silo (separate schema) deployed when needed for large garages/chains. user_id is the unit of login, subscription and role definitions (an owner’s roles and plan cover all their garages), not the operational data isolation boundary.

Implementation (Release 1.0–1.1): every request runs in a transaction that sets app.current_garage_id and app.current_user_id with set_config(..., true); policies use the NULL-safe helpers app_current_garage_id() / app_current_user_id(); every tenant table has FORCE ROW LEVEL SECURITY; and the backend connects as a login in the vietgara_app role, which has neither SUPERUSER nor BYPASSRLS (see Database Design, Section 6).

Rationale / Decision Criteria

  • The correct operational/isolation unit per the business is the garage (each garage has its own customers, inventory, quotes), not the login user — one chain owner can own many garages, but each garage’s operational data must still remain separate.
  • Row-Level Security at the PostgreSQL layer is a second line of defense, reducing risk if the application layer has a tenant-logic bug.
  • Most of the target 100–1,000 garages are small/medium — Pooled optimizes operating cost best, while keeping an upgrade path to Silo for large customers.

ADR-006 — Internal Service-to-Service Communication

Status: Accepted

Context

Choose the protocol for service-to-service communication (distinct from Gateway-to-client communication).

Options Considered

OptionProsCons
gRPC/ProtobufType-safe between Go services, high performance, supports streaming, auto-generates Go code from .protoHarder to debug than REST (binary); requires centralized .proto version management
Internal REST/JSONSimple, easy to debug, widely available toolingNot type-safe, higher JSON serialize/deserialize overhead at high volume
Internal GraphQLFlexible querying across multiple services in one requestNot suited to high-performance service-to-service communication; better fit for the BFF layer

Decision

Use gRPC/Protobuf for internal communication between Go services; REST/JSON at the API Gateway facing web/mobile clients (see API Specification).

Rationale / Decision Criteria

  • The entire backend uses Go (ADR-003) — fully leveraging gRPC’s type-safety and performance.
  • Separates the internal protocol (optimized for performance) from the external protocol (optimized for integration ease, easy for third-party partners to understand).

ADR-007 — Identity Provider

Status: Accepted (revised September 2026 — replaces GCP Identity Platform with an in-house Identity module)

Context

Choose an AuthN/AuthZ solution for the web console. The agreed scope (FRD FR-IAM-01..04) is e-mail/password with e-mail OTP verification, password reset by e-mail OTP, and Google sign-in; phone OTP and Facebook sign-in were dropped. Authorization is per garage and must change immediately when an owner edits roles or deactivates staff.

Options Considered

OptionProsCons
Firebase Authentication / GCP Identity PlatformManaged; built-in social and OTP providersCustom claims per garage go stale until the token refreshes; e-mail templates and OTP rules (2-minute validity, bilingual) are harder to control; another system to keep in sync with users
Keycloak (self-hosted on GKE)Full control, multi-realmOperating and patching a second stateful system for a small team
In-house Identity module (Go)Full control over OTP rules, templates and languages; one user table; no extra infrastructureThe team owns password hashing, token rotation and brute-force protection

Decision

Implement identity in the backend’s Identity module:

  • E-mail/password users (bcrypt), unverified until a 6-digit e-mail OTP (2 minutes) is confirmed; password reset by e-mail OTP and a short-lived reset token.
  • Google sign-in with the OAuth 2.0 authorization-code flow (popup), users created verified.
  • Short-lived HS256 access tokens carrying only the user id, e-mail-verified flag and platform-admin flag; opaque rotating refresh tokens stored in Redis.
  • No garages or roles in the token: garage membership and permissions are read from the database on every garage-scoped request (the Garage interceptor), so revocation is immediate.

Rationale / Decision Criteria

  • The OTP, template and language requirements are simple to implement and test directly, and harder to bend a managed provider to.
  • Reading access from the database removes the stale-claims problem entirely at the cost of one indexed query per request.
  • The Identity module remains a façade: moving to Identity Platform or Keycloak later only changes this module.

Consequences

  • Optional MFA for every user that signs in with e-mail and password (NFR-SE-04, FR-IAM-09) is provided by this module from Release 6.1; platform roles, staff groups and support access follow in Phase 8.
  • OTPs, sessions and refresh tokens live in Redis (Memorystore); OTP attempts and resend intervals are rate-limited per user.

ADR-008 — Primary Database

Status: Accepted

Context

Choose the primary relational database technology for business services, within the GCP ecosystem.

Options Considered

OptionProsCons
Cloud SQL for PostgreSQLFully managed, supports Row-Level Security (required for ADR-005), automated backup/PITR, familiar to the teamLimited vertical scaling compared to natively distributed databases; requires manual sharding at very large scale
Cloud SpannerNear-unlimited horizontal scaling, strong global consistencySignificantly higher cost at the 100–1,000 garage scale; unnecessary at the current scale; team has no experience
Firestore (NoSQL)Auto-scaling, integrates well with mobile offline-syncNot suited to complex relational data and financial transactions (lacks strong multi-row transactions like PostgreSQL); no SQL-style RLS support

Decision

Use Cloud SQL for PostgreSQL for all business services. While the backend is a modular monolith (ADR-001), all modules share one database and the public schema; each module owns its tables and no module queries another module’s tables directly (it calls the module’s use-case interface). When a module is split into its own service (the integration services in Phases 16-17, the rest, Repair Order included, in Phase 15), its tables move to that service’s own database (Database-per-service). Consider Firestore separately for the cache/offline-sync layer on the Technician App if needed (not a replacement for PostgreSQL for core business data).

Rationale / Decision Criteria

  • PostgreSQL supports Row-Level Security — a required condition for the Pooled multi-tenancy model chosen in ADR-005.
  • A managed service (Cloud SQL) reduces operational burden and provides PITR meeting NFR-BR-02 (15-minute RPO).
  • The 100–1,000 garage scale does not yet require Spanner’s global horizontal scaling — choosing Spanner at this stage would be over-engineering.

ADR-009 — Container Orchestration & Compute

Status: Accepted

Context

Choose the platform to run Go services on GCP.

Options Considered

OptionProsCons
GKE AutopilotGoogle manages all nodes/scaling, pay only per pod request, reduces cluster operational burdenLess node configuration flexibility than GKE Standard; some advanced features are restricted
GKE StandardFull control over node pools, deeper cost optimization once load is well understoodTeam must manage nodes, patching, and scaling itself — more operational effort
Cloud Run (fully managed, serverless containers)No cluster management, scale-to-zero, simplest optionLimited for services needing long-lived connections (gRPC streaming, persistent message-broker connections), difficult to apply across a complex microservices system

Decision

Use GKE Autopilot as the primary platform for all Go services. Consider Cloud Run for some stateless tasks that don’t need long-lived connections (e.g. simple webhook handlers) later if appropriate.

Rationale / Decision Criteria

  • Reduces cluster operational burden for a small early-stage team, in line with ADR-002’s preference for managed services.
  • GKE (not Cloud Run) is needed because many services use gRPC streaming and maintain persistent message-broker connections.
  • Autopilot still allows switching to Standard later for deeper cost optimization at larger scale — not a one-way decision.

ADR-010 — REST API Generated from gRPC Contracts

Status: Accepted

Context

ADR-006 chose gRPC internally and REST/JSON for clients. Maintaining hand-written REST handlers next to the gRPC services duplicated validation, authorization and error mapping, and the REST contract drifted from the documentation.

Options Considered

OptionProsCons
Hand-written REST handlersFull freedom per endpointDuplicated logic; contract drift; no generated client types
grpc-gateway from google.api.http annotationsOne contract (vietgara-proto) for gRPC, REST and OpenAPI; REST requests pass through the same interceptorsREST shape is constrained by the proto mapping rules
Connect-RPCgRPC-compatible, simple HTTPLess conventional REST paths; less mature OpenAPI output

Decision

Annotate every RPC with google.api.http following resource-oriented conventions (see API Specification) and serve REST with grpc-gateway v2 in the backend process, calling the gRPC server over an in-memory connection so authentication, authorization, RLS scoping and license checks run once for both protocols. protoc-gen-openapiv2 produces openapi/vietgara.swagger.json, from which the owner web generates its TypeScript types.

Consequences

  • The API Specification documents conventions; the OpenAPI document is the endpoint reference.
  • Breaking changes are detected by buf breaking in the proto CI.

ADR-011 — Transactional E-mail Provider

Status: Accepted

Context

Release 1.0 sends verification and password-reset OTPs and staff invitations by e-mail; later releases add invoices and renewal reminders (FR-NOT-04). Tests must never send real e-mail.

Options Considered

OptionProsCons
Mailjet (Send API v3.1)EU-based, simple API, good deliverability, free tier for stagingAnother vendor user
SendGridLarge ecosystemPricier at low volume
Direct SMTP through Google WorkspaceNo new vendorLow sending limits, poor deliverability tooling

Decision

Use Mailjet in staging and production and Mailpit (a local SMTP catcher with a web inbox) for local development and integration tests, selected by MAIL_PROVIDER (mailjet | smtp). Templates are embedded per language (Vietnamese, English). The sending domain is validated in Mailjet with SPF and DKIM; API keys live in Secret Manager (see the backend README).

ADR-012 — Separate Front Ends for Garage Owners and Platform Administrators

Status: Accepted

Context

vietgara-owner-web started as one React app that also held the platform-admin screens (plans and subscriptions, FR-LIC-05). Garage owners and the VietGara operators are different audiences with different security needs, release cadence and hosting. Garage owners also need a mobile experience.

Options Considered

OptionProsCons
One app, platform pages behind a role guard (previous state)One build, shared codePlatform-admin code is shipped to every garage user; one origin and session for both audiences; a UI bug can expose admin pages; admin releases tied to the garage product
Two apps in one monorepoShared code without copyingContradicts the polyrepo-per-platform decision (Git Strategy); a new repo layout for one case
Separate repositories per audienceSmaller bundles; separate origin and access controls (for example an IP allow-list); independent releasesSmall amount of duplicated scaffolding (auth, HTTP client, shell)

Decision

Split the owner web into vietgara-owner-web (garage owners and staff) and vietgara-admin-web (VietGara platform administrators), and add vietgara-owner-app (Flutter, garage owners). Both web apps use the same stack and the REST API under /api/v1; the platform admin only calls /api/v1/admin/*. The backend’s PlatformAdmin interceptor remains the authority for access.

Consequences

  • The owner web no longer contains /admin/* routes. The admin web has no self-registration and shows an access-denied page to non-admin users.
  • Each repository has its own CI and container image (ghcr.io/viettechno/<repo>). Deploying the platform admin (its own host, Kong route) is tracked as follow-up work in vietgara-infra.
  • Common code was first duplicated; the UI part (design tokens, components, shell) has since moved into the shared vietgara-web-ui kit (@vietgara/web-ui), pinned by tag in both web apps and built by its own CI. The HTTP client and auth session remain per app.
  • The default branch of every repository is master.

ADR-013 — Public Website and User Guide: Repositories, Framework and Hosting

Status: Proposed

Context

VietGara needs a place to sell and explain the product (company homepage, product page, contact) and a how-to guide for garage owners (Release 10.2). The company will build more products under viettechno.com, and the existing vietgara-docs holds internal design and architecture documents, which is another audience. Kiotviet, a comparable product, keeps its marketing site, its user guide and its app on separate hosts. The sites carry no tenant data and change on a different cadence from the product.

Options Considered

OptionProsCons
Put the guide into vietgara-docsNo new repositoryMixes customer-facing and internal documents; the guide would be released with design documents
One repository for the company site and every product’s guideOne buildProducts release together; a product’s guide is not near its owners
viettechno-website (company and product pages) and vietgara-help (the VietGara guide)Company-level site that grows with new products; each product’s guide is its own repository with its own cadence; matches the host naming <product>-<app>Two small repositories
Docusaurus for the guideMature, large plugin setSearch needs Algolia (free only for open source) or a plugin with weak Vietnamese diacritic handling
Astro Starlight for the guide, Astro and Tailwind for the websiteOne toolchain; static output; Pagefind search at build time with no service; i18n; almost no JavaScript on sales pagesSmaller ecosystem than Docusaurus
A CMS or a hosted site builderNon-developers editAnother vendor and data store; content outside the pull request flow

Decision

  • Repositories: viettechno-website (host viettechno.com, www.viettechno.com; one landing page per product) and vietgara-help (host vietgara-help.viettechno.com). vietgara-docs stays the internal specification.
  • Framework: Astro for both: Starlight with MDX and Pagefind for the guide; Astro, Tailwind CSS and TypeScript for the website. Both use the tokens of @vietgara/web-ui.
  • Hosting: a managed static host with a preview per pull request. Cloudflare Pages is proposed: free at this size, a CNAME in the company zone at PA Vietnam, and Functions for the one server-side route the contact form needs. Firebase Hosting on the existing GCP project is the alternative if the company prefers a single cloud; it needs a small Cloud Run or Cloud Function for the same route.
  • Contact form: the browser posts to the site’s own /contact function, which forwards to POST /api/v1/public/contact-requests on the API-only host. The API host stays a non-browser surface and gets no CORS rules (Deployment Architecture Section 2.2).
  • Not in the backend release train: the sites are published on every merge and do not use backend version tags.

Consequences

  • The sites are static; only the contact function runs code. There is no tenant data, no session and no cookie other than the anti-spam check’s.
  • Product pages must be updated by hand when plans or prices change (they are not read from the License module).
  • The guide’s screenshots come from a script run against staging with the shared test data, so that each release can refresh them.
  • If the company brand diverges from the product’s, the website takes its own tokens; the guide keeps the product’s.
  • The decision is Proposed until the hosting choice is confirmed; the rest does not depend on it.

ADR-014 — Internal Documentation Site: Publishing, Languages and Access

Status: Proposed

Context

vietgara-docs holds the specification: requirements, architecture, database and security design, test scenarios. It is Markdown read on GitHub, which works for engineers but not for the product and operations staff who also need it, and it is English only. Part of the team works in Vietnamese. The content is internal: the security architecture and the database design must not be public, and the search index and pull request previews of a published copy would expose it the same way the pages do. ADR-013 chose static hosting for the public sites and left vietgara-docs as the internal specification.

Options Considered

OptionProsCons
Keep reading on GitHubNo workNo search across documents, no Vietnamese, Mermaid only where GitHub renders it, requires a GitHub seat
Static site, password check in the page (for example an encrypted page)Works on any hostNot access control: the content ships to every visitor; the search index is open
Static site on Firebase HostingSame cloud as production (GCP)No access gate of its own: needs an authenticating Cloud Run or Function in front, and preview channels are reachable by URL
Static site on Cloudflare Pages behind Cloudflare AccessA policy at the edge covers production, the search index and previews; sign-in with Google or e-mail one-time code; free up to 50 usersAnother vendor; a custom domain behind Access needs the zone on Cloudflare, so the site starts on pages.dev
DocusaurusMatureDifferent toolchain from the other sites; weaker Vietnamese search
Astro Starlight, content kept in docs/Same toolchain as vietgara-help; Pagefind search; i18n; the Markdown stays readable on GitHub and existing links into the repository keep workingMermaid renders in the browser

Decision

  • Publishing: Astro Starlight, in the vietgara-docs repository. The documents stay in docs/; the site is built from them on every merge to master.
  • Languages: English is the default and the source of truth (docs/); Vietnamese lives at the same relative path under docs/vi/. A page without a translation falls back to English with Starlight’s notice. A translation records translated_from: <commit> of its English source; CI lists missing and stale translations and does not fail the build.
  • Access: Cloudflare Access in front of the Pages project, covering the production URL, previews and the search index. The site also sends noindex. The policy is managed in the Cloudflare dashboard, not in the repository.
  • Address: vietgara-docs.pages.dev until a custom domain is worth moving the company zone for. The company zone at PA Vietnam also carries the mailboxes; its MX, SPF and DKIM records must be copied unchanged if it ever moves.
  • Not published: references/ (third-party material) stays in the repository only.

Consequences

  • The specification gets search, a sidebar and a Vietnamese reading path without a second copy in another tool.
  • Two versions of every page must be kept in step; the English page is the reference and CI shows the gap.
  • Access depends on one more vendor and on the team’s Google or e-mail identities; people leaving are removed from the policy.
  • The decision is Proposed until the Cloudflare account and Access policy exist. ADR-013’s hosting choice and this one should be confirmed together.