Microsoft Microsoft | Azure Well-Architected · Microservices-Oriented Applications Workshop
View repository References Microsoft Learn
WORKSHOP · AZURE WELL-ARCHITECTED FRAMEWORK

Well-Architected microservices-oriented applications on Azure

A hands-on workshop for architects and lead engineers: re-platform a Blazor/.NET monolith into container-first, vertical-slice microservices on Azure Static Web Apps, API Management, and Azure Container Apps — evaluated through the five pillars of the Azure Well-Architected Framework. Every recommendation in this site is grounded in official Microsoft Learn documentation; see References.

Azure hub-and-spoke landing-zone architecture with shared networking and security services connected to subscription-based spokes, including a XYZ workload spoke with a Static Web Apps composite shell, four Container App modules, isolated SQL and Redis stores, Key Vault, Log Analytics, private endpoints, and Defender for Cloud
The hub centralizes connectivity, DNS, security, and monitoring. An isolated XYZ workload spoke contains the composite shell, independently deployed Container App modules, per-module data stores, secrets, observability, and private connectivity; the landing-zone pattern extends to additional subscriptions.
About the sample: the running example is Contoso, a fictitious company Microsoft uses in its learning content to avoid customer-specific or proprietary material. The legacy screens shown are illustrative wireframes for context only.
Frontend

React SPA on Azure Static Web Apps

Globally distributed static hosting with linked backends and Microsoft Entra authentication.1

Compute

Azure Container Apps (Consumption)

One container per vertical slice; KEDA scale-to-zero; multiple-revision traffic splitting.2

Delivery

Terraform + GitHub Actions (OIDC)

Workload-identity federation — no stored cloud secrets — across deployment rings.3

Mapped to the five Well-Architected pillars

PillarWhere this workshop addresses it
ReliabilityZone-redundant Container Apps, health probes, eventual consistency, saga compensation (§08, §10)
SecurityEntra ID/External ID, APIM JWT validation, managed identity, private data plane (§09, §10)
Cost OptimizationConsumption scale-to-zero, right-sized data tiers, cost model (§15)
Operational ExcellenceIaC, OIDC CI/CD, safe/progressive deployment, OpenTelemetry observability (§12–§14)
Performance EfficiencyEvent-driven autoscaling, CQRS read models, caching, near-real-time SSE (§06–§07)
Workshop scenario parameters (illustrative): 5+ capability slices · database-per-service (polyglot) · eventual consistency · SSE for near-real-time UI · Service Bus + Event Grid + Event Hubs · Entra ID + External ID · APIM gateway · public edge / private data plane · zone-redundant · enterprise + regulated-baseline security · parallel-rebuild migration. Availability and cost outcomes depend on the SKUs and regions you choose — validate targets against the applicable Azure Service Level Agreements and the Azure Pricing Calculator.

01 Transformation thesis

The legacy XYZ tool is a layered monolith: Razor/Blazor views, a shared service layer, one relational database, and calculation logic entangled with UI. Every rate-table change, approval-rule tweak, or realization formula edit forces a full redeploy and a full regression pass. We are not lifting-and-shifting — we are running a parallel rebuild where new vertical slices take live traffic behind the edge while the monolith is progressively retired.

What stays the same

  • The user experience — pixel parity with the current XYZ screens.
  • Business rules: realization, yield, XYZ %, and the approval matrix (EP/OMP/RMP/NMP/MMP).
  • The mental model: a request is drafted, calculated, then routed for approval.

What changes underneath

  • Each capability becomes an independently deployable, independently scalable slice.
  • Each slice owns its data; integration happens through events and gateway APIs.
  • Ship a single formula fix in minutes without touching approvals or listing code.
Success metric: lead-time-for-change per slice drops from a monolith release train to on-demand; blast radius of a change is contained to one container and one datastore.

02 Domain decomposition → capability slices

Boundaries follow business capabilities (not the legacy module layout). Every noun/verb from the legacy screens maps to exactly one owning slice. Reading the two legacy screens top-to-bottom yields the seams below.

Slice (bounded context)Owns (from legacy UI)DatastoreKey eventsScale trigger
EngagementENGAGEMENT INFORMATION form, Approvers, request lifecycle (Draft→Approved)Azure SQLRequestDrafted, RequestSubmittedHTTP concurrency
XYZ CalculationClient XYZ%, Realization, Yield, CALCULATE XYZ PERCENTAGE, OVERRIDE REALIZATIONAzure SQL + RedisGcmCalculatedCPU + queue depth
Rate Tables“refresh/generate rates”, PY billing, DEFINE PY ENGAGEMENTPostgreSQLRatesRefreshedService Bus queue
Approvals / WorkflowApproval matrix (Type/XYZ/Yield/Revenue), status breadcrumb, “Awaiting my approval”Azure SQLApprovalRequested, ApprovalGrantedService Bus queue
Requests Read Model“My XYZ Requests” lists, status dots, pagination, searchCosmos DBconsumes all of the aboveEvent subscription lag
Live Feed / NotificationsWarning banners, near-real-time right-rail updates, system statusRedis + Event Hubsfan-out to SSESSE connection count
Identity / ProfileJames Martin (Logout), USER PREFERENCES, avatarEntra ID / External IDtoken issuancen/a (managed)
Boundary test: if two “services” must be deployed together to ship a feature, the boundary is wrong. The read model deliberately duplicates data so the list screens never fan-out to five services at render time.

03 Vertical slice anatomy

Inside each service we organize by feature folder, not by technical layer. A slice contains its endpoint, contract, validation, handler, domain rules, persistence, and tests — everything needed to change one behavior lives in one place. Cross-cutting concerns are the only shared code, applied as pipeline behaviors.

Vertical slice anatomy
A feature folder cuts vertically through the stack; the shared kernel stays tiny and stable.

The CalculateGcm feature in the XYZ Calculation service — the code behind the “CALCULATE XYZ PERCENTAGE” button:

services/gcm-calculation/Features/CalculateGcm/CalculateGcm.cs
using MediatR;

namespace Gcm.Calculation.Features.CalculateGcm;

// ---- Contract (public shape returned to the portal right-rail) ----
public sealed record CalculateGcmCommand(Guid RequestId, decimal RealizationOverride)
    : IRequest<GcmResult>;

public sealed record GcmResult(decimal GcmPercent, decimal Realization, decimal Yield);

// ---- Endpoint: one minimal-API route, one authorization policy ----
public static class CalculateGcmEndpoint
{
    public static void Map(IEndpointRouteBuilder app) => app
        .MapPost("/gcm/{requestId:guid}/calculate",
            async (Guid requestId, CalculateGcmRequest body, ISender sender) =>
            {
                var result = await sender.Send(
                    new CalculateGcmCommand(requestId, body.RealizationOverride));
                return Results.Ok(result);
            })
        .RequireAuthorization("gcm.calculate")     // fine-grained policy
        .WithName("CalculateGcm");
}

public sealed record CalculateGcmRequest(decimal RealizationOverride);

// ---- Handler: business logic for THIS feature only ----
public sealed class CalculateGcmHandler(
    GcmDbContext db, IEventPublisher events)
    : IRequestHandler<CalculateGcmCommand, GcmResult>
{
    public async Task<GcmResult> Handle(CalculateGcmCommand cmd, CancellationToken ct)
    {
        var request = await db.Requests.FindAsync([cmd.RequestId], ct)
            ?? throw new NotFoundException(cmd.RequestId);

        // Aggregate enforces invariants; returns a new calculation.
        var calc = request.Calculate(cmd.RealizationOverride);

        // Transactional outbox: state + event committed atomically.
        db.Outbox.Add(GcmCalculated.From(request.Id, calc));
        await db.SaveChangesAsync(ct);

        return new GcmResult(calc.GcmPercent, calc.Realization, calc.Yield);
    }
}
Rule: handlers never call another slice's database. Need engagement data inside the calc service? Subscribe to RequestSubmitted and keep a local read-optimized copy, or call the Engagement API through APIM.

04 Target architecture

A public edge fronts a fully private application and data plane. The browser only ever talks to Static Web Apps and API Management; every service, database, cache, and message broker is reachable only over the VNet via private endpoints.

Target platform architecture
Public edge → private ACA environment (one container per slice) → per-slice data → async backbone & platform services.
Exploded view: see the same architecture rendered with the official Microsoft Azure service icons, separated into assembly layers — open the exploded diagram.

Edge

  • Front Door + WAF (OWASP managed rules)
  • Static Web Apps hosts the SPA
  • APIM validates JWTs & rate-limits

App plane

  • ACA environment, VNet-injected
  • Zone-redundant, workload profile optional
  • Managed identity per container app

Data + async

  • DB per slice, private endpoints only
  • Service Bus / Event Grid / Event Hubs
  • Key Vault + Azure Monitor

05 React portal on Azure Static Web Apps

Azure Static Web Apps provides globally distributed hosting for the SPA, linked backends (Azure Functions, Container Apps, or API Management), Microsoft Entra authentication, and role-based authorization.1 The wireframe below is an illustrative reference of the legacy layout being modernized — a top bar, left nav, a parent/child CRUD region, and a read-only right rail. It uses the fictitious Contoso sample and contains no personal or customer data. The right rail is driven by the same Server-Sent Events contract described in section 06 (simulated here).

Gross Contribution Margin (sample) HomeAdminPreferences Signed-in user
Warning: the rate table may be out of date. Refresh/generate rates before saving and submitting.
PROSPECTIVE GROSS CONTRIBUTION MARGIN REQUESTDraft | EP | MMP | MP | Approved
ENGAGEMENT INFORMATION
Client XYZ%
60%
108% Realization | Yield $1,000
LIVE (SSE) · near-real-time
Illustrative legacy wireframe (fictitious Contoso sample) — shown only to convey the layout being modernized. The XYZ% and feed animate every ~2s via a simulated Server-Sent Events stream.

Layout mapping & SSE client

portal/src/features/gcm/GcmRequestPage.tsx
import { useServerEvents } from "@/lib/useServerEvents";

export function GcmRequestPage({ requestId }: { requestId: string }) {
  // Right-rail read model, pushed from the server (near real time).
  const live = useServerEvents(`/api/live/gcm/${requestId}`, {
    gcm: { gcmPercent: 60, realization: 108, yield: 1000 },
  });

  return (
    <div className="portal-grid">            {/* CSS grid: main 1fr / rail 300px */}
      <EngagementForm requestId={requestId} />   {/* parent/child CRUD tablix */}
      <aside className="rail">
        <GcmGauge value={live.gcm.gcmPercent} />      {/* the big 60% */}
        <Realization value={live.gcm.realization} yield={live.gcm.yield} />
        <SystemFeed items={live.feed} />              {/* Azure updates / health */}
      </aside>
    </div>
  );
}
Pixel parity approach: extract the legacy CSS custom properties (colors, spacing, the crimson #c8102e accent, dark chrome) into design tokens, then rebuild components 1:1. Visual-regression tests (Playwright screenshots) gate every PR against baseline captures of the legacy screens.

05B Composition shell & micro-frontends optional flavor

The default in section 05 — one Static Web App with feature folders — is the right call for a single frontend team and pixel parity. But a single build makes the shell a shared coupling point: if shipping a right-rail widget means editing the shared SPA, one slice can block or break everyone. This optional flavor removes that coupling by splitting “the SWA” into a thin composition shell plus independently deployed slice remotes. Adopt it only when the pain below is real; the boundary was drawn at the feature-module level so the promotion is mechanical, not a rewrite.

Citation scope: this flavor composes Azure services (Static Web Apps, Container Apps, App Configuration — all cited in References) with general industry front-end composition techniques (runtime module federation, container queries) that are not Microsoft reference architectures. It is offered as an option; only the Azure building blocks are officially cited.
When to promote to this flavor: multiple frontend teams · right-rail widgets that must iterate on independent release cadences · the shell’s PR queue becomes the bottleneck · “I might break everyone’s UX to ship my slice.” If none of these are true yet, stay on the single-shell default.
Azure hub-and-spoke runtime topology with shared network and security services, Microsoft Entra identity platforms, a Static Web Apps composite shell, and four Container App modules with isolated SQL and Redis stores in the XYZ workload spoke

The diagrams progressively zoom in from the hub-and-spoke landing zone above, to the XYZ workload spoke, and then to the composite shell’s runtime flow below.

Users authenticate with Microsoft Entra ID through MSAL, access a composite shell hosted by Azure Static Web Apps, and call contract-defined Container App modules with per-app Azure SQL and Redis storage
Composition in context: shared hub and identity services connect to an isolated XYZ workload spoke. The Static Web Apps shell calls contract-defined Container App modules with per-module SQL and Redis stores, managed identity, private endpoints, and centralized observability.

Split composition from content (two owners, two lifecycles)

ConcernExamplesOwnerCadence
Composition shellTop bar, left nav, responsive grid, which regions show/hide per breakpoint, routing, auth session, design tokens, the slot contractPlatform / Experience teamRare, high review bar
Slice content (remotes)XYZ gauge, realization panel, PY-billing widget, the CRUD tablixSlice teamsFast, independent deploys
Composition shell with slots and remotes
The shell exposes named slots; each slice publishes a remote loaded into a slot at runtime. Responsive policy stays in the shell; widgets adapt via container queries.

The shell exposes slots; slices ship remotes

A developer ships their remote artifact — they never edit shell internals. The shell only changes for a genuinely cross-cutting concern (a new slot, a breakpoint-policy change), which is rare and centrally reviewed.

portal/src/shell/SlotHost.tsx
import { Suspense, lazy } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
import { REMOTES, type SlotId } from "./slots.config";

// The shell renders a slot by loading whatever remote is registered for it.
// It knows the CONTRACT (props in / events out), never the remote's internals.
export function SlotHost({ slot, ctx }: { slot: SlotId; ctx: SlotContext }) {
  const entry = REMOTES[slot];
  if (!entry) return null;                       // slot empty = render nothing

  const Remote = lazy(entry.load);               // dynamic import of the remote
  return (
    <section className={`slot slot-${slot}`} data-slot={slot}>
      <ErrorBoundary fallback={<SlotFallback slot={slot} />}>   {/* bad remote degrades, never white-screens */}
        <Suspense fallback={<SlotSkeleton />}>
          <Remote {...entry.props} context={ctx} />   {/* versioned contract */}
        </Suspense>
      </ErrorBoundary>
    </section>
  );
}

Responsive: shell owns the policy, the remote owns its adaptation

Two layers, two owners. The shell decides which regions appear at each breakpoint (rail collapses to a bottom sheet on mobile). The remote decides how it renders inside whatever box it’s given — using container queries, not media queries, so it works in the wide rail, a narrow drawer, or a mobile sheet without knowing the viewport.

portal/src/remotes/gcm-gauge/gcm-gauge.css
/* The remote adapts to its SLOT size, not the screen. It renders correctly
   whether the shell drops it in the wide rail, a drawer, or a mobile sheet. */
.gauge { container-type: inline-size; }

@container (min-width: 240px) {
  .gauge .value    { font-size: 44px; }
  .gauge .subtext  { display: block; }
}
@container (max-width: 239px) {
  .gauge .value    { font-size: 24px; }
  .gauge .subtext  { display: none; }   /* compact form in a narrow slot */
}

Isolation

Web Components + shadow DOM (or scoped tokens) mean a remote can’t bleed styles into a neighbor.

Independent release

Ship gcm-gauge@1.4 to 10% of users; roll back by moving the pointer — no shell redeploy.

Graceful failure

A per-slot error boundary degrades one widget to a placeholder instead of breaking the whole portal.

Packaging options for the remotes

OptionWhat it isIndependent deploy?Use when
One SWA shell, feature-sliced defaultSingle React build, folders map to slicesNo (one FE release train)One FE team, pixel parity, few conflicts
Shell + MF remotes this flavorHost SWA loads per-slice bundles at runtimeYes, per remoteMultiple FE teams / independent cadence
Multiple SWAsSeparate SWA per remote, stitched by Front Door routesYes, per SWATruly separate apps, hard isolation
Reference code: the runnable shell, slot registry, and sample remotes live under samples/swa-shell-aca-apps/. Compare it with the unified ACA web container on the architecture comparison page. Deep dive: docs/frontend-composition.md.

06 Near-real-time right rail with Server-Sent Events

The right rail mixes two source types: domain events (a recalculation changes XYZ%/realization/yield) and external feeds (Azure Updates RSS, Azure Service Health API, Azure Marketplace API). Both are multiplexed onto one SSE stream, keyed by the SSE event: field. A Redis backplane lets any Container Apps replica serve any connected browser.

Near-real-time SSE flow
Domain events (via outbox) and external feeds (via Event Hubs) converge on the SSE edge and push to the browser in under two seconds.
services/live-feed/Features/StreamGcm/StreamGcmEndpoint.cs
public static class StreamGcmEndpoint
{
    public static void Map(IEndpointRouteBuilder app) => app
        .MapGet("/live/gcm/{id:guid}", async (
            Guid id, HttpContext ctx, IConnectionMultiplexer redis,
            CancellationToken ct) =>
        {
            ctx.Response.Headers.ContentType = "text/event-stream";
            ctx.Response.Headers.CacheControl = "no-cache";
            ctx.Response.Headers["X-Accel-Buffering"] = "no";

            var sub = redis.GetSubscriber();
            var channel = $"gcm:{id}";                 // authorized per-request channel

            await sub.SubscribeAsync(channel, async (_, msg) =>
            {
                // msg = {"event":"gcm","data":{...}} produced by the projector
                var frame = SseFrame.Parse(msg!);
                await ctx.Response.WriteAsync(
                    $"event: {frame.Event}\nid: {frame.Id}\ndata: {frame.Data}\n\n", ct);
                await ctx.Response.Body.FlushAsync(ct);
            });

            // Keep-alive comment every 15s so proxies don't drop the stream.
            while (!ct.IsCancellationRequested)
            {
                await ctx.Response.WriteAsync(": keep-alive\n\n", ct);
                await ctx.Response.Body.FlushAsync(ct);
                await Task.Delay(TimeSpan.FromSeconds(15), ct);
            }
        })
        .RequireAuthorization("gcm.read");
}
ConcernDecisionWhy
TransportSSE (not WebSocket)One-way push, native auto-reconnect, passes cleanly through APIM/Front Door, trivial at 20 users.
Fan-outRedis pub/sub backplaneAny ACA replica can serve any client; supports scale-out and scale-to-zero recovery.
Delivery guaranteeAt-least-once + idempotency keysDe-dupe replayed frames on Last-Event-ID reconnect.
Latency budget< 2s domain, ≤ 30s externalMatches the “recalculate and see it update” expectation without over-engineering.

07 Polyglot persistence, one store per slice

Each slice picks the store that fits its access pattern. No slice reads another slice's database; integration is via events and APIs. This is what makes independent deployment real.

SliceStoreAccess patternConsistency
EngagementAzure SQL (Hyperscale/GP)Transactional CRUD, relational integrity, EF CoreStrong within aggregate
XYZ CalculationAzure SQL + RedisWrite audit of calcs; Redis caches rate lookupsStrong write, cached read
Rate TablesAzure Database for PostgreSQLVersioned rate matrices, range queriesStrong
Requests Read ModelCosmos DB (NoSQL)Denormalized list projections, partitioned by requesterEventual
Documents / exportsBlob StorageGenerated PDFs, attachmentsEventual
Eventual consistency, made honest: after a calc, the read model is updated asynchronously. The UI shows the authoritative value immediately from the calc response, then reconciles the list view when the projection catches up (typically < 1s). Idempotent projectors mean replays are safe.

08 Messaging, the outbox & consistency

We combine three brokers by role: Service Bus for commands/workflow (sessions, DLQ, sagas), Event Grid for lightweight domain-event fan-out, and Event Hubs for high-volume external feed ingestion. State changes and their events are committed atomically via a transactional outbox, then dispatched by a background relay.

shared/Messaging/OutboxDispatcher.cs
// Runs in-process; polls the outbox and publishes, marking rows dispatched.
public sealed class OutboxDispatcher(
    IServiceScopeFactory scopes, ServiceBusSender sender, ILogger<OutboxDispatcher> log)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            using var scope = scopes.CreateScope();
            var db = scope.ServiceProvider.GetRequiredService<GcmDbContext>();

            var batch = await db.Outbox
                .Where(o => o.DispatchedUtc == null)
                .OrderBy(o => o.OccurredUtc).Take(100).ToListAsync(ct);

            foreach (var evt in batch)
            {
                var msg = new ServiceBusMessage(evt.Payload)
                {
                    MessageId = evt.Id.ToString(),      // broker-side de-dupe
                    Subject   = evt.Type,
                    SessionId = evt.AggregateId.ToString()
                };
                await sender.SendMessageAsync(msg, ct);
                evt.DispatchedUtc = DateTime.UtcNow;
            }
            await db.SaveChangesAsync(ct);
            await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
        }
    }
}

Sagas

Approval routing (EP→OMP→RMP→NMP) is a Service Bus session-based saga with compensations if a level rejects.

Poison handling

Max-delivery → dead-letter queue; an alert + replay tool drains DLQ after a fix.

Ordering

Per-aggregate ordering via SessionId = AggregateId; no global ordering needed.

09 Identity & fine-grained authorization

Workforce users authenticate through Microsoft Entra ID; external customers through Entra External ID. APIM validates the JWT at the edge (fail fast, offload crypto), and each slice re-checks a fine-grained, resource-level policy — a partner can only approve requests within their market/organization.

apim/policies/inbound-gcm-api.xml
<inbound>
  <validate-jwt header-name="Authorization" failed-validation-httpcode="401">
    <openid-config url="https://login.microsoftonline.com/{{tenant}}/v2.0/.well-known/openid-configuration" />
    <audiences><audience>api://gcm</audience></audiences>
    <required-claims>
      <claim name="scp" match="any"><value>gcm.access</value></claim>
    </required-claims>
  </validate-jwt>
  <rate-limit-by-key calls="600" renewal-period="60"
      counter-key="@(context.Request.Headers.GetValueOrDefault('Authorization'))" />
  <set-backend-service backend-id="aca-engagement" />
</inbound>
Baseline

Enterprise

Managed identity everywhere, no secrets in code, Key Vault CMK, private data plane, full audit.

Attested

SOC 2 / ISO

Change approvals, immutable audit logs, evidence export, least-privilege RBAC via Terraform.

Regulated

PCI / HIPAA

Network isolation, TLS everywhere, data classification, retention policies, DLP on exports.

10 Networking & zone resilience (99.95%+)

  • Public surface = two resources only: Front Door (WAF) and Static Web Apps. Everything else is private.
  • VNet-injected ACA environment with an internal-only ingress; APIM (internal/VNet mode) is the sole caller.
  • Private endpoints for SQL, PostgreSQL, Cosmos, Redis, Service Bus, Key Vault, Blob, ACR — public network access disabled.
  • Zone redundancy on the ACA environment, SQL, Service Bus Premium, and zone-redundant storage (ZRS) to meet 99.95%+.
  • Private DNS zones resolve all private endpoints; no data-plane traffic traverses the public internet.
Multi-region ready: the topology is single-region for GA cost, but Front Door origin groups, geo-replicated SQL, and Cosmos multi-write make active/passive a configuration change, not a redesign.

11 Containerization on Azure Container Apps (Consumption)

Every slice ships as a multi-stage, distroless, non-root image. ACA Consumption gives serverless per-second billing, KEDA scale-to-zero, revisions for canary, and built-in Dapr/ingress — without owning a Kubernetes control plane.

services/gcm-calculation/Dockerfile
# --- build ---
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY *.sln ./
COPY services/gcm-calculation/*.csproj services/gcm-calculation/
RUN dotnet restore services/gcm-calculation
COPY . .
RUN dotnet publish services/gcm-calculation -c Release -o /app \
    /p:PublishTrimmed=false /p:UseAppHost=false

# --- runtime (distroless, non-root, read-only) ---
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
HEALTHCHECK CMD ["dotnet", "healthcheck.dll", "--url", "http://localhost:8080/health/ready"]
ENTRYPOINT ["dotnet", "Gcm.Calculation.dll"]
CapabilityACA Consumption feature
Scale-to-zeroKEDA-driven; min_replicas = 0, pay only while handling traffic/messages.
CanaryMultiple revision mode + traffic weights; shift 5–10% then promote.
HealthLiveness/readiness/startup probes wired to /health/* endpoints.
SecretsKey Vault references resolved via the app's managed identity — no secrets in the image.

12 Infrastructure as Code — Terraform authoritative

Terraform is the source of truth, structured as reusable modules (network, ACA env, data, messaging, identity) composed per ring. Bicep and ARM equivalents are provided for teams standardized on Azure-native tooling. All three provision the same resources; the same image digest is deployed by CI.

infra/terraform/modules/aca-env/main.tf
resource "azurerm_container_app_environment" "main" {
  name                       = "cae-gcm-${var.ring}"
  location                   = var.location
  resource_group_name        = var.rg_name
  log_analytics_workspace_id = var.law_id
  infrastructure_subnet_id   = var.aca_subnet_id
  zone_redundancy_enabled    = true                 # 99.95%+ target
  internal_load_balancer_enabled = true             # private ingress only
  tags = var.tags
}

resource "azurerm_container_app_environment_storage" "shared" {
  name                         = "artifacts"
  container_app_environment_id = azurerm_container_app_environment.main.id
  account_name                 = var.storage_account_name
  share_name                   = var.file_share_name
  access_key                   = var.storage_access_key
  access_mode                  = "ReadOnly"
}

output "environment_id" { value = azurerm_container_app_environment.main.id }
Module layout: modules/{network, aca-env, aca-app, data-sql, data-cosmos, messaging, identity, apim, frontdoor, observability}. Rings are just tfvars files (envs/dogfood.tfvars … envs/ga.tfvars) with remote state per ring. Prefer Azure Verified Modules where they exist.

13 CI/CD & progressive delivery rings

GitHub Actions with OIDC federation (no stored cloud secrets) builds one immutable image, then promotes it by digest through five rings. Infra is applied per ring with Terraform; promotions are gated by contract tests, security scans, SLO burn-rate, and human approvals.

Progressive delivery rings
Dogfood → Canary → Private Preview → Public Preview → GA, each an environment with its own approvals and gates.
.github/workflows/deploy.yml
name: build-and-promote
on:
  push: { branches: [main] }

permissions:
  id-token: write          # OIDC federation to Azure
  contents: read
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    outputs: { digest: ${{ steps.push.outputs.digest }} }
    steps:
      - uses: actions/checkout@v4
      - uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - name: Build, scan & push image
        id: push
        run: |
          az acr build -r $ACR -t gcm-calc:$GITHUB_SHA services/gcm-calculation
          trivy image --exit-code 1 --severity CRITICAL $ACR.azurecr.io/gcm-calc:$GITHUB_SHA
          echo "digest=$(az acr repository show -n $ACR --image gcm-calc:$GITHUB_SHA \
            --query digest -o tsv)" >> $GITHUB_OUTPUT

  dogfood:
    needs: build
    environment: dogfood
    runs-on: ubuntu-latest
    steps:
      - uses: hashicorp/setup-terraform@v3
      - run: terraform -chdir=infra/terraform apply -auto-approve \
               -var-file=envs/dogfood.tfvars -var=image_digest=${{ needs.build.outputs.digest }}
      - run: ./scripts/smoke-and-contract-tests.sh dogfood

  canary:
    needs: [build, dogfood]
    environment: canary          # requires 1 reviewer + green SLO gate
    runs-on: ubuntu-latest
    steps:
      - run: terraform -chdir=infra/terraform apply -auto-approve \
               -var-file=envs/canary.tfvars -var=image_digest=${{ needs.build.outputs.digest }}
      - run: ./scripts/watch-slo.sh --error-budget 0.1 --window 15m   # auto-rollback on burn
  # private-preview / public-preview / ga follow the same shape with stricter approvals
Rollback = traffic shift, not rebuild: if the SLO gate trips, CI reweights ACA traffic back to the last-good revision in seconds. Terraform plan runs on every PR to surface infra drift before it reaches a ring.

14 Observability — OpenTelemetry → Azure Monitor

Every slice emits traces, metrics, and structured logs via the OpenTelemetry SDK, exported to Application Insights. A trace follows a request from the SPA through APIM, across the calc and approval slices, and into the message bus — one correlated timeline.

shared/Telemetry/TelemetryExtensions.cs
builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("gcm-calculation"))
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddSqlClientInstrumentation()
        .AddSource("Gcm.Calculation")
        .AddAzureMonitorTraceExporter())        // connection string from Key Vault
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddMeter("Gcm.Calculation")
        .AddAzureMonitorMetricExporter());

// Custom business metric surfaced on the ops dashboard.
var meter = new Meter("Gcm.Calculation");
var calcDuration = meter.CreateHistogram<double>("gcm.calc.duration.ms");

Traces

End-to-end correlation across SPA, APIM, slices, and Service Bus.

SLOs

Availability & latency SLOs with error-budget burn alerts feeding the canary gate.

Dashboards

Azure Monitor workbooks per slice: RPS, p95, error rate, queue depth, calc duration.

15 Cost model

Consumption-first services scale to zero in non-GA rings; production keeps warm minimums only where latency demands. Pricing varies by region, agreement, usage, data transfer, retention, and selected tier. Build an architecture-level estimate in the Azure Pricing Calculator, and use the service pricing pages below to review current billing dimensions.

ServiceTier / configCurrent pricing
Azure Container AppsConsumption, scale-to-zero, ~7 appsContainer Apps pricing
API ManagementBasic v2 (or Developer in lower rings)API Management pricing
Static Web AppsStandardStatic Web Apps pricing
Azure SQL × 2General Purpose serverless, auto-pauseAzure SQL Database pricing
Cosmos DBServerless / autoscale 1000 RUAzure Cosmos DB pricing
PostgreSQL FlexibleBurstable B1msPostgreSQL Flexible Server pricing
Service Bus + Event Grid + Event HubsStandard / BasicService Bus · Event Grid · Event Hubs
RedisBasic C0/C1Azure Cache for Redis pricing
Front Door + WAF, Key Vault, Monitor, ACRStandard tiersFront Door · Key Vault · Azure Monitor · Container Registry
Architecture estimateModel each ring, region, and expected usage profileOpen Azure Pricing Calculator
Levers: auto-pause SQL and scale-to-zero ACA in dogfood/canary/preview; share one APIM across rings via products; consolidate to Basic brokers until throughput demands Standard/Premium. Recalculate for each target region and agreement before architecture approval and periodically during operation.

16 Migration runbook — parallel rebuild

  1. Stand up the platform (Terraform): network, ACA env, APIM, identity, observability — empty but production-shaped.
  2. Rebuild the read model first. Project legacy data into Cosmos so the “My XYZ Requests” lists render from the new stack immediately.
  3. Extract the calculation slice. It is pure logic with clear inputs/outputs — the highest-value, lowest-risk first cut.
  4. Route at the edge. Front Door / APIM sends specific paths to new slices; unmigrated paths still hit the monolith (strangler at the edge).
  5. Dual-write & reconcile during transition; compare new vs. legacy calc outputs in shadow mode before flipping traffic.
  6. Migrate Engagement CRUD, then Approvals (the saga), then Rate Tables. Each ships independently through the rings.
  7. Decommission monolith paths as each slice reaches GA; the last legacy endpoint retires when Approvals is proven.
Why parallel rebuild here: the legacy stack mixes Blazor Server, Blazor WASM, and React; incremental in-place extraction would fight three UI models. A clean React SPA + slice backends, running beside the monolith behind one edge, de-risks cutover and preserves pixel parity.

17 Team topology & the slice playbook

One team owns a slice end-to-end

  • React feature + API + domain + data + IaC module + on-call.
  • “You build it, you run it” — the team that writes the calc owns its SLO.
  • A thin platform team owns the shared kernel, IaC modules, and golden pipelines.

Definition of done for a slice

  • Feature folder complete: endpoint, validator, handler, domain, persistence, tests.
  • Contract tests published; events documented; idempotent consumers.
  • Health probes, OTel, dashboard, and SLO alert wired.
  • Terraform module + tfvars for every ring; deploys via OIDC, no secrets.
Guardrail: a pull request that needs changes in more than one slice to ship a single user-facing feature is a design smell — revisit the boundary before merging.

Reference repository map

This microsite ships alongside a runnable reference. The full tree:

microservices-swa-aca/
├─ site/                      # this microsite (HTML/CSS/JS + SVG diagrams)
├─ portal/                    # React SPA (Static Web Apps) — pixel-parity XYZ UI
├─ samples/
│  ├─ swa-shell-aca-apps/     # composition shell + micro apps
│  └─ aca-apps/               # integrated React web container on ACA
├─ services/
│  ├─ gcm-calculation/        # vertical-slice C# service (sample slice)
│  └─ live-feed/              # SSE edge + external feed worker
├─ shared/                    # tiny shared kernel: messaging, telemetry, outbox
├─ infra/
│  ├─ terraform/              # AUTHORITATIVE IaC (modules + per-ring tfvars)
│  ├─ bicep/                  # equivalent Bicep modules
│  └─ arm/                    # equivalent ARM templates
├─ apim/policies/             # validate-jwt, rate-limit, routing
├─ .github/workflows/         # OIDC build + ring promotion
└─ docs/                      # deep-dive markdown (C#, patterns, decisions)
Start here: open site/index.html for the workshop, then docs/ for the deep dives and infra/terraform/ to provision the platform.

References & attribution

Every architectural claim in this workshop is grounded in official Microsoft documentation. Sources were reviewed on 14 August 2026; Microsoft Learn is continuously updated, so confirm specifics against the live pages. Only official Microsoft content is referenced.

Azure Well-Architected Framework

Microservices architecture & patterns

Compute, hosting & gateway

Messaging, data & registry

Identity, security & networking

Delivery & observability

Scope of citations. The Azure building blocks above are backed by official Microsoft references. The optional frontend flavors (§05B, and the self-contained-systems variant in the docs) combine these Azure services with general industry front-end composition techniques (for example, runtime module federation and container queries) that are not Microsoft reference architectures; they are presented as options and only their Azure components are officially cited.