Prerequisite: Familiarity with the concepts introduced in Part 9 — Outbox Saga. Review it first if the terminology in this part is unfamiliar.

Answer-first: Architectural Decision Records (ADRs) enforce three core principles: resilience over simplicity, strict layer standardization, and explicit event-driven boundaries. Standardizing service layouts, outbox patterns, and database migrations before writing code ensures consistent microservices governance across large engineering teams.

21 services. 24 decisions. 3.5 months of deliberation captured in Architecture Decision Records.

An ADR (Architecture Decision Record) is a short document that answers the question: “Why did we choose X when Y and Z were also options?” Without ADRs, architectural knowledge lives in engineers’ heads. When they leave, the knowledge leaves too — and the next team rewrites the same component in the way that was already tried and rejected.

This article walks through all 24 ADRs of the Composable Commerce Platform, grouped by category, with the counter-intuitive choices highlighted. Most ADRs are one-paragraph summaries; the ones that are genuinely surprising get deeper treatment.

The Decision Timeline: What It Reveals

Answer-first: The ADR decision timeline highlights key architectural pivots, documenting why lightweight event-driven Go services replaced legacy PHP monoliths.

The chronological timeline below illustrates how architectural decisions evolved over a four-month period, moving from foundational event-driven principles to specific domain implementation details:

2025-11-17: ADR-001 — Event-Driven Architecture
            (The only decision made in 2025. 3 months before everything else.)

2026-02-03: ADR-002 through ADR-020 — 19 decisions in one day
            (This is a team alignment session, not 19 independent decisions)

2026-02-12: ADR-021 — Price & Stock Data Ownership
            (Discovered after first integration testing revealed ownership ambiguity)

2026-03-02: ADR-022, ADR-023, ADR-024 — EAV + Caching + Inventory
            (After the EAV migration spike revealed gaps in earlier decisions)

ADR-001 being 3 months earlier is the most revealing fact about the platform. The team decided on event-driven architecture in November 2025 — before they had any other decisions. “Events as first-class citizens” was the founding principle; everything else was derived from it. Dapr (ADR-003), saga pattern (Part 9), dual-write via events (Part 7), and the Transactional Outbox (Part 9) are all downstream of ADR-001.

The Feb 3 batch of 19 decisions represents an architecture alignment session where decisions made informally during design were formally documented into MADR version 3 templates.

Category 1: Architecture & Design (ADR-001 to ADR-004)

Architecture decisions establish DDD domain boundaries, Strangler Fig migration patterns, and event-driven microservice decoupling.

ADR-001: Event-Driven Architecture (2025-11-17)

Decision: Dapr Pub/Sub with Redis Streams for ALL transactional events

This is the thesis decision. The ADR establishes that every significant state change — order created, payment captured, stock reserved, customer updated — must be represented as a domain event. Services react to events, not to direct calls. Direct calls (gRPC) are allowed only for synchronous user-facing flows where latency matters.

The implication: events are contracts, not implementation details. Changing an event schema is a breaking change that requires all subscribers to be updated. The team committed to this discipline in November 2025.

ADR-002: Microservices Architecture

Decision: 21 services across 6 bounded context groups

The ADR explicitly documents Conway’s Law as a justification: “Service count ≈ team count × 2–3.” With multiple engineering teams, 21 services is the right size. Service boundaries align with domain-driven design (DDD) subdomains: Catalog, Order, Inventory, Payment, Customer, and Analytics. Each service operates independently with separate build pipelines and failure domains. For a 5-person team, the same platform should have 5–7 services.

ADR-003: Dapr over Raw Redis Streams

Counter-intuitive choice. Most Go microservices tutorials use Redis directly.

The code snippets below compare direct Redis driver coupling against Dapr’s cloud-native pub/sub abstraction:

// ❌ Raw Redis Streams — what most tutorials show
client := redis.NewClient(...)
client.XAdd(ctx, &redis.XAddArgs{Stream: "orders", Values: event})

The initial batch of Architecture Decision Records (ADR-002 through ADR-020) was formalized during a single architecture alignment sprint, codifying core microservice governance rules:

// ✅ Dapr PubSub — what the platform uses
daprClient.PublishEvent(ctx, "pubsub", "orders.order.created", payload)

The difference: Dapr abstracts the underlying broker. Today it’s Redis Streams. Tomorrow it could be Azure Service Bus or AWS SNS — with zero code change. The pubsub component name in the Dapr manifest is the only thing that changes.

The risk accepted: Dapr adds a sidecar container to every pod (memory overhead ~64MB per service). For 21 services on 3-node cluster: ~1.3GB additional memory. The team accepted this cost for the portability benefit.

ADR-004: Database Per Service

Decision: Each service owns its own PostgreSQL instance. No cross-service database queries.

This is what makes the EAV migration necessary (Part 5). Without database-per-service, the Catalog Service could continue querying Magento’s MySQL tables directly. With it, the Catalog Service must have its own normalized schema — requiring the EAV extraction pipeline from Part 5.

The discipline: services that need data from another service’s domain must either (a) call that service via gRPC or (b) maintain a local read model updated via domain events. No JOIN across service databases.

Category 2: Technology Stack (ADR-005 to ADR-007)

Tech stack decisions select Golang for microservice performance, PostgreSQL for transactional storage, and Dapr for distributed primitives.

ADR-005: Go 1.25 + go-kratos v2

Counter-intuitive choice. Three Go HTTP frameworks were evaluated during initial discovery.

The comparison table below evaluates the primary framework candidates against the platform’s dual-transport requirements:

FrameworkStrengthsRejected because
GinFast, popular, large ecosystemNo native gRPC transport; dual HTTP+gRPC requires 2× code
EchoClean API, middleware chainSame gRPC limitation as Gin
go-kratos v2DDD convention, dual transport, Wire DISteeper learning curve

Kratos won because ADR-002 mandated dual HTTP+gRPC transport for every service. With Gin or Echo, you’d write HTTP handlers AND a separate gRPC server — effectively duplicating every endpoint. Kratos makes HTTP and gRPC both first-class citizens from the same proto definition.

ADR-006: Service Discovery with Consul

Counter-intuitive choice. Most Kubernetes-native platforms rely entirely on K8s DNS. The platform uses both:

  • K8s DNS for same-cluster service discovery (the common case)
  • Consul for cross-cluster and external service registration

Consul was chosen because the platform has a multi-datacenter future roadmap. K8s DNS doesn’t span clusters natively without complex multi-cluster CNI setups. Consul’s service mesh health checks are also richer than K8s liveness probes for multi-region service discovery.

ADR-007: Docker Multi-Stage + Distroless

Decision: Docker multi-stage builds with distroless base images for production

The Dockerfile snippet below illustrates the two-stage build pipeline, producing minimal container artifacts without underlying operating system binaries:

# Two-stage: build in Go, run in distroless
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /app/service ./cmd/order-service

FROM gcr.io/distroless/base-debian12
COPY --from=builder /app/service /service
ENTRYPOINT ["/service"]

Distroless images contain only application binaries and essential SSL certificates — no shell, no package manager, no curl. Attack surface drops ~10× compared to Alpine. The trade-off: in-pod debugging requires kubectl exec with an ephemeral debug sidecar container rather than invoking an internal bash shell.

Category 3: Deployment & Operations (ADR-008 to ADR-010)

Deployment decisions standardize on Kubernetes EKS, Kustomize overlays, and ArgoCD GitOps pipelines for automated zero-downtime releases.

ADR-008: GitLab CI (not GitHub Actions)

The team was already on GitLab for code hosting. GitLab CI’s reusable pipeline templates (.gitlab-ci.yml include:) allow a central DevOps team to define shared build + test + deploy pipelines that all 21 service repos inherit. Updating a security scanner or Go version across 21 repos requires changing a single upstream pipeline template.

ADR-009: ArgoCD + Kustomize (not Helm)

Counter-intuitive choice. Helm is the most widely adopted Kubernetes package manager. Why Kustomize?

The feature table below contrasts Helm’s template abstraction against Kustomize’s declarative YAML patching model:

AspectHelmKustomize
TemplatesGo templates with {{ }}Strategic merge patches
ComplexityHigh (template functions, conditionals)Low (YAML patches)
Debugginghelm template to see rendered YAMLVanilla YAML throughout
Learning curveSteepMinimal (pure K8s YAML knowledge)

For a team managing 21 microservices across 3 environments, Helm’s templating complexity creates a maintenance burden. Kustomize’s pure-YAML patches are readable by anyone with Kubernetes knowledge — including junior engineers who don’t know Helm template syntax. The base+overlay pattern also maps cleanly to the team’s environment structure (dev, staging, production).

ADR-010: Prometheus + Grafana + Jaeger

Standard observability stack. Notable: Jaeger with OpenTelemetry instrumented at the common/metrics library level — all 21 services get distributed tracing for free by importing common v1.9.5. Prometheus scrapes metrics from standard /metrics endpoints, feeding pre-configured Grafana dashboards for latency, throughput, and error rates (RED metrics).

Category 4: APIs & Integration (ADR-011 to ADR-013)

API decisions mandate gRPC for internal inter-service communication and GraphQL for unified frontend client data fetching.

ADR-011: gRPC + REST (Dual Protocol)

The proto file generates both gRPC handlers and HTTP routes (via google/api/annotations.proto). No duplication. External clients get REST; internal microservices communicate over high-performance gRPC with HTTP/2 multiplexing. Protobuf contracts guarantee strict type safety across microservice boundaries, eliminating runtime JSON parsing errors.

Decision: Elasticsearch (not Meilisearch, Typesense, or PostgreSQL full-text)

At 10,000+ SKUs with EAV attributes and Vietnamese language support (diacritic-heavy), Elasticsearch’s custom Vietnamese analyzer (vi_analyzer) and multi-field aggregation support were decisive. The Search Service indexes products from Catalog Service events asynchronously — enforcing zero direct database queries to catalog PostgreSQL instances.

ADR-013: JWT + RBAC Authentication

JWT tokens issued by Auth Service, validated at the API Gateway before any request reaches internal microservices. Role-Based Access Control (RBAC) manages permissions (orders:create, products:edit) directly within claims. Internal services operate behind a Zero Trust architecture, verifying JWT signature headers on every RPC request.

Category 5: Configuration & Data (ADR-014 to ADR-015)

Configuration decisions enforce 12-factor environment variables and secret management via HashiCorp Vault integration.

ADR-014: go-kratos Config + K8s ConfigMaps

Configuration hierarchy: configs/config.yaml (base, committed) → K8s ConfigMap (environment-specific overrides, managed by GitOps) → K8s Secrets (database credentials, managed by HashiCorp Vault with External Secrets Operator). This separation prevents accidental leakage of production credentials while keeping service configuration transparent.

ADR-015: Goose for Schema Migrations

Counter-intuitive choice. Two alternatives rejected:

  • golang-migrate: Popular but doesn’t wrap each migration in a database transaction by default. A failed migration can leave the schema in a partial, broken state requiring manual database cleanup.
  • Atlas: Automatic schema diffing (inspects DB and generates diffs). Powerful but removes explicit developer control over migration steps and lock mechanisms.

Goose wraps every migration in BEGIN; ... COMMIT; — if a migration fails halfway, the entire step rolls back atomically. For an e-commerce database with financial transactions, schema atomicity is non-negotiable.

Category 6: Frontend & Development (ADR-016 to ADR-020)

Frontend decisions adopt Next.js PWA frontends connected to Go microservices via edge API gateways.

ADR-016: React + Next.js

Decision: React 18 + Next.js 14 (App Router + RSC)

Next.js Server-Side Rendering (SSR) is critical for SEO on the public storefront. Vietnamese e-commerce customers predominantly discover products via Google search. A pure CSR (Create React App) storefront would rank poorly. The internal admin dashboard uses client-side React without SSR (zero SEO requirement for internal tools).

ADR-017: Common Library Architecture

As detailed in Part 3, the gitlab.com/ta-microservices/common v1.9.5 library eliminated 4,150 lines of duplicate code across 19 services. The key insight: “Don’t copy-paste middleware. Abstract it into a shared central module.” Common contains standardized logging, telemetry, outbox workers, and gRPC interceptors.

ADR-018: K3d + Tilt for Local Development

Decision: K3d (lightweight K3s in Docker) + Tilt hot reload — not Docker Compose

Docker Compose doesn’t simulate Dapr sidecar injection, K8s resource limits, or ConfigMap/Secret mounting. Developers who run Compose locally and Kubernetes in staging constantly hit “it works on Compose, fails on K8s” issues. K3d local gives a complete Kubernetes environment on a laptop (8GB RAM required for all 21 services) with Tilt live-reloading code changes instantly.

ADR-019: Structured Logging with Correlation IDs

Every log line outputs structured JSON containing: service, trace_id, correlation_id, user_id, request_id. Correlation IDs propagate through Dapr event metadata. A single correlation_id can be searched in Grafana Loki to display the complete request journey across 5+ microservices in a single log pane.

ADR-020: Error Handling & Resilience

Circuit breaker configuration (ADR-020): 5 consecutive failures → open for 60 seconds → half-open (5 test requests) → closed. Exponential backoff retry policy: 1s → 2s → 4s → 8s → 16s → give up. Standardized error response format across REST and gRPC API boundaries (common/errors).

Category 7: Data & Domain (ADR-021 to ADR-024)

Data decisions enforce transactional outbox patterns, UUID primary keys, and asynchronous event-driven state reconciliation.

ADR-021: Price & Stock Data Ownership

Decision: Pricing Service owns price; Warehouse Service owns stock quantity; Promotion Service applies discount rules

This addresses a common Magento architecture mistake: treating price and stock as attributes of the product entity. In the composable platform, they represent attributes of separate business capabilities. Catalog Service knows what a product is. Pricing Service knows how much it costs. Warehouse Service knows how many exist.

ADR-022: Dynamic SQL Pivoting for EAV

The foundational decision for Part 5 of this series: attribute IDs must never be hardcoded. The eav_attribute.attribute_code lookup pattern is the safe migration approach. This ADR was written on March 2, 2026 — after the EAV spike revealed that the team’s initial extraction scripts used hardcoded IDs that broke when deployed across staging environments.

ADR-023: Standardized Caching + Worker Patterns

The ADR that enabled common v1.9.5: single-flight cache stampede protection, RedLock distributed cron, and the outbox processor standardized into the common library. Before this ADR (March 2026), each service had its own outbox implementation with slight variations, leading to subtle bugs in worker polling.

ADR-024: Inventory Data Ownership (17KB — The Most Debated Decision)

At 17KB, ADR-024 is 3× longer than any other ADR. Inventory data ownership was the most contested decision in the platform’s design: should Catalog Service own stock levels (as in Magento, where cataloginventory_stock_item is a catalog table) or should Warehouse Service own them?

The decision: Warehouse Service owns stock; Catalog Service shows a read-only stock indicator (in_stock: true/false) populated by Warehouse Service events.

The contested alternative was “Catalog shows stock level from Warehouse via gRPC synchronous call on every product page.” This was rejected because a product page load would then be gated on Warehouse Service availability — a single service failure would take down product browsing. By pre-computing in_stock into Catalog’s own database (via events), Catalog pages are resilient to Warehouse Service failures.

The 5 Most Counter-Intuitive Decisions

Counter-intuitive decisions include choosing Dapr over raw Kafka, Kustomize over Helm, and PostgreSQL outbox tables over external message brokers.

The evaluation matrix below synthesizes the five most counter-intuitive architectural decisions made during the design phase, contrasting standard community defaults against the platform’s selected choices:

DecisionWhat most engineers would chooseWhat was chosenKey reason
Event messagingRaw Redis Streams directlyDapr abstraction layerBroker portability
HTTP frameworkGin or Echogo-kratos v2Native dual HTTP+gRPC transport
K8s configHelmKustomizeSimpler, pure YAML
Schema migrationsgolang-migrateGooseAtomic transaction wrapping
Inventory ownershipIn Catalog ServiceIn Warehouse Service (separate)Catalog page resilience

These five decisions illustrate the core architectural philosophy of the platform: resilience over simplicity, layer standardization, and explicit domain boundaries. Each decision sacrificed short-term setup speed for long-term operational clarity.

FAQ

Architecture Decision Records (ADRs) document architectural context, trade-offs, and governance policies to prevent technical debt during composable migrations.

Why use Markdown Architecture Decision Records (ADRs) instead of wiki pages or design docs?

Versioning ADRs alongside source code in Git ensures architectural decisions evolve directly with the codebase and are reviewed via standard pull request workflows. Wiki pages frequently drift from production implementation, whereas Git-managed ADRs provide an immutable audit trail of architectural trade-offs accessible to all engineers.

How do you handle superseding or revising an existing ADR when requirements change?

Never edit or delete an accepted historical ADR. Instead, create a new ADR (e.g., ADR-025) with an explicit status of “Accepted”, reference the original ADR, and update the original ADR’s status header to “Superseded by ADR-025”. This preserves the historical reasoning behind previous decisions while documenting new architectural directions.

What is the MADR standard and why was it chosen for this project?

MADR (Markdown Architectural Decision Records) provides a minimalist, standardized template focusing on context drivers, considered options, and positive/negative consequences. It eliminates unnecessary administrative overhead while forcing engineering teams to explicitly articulate trade-offs, performance benchmarks, and maintenance costs before adopting new technologies.

Final Word

Documenting architecture decision records (ADRs) maintains engineering alignment and prevents regression during multi-phase monolith migrations.

24 decisions, all accepted, none rejected, none superseded — that’s either a sign of excellent upfront design or a sign that the team hasn’t been honest about retrospective rethinking. The March 2026 batch (ADR-022, 023, 024) suggests some honest retrospective happened: these decisions document gaps found during implementation, not upfront design.

The most important single decision remains ADR-001 — made 3 months before everything else. Event-driven first shaped the entire migration strategy: CDC with Debezium, dual-write via events, Transactional Outbox, choreography saga. Without that founding commitment, the 3-phase Strangler Fig migration would not have succeeded.


This concludes the Composable Commerce Migration series. If you’re planning a similar migration for your own Magento platform, the series index has a navigator by role — CTOs start at Part 0, Magento backend engineers start at Part 5, and Golang engineers start at Part 3.

Need architecture consulting for your migration? → Book a 1:1 session

🔗 Next Step: You have reached the final part of this series. Revisit the series index at /series/composable-commerce-migration/ or explore other series linked below.