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.

React SPA on Azure Static Web Apps
Globally distributed static hosting with linked backends and Microsoft Entra authentication.1
Azure Container Apps (Consumption)
One container per vertical slice; KEDA scale-to-zero; multiple-revision traffic splitting.2
Terraform + GitHub Actions (OIDC)
Workload-identity federation — no stored cloud secrets — across deployment rings.3
Mapped to the five Well-Architected pillars
| Pillar | Where this workshop addresses it |
|---|---|
| Reliability | Zone-redundant Container Apps, health probes, eventual consistency, saga compensation (§08, §10) |
| Security | Entra ID/External ID, APIM JWT validation, managed identity, private data plane (§09, §10) |
| Cost Optimization | Consumption scale-to-zero, right-sized data tiers, cost model (§15) |
| Operational Excellence | IaC, OIDC CI/CD, safe/progressive deployment, OpenTelemetry observability (§12–§14) |
| Performance Efficiency | Event-driven autoscaling, CQRS read models, caching, near-real-time SSE (§06–§07) |
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.
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) | Datastore | Key events | Scale trigger |
|---|---|---|---|---|
| Engagement | ENGAGEMENT INFORMATION form, Approvers, request lifecycle (Draft→Approved) | Azure SQL | RequestDrafted, RequestSubmitted | HTTP concurrency |
| XYZ Calculation | Client XYZ%, Realization, Yield, CALCULATE XYZ PERCENTAGE, OVERRIDE REALIZATION | Azure SQL + Redis | GcmCalculated | CPU + queue depth |
| Rate Tables | “refresh/generate rates”, PY billing, DEFINE PY ENGAGEMENT | PostgreSQL | RatesRefreshed | Service Bus queue |
| Approvals / Workflow | Approval matrix (Type/XYZ/Yield/Revenue), status breadcrumb, “Awaiting my approval” | Azure SQL | ApprovalRequested, ApprovalGranted | Service Bus queue |
| Requests Read Model | “My XYZ Requests” lists, status dots, pagination, search | Cosmos DB | consumes all of the above | Event subscription lag |
| Live Feed / Notifications | Warning banners, near-real-time right-rail updates, system status | Redis + Event Hubs | fan-out to SSE | SSE connection count |
| Identity / Profile | James Martin (Logout), USER PREFERENCES, avatar | Entra ID / External ID | token issuance | n/a (managed) |
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.
The CalculateGcm feature in the XYZ Calculation service — the code behind the “CALCULATE XYZ PERCENTAGE” button:
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);
}
}
using FluentValidation;
namespace Gcm.Calculation.Features.CalculateGcm;
public sealed class CalculateGcmValidator : AbstractValidator<CalculateGcmCommand>
{
public CalculateGcmValidator()
{
RuleFor(x => x.RequestId).NotEmpty();
RuleFor(x => x.RealizationOverride)
.InclusiveBetween(0m, 2.0m) // 0%..200% realization
.WithMessage("Realization override must be between 0% and 200%.");
}
}
// Registered once as a MediatR pipeline behavior; runs before every handler.
public sealed class ValidationBehavior<TReq, TRes>(
IEnumerable<IValidator<TReq>> validators)
: IPipelineBehavior<TReq, TRes> where TReq : notnull
{
public async Task<TRes> Handle(
TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct)
{
foreach (var v in validators)
await v.ValidateAndThrowAsync(request, ct);
return await next();
}
}
namespace Gcm.Calculation.Domain;
// Aggregate root: the ONLY place XYZ invariants live.
public sealed class GcmRequest
{
public Guid Id { get; private set; }
public decimal FeeBilling { get; private set; }
public decimal DirectCosts { get; private set; }
public decimal GeneratedExpenses { get; private set; }
private decimal _standardRealization = 1.0m;
public Calculation Calculate(decimal realizationOverride)
{
if (realizationOverride is < 0m or > 2.0m)
throw new DomainException("Realization out of allowed range.");
var realization = realizationOverride == 0m
? _standardRealization : realizationOverride;
var revenue = FeeBilling * realization;
var cost = DirectCosts + GeneratedExpenses;
var gcm = revenue == 0 ? 0 : (revenue - cost) / revenue; // XYZ %
var yield = revenue - cost;
return new Calculation(
GcmPercent: decimal.Round(gcm * 100, 0),
Realization: decimal.Round(realization * 100, 0),
Yield: decimal.Round(yield, 0));
}
}
public sealed record Calculation(decimal GcmPercent, decimal Realization, decimal Yield);
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.
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).
Layout mapping & SSE client
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>
);
}
import { useEffect, useState } from "react";
// Thin EventSource wrapper: auto-reconnect, Last-Event-ID, multiplexed events.
export function useServerEvents<T>(url: string, initial: T): T & { feed: string[] } {
const [state, setState] = useState({ ...initial, feed: [] as string[] });
useEffect(() => {
const es = new EventSource(url, { withCredentials: true });
es.addEventListener("gcm", (e) =>
setState((s) => ({ ...s, gcm: JSON.parse((e as MessageEvent).data) })));
es.addEventListener("system", (e) =>
setState((s) => ({ ...s, feed: [(e as MessageEvent).data, ...s.feed].slice(0, 5) })));
es.onerror = () => { /* browser retries automatically with Last-Event-ID */ };
return () => es.close();
}, [url]);
return state as T & { feed: string[] };
}
{
"$schema": "https://json.schemastore.org/staticwebapp.config.json",
"navigationFallback": { "rewrite": "/index.html", "exclude": ["/assets/*"] },
"routes": [
{ "route": "/api/*", "allowedRoles": ["authenticated"] },
{ "route": "/admin/*", "allowedRoles": ["gcm.admin"] }
],
"auth": {
"identityProviders": {
"azureActiveDirectory": {
"registration": {
"openIdIssuer": "https://login.microsoftonline.com/<tenant>/v2.0",
"clientIdSettingName": "AAD_CLIENT_ID",
"clientSecretSettingName": "AAD_CLIENT_SECRET"
}
}
}
},
"globalHeaders": {
"Content-Security-Policy": "default-src 'self'; connect-src 'self' https://*.azure-api.net",
"X-Content-Type-Options": "nosniff",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload"
}
}
#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.

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.

Split composition from content (two owners, two lifecycles)
| Concern | Examples | Owner | Cadence |
|---|---|---|---|
| Composition shell | Top bar, left nav, responsive grid, which regions show/hide per breakpoint, routing, auth session, design tokens, the slot contract | Platform / Experience team | Rare, high review bar |
| Slice content (remotes) | XYZ gauge, realization panel, PY-billing widget, the CRUD tablix | Slice teams | Fast, independent deploys |
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.
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>
);
}
// portal/src/shell/slots.config.ts
// The registry maps slots -> remote pointers. Each pointer is versioned and
// canaried like a backend revision; changing it needs NO shell rebuild.
export type SlotId = "main" | "rail-1" | "rail-2";
export interface RemoteEntry {
team: string;
version: string;
load: () => Promise<{ default: React.ComponentType<any> }>;
props?: Record<string, unknown>;
}
export const REMOTES: Record<SlotId, RemoteEntry> = {
"main": { team: "engagement", version: "2.3",
load: () => import("engagement/Form") },
"rail-1": { team: "gcm-calc", version: "1.4",
load: () => import("gcmGauge/Widget") },
"rail-2": { team: "rate-tables",version: "3.0",
load: () => import("pyBilling/Widget") },
};
// Slot contract every remote must honor (props in, events out).
export interface SlotContext {
requestId: string;
tokens: DesignTokens; // shared theme = no divergent styling
emit: (event: string, detail: unknown) => void; // remote -> shell events
}
// portal/vite.config.mfe.ts (host shell)
import { defineConfig } from "vite";
import federation from "@originjs/vite-plugin-federation";
export default defineConfig({
plugins: [
federation({
name: "shell",
remotes: {
// Each remote is a separately deployed bundle (own SWA or CDN path).
engagement: "https://gcm.contoso.com/remotes/engagement/remoteEntry.js",
gcmGauge: "https://gcm.contoso.com/remotes/gcm-gauge/remoteEntry.js",
pyBilling: "https://gcm.contoso.com/remotes/py-billing/remoteEntry.js",
},
shared: ["react", "react-dom"], // singletons — one copy across remotes
}),
],
});
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.
/* 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 */
}
/* portal/src/shell/shell.css — the shell owns cross-cutting region policy */
.portal-grid {
display: grid;
grid-template-columns: 150px 1fr 300px; /* nav | main | rail */
grid-template-areas: "nav main rail";
}
/* Narrow: rails leave the grid and become a bottom sheet; nav collapses. */
@media (max-width: 860px) {
.portal-grid { grid-template-columns: 1fr; grid-template-areas: "main"; }
.slot-rail-1, .slot-rail-2 { position: fixed; bottom: 0; left: 0; right: 0; }
.left-nav { transform: translateX(-100%); } /* behind a hamburger */
}
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
| Option | What it is | Independent deploy? | Use when |
|---|---|---|---|
| One SWA shell, feature-sliced default | Single React build, folders map to slices | No (one FE release train) | One FE team, pixel parity, few conflicts |
| Shell + MF remotes this flavor | Host SWA loads per-slice bundles at runtime | Yes, per remote | Multiple FE teams / independent cadence |
| Multiple SWAs | Separate SWA per remote, stitched by Front Door routes | Yes, per SWA | Truly separate apps, hard isolation |
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.
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");
}
// Background worker: pulls external sources, normalizes, publishes to Redis.
public sealed class ExternalFeedWorker(
IHttpClientFactory http, IConnectionMultiplexer redis, ILogger<ExternalFeedWorker> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var sources = new (string Name, string Url)[]
{
("azure-updates", "https://www.microsoft.com/releasecommunications/api/v2/azure/rss"),
("service-health", "https://management.azure.com/.../events?api-version=2022-10-01"),
("marketplace", "https://catalogapi.azure.com/products?api-version=2023-01-01"),
};
var sub = redis.GetSubscriber();
while (!ct.IsCancellationRequested)
{
foreach (var (name, url) in sources)
{
try
{
var client = http.CreateClient("feeds"); // managed identity for ARM calls
var items = await FeedReader.ReadAsync(client, url, ct);
foreach (var item in items)
await sub.PublishAsync("system:status",
SseFrame.System(name, item).ToJson());
}
catch (Exception ex) { log.LogWarning(ex, "Feed {Name} failed", name); }
}
await Task.Delay(TimeSpan.FromSeconds(30), ct); // < 5s for domain, ≤30s external
}
}
}
| Concern | Decision | Why |
|---|---|---|
| Transport | SSE (not WebSocket) | One-way push, native auto-reconnect, passes cleanly through APIM/Front Door, trivial at 20 users. |
| Fan-out | Redis pub/sub backplane | Any ACA replica can serve any client; supports scale-out and scale-to-zero recovery. |
| Delivery guarantee | At-least-once + idempotency keys | De-dupe replayed frames on Last-Event-ID reconnect. |
| Latency budget | < 2s domain, ≤ 30s external | Matches 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.
| Slice | Store | Access pattern | Consistency |
|---|---|---|---|
| Engagement | Azure SQL (Hyperscale/GP) | Transactional CRUD, relational integrity, EF Core | Strong within aggregate |
| XYZ Calculation | Azure SQL + Redis | Write audit of calcs; Redis caches rate lookups | Strong write, cached read |
| Rate Tables | Azure Database for PostgreSQL | Versioned rate matrices, range queries | Strong |
| Requests Read Model | Cosmos DB (NoSQL) | Denormalized list projections, partitioned by requester | Eventual |
| Documents / exports | Blob Storage | Generated PDFs, attachments | Eventual |
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.
// 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);
}
}
}
// Consumers are idempotent: a processed-message table guards re-delivery.
public sealed class GcmCalculatedConsumer(ReadModelDbContext db)
{
public async Task Handle(ServiceBusReceivedMessage msg, CancellationToken ct)
{
if (await db.Processed.AnyAsync(p => p.MessageId == msg.MessageId, ct))
return; // already applied — skip
var e = msg.Body.ToObjectFromJson<GcmCalculated>();
var row = await db.Requests.FindAsync([e.RequestId], ct)
?? new RequestListItem { Id = e.RequestId };
row.ExpectedGcm = e.GcmPercent; // update the "My XYZ Requests" list
row.Status = "Draft";
db.Requests.Update(row);
db.Processed.Add(new Processed(msg.MessageId, DateTime.UtcNow));
await db.SaveChangesAsync(ct); // state + dedupe committed together
}
}
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.
<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>
// Fine-grained: not just "is a partner", but "owns THIS request's market".
public sealed class ApproveRequestAuthorizer(IOrgGraph org) : IAuthorizationHandler
{
public async Task<bool> CanApprove(ClaimsPrincipal user, Guid requestId)
{
var market = await org.MarketForRequest(requestId);
var userOid = user.FindFirstValue("oid");
// Relationship-based check against the org graph + Entra app roles.
var isApprover = user.IsInRole("gcm.approver");
var inMarket = await org.IsPartnerInMarket(userOid, market);
var notSelf = await org.RequesterOf(requestId) != userOid; // no self-approval
return isApprover && inMarket && notSelf;
}
}
Enterprise
Managed identity everywhere, no secrets in code, Key Vault CMK, private data plane, full audit.
SOC 2 / ISO
Change approvals, immutable audit logs, evidence export, least-privilege RBAC via Terraform.
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.
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.
# --- 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"]
resource "azurerm_container_app" "gcm_calc" {
name = "aca-gcm-calc"
container_app_environment_id = azurerm_container_app_environment.main.id
resource_group_name = azurerm_resource_group.app.name
revision_mode = "Multiple" # enables canary traffic split
identity { type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.gcm_calc.id] }
ingress {
external_enabled = false # private; only APIM calls it
target_port = 8080
transport = "http"
traffic_weight { latest_revision = true percentage = 90 }
traffic_weight { revision_suffix = "stable" percentage = 10 }
}
template {
min_replicas = 0 # scale-to-zero (Consumption)
max_replicas = 10
container {
name = "gcm-calc"
image = "${azurerm_container_registry.acr.login_server}/gcm-calc@${var.image_digest}"
cpu = 0.5
memory = "1Gi"
liveness_probe { transport = "HTTP" port = 8080 path = "/health/live" }
readiness_probe { transport = "HTTP" port = 8080 path = "/health/ready" }
}
custom_scale_rule {
name = "sb-queue"
custom_rule_type = "azure-servicebus"
metadata = { queueName = "gcm-calc", messageCount = "20" }
}
}
}
| Capability | ACA Consumption feature |
|---|---|
| Scale-to-zero | KEDA-driven; min_replicas = 0, pay only while handling traffic/messages. |
| Canary | Multiple revision mode + traffic weights; shift 5–10% then promote. |
| Health | Liveness/readiness/startup probes wired to /health/* endpoints. |
| Secrets | Key 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.
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 }
// infra/bicep/modules/aca-env.bicep
param ring string
param location string = resourceGroup().location
param lawId string
param acaSubnetId string
param tags object = {}
resource cae 'Microsoft.App/managedEnvironments@2024-03-01' = {
name: 'cae-gcm-${ring}'
location: location
tags: tags
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: { customerId: lawId }
}
zoneRedundant: true
vnetConfiguration: {
infrastructureSubnetId: acaSubnetId
internal: true
}
}
}
output environmentId string = cae.id
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"ring": { "type": "string" },
"lawId": { "type": "string" },
"acaSubnetId": { "type": "string" }
},
"resources": [
{
"type": "Microsoft.App/managedEnvironments",
"apiVersion": "2024-03-01",
"name": "[concat('cae-gcm-', parameters('ring'))]",
"location": "[resourceGroup().location]",
"properties": {
"zoneRedundant": true,
"vnetConfiguration": {
"infrastructureSubnetId": "[parameters('acaSubnetId')]",
"internal": true
},
"appLogsConfiguration": {
"destination": "log-analytics",
"logAnalyticsConfiguration": { "customerId": "[parameters('lawId')]" }
}
}
}
],
"outputs": {
"environmentId": {
"type": "string",
"value": "[resourceId('Microsoft.App/managedEnvironments', concat('cae-gcm-', parameters('ring')))]"
}
}
}
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.
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
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.
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");
resource "azurerm_monitor_scheduled_query_rules_alert_v2" "gcm_error_budget" {
name = "gcm-calc-error-budget-burn"
resource_group_name = var.rg_name
location = var.location
severity = 1
evaluation_frequency = "PT5M"
window_duration = "PT15M"
scopes = [var.app_insights_id]
criteria {
query = <<-KQL
requests
| where cloud_RoleName == "gcm-calculation"
| summarize failRate = 100.0 * countif(success == false) / count()
KQL
operator = "GreaterThan"
threshold = 1 # > 1% error rate over 15m
time_aggregation_method = "Average"
}
action { action_groups = [var.oncall_action_group_id] }
}
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.
| Service | Tier / config | Current pricing |
|---|---|---|
| Azure Container Apps | Consumption, scale-to-zero, ~7 apps | Container Apps pricing |
| API Management | Basic v2 (or Developer in lower rings) | API Management pricing |
| Static Web Apps | Standard | Static Web Apps pricing |
| Azure SQL × 2 | General Purpose serverless, auto-pause | Azure SQL Database pricing |
| Cosmos DB | Serverless / autoscale 1000 RU | Azure Cosmos DB pricing |
| PostgreSQL Flexible | Burstable B1ms | PostgreSQL Flexible Server pricing |
| Service Bus + Event Grid + Event Hubs | Standard / Basic | Service Bus · Event Grid · Event Hubs |
| Redis | Basic C0/C1 | Azure Cache for Redis pricing |
| Front Door + WAF, Key Vault, Monitor, ACR | Standard tiers | Front Door · Key Vault · Azure Monitor · Container Registry |
| Architecture estimate | Model each ring, region, and expected usage profile | Open Azure Pricing Calculator |
16 Migration runbook — parallel rebuild
- Stand up the platform (Terraform): network, ACA env, APIM, identity, observability — empty but production-shaped.
- Rebuild the read model first. Project legacy data into Cosmos so the “My XYZ Requests” lists render from the new stack immediately.
- Extract the calculation slice. It is pure logic with clear inputs/outputs — the highest-value, lowest-risk first cut.
- Route at the edge. Front Door / APIM sends specific paths to new slices; unmigrated paths still hit the monolith (strangler at the edge).
- Dual-write & reconcile during transition; compare new vs. legacy calc outputs in shadow mode before flipping traffic.
- Migrate Engagement CRUD, then Approvals (the saga), then Rate Tables. Each ships independently through the rings.
- Decommission monolith paths as each slice reaches GA; the last legacy endpoint retires when Approvals is proven.
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.
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)
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
- Microsoft Learn — What is the Azure Well-Architected Framework?
- Microsoft Learn — Azure Well-Architected Framework pillars (Reliability, Security, Cost Optimization, Operational Excellence, Performance Efficiency)
- Microsoft Learn — Safe deployment practices
Microservices architecture & patterns
- Microsoft Learn — Microservices architecture style
- Microsoft Learn — Design a microservices architecture, Design patterns for microservices, Compute options, API gateways
- Microsoft Learn — Strangler Fig, Saga, CQRS, Backends for Frontends, Health Endpoint Monitoring
Compute, hosting & gateway
- 1 Microsoft Learn — What is Azure Static Web Apps? and Static Web Apps with Azure Front Door
- 2 Microsoft Learn — Set scaling rules in Azure Container Apps (KEDA, scale-to-zero, revisions), Reliability in Azure Container Apps (zone redundancy), Billing
- Microsoft Learn — API Management: validate-jwt policy, validate-azure-ad-token policy
Messaging, data & registry
- Microsoft Learn — Service Bus message sessions and dead-letter queues; Event Grid; Event Hubs
- Microsoft Learn — Azure SQL Database; Azure Cosmos DB; Azure Database for PostgreSQL; Azure Cache for Redis
- Microsoft Learn — Azure Container Registry
Identity, security & networking
- Microsoft Learn — Managed identities for Azure resources; Key Vault authentication
- Microsoft Learn — Microsoft Entra External ID; Azure Front Door; Web Application Firewall on Front Door; Azure Private Endpoint
Delivery & observability
- 3 Microsoft Learn — Connect GitHub Actions to Azure with OpenID Connect; Workload identity federation
- Microsoft Learn — Terraform on Azure
- Microsoft Learn — Application Insights overview; Enable OpenTelemetry with Azure Monitor