diff --git a/.github/workflows/content.yml b/.github/workflows/content.yml index 04d80871..d7b2a936 100644 --- a/.github/workflows/content.yml +++ b/.github/workflows/content.yml @@ -28,6 +28,11 @@ jobs: - name: Validate site content run: python scripts/validate_site.py + # The Markdown text copies of the decks and documents under lgtm-presentation/text/ + # must match the committed .pptx and .docx files (standard library only). + - name: Deck text copies are current + run: python scripts/deck_text.py --check + # Professional voice: no ban-tier phrase on any reader-facing page, README or # diagram label. The scanner is vendored from lgtm-skills (scripts/voice/README.md); # the scope lives in scripts/voice-scan.sh. diff --git a/CLAUDE.md b/CLAUDE.md index d6df570b..79f403cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ otherwise carry over from the claude.ai sessions where this repo was built. A Jekyll / GitHub Pages tutorial site teaching cloud-native API design, with **six-language code tabs** (Spring Boot, Quarkus, .NET, Python, C++, Go), paired **SVG + Excalidraw** -diagrams, and a companion **six-deck PPTX series** in `lgtm-presentation/`. +diagrams, and a companion **eight-deck PPTX set** in `lgtm-presentation/` (six language decks and two policy decks). - Live site: https://patterncatalyst.github.io/cloud-native-design-patterns/ - Owner: `patterncatalyst`. Deploy: GitHub Actions → Pages (`.github/workflows/pages.yml`). @@ -29,7 +29,7 @@ approvals**, no direct pushes. The required checks are summary jobs that always | Check | Workflow | Covers | |-------|----------|--------| | `site` | `pages.yml` | Jekyll build (deploys only on a push to main) | -| `content-ok` | `content.yml` | `scripts/validate_site.py` (the "Validate before commit" rules) + voice scan, ban tier (`scripts/voice-scan.sh`) | +| `content-ok` | `content.yml` | `scripts/validate_site.py` (the "Validate before commit" rules, including the positional, old-engine and stale-stack checks over `lgtm-presentation/`) + voice scan, ban tier (`scripts/voice-scan.sh`) + `scripts/deck_text.py --check` (the deck text copies) | | `examples-ok` | `examples.yml` | build matrix per changed example project | Workflows trigger on `push: branches: [main]` plus `pull_request:` (no `paths:` filters). @@ -193,13 +193,25 @@ k6, Locust. `content-ok`). Watch-tier hits are reviewed in context: rewrite them unless the word is literal (a machine that fails silently, minikube as the subject, a result table). Scope: `_docs`, `_parts`, `_example_pages`, `_includes`, `_layouts`, `index.html`, `README.md`, - `assets/diagrams/*.svg`, and `examples/**/*.md`; `lgtm-presentation/` joins in S12. When a + `assets/diagrams/*.svg`, `examples/**/*.md`, and the Markdown in `lgtm-presentation/` (the text copies under `text/`, the policy-deck companions, `BUILD.md`; the `.pptx` and `.docx` files are not scanned). When a diagram label changes, change the SVG, both `text` and `originalText` in the `.excalidraw`, and any alt text or caption that repeats it. The scanner is vendored from lgtm-skills (`scripts/voice/README.md`): fix it upstream and re-vendor, never edit the copy. Check a few files while writing with `scripts/voice-scan.sh --tier all --hits all ` (paths relative to the current directory; `examples/` paths are scanned as Markdown). +## The decks (`lgtm-presentation/`) + +Eight decks: Python, Spring Boot, Quarkus, .NET, Go and C++ (`Designing-Cloud-Native-APIs-.pptx`, +Appendix R on minikube in all six, no Appendix S) plus `K8s-Environment-Access-Policies` and +`Network-Policy-Process-Advisory` with their `.md` and `.docx` companions. No generator exists: change a deck +in place with `scripts/deck_edit.py` and an edit file under `lgtm-presentation/edits/` (applied once, to the +committed deck; the S12 venv holds python-pptx and cairosvg), then run `python3 scripts/deck_text.py --write` +to refresh the Markdown copies in `lgtm-presentation/text/`. CI fails when a copy is stale +(`deck_text.py --check`). `scripts/deck_render.sh` renders slides for inspection (brand fonts required) and +`scripts/deck_fit.py` checks a render for overflow. Code on a slide follows `examples/`; the decks are +vendor-neutral and name no cloud SDK. `lgtm-presentation/BUILD.md` records the method, slide counts and pins. + ## Workflow in Claude Code (changed from claude.ai) - Edit files in place, run docker, builds and tests directly, commit normally. **The delta-tarball diff --git a/README.md b/README.md index 5034c200..e2263638 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,11 @@ the *Designing Cloud-Native APIs* deck series; the decks themselves live in are plain HTML + JS and work on GitHub Pages). - **Paired diagrams** — every figure ships as a themed `name.svg` plus an editable `name.excalidraw` source under `assets/diagrams/`. -- **A companion deck series** — six Red Hat-branded conference decks (one per - language) in [`lgtm-presentation/`](lgtm-presentation/). +- **A companion deck series** — eight Red Hat-branded decks in + [`lgtm-presentation/`](lgtm-presentation/): one per language (Python, Spring Boot, + Quarkus, .NET, Go, C++) and two policy decks, each edited in place with the toolkit + in `scripts/` and mirrored as Markdown text copies under `lgtm-presentation/text/`, + which CI checks. ## The running example @@ -58,7 +61,7 @@ cd python && docker compose down -v | `_docs/` | the setup guide, chapters, appendices A–S, and the glossary | | `_includes/codetabs.html` + `assets/js/codetabs.js` | the multi-language code tabs (no plugin) | | `assets/diagrams/` | paired `name.svg` + `name.excalidraw` figures | -| `lgtm-presentation/` | the six source decks + `BUILD.md` manifest | +| `lgtm-presentation/` | the eight decks (six language decks, two policy decks), their text copies in `text/`, the edit files in `edits/`, and the `BUILD.md` record | | `_plans/` | internal planning notes | ## Deploy diff --git a/_config.yml b/_config.yml index c7b4dcfa..9a7d0818 100644 --- a/_config.yml +++ b/_config.yml @@ -84,6 +84,7 @@ exclude: - node_modules/ - examples/ # ← important: trailing slash; runnable code lives here, not site content - scripts/ + - lgtm-presentation/ - README.md - LICENSE - PRD.md diff --git a/_docs/01-cloud-native-principles.md b/_docs/01-cloud-native-principles.md index fbf7d5c6..550369dd 100644 --- a/_docs/01-cloud-native-principles.md +++ b/_docs/01-cloud-native-principles.md @@ -85,7 +85,7 @@ write. {% include excalidraw.html file="01-factors-mapped" - alt="Five rows mapping groups of factors to the system: build and ship becomes built once, run anywhere (one repo, Poetry, one image per release); configure becomes config from the environment (pydantic-settings, per-profile, secrets injected); run on the platform becomes disposable, stateless, scaled (KEDA scale-out and to-zero, fast start/stop, behind Istio); attach resources becomes backing services as swappable URLs (Postgres, Kafka via Strimzi, Apicurio, never in-process); operate becomes signals and admin off the hot path (OpenTelemetry to LGTM, admin as Jobs and CronJobs). Each row carries a section reference." + alt="Five rows mapping groups of factors to the system: build and ship becomes built once, run anywhere (one repo, pinned requirements, one image per release); configure becomes config from the environment (pydantic-settings, per-profile, secrets injected); run on the platform becomes disposable, stateless, scaled (KEDA scale-out and to-zero, fast start/stop, behind Istio); attach resources becomes backing services as swappable URLs (Postgres, Kafka via Strimzi, Apicurio, never in-process); operate becomes signals and admin off the hot path (OpenTelemetry to LGTM, admin as Jobs and CronJobs). Each row carries a section reference." caption="Figure 1.4 — The twelve factors grouped by ask, mapped to where each lives in the system" %} Five groups cover all twelve. *Build & ship* (codebase, dependencies, build/release/run, @@ -107,157 +107,149 @@ in each stack — pick your tab. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -# application.yml — config from the environment (factor III) -spring: - application: - name: order-service # surfaces in metrics, traces - kafka: - bootstrap-servers: ${KAFKA_BOOTSTRAP} # injected at runtime - -management: # Actuator publishes probes - endpoint: - health: - probes: - enabled: true # /actuator/health/liveness - health: # /actuator/health/readiness - livenessstate.enabled: true - readinessstate.enabled: true - -// Custom readiness contributor — auto-discovered as a HealthIndicator bean -@Component -public class DatabaseReady implements HealthIndicator { - private final JdbcTemplate jdbc; - public DatabaseReady(JdbcTemplate jdbc) { this.jdbc = jdbc; } - - @Override public Health health() { - try { jdbc.queryForObject("SELECT 1", Integer.class); return Health.up().build(); } - catch (DataAccessException e) { return Health.down(e).build(); } +// HealthController.java (excerpt), examples/01-cloud-native-principles/spring-boot +// config from env (factor III): SPRING_DATASOURCE_URL, SERVICE_VERSION -> service.version +@RestController +public class HealthController { + private final DataSource dataSource; + @Value("${service.version}") + private String serviceVersion; + public HealthController(DataSource dataSource) { this.dataSource = dataSource; } + @GetMapping("/healthz") // liveness: the process is up + public Map healthz() { return Map.of("status", "ok"); } + @GetMapping("/readyz") // readiness: SELECT 1 on the pool + public ResponseEntity> readyz() { + try (var conn = dataSource.getConnection()) { + conn.createStatement().executeQuery("SELECT 1").close(); + } catch (Exception e) { // down: 503, the pod leaves the Service + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("status", "down", "checks", Map.of("database", "unreachable"))); + } + return ResponseEntity.ok(Map.of("status", "ready", + "checks", Map.of("database", "ok"))); } } ``` ```java -# application.properties — config from the environment (factor III) -quarkus.application.name=order-service -quarkus.application.version=1.4.0 -%prod.kafka.bootstrap.servers=${KAFKA_BOOTSTRAP} - -// SmallRye Health publishes the probes Kubernetes calls -@Liveness // /q/health/live — process is up -public class Alive implements HealthCheck { - public HealthCheckResponse call() { return HealthCheckResponse.up("alive"); } -} - -@Readiness // /q/health/ready — deps reachable -public class Ready implements HealthCheck { - @Inject PgPool db; // injected datastore client - public HealthCheckResponse call() { - return db.query("SELECT 1") != null ? up("ready") : down("ready"); } +// HealthResource.java (excerpt), examples/01-cloud-native-principles/quarkus +@Path("/") @Produces(MediaType.APPLICATION_JSON) +public class HealthResource { + @ConfigProperty(name = "quarkus.datasource.jdbc.url") String jdbcUrl; // env, factor III + @GET @Path("healthz") // liveness: the process is up + public Map healthz() { return Map.of("status", "ok"); } + @GET @Path("readyz") // readiness: SELECT 1 (one retry elided) + public Response readyz() { + try (var conn = DriverManager.getConnection(jdbcUrl, dbUser, dbPass); + var rs = conn.createStatement().executeQuery("SELECT 1")) { + return Response.ok(Map.of("status", "ready", + "checks", Map.of("database", "ok"))).build(); + } catch (Exception e) { // down: 503, out of rotation + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("status", "down", "checks", Map.of("database", "unreachable"))) + .build(); + } + } } ``` ```csharp -// Program.cs — minimal hosting, env-driven config (factor III) +// Program.cs (excerpt), examples/01-cloud-native-principles/dotnet var builder = WebApplication.CreateBuilder(args); -builder.Configuration.AddEnvironmentVariables(); // overrides appsettings.json - -// Health checks: liveness vs readiness, just like the WCF heartbeat — -// only Kubernetes calls them now, not your monitoring server. -builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), tags: new[] { "live" }) - .AddNpgSql(builder.Configuration.GetConnectionString("Db")!, - name: "db", tags: new[] { "ready" }); // AspNetCore.HealthChecks - +// ... ConnectionStrings__Default from the environment (factor III) -> NpgsqlDataSource var app = builder.Build(); - -// The cloud-native equivalent of IIS "Ping" + ARR health probes, -// but baked into the app, not the host. -app.MapHealthChecks("/healthz/live", new() { Predicate = c => c.Tags.Contains("live") }); -app.MapHealthChecks("/healthz/ready", new() { Predicate = c => c.Tags.Contains("ready") }); - -app.Run(); +app.MapGet("/healthz", () => new { status = "ok" }); // liveness: the process is up +app.MapGet("/readyz", async (NpgsqlDataSource db) => // readiness: SELECT 1 +{ + try + { + await using var conn = await db.OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT 1"; + await cmd.ExecuteScalarAsync(); + return Results.Ok(new { status = "ready", checks = new { database = "ok" } }); + } + catch // down: 503, out of rotation + { + return Results.Json(new { status = "down", checks = new { database = "unreachable" } }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } +}); ``` ```python -# pyproject.toml (Poetry) pins everything; config comes from the environment +# main.py (excerpt); requirements.txt pins every package with == from fastapi import FastAPI +from fastapi.responses import JSONResponse from pydantic_settings import BaseSettings class Settings(BaseSettings): # config from env — factor III database_url: str - kafka_bootstrap: str + kafka_bootstrap: str = "" + service_version: str = "0.0.0" settings = Settings() -app = FastAPI(title="order-service", version="1.4.0") +app = FastAPI(title="order-service", version=settings.service_version, lifespan=lifespan) -@app.get("/healthz") # liveness — process is up +@app.get("/healthz") # liveness — the process is up def healthz(): return {"status": "ok"} -@app.get("/readyz") # readiness — deps are reachable +@app.get("/readyz") # readiness — dependencies are reachable async def readyz(): - await check_db(); await check_kafka() - return {"status": "ready"} + try: + async with pool.acquire() as conn: + await conn.fetchval("SELECT 1") + except Exception: + return JSONResponse({"status": "down", "checks": {"database": "unreachable"}}, 503) + return {"status": "ready", "checks": {"database": "ok"}} ``` ```cpp -{% raw %}// conanfile.py pins everything; config comes from the environment -#include -#include - -struct Settings { // config from env — factor III - std::string database_url = std::getenv("DATABASE_URL"); - std::string kafka_bootstrap = std::getenv("KAFKA_BOOTSTRAP"); -}; - -const Settings settings; // parsed once, immutable -constinit std::string_view kVersion = "1.4.0"; - +// main.cpp (excerpt), examples/01-cloud-native-principles/cpp; conan.lock pins every library +using Callback = std::function; +const char* env(const char* name, const char* fallback) { // config from env, factor III + const char* v = std::getenv(name); return v ? v : fallback; // unset: the default +} int main() { - auto& app = drogon::app(); - app.registerHandler("/healthz", [](auto&&, auto&& cb){ // liveness — process is up - cb(drogon::HttpResponse::newHttpJsonResponse({{"status","ok"}})); - }); - app.registerHandler("/readyz", [](auto&&, auto&& cb){ // readiness — deps reachable - check_db(); check_kafka(); - cb(drogon::HttpResponse::newHttpJsonResponse({{"status","ready"}})); - }); - app.addListener("0.0.0.0", 8080).run(); // factor VII · port binding -}{% endraw %} + g_version = env("SERVICE_VERSION", "1.0.0"); + g_pool = std::make_unique( + env("PG_CONNINFO", "postgresql://appuser:apppass@postgres:5432/appdb"), + std::atoi(env("PG_POOL_SIZE", "4"))); + app().registerHandler("/healthz", [](const HttpRequestPtr&, Callback&& cb) { + Json::Value j; j["status"] = "ok"; cb(HttpResponse::newHttpJsonResponse(j)); // liveness + }, {Get}); + app().registerHandler("/readyz", [](const HttpRequestPtr&, Callback&& cb) { + const bool db_ok = db_ready(); // SELECT 1 via the libpq pool (retry elided) + Json::Value j; j["status"] = db_ok ? "ready" : "down"; + auto resp = HttpResponse::newHttpJsonResponse(j); + if (!db_ok) resp->setStatusCode(k503ServiceUnavailable); // down: out of rotation + cb(resp); + }, {Get}); + app().addListener("0.0.0.0", 8080).run(); // factor VII: port binding +} ``` ```go -// main.go — config from the environment (factor III); two probes, not one -type Settings struct { - DatabaseURL string // DATABASE_URL - KafkaBootstrap string // KAFKA_BOOTSTRAP +// main.go (excerpt), examples/01-cloud-native-principles/go: config from env, two probes +func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) // liveness: the process is up } - -func load() Settings { // config from env, never a baked-in file - return Settings{ - DatabaseURL: os.Getenv("DATABASE_URL"), - KafkaBootstrap: os.Getenv("KAFKA_BOOTSTRAP"), +func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + w.Header().Set("Content-Type", "application/json") + if err := s.pool.Ping(ctx); err != nil { // readiness: the database answers + w.WriteHeader(http.StatusServiceUnavailable) // 503: out of rotation, not restarted + json.NewEncoder(w).Encode(ReadinessResponse{Status: "down", + Checks: map[string]string{"database": "unreachable"}}) + return } + json.NewEncoder(w).Encode(ReadinessResponse{Status: "ready", + Checks: map[string]string{"database": "ok"}}) } - -func main() { - cfg := load() - mux := http.NewServeMux() - - mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) // liveness - }) - mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) { - // readiness drains traffic on failure; it never restarts the pod - if err := errors.Join(checkDB(r.Context()), checkKafka(r.Context())); err != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "down"}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) - }) - - slog.Info("starting", "version", "1.4.0", "kafka", cfg.KafkaBootstrap) - _ = http.ListenAndServe(":8080", mux) // factor VII · port binding -} +// main(): SERVICE_VERSION and DATABASE_URL come from the environment (factor III); the mux +// serves /, /healthz, /readyz and /orders on :8080 (factor VII, port binding) ``` ### How the code works @@ -265,19 +257,23 @@ func main() { The same two ideas appear in every tab: 1. **Config comes from the environment, never from a baked-in file.** Spring and - Quarkus read `${KAFKA_BOOTSTRAP}` from the environment, .NET calls - `AddEnvironmentVariables()` so env vars override `appsettings.json`, Python - binds a `BaseSettings` class to env vars, and C++ reads `std::getenv`. One + Quarkus resolve their datasource URL and `service.version` from environment + variables, .NET reads `ConnectionStrings__Default` through its configuration + (env vars override `appsettings.json`), Python binds a `BaseSettings` class to env + vars, and C++ and Go read `std::getenv` and `os.Getenv`. One image, many environments — factor III, and the thing that lets the same artifact run in dev, staging, and production unchanged. 2. **Two probes, not one.** *Liveness* answers "is the process up?" — if it fails, the platform restarts the pod. *Readiness* answers "are my dependencies reachable?" — if it fails, the platform stops routing traffic to this instance but leaves it running. Conflating the two is a classic mistake: a readiness - blip should drain traffic, not trigger a restart loop. The frameworks differ - in surface — Actuator endpoints, SmallRye `@Liveness`/`@Readiness`, ASP.NET - tag-filtered health checks, plain handlers — but Kubernetes calls them the same - way. + blip should drain traffic, not trigger a restart loop. The probe reads the + **status code**, not the body: Kubernetes counts any answer from 200 to 399 as + ready, so `/readyz` answers `503` (with `"status": "down"` in the body for + people) when the database is unreachable. Every tab writes the two handlers by + hand, as the examples do, so all six languages serve the same paths and bodies; + Spring Boot Actuator, SmallRye Health and ASP.NET Core health checks are the + framework routes to the same pair, and Kubernetes calls them the same way. Notice what *isn't* here: no retry logic, no scaling code, no port-binding plumbing. Those are the right column of Figure 1.3 — the platform's job. @@ -287,12 +283,12 @@ plumbing. Those are the right column of Figure 1.3 — the platform's job. With the service running, the probes are just HTTP. Hit liveness and readiness with `curl` and confirm liveness returns healthy while the database is down but readiness does *not* — that difference is the whole point. `curl -i -localhost:8080/readyz` should flip from ready to not-ready as you stop and start -the database, without the process ever restarting. +localhost:8080/readyz` should flip from `200` ready to `503` down as you stop the +database and back as you start it, without the process ever restarting. The code is in [`examples/01-cloud-native-principles/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/01-cloud-native-principles/). The run script there builds and runs it; its `README.md` covers what it does and how to drive it. --- -*Verification status: verified — [`examples/01-cloud-native-principles/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/01-cloud-native-principles/) passes 10/10 checks in each of the six languages on Docker Engine (2026-10-10) +*Verification status: verified — [`examples/01-cloud-native-principles/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/01-cloud-native-principles/) passes 13/13 checks in each of the six languages on Docker Engine (2026-10-12) (health probes, config from environment, CRUD, readiness flip on dependency failure).* diff --git a/_docs/02-communications.md b/_docs/02-communications.md index 7ab4cb77..c55e62ae 100644 --- a/_docs/02-communications.md +++ b/_docs/02-communications.md @@ -18,7 +18,7 @@ one. {% include excalidraw.html file="02-pick-protocol" - alt="Four columns of interaction styles, each tagged sync or async: REST/HTTP (public and edge surface, wide reach, cacheable, resource-shaped, FastAPI), sync; gRPC (internal service-to-service, low latency, streaming, schema-first proto), sync; GraphQL (client-driven reads, compose many sources, one round-trip, Strawberry/Graphene), sync; and Async/events (fire-and-forget facts, decoupled in time, fan-out to many, Kafka/Pulsar), async." + alt="Four columns of interaction styles, each tagged sync or async: REST/HTTP (public and edge surface, wide reach, cacheable, resource-shaped, OpenAPI-described), sync; gRPC (internal service-to-service, low latency, streaming, schema-first proto, grpc + protobuf), sync; GraphQL (client-driven reads, compose many sources, one round-trip, schema (SDL) + resolvers), sync; and Async/events (fire-and-forget facts, decoupled in time, fan-out to many, Kafka/Pulsar), async." caption="Figure 2.1 — Four interaction styles, four defaults — three synchronous, one asynchronous" %} Read it by the sync/async tags along the bottom: REST, gRPC, and GraphQL are all @@ -102,11 +102,7 @@ public class OrderResource { // Quarkus REST ```csharp // Program.cs — minimal API style; FluentValidation for input contracts -var orders = app.MapGroup("/orders").WithOpenApi(); - -public record OrderIn( // immutable, validated - [Required, StringLength(64)] string Sku, - [Range(1, 1_000)] int Quantity); +var orders = app.MapGroup("/orders"); orders.MapPost("/", async ( [FromBody] OrderIn body, @@ -124,6 +120,10 @@ orders.MapPost("/", async ( orders.MapGet("/", (string? after, int limit, IOrderService svc) => svc.Page(after, limit)); // cursor pagination, not offset + +public record OrderIn( // immutable, validated + [Required, StringLength(64)] string Sku, + [Range(1, 1_000)] int Quantity); ``` ```python @@ -247,19 +247,22 @@ produces and fill in the one method, never touching the wire format itself. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -@GrpcService // grpc-spring-boot-starter -public class InventoryService extends InventoryGrpc.InventoryImplBase { - private final Stock stock; - public InventoryService(Stock stock) { this.stock = stock; } - - @Override // generated base method - public void reserveStock(ReserveRequest req, StreamObserver obs) { - int remaining = stock.reserve(req.getSku(), req.getQuantity()); // generated getters - obs.onNext(ReserveReply.newBuilder() - .setReserved(remaining >= 0) - .setRemaining(Math.max(remaining, 0)).build()); - obs.onCompleted(); - } +// inventory-service (excerpt): grpc-java server; protobuf-maven-plugin generates the stubs +@SpringBootApplication +public class InventoryServiceApplication extends InventoryGrpc.InventoryImplBase + implements CommandLineRunner { + @Override public void run(String... args) throws Exception { + server = ServerBuilder.forPort(grpcPort).addService(this).build().start(); + server.awaitTermination(); + } + @Override public void reserveStock(ReserveRequest req, StreamObserver obs) { + stock.putIfAbsent(req.getSku(), initialStock); + int remaining = stock.get(req.getSku()) - req.getQuantity(); + if (remaining >= 0) stock.put(req.getSku(), remaining); // reserve + obs.onNext(ReserveReply.newBuilder().setReserved(remaining >= 0) + .setRemaining(stock.get(req.getSku())).build()); + obs.onCompleted(); + } } ``` @@ -366,7 +369,7 @@ per-service reimplementations. {% include excalidraw.html file="02-mesh-handles-wire" - alt="Two pods, each containing an app (FastAPI or grpc server, speaking plain localhost) beside an istio-proxy Envoy sidecar that handles mTLS, retries, timeouts, and traces. The two sidecars communicate over mutual TLS, encrypted and authenticated. Below, the istiod control plane distributes policy, SPIFFE identity, and telemetry config to both pods." + alt="Two pods, each containing an app (a REST or gRPC server speaking plain localhost) beside an istio-proxy Envoy sidecar that handles mTLS, retries, timeouts, and traces. The two sidecars communicate over mutual TLS, encrypted and authenticated. The istiod control plane distributes policy, SPIFFE identity, and telemetry config to both pods." caption="Figure 2.3 — The mesh moves cross-cutting concerns off your code: sidecars handle mTLS, retries, timeouts, and telemetry, configured by istiod" %} Each pod runs your service next to an Envoy sidecar. Your code talks plain HTTP or gRPC diff --git a/_docs/04-data.md b/_docs/04-data.md index 4f8fbe38..3f1e2b29 100644 --- a/_docs/04-data.md +++ b/_docs/04-data.md @@ -136,27 +136,23 @@ sits on the request path. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// Spring Data JPA: the state change AND the outbox row in one @Transactional. -@Service -public class OrderService { - private final OrderRepository orders; - private final OutboxRepository outbox; - - public OrderService(OrderRepository orders, OutboxRepository outbox) { - this.orders = orders; - this.outbox = outbox; - } - - @Transactional // single local transaction - public Order create(OrderIn body) { - Order order = Order.from(body); - orders.save(order); // state change - outbox.save(OutboxEntry.of( // same txn -> outbox row - "order.placed", "orders", order.id(), order.toJson())); - return order; // Debezium tails the WAL, publishes - } +// order-service OrderController.java (excerpt): order row and outbox row, one transaction +@PostMapping("/orders") +@ResponseStatus(HttpStatus.CREATED) +@Transactional // single local transaction +public Map createOrder(@RequestBody Map body) { + String orderId = UUID.randomUUID().toString(); + // sku, quantity and status come from the body (elided) + jdbc.sql("INSERT INTO orders (id, sku, quantity, status) VALUES (?, ?, ?, ?)") + .param(orderId).param(sku).param(quantity).param(status) + .update(); // state change + String payloadJson = objectMapper.writeValueAsString(payload); // Jackson 3 + jdbc.sql("INSERT INTO outbox (aggregate_id, event_type, payload) VALUES (?, ?, ?::jsonb)") + .param(orderId).param("order.placed").param(payloadJson) + .update(); // same transaction: outbox row + return Map.of("id", orderId, "sku", sku, "quantity", quantity, "status", status); } -// Broker down? The order still commits; the event ships when CDC catches up. +// No Kafka call on the request path: Debezium tails the WAL and publishes order.placed. ``` ```java @@ -219,19 +215,22 @@ async def create(body: OrderIn) -> Order: ``` ```cpp -Task Orders::create_impl(OrderIn body) { - PgTxn txn = co_await pg_.begin(); // single local txn - Order o = co_await txn.exec_one(insert_order_sql, body); - co_await txn.exec( - insert_outbox_sql, // atomic with the state change - "order.placed", // topic - o.id, // key - to_json(o)); // payload - co_await txn.commit(); // Debezium publishes - co_return o; -} -// PgTxn is RAII: the destructor rolls back if commit() was never called, so any -// exception thrown inside the handler unwinds the transaction cleanly. +// order-service (excerpt): order row + outbox row in one libpq transaction +PGresult* res = PQexec(conn, "BEGIN"); // single local txn +if (PQresultStatus(res) != PGRES_COMMAND_OK) { /* 500, nothing written */ } +PQclear(res); +const char* params_order[4] = {id.c_str(), sku.c_str(), quantity_s.c_str(), status.c_str()}; +res = PQexecParams(conn, + "INSERT INTO orders (id, sku, quantity, status) VALUES ($1, $2, $3, $4)", + 4, nullptr, params_order, nullptr, nullptr, 0); // the state change +// on any error: PQexec(conn, "ROLLBACK") and answer 500 +const char* params_outbox[3] = {id.c_str(), "order.placed", payload_str.c_str()}; +res = PQexecParams(conn, + "INSERT INTO outbox (aggregate_id, event_type, payload) VALUES ($1, $2, $3::jsonb)", + 3, nullptr, params_outbox, nullptr, nullptr, 0); // atomic with the state change +res = PQexec(conn, "COMMIT"); // Debezium publishes the row +// No Kafka call on the request path: if the broker is down the order +// still commits, and the event is delivered when CDC catches up. ``` ```go diff --git a/_docs/05-event-driven.md b/_docs/05-event-driven.md index 0603daae..ec79892a 100644 --- a/_docs/05-event-driven.md +++ b/_docs/05-event-driven.md @@ -93,9 +93,10 @@ public class Notifier { ```java // producer — order-service (or the Debezium relay) emits the fact +// channel order-placed → topic order.placed (mp.messaging.*.order-placed.topic) @ApplicationScoped public class OrderEvents { - @Inject @Channel("order.placed") Emitter emitter; // Reactive Messaging + @Inject @Channel("order-placed") Emitter emitter; // Reactive Messaging public void publish(Order o) { emitter.send(KafkaRecord.of(o.id(), o)); // keyed; schema-aware serde } @@ -104,7 +105,7 @@ public class OrderEvents { // consumer — notification-service, Kafka-only by design @ApplicationScoped public class Notifier { - @Incoming("order.placed") // offset tracked per group + @Incoming("order-placed") // offset tracked per group public Uni onOrder(Message msg) { return sendEmail(msg.getPayload()) .chain(msg::ack); // ack after the side-effect — at-least-once @@ -122,7 +123,7 @@ var producer = new ProducerBuilder( var json = JsonSerializer.Serialize(new { id, sku, quantity, status }); await producer.ProduceAsync("order.placed", - new Message { Value = json }); // fire-and-forget ack + new Message { Value = json }); // awaits the broker ack // consumer — notification-service var consumer = new ConsumerBuilder(new ConsumerConfig @@ -154,7 +155,7 @@ async def publish(topic: str, order: Order): # consumer — notification-service, Kafka-only by design consumer = AIOKafkaConsumer("order.placed", bootstrap_servers=settings.kafka_bootstrap, - group_id="notification", # offset tracked per group + group_id="notification-group", # offset tracked per group enable_auto_commit=False) # commit AFTER the side-effect async for msg in consumer: @@ -182,7 +183,7 @@ void publish_order_placed(const Order& o) { // consumer — notification-service, Kafka-only by design std::unique_ptr cconf(RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL)); cconf->set("bootstrap.servers", cfg.kafka_bootstrap, err); -cconf->set("group.id", "notification", err); // offset tracked per group +cconf->set("group.id", "notification-group", err); // offset tracked per group cconf->set("enable.auto.commit", "false", err); // commit AFTER the side-effect std::unique_ptr consumer(RdKafka::KafkaConsumer::create(cconf.get(), err)); consumer->subscribe({"order.placed"}); @@ -214,7 +215,7 @@ func publish(ctx context.Context, cl *kgo.Client, topic string, o Order) error { func consume(ctx context.Context, cfg Settings) error { cl, err := kgo.NewClient( kgo.SeedBrokers(cfg.Brokers...), - kgo.ConsumerGroup("notification"), // offset tracked per group + kgo.ConsumerGroup("notification-group"), // offset tracked per group kgo.ConsumeTopics("order.placed"), kgo.DisableAutoCommit(), // commit AFTER the side-effect ) @@ -494,7 +495,7 @@ public OrderValidated process(Order o) { ``` ```java -@Incoming("order.placed") // consume (Reactive Messaging) +@Incoming("order-placed") // consume (Reactive Messaging) @Outgoing("order.validated") // produce public OrderValidated process(Order o) { return new OrderValidated(o.id(), validate(o)); // map: one in, one out @@ -693,7 +694,7 @@ public void onOrder(Order o) { ``` ```java -@Incoming("order.placed") // fold the event into a local view +@Incoming("order-placed") // fold the event into a local view public void onOrder(Order o) { views.upsert(new OrderView(o.id(), o.customer(), o.total(), o.status())); // local read model, no call-back @@ -832,7 +833,7 @@ func aggregate(ctx goka.Context, msg any) { ``` On the ecosystem side, Kafka Streams (Spring, Quarkus), its .NET port Streamiz, and -Python's Faust give you the state store and its changelog as first-class `KTable` / `Table` +Python's faust-streaming give you the state store and its changelog as first-class `KTable` / `Table` abstractions; Go's **goka** does the same with a group table; and in C++, with no such framework, you keep the store and mirror writes to a compacted topic by hand — more code, the identical pattern. @@ -982,7 +983,7 @@ public void process(Order o) { // SmallRye KafkaTransactions — produce inside the transaction, offsets bound on commit @Inject @Channel("order.validated") KafkaTransactions tx; -@Incoming("order.placed") +@Incoming("order-placed") public Uni process(Order o) { return tx.withTransaction(em -> { em.send(validate(o)); // produce in-transaction @@ -1093,7 +1094,7 @@ public void onOrder(Order o) { ``` ```java -@Incoming("order.placed") +@Incoming("order-placed") @Outgoing("payment.result") // routed to taken / failed downstream public PaymentResult onOrder(Order o) { try { diff --git a/_docs/06-stream-processing.md b/_docs/06-stream-processing.md index 7b4e8082..ddf2469d 100644 --- a/_docs/06-stream-processing.md +++ b/_docs/06-stream-processing.md @@ -40,79 +40,82 @@ database in the hot path. Here is the canonical example — revenue per merchant, in tumbling five-minute windows — in each ecosystem. The JVM stacks use Kafka Streams; .NET uses Streamiz -(a native Kafka Streams port); Python uses Faust; C++ keeps bounded state +(a native Kafka Streams port); Python uses faust-streaming; C++ keeps bounded state in-process and reaches for Flink only when the state outgrows a single worker. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// spring-kafka: the topology is built via a StreamsBuilder bean +// stream-processor RevenueTopology.java (excerpt): order.placed -> revenue.by-merchant @Configuration @EnableKafkaStreams public class RevenueTopology { @Bean - public KStream revenueStream(StreamsBuilder b) { - KStream s = b.stream("order.placed", - Consumed.with(Serdes.String(), orderSerde)); - s.groupBy((k, o) -> o.merchantId()) - .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5))) - .aggregate(() -> 0.0, // local, changelog-backed - (mId, o, total) -> total + o.total(), - Materialized.with(Serdes.String(), Serdes.Double())) - .toStream() - .to("revenue.by-merchant"); // derived stream out - return s; + public KStream revenueStream(StreamsBuilder builder) { + JacksonJsonSerde accSerde = new JacksonJsonSerde<>(Acc.class).noTypeInfo(); + var revenue = builder.stream(ORDERS, Consumed.with(Serdes.String(), Serdes.String())) + .map((key, json) -> parse(json)) // re-key by merchant_id + .filter((merchant, amount) -> merchant != null) + .groupByKey(Grouped.with(Serdes.String(), Serdes.String())) + .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(windowSeconds))) + .aggregate(() -> new Acc(0, BigDecimal.ZERO), // local, changelog-backed + (merchant, amount, acc) -> add(merchant, acc, amount), + Materialized.with(Serdes.String(), accSerde)) + .toStream() + .map((window, acc) -> KeyValue.pair(window.key(), toJson(window, acc))); + revenue.to(REVENUE, Produced.with(Serdes.String(), Serdes.String())); + return revenue; } } ``` ```java -// quarkus-kafka-streams: the topology is a CDI-produced bean +// stream-processor RevenueTopology.java (excerpt), quarkus-kafka-streams: a CDI-produced Topology @ApplicationScoped public class RevenueTopology { - @Produces - public Topology build() { - StreamsBuilder b = new StreamsBuilder(); - b.stream("order.placed", Consumed.with(Serdes.String(), orderSerde)) - .groupBy((k, o) -> o.merchantId()) - .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5))) - .aggregate(() -> 0.0, // local, changelog-backed - (mId, o, total) -> total + o.total(), - Materialized.as("revenue-by-merchant")) - .toStream() - .to("revenue.by-merchant"); - return b.build(); - } + @Produces + public Topology topology() { + StreamsBuilder builder = new StreamsBuilder(); + ObjectMapperSerde accSerde = new ObjectMapperSerde<>(Acc.class); + builder.stream(ORDERS, Consumed.with(Serdes.String(), Serdes.String())) + .map((key, json) -> parse(json)) // re-key by merchant_id + .filter((merchant, amount) -> merchant != null) + .groupByKey(Grouped.with(Serdes.String(), Serdes.String())) + .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(windowSeconds))) + .aggregate(() -> new Acc(0, BigDecimal.ZERO), // local, changelog-backed + (merchant, amount, acc) -> add(merchant, acc, amount), + Materialized.with(Serdes.String(), accSerde)) + .toStream() + .map((window, acc) -> KeyValue.pair(window.key(), toJson(window, acc))) + .to(REVENUE, Produced.with(Serdes.String(), Serdes.String())); + return builder.build(); + } } ``` ```csharp -// Streamiz.Kafka.Net (MIT) — a .NET port of Kafka Streams: same -// topology / state-store / changelog model, native C#, no JVM in the container. - +// StreamProcessor/Program.cs (excerpt) — Streamiz.Kafka.Net 1.8.2 var config = new StreamConfig { - ApplicationId = "revenue-aggregator", - BootstrapServers = "kafka:9092", - StateDir = "/var/streamiz/state", // local RocksDB, changelog-backed + ApplicationId = "stream-processor", // also the consumer group + BootstrapServers = kafkaBootstrap, + StateDir = "/tmp/kafka-streams", // local RocksDB, changelog-backed }; - var builder = new StreamBuilder(); - -builder.Stream>("order.placed") - .GroupBy((k, o, _) => o.MerchantId) - .WindowedBy(TumblingWindowOptions.Of(TimeSpan.FromMinutes(5), TimeSpan.Zero)) // no grace - .Aggregate(() => 0.0, // local, changelog-backed - (mId, o, total) => total + o.Total, - RocksDbWindows.As("revenue-by-merchant") - .WithKeySerdes(new StringSerDes()) - .WithValueSerdes(new DoubleSerDes())) +builder.Stream("order.placed") + .Map((_, json, _) => Rekey(json)) // key by merchant_id + .Filter((merchant, _, _) => merchant.Length > 0) + .GroupByKey() + .WindowedBy(TumblingWindowOptions.Of(TimeSpan.FromSeconds(windowSeconds), TimeSpan.Zero)) + .Aggregate(() => new Acc(), + (merchant, amount, acc) => Add(merchant, acc, amount), // exact decimal total + RocksDbWindows.As("revenue-store") + .WithKeySerdes(new StringSerDes()) + .WithValueSerdes(new JsonSerDes())) .ToStream() - .Map((w, t, _) => KeyValuePair.Create(w.Key, t)) // unwrap Windowed to the merchant id - .To("revenue.by-merchant"); // derived stream out - -var stream = new KafkaStream(builder.Build(), config); -await stream.StartAsync(); + // .Map(...): window_start, merchant_id, order_count, revenue rounded half-up + .To("revenue.by-merchant"); +await new KafkaStream(builder.Build(), config).StartAsync(cts.Token); ``` ```python @@ -141,26 +144,27 @@ async def aggregate(stream): ``` ```cpp -// In-process windowed aggregation: revenue-per-merchant, 5-minute tumbling. -// Stateful but bounded — fits in-process. For joins or large state: use Flink. -struct Window { - std::chrono::sys_seconds start; - std::unordered_map revenue; // per-merchant -}; -Window current{floor_to_5min(now()), {}}; - -consumer.subscribe({"order.placed"}); -while (!stop_token.stop_requested()) { - for (auto& msg : consumer.poll(100ms)) { - Order o = deserialize(msg.value()); - auto win_start = floor_to_5min(o.placed_at); - if (win_start != current.start) { // window rolled over - emit_window(current); // flush to revenue.by-merchant - current = Window{win_start, {}}; - } - current.revenue[o.merchant_id] += o.total; // stateful, in-process - } +// stream-processor (excerpt, simplified): order.placed -> per-merchant tumbling +// windows -> revenue.by-merchant. Bounded state fits in-process; joins: Flink. +std::unordered_map merchants_; // open windows + +void aggregate(const std::string& merchant, int64_t ts, const cndp::Decimal& amount) { + auto it = merchants_.try_emplace(merchant, + cndp::MerchantWindows(window_seconds_)).first; // WINDOW_SECONDS + auto update = it->second.apply(ts, amount); // nullopt: late record, dropped + if (!update) return; + send("revenue.by-merchant", merchant, // running total for the window + cndp::revenue_json(merchant, *update), 0); } + +bool commit_batch() { // after each batch of up to 500 + producer_->flush(30000); // outputs delivery-reported first + if (reports_.failed() > 0) return false; // else exit, replay from last commit + consumer_->commitSync(); // then commit the input offsets + return true; +} +// The example re-keys order.placed through a repartition topic first, so each +// merchant's records land on one partition and one instance. ``` ```go @@ -190,7 +194,7 @@ func aggregate(ctx goka.Context, msg any) { Every tab is the same topology: read `order.placed`, group by merchant, bucket into five-minute windows, fold each window into a running total, and write the result to -a derived `revenue.by-merchant` stream. The JVM, .NET, Python and Go versions (Kafka Streams, Streamiz, Faust, goka) get a +a derived `revenue.by-merchant` stream. The JVM, .NET, Python and Go versions (Kafka Streams, Streamiz, faust-streaming, goka) get a changelog-backed store built in — kill the pod and the window state rebuilds by replaying the changelog topic. The C++ version keeps the window map in process, which is fine while the state is bounded; once you need joins or state larger than a @@ -237,7 +241,7 @@ where double-counting would be incorrect rather than merely redundant. ## Beyond tumbling windows The revenue example uses a tumbling window — fixed-size, non-overlapping buckets. -Kafka Streams, Faust, and Streamiz support three other window types, each suited to +Kafka Streams, faust-streaming, and Streamiz support three other window types, each suited to a different access pattern: {% include excalidraw.html @@ -269,7 +273,7 @@ Flink extends this with **watermarks** — a system-wide notion of "how far behi event-time we expect to be" — which is essential for multi-source joins where sources have different latencies. -**When to reach for Flink** — in-process stream processing (Kafka Streams, Faust, +**When to reach for Flink** — in-process stream processing (Kafka Streams, faust-streaming, Streamiz, goka, the C++ loop in this chapter's example) works well for single-topic, bounded-state computations. Once you need **stream-to-stream joins** (correlate orders with payments by key and time), **state larger than a single worker's @@ -294,17 +298,24 @@ One mechanism, two signals. # event-driven: scale consumers on Kafka lag apiVersion: keda.sh/v1alpha1 kind: ScaledObject +metadata: { name: payment-consumer } spec: scaleTargetRef: { name: payment-consumer } minReplicaCount: 0 # scale to zero when there is no lag maxReplicaCount: 20 triggers: - type: kafka - metadata: { topic: order.placed, consumerGroup: payment, lagThreshold: "100" } + metadata: { bootstrapServers: "cndp-kafka-bootstrap:9092", topic: order.placed, + consumerGroup: payment, lagThreshold: "100" } --- # synchronous: scale the HTTP service on concurrent requests (KEDA http-add-on) +apiVersion: http.keda.sh/v1alpha1 kind: HTTPScaledObject -spec: { scaleTargetRef: { service: order-service }, replicas: { min: 0, max: 30 } } +metadata: { name: order-service } +spec: + hosts: [orders.example.com] + scaleTargetRef: { name: order-service, service: order-service, port: 8080 } + replicas: { min: 0, max: 30 } ``` The `lagThreshold: "100"` is the tuning knob — how many messages of backlog a diff --git a/_docs/09-api-registry.md b/_docs/09-api-registry.md index 89bc14cf..5f5edeaf 100644 --- a/_docs/09-api-registry.md +++ b/_docs/09-api-registry.md @@ -49,7 +49,8 @@ curl -sf -X POST \ curl -s -o /dev/null -w "%{http_code}" -X POST \ http://apicurio.registry.svc/apis/registry/v3/groups/orders/artifacts/order-placed/versions \ -H "Content-Type: application/json" \ - --data-binary @order-placed.avsc # → 200 if compatible, 400 if not + -d "{\"content\": {\"content\": $(jq -Rs '.' < order-placed.avsc), \"contentType\": \"application/json\"}}" +# → 200 if compatible, 400 if not ``` The same call works from application or pipeline code in any language, returning @@ -65,14 +66,15 @@ public class RegistryClient { "http://apicurio.registry.svc/apis/registry/v3"; private final RestClient rest = RestClient.create(); - public int publish(String group, String artifactId, byte[] schema) { - var resp = rest.post() + public int publish(String group, String artifactId, String schema) { + var body = Map.of("content", // the v3 CreateVersion body + Map.of("content", schema, "contentType", "application/json")); + return rest.post() .uri(REGISTRY + "/groups/{g}/artifacts/{a}/versions", group, artifactId) .contentType(MediaType.APPLICATION_JSON) - .body(schema) - .retrieve() - .toBodilessEntity(); - return resp.getStatusCode().value(); // 200 if compatible, 400 if rejected + .body(body) + .exchange((req, res) -> res.getStatusCode().value()); // 4xx returned, not thrown + // 200 if compatible, 400 (RuleViolationException) if the rule rejects it } } ``` @@ -82,12 +84,15 @@ public class RegistryClient { @RegisterRestClient(baseUri = "http://apicurio.registry.svc/apis/registry/v3") @Path("/groups/{group}/artifacts/{artifactId}/versions") public interface RegistryClient { + record VersionContent(String content, String contentType) {} + record CreateVersion(VersionContent content) {} // the v3 request body @POST @Consumes(MediaType.APPLICATION_JSON) Response publish(@PathParam("group") String group, @PathParam("artifactId") String artifactId, - byte[] schema); - // caller checks response.getStatus(): 200 = compatible, 400 = rejected + CreateVersion version); + // call with new CreateVersion(new VersionContent(schema, "application/json")); + // 200 = compatible; a 400 (rule violated) arrives as a WebApplicationException } ``` @@ -98,12 +103,11 @@ public class RegistryClient(HttpClient http) private const string Registry = "http://apicurio.registry.svc/apis/registry/v3"; - public async Task PublishAsync(string group, string artifactId, byte[] schema) + public async Task PublishAsync(string group, string artifactId, string schema) { - var content = new ByteArrayContent(schema); - content.Headers.ContentType = new("application/json"); - var resp = await http.PostAsync( - $"{Registry}/groups/{group}/artifacts/{artifactId}/versions", content); + var body = new { content = new { content = schema, contentType = "application/json" } }; + var resp = await http.PostAsJsonAsync( // the v3 CreateVersion body + $"{Registry}/groups/{group}/artifacts/{artifactId}/versions", body); return (int)resp.StatusCode; // 200 if compatible, 400 if rejected } } @@ -115,29 +119,32 @@ import requests REGISTRY = "http://apicurio.registry.svc/apis/registry/v3" -def publish(group: str, artifact_id: str, schema: bytes) -> int: +def publish(group: str, artifact_id: str, schema: str) -> int: r = requests.post( f"{REGISTRY}/groups/{group}/artifacts/{artifact_id}/versions", - headers={"Content-Type": "application/json"}, - data=schema, - ) + json={"content": {"content": schema, "contentType": "application/json"}}, + ) # the v3 CreateVersion body; the schema text is a string return r.status_code # 200 if compatible, 400 if the rule rejects it ``` {% raw %} ```cpp -// registry_client.h — register a new version; the registry enforces the rule -#include - -inline int publish(std::string_view group, std::string_view artifact_id, - std::string_view schema) { - auto url = fmt::format( - "http://apicurio.registry.svc/apis/registry/v3" - "/groups/{}/artifacts/{}/versions", group, artifact_id); - auto r = cpr::Post(cpr::Url{url}, - cpr::Header{{"Content-Type", "application/json"}}, - cpr::Body{std::string(schema)}); - return r.status_code; // 200 if compatible, 400 if rejected +// registry_client.h — register a new version (Drogon HttpClient); the registry enforces the rule +#include + +inline int publish(const std::string& group, const std::string& artifact_id, + const std::string& schema) { + Json::Value body; // the v3 CreateVersion body + body["content"]["content"] = schema; // the schema text, as a string + body["content"]["contentType"] = "application/json"; + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + req->setPath("/apis/registry/v3/groups/" + group + "/artifacts/" + artifact_id + + "/versions"); + auto client = drogon::HttpClient::newHttpClient("http://apicurio.registry.svc"); + auto [result, resp] = client->sendRequest(req); // synchronous: not on the event loop + if (result != drogon::ReqResult::Ok) return 0; // no answer from the registry + return static_cast(resp->statusCode()); // 200 if compatible, 400 if rejected } ``` {% endraw %} @@ -148,7 +155,10 @@ func publish(group, artifactID string, schema []byte) (int, error) { url := fmt.Sprintf( "http://apicurio.registry.svc/apis/registry/v3/groups/%s/artifacts/%s/versions", group, artifactID) - resp, err := http.Post(url, "application/json", bytes.NewReader(schema)) + body, _ := json.Marshal(map[string]any{ // the v3 CreateVersion body + "content": map[string]string{"content": string(schema), "contentType": "application/json"}, + }) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) if err != nil { return 0, err } @@ -176,7 +186,8 @@ discovered broken in production by a downstream team three days later. run: | code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ "$REGISTRY/groups/orders/artifacts/order-placed/versions" \ - -H "Content-Type: application/json" --data-binary @order-placed.avsc) + -H "Content-Type: application/json" \ + -d "{\"content\": {\"content\": $(jq -Rs '.' < order-placed.avsc), \"contentType\": \"application/json\"}}") if [ "$code" = "400" ]; then echo "::error::Incompatible schema change — blocking the merge" exit 1 @@ -232,15 +243,16 @@ polluting the registry with test versions. ```java // DryRunValidator.java — validate a schema without creating a version -public boolean isCompatible(String group, String artifactId, byte[] schema) { - var resp = restClient.post() - .uri(REGISTRY + "/groups/{g}/artifacts/{a}/versions", group, artifactId) - .header("X-Registry-DryRun", "true") // dry-run: no version created +public boolean isCompatible(String group, String artifactId, String schema) { + var body = Map.of("content", // the v3 CreateVersion body + Map.of("content", schema, "contentType", "application/json")); + int status = restClient.post() + .uri(REGISTRY + "/groups/{g}/artifacts/{a}/versions?dryRun=true", // no version created + group, artifactId) .contentType(MediaType.APPLICATION_JSON) - .body(schema) - .retrieve() - .toBodilessEntity(); - return resp.getStatusCode().is2xxSuccessful(); // true = compatible + .body(body) + .exchange((req, res) -> res.getStatusCode().value()); + return status == 200; // 400 = the rule rejects it } ``` @@ -251,55 +263,51 @@ public boolean isCompatible(String group, String artifactId, byte[] schema) { public interface DryRunValidator { @POST @Consumes(MediaType.APPLICATION_JSON) - @ClientHeaderParam(name = "X-Registry-DryRun", value = "true") Response validate(@PathParam("group") String group, @PathParam("artifactId") String artifactId, - byte[] schema); - // 200 = compatible (no version created); 400 = incompatible + @QueryParam("dryRun") boolean dryRun, // call with true: no version created + RegistryClient.CreateVersion version); // the same v3 body + // 200 = compatible (no version created); 400 = incompatible (WebApplicationException) } ``` ```csharp // DryRunValidator.cs — validate a schema without creating a version -public async Task IsCompatibleAsync(string group, string artifactId, byte[] schema) +public async Task IsCompatibleAsync(string group, string artifactId, string schema) { - var content = new ByteArrayContent(schema); - content.Headers.ContentType = new("application/json"); - using var req = new HttpRequestMessage(HttpMethod.Post, - $"{Registry}/groups/{group}/artifacts/{artifactId}/versions"); - req.Headers.Add("X-Registry-DryRun", "true"); // dry-run: no version created - req.Content = content; - var resp = await http.SendAsync(req); - return resp.IsSuccessStatusCode; // true = compatible + var body = new { content = new { content = schema, contentType = "application/json" } }; + var resp = await http.PostAsJsonAsync( // the v3 CreateVersion body + $"{Registry}/groups/{group}/artifacts/{artifactId}/versions?dryRun=true", body); + return resp.IsSuccessStatusCode; // no version created either way } ``` ```python -def is_compatible(group: str, artifact_id: str, schema: bytes) -> bool: +def is_compatible(group: str, artifact_id: str, schema: str) -> bool: r = requests.post( f"{REGISTRY}/groups/{group}/artifacts/{artifact_id}/versions", - headers={ - "Content-Type": "application/json", - "X-Registry-DryRun": "true", # dry-run: no version created - }, - data=schema, + params={"dryRun": "true"}, # no version created + json={"content": {"content": schema, "contentType": "application/json"}}, ) - return r.status_code == 200 # True = compatible + return r.status_code == 200 # True = compatible ``` {% raw %} ```cpp -// dry_run_validator.h — validate a schema without creating a version -inline bool is_compatible(std::string_view group, std::string_view artifact_id, - std::string_view schema) { - auto url = fmt::format( - "http://apicurio.registry.svc/apis/registry/v3" - "/groups/{}/artifacts/{}/versions", group, artifact_id); - auto r = cpr::Post(cpr::Url{url}, - cpr::Header{{"Content-Type", "application/json"}, - {"X-Registry-DryRun", "true"}}, - cpr::Body{std::string(schema)}); - return r.status_code == 200; // true = compatible +// dry_run_validator.h — validate a schema without creating a version (Drogon HttpClient) +inline bool is_compatible(const std::string& group, const std::string& artifact_id, + const std::string& schema) { + Json::Value body; // the v3 CreateVersion body + body["content"]["content"] = schema; + body["content"]["contentType"] = "application/json"; + auto req = drogon::HttpRequest::newHttpJsonRequest(body); + req->setMethod(drogon::Post); + req->setPath("/apis/registry/v3/groups/" + group + "/artifacts/" + artifact_id + + "/versions"); + req->setParameter("dryRun", "true"); // query string: no version created + auto client = drogon::HttpClient::newHttpClient("http://apicurio.registry.svc"); + auto [result, resp] = client->sendRequest(req); + return result == drogon::ReqResult::Ok && resp->statusCode() == drogon::k200OK; } ``` {% endraw %} @@ -307,18 +315,18 @@ inline bool is_compatible(std::string_view group, std::string_view artifact_id, ```go // dry_run_validator.go — validate a schema without creating a version func isCompatible(group, artifactID string, schema []byte) (bool, error) { - url := fmt.Sprintf( - "http://apicurio.registry.svc/apis/registry/v3/groups/%s/artifacts/%s/versions", + url := fmt.Sprintf( // dryRun=true: no version created + "http://apicurio.registry.svc/apis/registry/v3/groups/%s/artifacts/%s/versions?dryRun=true", group, artifactID) - req, _ := http.NewRequest("POST", url, bytes.NewReader(schema)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Registry-DryRun", "true") // dry-run: no version created - resp, err := http.DefaultClient.Do(req) + body, _ := json.Marshal(map[string]any{ // the v3 CreateVersion body + "content": map[string]string{"content": string(schema), "contentType": "application/json"}, + }) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) if err != nil { return false, err } defer resp.Body.Close() - return resp.StatusCode == 200, nil // true = compatible + return resp.StatusCode == 200, nil // true = compatible } ``` diff --git a/_docs/11-observability.md b/_docs/11-observability.md index 648aa7ff..9a873ff6 100644 --- a/_docs/11-observability.md +++ b/_docs/11-observability.md @@ -102,7 +102,7 @@ public class OrderService { @Inject @GrpcClient("inventory") Inventory inventory; @WithSpan("reserve_stock") // custom span on this method - public ReserveReply reserve(String sku, int qty) { + public Uni reserve(String sku, int qty) { return inventory.reserveStock(req(sku, qty)); } // trace_id flows: REST -> gRPC -> Kafka -> consumer @@ -116,15 +116,14 @@ builder.Services.AddOpenTelemetry() .ConfigureResource(r => r.AddService("order-service")) .WithTracing(t => t .AddAspNetCoreInstrumentation() // every HTTP request → span - .AddHttpClientInstrumentation() // outbound HttpClient → span - .AddGrpcClientInstrumentation() // gRPC stub calls → span - .AddEntityFrameworkCoreInstrumentation() // EF Core queries → span - .AddSource("OrderService") // your custom spans - .AddOtlpExporter(o => o.Endpoint = new Uri("http://otel-collector:4317"))); + // outbound HttpClient, gRPC client and EF Core spans come from their own + // instrumentation packages when the service uses those clients + .AddSource("order-service") // your custom spans + .AddOtlpExporter()); // endpoint from OTEL_EXPORTER_OTLP_ENDPOINT // custom business span -private static readonly ActivitySource Activity = new("OrderService"); -using var span = Activity.StartActivity("reserve_stock"); +var activitySource = new ActivitySource("order-service"); +using var span = activitySource.StartActivity("reserve-stock"); await inventory.ReserveStockAsync(sku, qty); // trace_id flows: REST -> gRPC -> Kafka -> consumer ``` @@ -148,13 +147,13 @@ async def place_order(body: OrderIn): ```cpp // otel_init.cc — initialise once in main(), before serving traffic -#include +#include #include #include namespace otel = opentelemetry; void init_tracing() { - auto exporter = otel::exporter::otlp::OtlpGrpcExporterFactory::Create({}); + auto exporter = otel::exporter::otlp::OtlpHttpExporterFactory::Create({}); auto processor = otel::sdk::trace::BatchSpanProcessorFactory::Create( // BATCH, not Simple std::move(exporter), {}); otel::trace::Provider::SetTracerProvider( @@ -185,7 +184,7 @@ func main() { var tracer = otel.Tracer("order-service") func placeOrder(w http.ResponseWriter, r *http.Request) { - ctx, span := tracer.Start(r.Context(), "reserve_stock") // custom span + ctx, span := tracer.Start(r.Context(), "reserve-stock") // custom span defer span.End() inventory.ReserveStock(ctx, body.SKU, body.Quantity) // trace_id flows: REST -> gRPC -> Kafka -> consumer diff --git a/_docs/12-security.md b/_docs/12-security.md index 01e84c08..a8fe0190 100644 --- a/_docs/12-security.md +++ b/_docs/12-security.md @@ -171,7 +171,7 @@ talk to the storage system directly. {% include excalidraw.html file="12-valet-key" - alt="A client requests a key from the application, which mints a scoped, time-bound, operation-restricted valet key and returns a signed token. The client then PUTs or GETs directly against the resource (S3, blob, file) using the token; the storage/KMS layer validates the token, permits the named operation, and rejects everything else. The app never proxies the bytes." + alt="A client requests a key from the application, which mints a scoped, time-bound, operation-restricted valet key and returns a signed token. The client then PUTs or GETs directly against the resource (object store, file) using the token; the storage/KMS layer validates the token, permits the named operation, and rejects everything else. The app never proxies the bytes." caption="Figure 12.3 — The app mints a scoped, time-bound token; the client accesses storage directly and the app never proxies the bytes" %} The application mints a key that is *scoped* (this one object), *time-bound* (minutes, @@ -183,111 +183,158 @@ on the client, and your service is out of the data path: {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// ValetKeyService.java — mint a scoped, time-bound upload ticket +// ValetKeyService.java (excerpt), examples/12-security/spring-boot: HMAC-SHA256 valet key @Service public class ValetKeyService { - private final S3Presigner presigner; - public ValetKeyService(S3Presigner presigner) { this.presigner = presigner; } - - public String uploadTicket(String bucket, String key) { - var req = PutObjectRequest.builder().bucket(bucket).key(key).build(); - var presignReq = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(5)) // time-bound - .putObjectRequest(req) // this object, PUT only - .build(); - return presigner.presignPutObject(presignReq).url().toString(); + private static final int TTL_SECONDS = 300; // time-bound + private final byte[] secret; // VALET_SECRET, injected through @Value + public Map mint(String resource, String operation) { + long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS; + String payload = resource + ":" + operation + ":" + expires; // one object, one op + Map result = new LinkedHashMap<>(); + result.put("resource", resource); + result.put("operation", operation); + result.put("expires", expires); + result.put("token", hmac(payload)); // HmacSHA256, hex-encoded + return result; + } + public boolean verify(String resource, String operation, long expires, String token) { + if (System.currentTimeMillis() / 1000 > expires) return false; // expired + String expected = hmac(resource + ":" + operation + ":" + expires); + return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time } - // client PUTs straight to S3; the app never proxies the bytes } +// OrderController maps POST /valet-key to mint() and GET /verify-valet to verify() ``` ```java -// ValetKeyService.java — Quarkus with the AWS SDK +// ValetKeyService.java (excerpt), examples/12-security/quarkus: HMAC-SHA256 valet key @ApplicationScoped public class ValetKeyService { - @Inject S3Presigner presigner; - - public String uploadTicket(String bucket, String key) { - var req = PutObjectRequest.builder().bucket(bucket).key(key).build(); - var presignReq = PutObjectPresignRequest.builder() - .signatureDuration(Duration.ofMinutes(5)) // time-bound - .putObjectRequest(req) // scoped: this object, PUT only - .build(); - return presigner.presignPutObject(presignReq).url().toString(); + private static final int TTL_SECONDS = 300; // time-bound + private final byte[] secret; // VALET_SECRET, injected through @ConfigProperty + public Map mint(String resource, String operation) { + long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS; + String payload = resource + ":" + operation + ":" + expires; // one object, one op + Map result = new LinkedHashMap<>(); + result.put("resource", resource); + result.put("operation", operation); + result.put("expires", expires); + result.put("token", hmac(payload)); // HmacSHA256, hex-encoded + return result; + } + public boolean verify(String resource, String operation, long expires, String token) { + if (System.currentTimeMillis() / 1000 > expires) return false; // expired + String expected = hmac(resource + ":" + operation + ":" + expires); + return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time } } +// OrderResource maps POST /valet-key to mint() and GET /verify-valet to verify() ``` ```csharp -// ValetKeyService.cs — mint a scoped, time-bound upload ticket -public class ValetKeyService(IAmazonS3 s3) +// Program.cs (excerpt), examples/12-security/dotnet: an HMAC-SHA256 valet key +var valetSecret = Environment.GetEnvironmentVariable("VALET_SECRET") + ?? "demo-secret-do-not-use-in-prod"; +app.MapPost("/valet-key", (HttpContext ctx) => { - public async Task UploadTicketAsync(string bucket, string key) - { - var url = await s3.GetPreSignedURLAsync(new GetPreSignedUrlRequest - { - BucketName = bucket, - Key = key, - Verb = HttpVerb.PUT, // this object, PUT only - Expires = DateTime.UtcNow.AddMinutes(5), // time-bound - }); - return url; // client PUTs straight to S3; the app never sees the bytes - } -} + var resource = ctx.Request.Query["resource"].ToString(); + var operation = ctx.Request.Query["operation"].FirstOrDefault() ?? "GET"; + var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 300; // time-bound + var payload = $"{resource}:{operation}:{expires}"; // one object, one op + var sig = ComputeHmac(valetSecret, payload); + return Results.Ok(new { resource, operation, expires, token = sig }); +}); +// GET /verify-valet: 403 once expired, then a constant-time compare + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires) + return Results.Json(new { detail = "expired valet key" }, statusCode: 403); + var expected = ComputeHmac(valetSecret, $"{resource}:{operation}:{expires}"); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(expected))) + return Results.Json(new { detail = "invalid or expired valet key" }, statusCode: 403); +// ComputeHmac: HMACSHA256 keyed with the secret, Convert.ToHexStringLower of the hash ``` ```python -import boto3 -from datetime import timedelta - -s3 = boto3.client("s3") - -def upload_ticket(bucket: str, key: str) -> str: - # a valet key: this object, PUT only, expires in 5 minutes - return s3.generate_presigned_url( - "put_object", - Params={"Bucket": bucket, "Key": key}, - ExpiresIn=int(timedelta(minutes=5).total_seconds()), - ) -# the client PUTs straight to S3 with the returned URL; the app never sees the bytes +# main.py (excerpt), examples/12-security/python: an HMAC-SHA256 valet key +VALET_SECRET = os.environ.get("VALET_SECRET", "demo-secret-do-not-use-in-prod") + +def _mint_valet(resource: str, operation: str, ttl: int = 300) -> dict: + expires = int(time.time()) + ttl # time-bound + payload = f"{resource}:{operation}:{expires}" # one object, one operation + sig = hmac.new(VALET_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + return {"resource": resource, "operation": operation, "expires": expires, "token": sig} + +def _verify_valet(resource: str, operation: str, expires: int, token: str) -> bool: + if time.time() > expires: + return False # expired: worthless + payload = f"{resource}:{operation}:{expires}" + expected = hmac.new( + VALET_SECRET.encode(), payload.encode(), hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(token, expected) # constant-time compare + +@app.post("/valet-key") +async def mint_key(resource: str, operation: str = "GET"): + return _mint_valet(resource, operation) +# GET /verify-valet answers 403 for an expired or tampered key ``` ```cpp -// valet_key.h — mint a scoped, time-bound upload ticket (AWS SDK for C++) -#include -#include - -inline std::string upload_ticket(const Aws::S3::S3Client& s3, - const std::string& bucket, - const std::string& key) { - Aws::S3::Model::PutObjectRequest req; - req.SetBucket(bucket); - req.SetKey(key); - // scoped: this object, PUT only, expires in 5 minutes - return s3.GeneratePresignedUrl(bucket, key, - Aws::Http::HttpMethod::HTTP_PUT, - /*expirationInSeconds=*/300); - // client PUTs straight to S3; the app never proxies the bytes +// valet.hpp (excerpt), examples/12-security/cpp: the valet key is an HS256 JWT (jwt-cpp) +inline Minted mint(const std::string& secret, const std::string& resource, + const std::string& operation, + std::chrono::seconds ttl = std::chrono::minutes(5)) { // time-bound + const auto now = std::chrono::system_clock::now(); + const auto exp = std::chrono::time_point_cast(now + ttl); + auto token = jwt::create() + .set_type("JWT") + .set_issuer(kIssuer) // "order-service" + .set_issued_at(now) + .set_expires_at(exp) + .set_payload_claim("res", jwt::claim(resource)) // one object + .set_payload_claim("op", jwt::claim(operation)) // one operation + .sign(jwt::algorithm::hs256{secret}); + return {std::move(token), exp.time_since_epoch().count()}; } +// verify(): HS256 pinned ("none" and RS256 never reach the verifier), then iss, res, op +const auto decoded = jwt::decode(token); +if (decoded.get_algorithm() != "HS256") return false; +jwt::verify().allow_algorithm(jwt::algorithm::hs256{secret}).with_issuer(kIssuer) + .with_claim("res", jwt::claim(resource)).with_claim("op", jwt::claim(operation)) + .verify(decoded); // signature + exp; throws on failure ``` ```go -// valet_key.go — mint a scoped, time-bound upload ticket -func uploadTicket(ctx context.Context, client *s3.PresignClient, - bucket, key string) (string, error) { - - req, err := client.PresignPutObject(ctx, &s3.PutObjectInput{ - Bucket: &bucket, - Key: &key, - }, s3.WithPresignExpires(5*time.Minute)) // scoped: this object, PUT only, 5 min +// main.go (excerpt), examples/12-security/go — the valet key is an HS256 JWT (golang-jwt v5) +type valetClaims struct { + Resource string `json:"res"` + Operation string `json:"op"` + jwt.RegisteredClaims +} + +func mintValet(resource, operation string) map[string]any { + expires := time.Now().Add(5 * time.Minute) // time-bound + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, valetClaims{ + Resource: resource, // scoped: this one object + Operation: operation, // operation-restricted: GET or PUT, not both + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expires), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "order-service", + }, + }).SignedString([]byte(valetSecret)) if err != nil { - return "", err + slog.Error("sign valet key", "err", err) } - return req.URL, nil // client PUTs straight to S3; the app never sees the bytes + return map[string]any{"resource": resource, "operation": operation, + "expires": expires.Unix(), "token": token} } +// the verifier accepts HS256 only, requires exp, checks the issuer, then matches res, op and exp ``` -The same shape covers Azure SAS tokens, GCS signed URLs, and AWS STS scoped roles. +Example 12 mints and verifies the ticket in the service itself. Most object stores, open-source ones included, +accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. ## Zero-trust: every request proves itself @@ -374,42 +421,41 @@ a per-call thread group, or a semaphore that caps concurrency: {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// Per-tenant bulkhead with Resilience4j; A's storm can't drain B's capacity +// BulkheadService.java + OrderController.java (excerpt), examples/12-security/spring-boot @Service -public class TenantBulkhead { - private final ConcurrentMap bulkheads = new ConcurrentHashMap<>(); - - private Bulkhead forTenant(String tenant) { - return bulkheads.computeIfAbsent(tenant, t -> - Bulkhead.of(t, BulkheadConfig.custom() - .maxConcurrentCalls(20) // 20 concurrent per tenant - .maxWaitDuration(Duration.ZERO) // fail-fast when full - .build())); - } - - public T handle(String tenant, Supplier work) { - return Bulkhead.decorateSupplier(forTenant(tenant), work).get(); +public class BulkheadService { + static final int CAPACITY = 5; // 5 concurrent per tenant + private final ConcurrentHashMap semaphores = new ConcurrentHashMap<>(); + public Semaphore forTenant(String tenant) { + return semaphores.computeIfAbsent(tenant, k -> new Semaphore(CAPACITY)); } } +// POST /orders: A's storm can't drain B's capacity +Semaphore sem = bulkhead.forTenant(body.tenant()); +if (!sem.tryAcquire(5, TimeUnit.SECONDS)) { // wait briefly, then shed + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(Map.of("detail", "tenant bulkhead full")); +} +try { /* create the order */ } finally { sem.release(); } ``` ```java -// Per-tenant bulkhead with MicroProfile Fault Tolerance +// BulkheadService.java + OrderResource.java (excerpt), examples/12-security/quarkus @ApplicationScoped -public class TenantBulkhead { - private final ConcurrentMap sems = new ConcurrentHashMap<>(); - - private Semaphore forTenant(String tenant) { - return sems.computeIfAbsent(tenant, t -> new Semaphore(20)); // 20 / tenant - } - - public T handle(String tenant, Callable work) throws Exception { - Semaphore sem = forTenant(tenant); - if (!sem.tryAcquire()) throw new BulkheadException(tenant); - try { return work.call(); } - finally { sem.release(); } // blocks only this tenant when full +public class BulkheadService { + static final int CAPACITY = 5; // 5 concurrent per tenant + private final ConcurrentHashMap semaphores = new ConcurrentHashMap<>(); + public Semaphore forTenant(String tenant) { + return semaphores.computeIfAbsent(tenant, k -> new Semaphore(CAPACITY)); } } +// POST /orders: A's storm can't drain B's capacity +Semaphore sem = bulkhead.forTenant(body.tenant()); +if (!sem.tryAcquire(5, TimeUnit.SECONDS)) { // wait briefly, then shed + return Response.status(429) + .entity(Map.of("detail", "tenant bulkhead full")).build(); +} +try { /* create the order */ } finally { sem.release(); } ``` ```csharp @@ -420,7 +466,7 @@ public class TenantBulkhead public async Task HandleAsync(string tenant, Func> work) { - var sem = _sems.GetOrAdd(tenant, _ => new SemaphoreSlim(20)); // 20 / tenant + var sem = _sems.GetOrAdd(tenant, _ => new SemaphoreSlim(5)); // 5 / tenant await sem.WaitAsync(); // blocks only this tenant when full try { return await work(); } finally { sem.Release(); } @@ -435,7 +481,7 @@ import asyncio _sems: dict[str, asyncio.Semaphore] = {} def _tenant_sem(tenant: str) -> asyncio.Semaphore: - return _sems.setdefault(tenant, asyncio.Semaphore(20)) # 20 concurrent / tenant + return _sems.setdefault(tenant, asyncio.Semaphore(5)) # 5 concurrent / tenant async def handle(tenant: str, work): async with _tenant_sem(tenant): # blocks only this tenant when full @@ -443,22 +489,28 @@ async def handle(tenant: str, work): ``` ```cpp -// Per-tenant bulkhead with a counting semaphore; A's storm can't drain B's -std::unordered_map> bulkheads_; -std::mutex mu_; - -std::counting_semaphore<20>& for_tenant(const std::string& tenant) { - std::lock_guard lk{mu_}; - auto [it, _] = bulkheads_.try_emplace(tenant, 20); // 20 concurrent / tenant - return it->second; -} - -template -auto handle(const std::string& tenant, F&& work) { - auto& sem = for_tenant(tenant); - sem.acquire(); // blocks only this tenant - auto guard = scope_exit([&] { sem.release(); }); - return work(); +// main.cpp (excerpt), examples/12-security/cpp: one lock-free counter per tenant +struct TenantBulkhead { + static constexpr int CAPACITY = 5; // 5 concurrent per tenant + std::atomic active{0}; + bool try_acquire() { + int current = active.load(); + while (current < CAPACITY) { + if (active.compare_exchange_weak(current, current + 1)) return true; + } + return false; // full: shed, don't queue + } + void release() { active.fetch_sub(1); } +}; +// POST /orders: A's storm can't drain B's capacity +auto& bulkhead = bulkhead_manager.get_or_create(tenant); // map + mutex, one per tenant +if (!bulkhead.try_acquire()) { + Json::Value error; + error["detail"] = "tenant capacity exceeded"; + auto resp = HttpResponse::newHttpJsonResponse(error); + resp->setStatusCode(k429TooManyRequests); + callback(resp); + return; } ``` @@ -469,7 +521,7 @@ var ( ) func tenantSem(tenant string) chan struct{} { - v, _ := bulkheads.LoadOrStore(tenant, make(chan struct{}, 20)) // 20 / tenant + v, _ := bulkheads.LoadOrStore(tenant, make(chan struct{}, 5)) // 5 / tenant return v.(chan struct{}) } @@ -505,102 +557,142 @@ security-angled cousin of the large-payload handling in the failure-modes append {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// producer: store the body, publish only a reference -String key = "payloads/" + orderId; -store.put(key, largePayload, true); // encrypted at rest, ACL'd -kafkaTemplate.send("order.documents", Map.of( // claim only — small, routable - "order_id", orderId, - "uri", "s3://vault/" + key, - "token", mintScopedToken(key, 900))); // time-bound, revocable - +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +@Service +public class ClaimPublisher { + private final ObjectStore store; // any encrypted object store + private final KafkaTemplate> kafka; + private final ValetKeyService valet; // examples/12-security + // constructor injection omitted + public void publish(String orderId, byte[] body) { + String key = "payloads/" + orderId; + store.put(key, body, true); // encrypted at rest, ACL'd + Map claim = new LinkedHashMap<>(valet.mint(key, "GET")); + claim.put("order_id", orderId); + claim.put("uri", "https://objects.internal/" + key); + kafka.send("order.documents", orderId, claim); // only the claim on the topic + } +} // consumer: read the claim, fetch the body out of band -@KafkaListener(topics = "order.documents") -void onClaim(Map claim) { - byte[] body = store.get(claim.get("uri"), claim.get("token")); // PII never on topic +@KafkaListener(topics = "order.documents", groupId = "documents") +public void onClaim(Map claim) { + byte[] body = store.get((String) claim.get("uri"), (String) claim.get("token")); + handle(body); // PII never touched the topic } ``` ```java -// producer: store the body, publish only a reference -String key = "payloads/" + orderId; -store.put(key, largePayload, true); // encrypted at rest, ACL'd -emitter.send(Message.of(Map.of( // claim only — small, routable - "order_id", orderId, - "uri", "s3://vault/" + key, - "token", mintScopedToken(key, 900)))); // time-bound, revocable - -// consumer: read the claim, fetch the body out of band -@Incoming("order-documents") -void onClaim(JsonObject claim) { - byte[] body = store.get(claim.getString("uri"), claim.getString("token")); +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +@ApplicationScoped +public class ClaimPublisher { + @Inject ObjectStore store; // any encrypted object store + @Inject ValetKeyService valet; // examples/12-security + @Inject @Channel("documents-out") Emitter claims; // topic order.documents + public void publish(String orderId, byte[] body) { + String key = "payloads/" + orderId; + store.put(key, body, true); // encrypted at rest, ACL'd + JsonObject claim = new JsonObject(valet.mint(key, "GET")) + .put("order_id", orderId) + .put("uri", "https://objects.internal/" + key); + claims.send(claim); // only the claim on the topic + } +} +@ApplicationScoped +public class ClaimConsumer { + @Inject ObjectStore store; + @Incoming("documents-in") // topic order.documents + public void onClaim(JsonObject claim) { + byte[] body = store.get(claim.getString("uri"), claim.getString("token")); + handle(body); // PII never touched the topic + } } ``` ```csharp -// producer: store the body, publish only a reference +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key var key = $"payloads/{orderId}"; await store.PutAsync(key, largePayload, encrypt: true); // encrypted at rest, ACL'd +var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 900; +var token = ComputeHmac(valetSecret, $"{key}:GET:{expires}"); // GET only, 15 minutes await producer.ProduceAsync("order.documents", - new Message {% raw %}{ Value = JsonSerializer.Serialize(new { + new Message { Value = JsonSerializer.Serialize(new { order_id = orderId, - uri = $"s3://vault/{key}", - token = MintScopedToken(key, ttlSeconds: 900), - }) }{% endraw %}); // claim only — small, routable - + uri = $"https://objects.internal/{key}", + resource = key, operation = "GET", expires, token, + }) }); // claim only: small, routable // consumer: read the claim, fetch the body out of band +var claim = JsonSerializer.Deserialize(cr.Message.Value)!; var body = await store.GetAsync(claim.Uri, claim.Token); // PII never touched the topic +consumer.Commit(cr); ``` ```python -# producer: store the body, publish only a reference -key = f"payloads/{order_id}" -store.put(key, large_payload, encrypt=True) # encrypted at rest, ACL'd -await producer.send("order.documents", { # claim only — small, routable - "order_id": order_id, - "uri": f"s3://vault/{key}", - "token": mint_scoped_token(key, ttl_seconds=900), # time-bound, revocable -}) +# claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +async def publish_with_claim(producer: AIOKafkaProducer, order_id: str, body: bytes): + key = f"payloads/{order_id}" + await store.put(key, body, encrypt=True) # encrypted at rest, ACL'd + claim = { + "order_id": order_id, + "uri": f"https://objects.internal/{key}", + **_mint_valet(key, "GET", ttl=900), # resource, op, expires, token + } + await producer.send_and_wait( # only the claim goes on the topic + "order.documents", json.dumps(claim).encode(), key=order_id.encode()) # consumer: read the claim, fetch the body out of band async for msg in consumer: - body = store.get(msg["uri"], token=msg["token"]) # PII never touched the topic + claim = json.loads(msg.value) + body = await store.get(claim["uri"], token=claim["token"]) # the store checks the ticket + await handle(body) # PII never touched the topic + await consumer.commit() # after the side effect ``` ```cpp -// producer: store the body, publish only a reference -auto key = "payloads/" + order_id; -store.put(key, large_payload, /*encrypt=*/true); // encrypted at rest, ACL'd -nlohmann::json claim = { - {"order_id", order_id}, - {"uri", "s3://vault/" + key}, - {"token", mint_scoped_token(key, 900)}}; // time-bound, revocable -producer.produce("order.documents", claim.dump()); // claim only — small, routable - -// consumer: read the claim, fetch the body out of band -for (auto& msg : consumer.poll(100ms)) { - auto c = nlohmann::json::parse(msg.value()); - auto body = store.get(c["uri"], c["token"]); // PII never touched the topic +{% raw %} +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +void publish_with_claim(const std::string& order_id, const std::string& body) { + const auto key = "payloads/" + order_id; + store_.put(key, body, /*encrypt=*/true); // 1. store: encrypted, ACL'd + auto ticket = cndp::valet::mint(VALET_SECRET, key, "GET", std::chrono::minutes(15)); + auto claim = nlohmann::json{{"order_id", order_id}, + {"uri", "https://objects.internal/" + key}, + {"token", ticket.token}, {"expires", ticket.expires}}.dump(); + producer_->produce("order.documents", RdKafka::Topic::PARTITION_UA, + RdKafka::Producer::RK_MSG_COPY, claim.data(), claim.size(), + order_id.data(), order_id.size(), 0, nullptr); // 2. claim only +} +// Consumer: consume the claim, fetch the payload, then handle +std::unique_ptr msg(consumer_->consume(100)); +if (msg->err() == RdKafka::ERR_NO_ERROR) { + auto claim = nlohmann::json::parse(payload_of(*msg)); + auto body = store_.get(claim["uri"].get(), + claim["token"].get()); // the store checks the ticket + handle(body); // PII never touched the topic + consumer_->commitSync(msg.get()); // after handle } +{% endraw %} ``` ```go -// producer: store the body, publish only a reference -key := "payloads/" + orderID -store.Put(ctx, key, largePayload, true) // encrypted at rest, ACL'd -claim, _ := json.Marshal(map[string]string{ - "order_id": orderID, - "uri": "s3://vault/" + key, - "token": mintScopedToken(key, 900), // time-bound, revocable -}) -client.Produce(ctx, &kgo.Record{Topic: "order.documents", Value: claim}, nil) - -// consumer: read the claim, fetch the body out of band -fetches := client.PollFetches(ctx) -fetches.EachRecord(func(r *kgo.Record) { +// claim-check sketch: store is any encrypted object store; the token is the valet-key JWT +func publishWithClaim(ctx context.Context, cl *kgo.Client, orderID string, body []byte) error { + key := "payloads/" + orderID + if err := store.Put(ctx, key, body, true); err != nil { return err } // encrypted, ACL'd + claim, _ := json.Marshal(map[string]any{ + "order_id": orderID, + "uri": "https://objects.internal/" + key, + "token": mintValet(key, "GET")["token"], // GET only, five minutes + }) + return cl.ProduceSync(ctx, // only the claim goes on the topic + &kgo.Record{Topic: "order.documents", Key: []byte(orderID), Value: claim}).FirstErr() +} +func onClaim(ctx context.Context, r *kgo.Record) error { var c Claim - json.Unmarshal(r.Value, &c) - body := store.Get(ctx, c.URI, c.Token) // PII never touched the topic -}) + if err := json.Unmarshal(r.Value, &c); err != nil { return err } + body, err := store.Get(ctx, c.URI, c.Token) // the store verifies the ticket + if err != nil { return err } + return handle(ctx, body) // PII never touched the topic +} ``` ## Choosing the right pattern diff --git a/_docs/14-appendix-a-protocols.md b/_docs/14-appendix-a-protocols.md index 6b14296c..c8471ab8 100644 --- a/_docs/14-appendix-a-protocols.md +++ b/_docs/14-appendix-a-protocols.md @@ -78,9 +78,12 @@ each language gets a generated client from the one `.proto`. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// grpc-spring-boot-starter injects a generated blocking stub -@GrpcClient("inventory") -private InventoryGrpc.InventoryBlockingStub inventory; +// a Spring bean wraps the generated blocking stub (grpc-java, plaintext channel) +@Bean InventoryGrpc.InventoryBlockingStub inventoryStub() { + return InventoryGrpc.newBlockingStub( + ManagedChannelBuilder.forAddress(host, port).usePlaintext().build()); +} +// constructor-injected: OrderService(InventoryGrpc.InventoryBlockingStub inventory) int reserve(String sku, int qty) { ReserveReply reply = inventory.reserveStock( // one typed, blocking call @@ -177,7 +180,7 @@ note how every one expresses "push many, then finish." {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -@GrpcService // server-streaming RPC +// server-streaming RPC; registered with ServerBuilder.forPort(port).addService(...) public class OrderEvents extends OrderEventsGrpc.OrderEventsImplBase { @Override public void streamOrderEvents(StreamRequest req, diff --git a/_docs/16-appendix-c-websockets.md b/_docs/16-appendix-c-websockets.md index 95caa413..4ca924a0 100644 --- a/_docs/16-appendix-c-websockets.md +++ b/_docs/16-appendix-c-websockets.md @@ -60,11 +60,11 @@ public class OrderSocket { ```csharp // raw ASP.NET Core WebSockets — no SignalR, verify.sh can test with wscat. app.UseWebSockets(); -app.Map("/ws/{userId}", async (HttpContext ctx, string userId) => +app.Map("/ws/{clientId}", async (HttpContext ctx, string clientId) => { if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; } var ws = await ctx.WebSockets.AcceptWebSocketAsync(); - clients[userId] = ws; // per-pod state + clients[clientId] = ws; // per-pod state var buf = new byte[4096]; while (ws.State == WebSocketState.Open) { @@ -73,7 +73,7 @@ app.Map("/ws/{userId}", async (HttpContext ctx, string userId) => var msg = Encoding.UTF8.GetString(buf, 0, result.Count); await Broadcast(msg); // fan-out to this pod's clients } - clients.Remove(userId, out _); + clients.TryRemove(clientId, out _); }); ``` @@ -516,9 +516,8 @@ available). It provides: client-side resume logic needed. - **Connection groups** — server-side `Groups.AddToGroupAsync("tenant-A", connectionId)` replaces the per-pod registry + backplane fan-out with a single API call. -- **Managed backplane** — `AddStackExchangeRedis()` wires up a Redis backplane for - scale-out, or **Azure SignalR Service** offloads the persistent connections entirely - to a managed endpoint. +- **Backplane** — `AddStackExchangeRedis()` wires up a Redis backplane for + scale-out. **When SignalR is the right choice**: .NET-only teams where every producer and consumer is C#; projects that need automatic fallback transports; teams that want connection diff --git a/_docs/17-appendix-d-sagas.md b/_docs/17-appendix-d-sagas.md index 0a52b515..879625b0 100644 --- a/_docs/17-appendix-d-sagas.md +++ b/_docs/17-appendix-d-sagas.md @@ -230,15 +230,24 @@ async def compensate(saga_id: str): ``` ```cpp -// Compensations are a separate boost::sml branch in OrderSaga's table: -// "comp_stock"_s + event = "comp_pay"_s, -// "comp_pay"_s + event = X, // done -// The same advance() loop drives forward steps and compensations; -// invoke_next() dispatches on the current state name, newest-first. -Task<> compensate(sml::sm& sm, Saga& s) { - for (auto& step : reversed(s.completed_steps())) // newest first - co_await invoke(inverse_of(step), s.context); // semantic inverse - s.status = "COMPENSATED"; +// main.cpp (excerpt): COMPENSATING undoes the completed steps, newest first +if (status == "COMPENSATING") { + for (int i : cndp::saga::compensation_order(progress.step_index)) { // n-1 .. 0 + auto& step = g_steps[i]; // charge_payment -> refund_payment, ... + if (!logStep(db.conn, sagaId, step.compensationName, "compensate", + step.compensate(context))) { abandon(); return; } + } + sm.process_event(cndp::saga::Compensated{}); + setState(db.conn, sagaId, cndp::saga::status_of(sm), progress.step_index, nullptr); + exec(db.conn, "COMMIT"); // one transaction for the unwind +} + +// A forward step that throws is logged, then StepFailed moves the saga +// from RUNNING to COMPENSATING; the next advance() runs the block. +catch (const std::exception& e) { + logStep(db.conn, sagaId, step.name, "failed", failResult); + sm.process_event(cndp::saga::StepFailed{}); + setState(db.conn, sagaId, cndp::saga::status_of(sm), progress.step_index, nullptr); } ``` diff --git a/_docs/18-appendix-e-errors.md b/_docs/18-appendix-e-errors.md index bdef9cfa..3029aa76 100644 --- a/_docs/18-appendix-e-errors.md +++ b/_docs/18-appendix-e-errors.md @@ -150,7 +150,7 @@ public class DomainExceptionHandler : IExceptionHandler }; ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/problem+json"; // RFC 9457 - await ctx.Response.WriteAsJsonAsync(problem, ct); + await ctx.Response.WriteAsJsonAsync(problem, (JsonSerializerOptions?)null, "application/problem+json", ct); return true; } } diff --git a/_docs/19-appendix-f-ddd-hexagonal.md b/_docs/19-appendix-f-ddd-hexagonal.md index 6d03d993..df1c4a00 100644 --- a/_docs/19-appendix-f-ddd-hexagonal.md +++ b/_docs/19-appendix-f-ddd-hexagonal.md @@ -228,76 +228,66 @@ async def place_order(body: OrderIn, uc: PlaceOrder = Depends(build_uc)): ``` ```cpp -// domain/ports.hpp — the domain declares the interfaces it needs. -struct OrderRepository { // outbound port - virtual Task<> save(const Order&) = 0; +// domain/ports.hpp — the domain declares the interfaces it needs (no framework) +struct OrderRepository { // outbound port virtual ~OrderRepository() = default; + virtual void save(const Order& order) = 0; + virtual std::optional find_by_id(const std::string& id) = 0; }; -struct EventPublisher { // outbound port - virtual Task<> publish(const DomainEvent&) = 0; +struct EventPublisher { // outbound port virtual ~EventPublisher() = default; + virtual void publish(const OrderPlaced& event) = 0; }; - -// domain/place_order.hpp — pure logic: no framework, no I/O -class PlaceOrder { +// domain/service.hpp — the use case: pure logic, depends only on ports +class PlaceOrderUseCase { public: - PlaceOrder(OrderRepository& repo, EventPublisher& events) - : repo_(repo), events_(events) {} - Task operator()(PlaceOrderCmd cmd) { - Order o = Order::create(cmd); // aggregate invariants - co_await repo_.save(o); - co_await events_.publish(OrderPlaced{o.id}); - co_return o; + PlaceOrderUseCase(OrderRepository& repo, EventPublisher& publisher) + : repo_(repo), publisher_(publisher) {} + Order execute(const PlaceOrderCmd& cmd) { + if (cmd.quantity <= 0) throw std::invalid_argument("quantity must be positive"); + Order order{generate_uuid(), cmd.sku, cmd.quantity, "placed"}; // aggregate + repo_.save(order); + publisher_.publish(OrderPlaced{order.id, order.sku, order.quantity}); + return order; } - private: - OrderRepository& repo_; - EventPublisher& events_; -}; - -// adapters/rest.cpp — driving adapter; gRPC/GraphQL look the same -Task<> Orders::place(HttpRequestPtr req, auto cb) { - Order o = co_await place_order_(parse(req).to_cmd()); - cb(HttpResponse::newHttpJsonResponse(to_json(o))); -} + private: OrderRepository& repo_; EventPublisher& publisher_; +}; // adapters/: rest_adapter.cpp (Drogon), pg_repository.hpp (libpq), log_publisher ``` ```go // domain/ports.go — the domain declares the small interfaces it needs (ports) -type OrderRepository interface { // outbound port - Save(ctx context.Context, o Order) error +type OrderRepository interface { // outbound port (plus FindByID and FindAll) + Save(ctx context.Context, order *Order) error } type EventPublisher interface { // outbound port - Publish(ctx context.Context, e DomainEvent) error + Publish(event OrderPlaced) } -// domain/place_order.go — pure application logic: no framework, no I/O +// domain/service.go — pure application logic: no framework, no I/O type PlaceOrder struct { - repo OrderRepository - events EventPublisher + repo OrderRepository + pub EventPublisher } -func (uc PlaceOrder) Do(ctx context.Context, cmd PlaceOrderCmd) (Order, error) { - o, err := NewOrder(cmd) // aggregate enforces invariants - if err != nil { - return Order{}, err +func (s *PlaceOrder) Execute(ctx context.Context, cmd PlaceOrderCmd) (*Order, error) { + if cmd.SKU == "" || cmd.Quantity <= 0 { + return nil, fmt.Errorf("invalid order: sku and quantity > 0 required") } - if err := uc.repo.Save(ctx, o); err != nil { - return Order{}, err + order := &Order{ID: newUUID(), SKU: cmd.SKU, Quantity: cmd.Quantity, Status: "placed"} + if err := s.repo.Save(ctx, order); err != nil { + return nil, err } - return o, uc.events.Publish(ctx, OrderPlaced{o.ID}) + s.pub.Publish(OrderPlaced{OrderID: order.ID, SKU: order.SKU, Quantity: order.Quantity}) + return order, nil } -// adapters/rest.go — a driving adapter; gRPC/GraphQL look the same -func (s *Server) place(w http.ResponseWriter, r *http.Request) { - var in OrderIn - _ = json.NewDecoder(r.Body).Decode(&in) - o, err := s.uc.Do(r.Context(), in.toCmd()) // protocol -> domain, nothing more - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, http.StatusCreated, o) +// cmd/server/main.go — a driving adapter; the CLI calls Execute the same way +order, err := placeOrder.Execute(r.Context(), domain.PlaceOrderCmd{SKU: in.SKU, Quantity: in.Quantity}) +if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) // protocol -> domain, nothing more + return } +writeJSON(w, http.StatusCreated, order) ``` Read the three blocks the same way in every language. The **ports** are plain diff --git a/_docs/21-appendix-h-shutdown.md b/_docs/21-appendix-h-shutdown.md index 3e915943..a88d25c5 100644 --- a/_docs/21-appendix-h-shutdown.md +++ b/_docs/21-appendix-h-shutdown.md @@ -73,195 +73,179 @@ Underneath all of it is the safety net: because a hard `SIGKILL` is always possi ## The application side -The application's job is three steps in order: flip readiness down the instant `SIGTERM` -arrives, let the HTTP server drain in-flight requests under a bounded timeout, then drain -everything else — consumers, pools, sockets — in the shutdown hook that runs after HTTP -is done. Doing the readiness flip *at the signal* rather than later is what makes it -early enough to matter. +The application's job is a short sequence, all driven by one `SIGTERM` (Kubernetes sends +only one, then `SIGKILL` when the grace period runs out): flip readiness down the instant +the signal arrives, keep serving for a short drain delay while the endpoint removal +propagates, then stop accepting and let the HTTP server drain in-flight requests under a +bounded timeout, and finally drain everything else (consumers, pools, sockets) in the +shutdown hook that runs after HTTP is done, and exit 0. Doing the readiness flip *at the +signal* rather than later is what makes it early enough to matter. The tabs are excerpts of +`examples/21-graceful-shutdown`: a 5-second `DRAIN_DELAY_SECONDS` and a 20-second drain +bound, which together fit the 30-second grace period. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// application.yml — enable graceful shutdown and bound the drain: -// server.shutdown: graceful -// spring.lifecycle.timeout-per-shutdown-phase: 15s - -// flip readiness DOWN explicitly so Kubernetes stops routing first -@Component -public class ShutdownLifecycle { - private final ApplicationEventPublisher events; - private final Consumer consumer; // Kafka consumer - - public ShutdownLifecycle(ApplicationEventPublisher e, Consumer c) { - this.events = e; this.consumer = c; - } - - @EventListener(ContextClosedEvent.class) - public void onShutdown(ContextClosedEvent e) { - events.publishEvent(new AvailabilityChangeEvent<>(this, - ReadinessState.REFUSING_TRAFFIC)); // readiness → DOWN - log.info("draining"); - consumer.wakeup(); // stop polling; commit on close +// OrderServiceApplication.java (excerpt), examples/21-graceful-shutdown/spring-boot +// application.properties: server.shutdown=graceful +// spring.lifecycle.timeout-per-shutdown-phase=20s +@SpringBootApplication +public class OrderServiceApplication { + public static void main(String[] args) { + // registered before Spring starts: replaces the JVM's default SIGTERM handler + Signal.handle(new Signal("TERM"), signal -> { + ShutdownState state = ShutdownState.getInstance(); // GET /readyz reads it: 503 + if (!state.markShuttingDown()) return; // one SIGTERM drives the drain + long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5 + new Thread(() -> { + try { Thread.sleep(delay * 1000); } // keep serving: endpoints update + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + System.exit(0); // shutdown hook: stop accepting, finish in-flight, close pool + }, "drain").start(); + }); + SpringApplication.run(OrderServiceApplication.class, args); } } -// HTTP drains automatically (server.shutdown=graceful); -// Kafka, the scheduler, and WebSockets you drain yourself in onShutdown. +// Kafka consumers, the scheduler and WebSockets drain in a ContextClosedEvent listener. ``` ```java -// application.properties — the 'graceful' profile: -// quarkus.shutdown.timeout=15s (runtime: wait for in-flight HTTP) -// quarkus.shutdown.delay-enabled=true (BUILD time — must be baked in) -// quarkus.shutdown.delay=5s (pre-shutdown phase: readiness DOWN) - -// observe the lifecycle so the sequence is visible in the logs +// SignalHandler.java (excerpt), examples/21-graceful-shutdown/quarkus +// application.properties: quarkus.shutdown.timeout=20s (bounds the in-flight wait) @ApplicationScoped -public class ShutdownLifecycle { - void onDelay(@Observes ShutdownDelayInitiatedEvent e) { - Log.info("readiness now DOWN — HTTP still draining"); // SmallRye reports it - } - void onStop(@Observes ShutdownEvent e) { - Log.info("draining"); consumer.close(); pool.close(); // your drain code +public class SignalHandler { + @Inject ShutdownState shutdownState; // GET /readyz answers 503 once it is set + void onStart(@Observes StartupEvent ev) { // replaces the JVM's default SIGTERM handler + Signal.handle(new Signal("TERM"), signal -> { + if (!shutdownState.markShuttingDown()) return; // one SIGTERM drives the drain + long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5 + new Thread(() -> { + try { Thread.sleep(delay * 1000); } // keep serving: endpoints update + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + Quarkus.asyncExit(0); // stop accepting, finish in-flight, close the pool + }, "drain").start(); + }); } } -// HTTP drains automatically (Quarkus 3.32+ answers instead of 503-ing); -// Kafka, the scheduler and WebSockets you drain in onStop yourself. ``` ```csharp -// Program.cs — set the budget; the host honours it on SIGTERM -builder.Services.Configure(o => +// Program.cs (excerpt), examples/21-graceful-shutdown/dotnet: one SIGTERM drives the drain +builder.Services.AddSingleton(); // not ConsoleLifetime +builder.Services.Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20)); +var app = builder.Build(); +var shuttingDown = false; // GET /readyz answers 503 once it is set +var drainDelay = int.TryParse(Environment.GetEnvironmentVariable("DRAIN_DELAY_SECONDS"), + out var d) && d >= 0 ? d : 5; +PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { - o.ShutdownTimeout = TimeSpan.FromSeconds(30); // total budget - o.BackgroundServiceExceptionBehavior = // tolerate handler errors - BackgroundServiceExceptionBehavior.Ignore; -}); - -// Flip readiness DOWN on shutdown via a mutable IHealthCheck -public class ReadinessCheck : IHealthCheck -{ - private volatile bool _ready = true; - public void MarkUnready() => _ready = false; - public Task CheckHealthAsync( - HealthCheckContext ctx, CancellationToken ct) => - Task.FromResult(_ready ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy()); -} - -// One BackgroundService per long-running concern; StopAsync gets the drain hook -public class KafkaConsumerHost(IConsumer consumer, - ReadinessCheck readiness, - IHostApplicationLifetime lifetime) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken ct) + ctx.Cancel = true; // the host does not stop at once + if (shuttingDown) return; // already draining + shuttingDown = true; // 1. readiness fails first + _ = Task.Run(async () => { - while (!ct.IsCancellationRequested) - { - var result = consumer.Consume(ct); - await Handle(result, ct); // your work - consumer.Commit(result); // ack after side-effect - } - } - public override async Task StopAsync(CancellationToken ct) // ← the drain hook - { - readiness.MarkUnready(); // /healthz/ready → 503 - consumer.Close(); // commit + leave group cleanly - await base.StopAsync(ct); // bounded by ShutdownTimeout - } -} + await Task.Delay(TimeSpan.FromSeconds(drainDelay)); // 2. serve on: endpoints update + app.Lifetime.StopApplication(); // 3. Kestrel stops accepting, finishes in-flight + }); +}); +app.Lifetime.ApplicationStopped.Register(() => dataSource.Dispose()); // 4. close the pool +// ... MapGet /healthz (always 200), /readyz, /orders; app.Run(); +// ManualLifetime : IHostLifetime: WaitForStartAsync and StopAsync return Task.CompletedTask ``` ```python -import signal +# main.py (excerpt), examples/21-graceful-shutdown/python +import asyncio, signal from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.responses import JSONResponse - -ready = True # the flag the readiness probe reads -def _drain(*_): # SIGTERM -> fail readiness FIRST - global ready; ready = False # Service stops routing new traffic -signal.signal(signal.SIGTERM, _drain) - +DRAIN_DELAY_SECONDS = 5 # lets endpoint removal propagate (the preStop sleep) +shutting_down, uvicorn_sigterm, loop = False, None, None +def handle_sigterm(signum, frame): + global shutting_down + if shutting_down: return # one SIGTERM drives the whole drain + shutting_down = True # fail readiness now, keep serving for the preStop delay + loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS, + uvicorn_sigterm, signum, frame) # then uvicorn drains HTTP @asynccontextmanager async def lifespan(app): - yield # --- shutdown runs after HTTP has drained --- - await consumer.stop() # aiokafka: finish, commit offsets, leave group - await db.close() # close pools, flush, release resources - + global uvicorn_sigterm, loop # uvicorn's handler is already installed: chain to it + loop = asyncio.get_running_loop() + uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm) + yield # --- runs after uvicorn has drained HTTP --- + await db_pool.close() # close pools, flush, release resources app = FastAPI(lifespan=lifespan) @app.get("/readyz") -async def readyz(): return JSONResponse({}, 200 if ready else 503) -# run: uvicorn app:app --timeout-graceful-shutdown 15 # bounds the HTTP drain +async def readyz(): return JSONResponse({}, 503 if shutting_down else 200) +# run: uvicorn main:app --timeout-graceful-shutdown 20 # bounds the HTTP drain ``` ```cpp -// Graceful shutdown in a Drogon binary — SIGTERM + std::stop_token (C++20) -#include -#include -std::stop_source g_stop; -std::atomic g_ready{true}; // readiness flag -extern "C" void handle_sigterm(int) { - g_ready.store(false); // fail readiness FIRST - g_stop.request_stop(); // cooperative stop +// order-service (excerpt), examples/21-graceful-shutdown/cpp: one SIGTERM drives the drain +std::atomic g_shutting_down{false}; // /readyz answers 503 once it is set +std::atomic g_in_flight{0}; // RAII InFlightGuard: one per request +extern "C" void sigterm_handler(int) { // async-signal-safe: one atomic store + g_shutting_down.store(true, std::memory_order_release); +} +void shutdown_watcher() { // outside signal context + while (!g_shutting_down.load(std::memory_order_acquire)) + std::this_thread::sleep_for(50ms); // (also returns on g_watcher_stop) + const int delay = std::atoi(env("DRAIN_DELAY_SECONDS", "5")); + std::this_thread::sleep_for(std::chrono::seconds(delay)); // endpoints update + const auto deadline = std::chrono::steady_clock::now() + 20s; // bounded in-flight wait + while (g_in_flight.load() > 0 && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(100ms); + app().quit(); // stop the listeners: run() returns } int main() { - std::signal(SIGTERM, handle_sigterm); - std::signal(SIGINT, handle_sigterm); - drogon::app().registerHandler("/readyz", [](auto, auto cb) { - auto r = drogon::HttpResponse::newHttpResponse(); - r->setStatusCode(g_ready ? drogon::k200OK - : drogon::k503ServiceUnavailable); - cb(r); - }); - // workers receive the stop_token; consumer threads exit cleanly - std::jthread kafka_worker(consume_loop, g_stop.get_token()); - drogon::app().run(); // drains HTTP on signal - kafka_worker.join(); // joins on stop_token - return 0; // RAII unwinds remaining state + app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); }); // after init + std::thread watcher(shutdown_watcher); // ... handlers, addListener("0.0.0.0", 8080) + app().run(); g_watcher_stop.store(true); watcher.join(); + g_pool.reset(); // close the database connections; exit 0 } ``` ```go -// graceful shutdown — SIGTERM flips readiness FIRST, then drains within a budget +// main.go (excerpt), examples/21-graceful-shutdown/go: one SIGTERM drives the drain +const shutdownTimeout = 20 * time.Second // drain delay + this stays under the 30s grace func main() { - ctx, stop := signal.NotifyContext(context.Background(), - syscall.SIGTERM, syscall.SIGINT) + // ... pgx pool, s := &server{pool: pool}; GET /readyz answers 503 once s.shuttingDown + httpServer := &http.Server{Addr: ":8080", Handler: mux} + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() - - var ready atomic.Bool - ready.Store(true) - - mux := http.NewServeMux() - mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, _ *http.Request) { - if !ready.Load() { - w.WriteHeader(http.StatusServiceUnavailable) // 503 → Service stops routing - return - } - w.WriteHeader(http.StatusOK) - }) - srv := &http.Server{Addr: ":8080", Handler: mux} - go srv.ListenAndServe() - - <-ctx.Done() // SIGTERM received - ready.Store(false) // fail readiness FIRST, before draining - - shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - _ = srv.Shutdown(shutCtx) // bounds the HTTP drain - consumer.Close() // finish, commit offsets, leave the group - db.Close() // close pools, flush, release resources -} + go httpServer.ListenAndServe() + <-ctx.Done() // 1. the single SIGTERM + stop() // a later signal terminates at once + s.shuttingDown.Store(true) // readiness fails first, the server keeps serving + time.Sleep(drainDelay()) // 2. DRAIN_DELAY_SECONDS (5s): endpoints update + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() // 3. stop accepting, finish in-flight requests + if err := httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown incomplete", "err", err, "in_flight", s.inFlight.Load()) + } +} // 4. deferred pool.Close(), exit 0 ``` -The same three moving parts appear in every stack. A `SIGTERM` handler flips the -readiness flag so the readiness endpoint returns `503` immediately — the "stop new -traffic" step, registered at the signal so it fires before anything else. The HTTP server -drains in-flight requests automatically, bounded by an explicit timeout -(`--timeout-graceful-shutdown`, `timeout-per-shutdown-phase`, `ShutdownTimeout`) so a -stuck request cannot hang the deploy. And the shutdown hook — FastAPI's `lifespan` after -the `yield`, Spring's `ContextClosedEvent`, the .NET `StopAsync`, the C++ `jthread` join -on the `stop_token` — runs once HTTP is done and is where you stop the consumer (which -commits offsets and leaves the group) and close pools. The order is the whole point: -readiness down, drain HTTP, then drain everything else. +The same moving parts appear in every stack. A `SIGTERM` handler flips the readiness flag +so the readiness endpoint returns `503` immediately (the "stop new traffic" step, +registered at the signal so it fires before anything else), while `/healthz` stays `200`. +Each runtime's default handler would start the shutdown at once, so the examples take the +signal themselves: a `sun.misc.Signal` handler on the JVM, `PosixSignalRegistration` with a +manual host lifetime in .NET, an async-signal-safe atomic store and a watcher thread in +C++, `signal.NotifyContext` in Go. After `DRAIN_DELAY_SECONDS` (the in-process form of a +`preStop` sleep) the service stops accepting and the HTTP server drains in-flight requests, +bounded by an explicit timeout (`--timeout-graceful-shutdown`, `timeout-per-shutdown-phase`, +`quarkus.shutdown.timeout`, `ShutdownTimeout`, the `Shutdown` context, the C++ deadline) so a +stuck request cannot hang the deploy. In Python the readiness handler takes the place of +uvicorn's own, so it keeps uvicorn's handler and chains to it: the single `SIGTERM` flips +readiness, and after the drain delay the handler hands over to uvicorn, which then drains. A +handler installed at import time, before uvicorn starts, would replace uvicorn's for good and +the server would never drain. And the shutdown hook (FastAPI's `lifespan` after the +`yield`, a Spring `ContextClosedEvent` listener, a Quarkus `ShutdownEvent` observer, a .NET +hosted service's `StopAsync`, the C++ code after `run()` returns) runs once HTTP is done and +is where you stop the consumer (which commits offsets and leaves the group) and close pools +before the process exits 0. The order is the whole point: readiness down, drain delay, drain +HTTP, then drain everything else. ## The Kubernetes side @@ -279,7 +263,7 @@ spec: periodSeconds: 5 failureThreshold: 2 # ~10s to stop routing after DOWN livenessProbe: # must NOT fail during the drain - httpGet: { path: /livez, port: 8080 } + httpGet: { path: /healthz, port: 8080 } lifecycle: preStop: exec: @@ -290,7 +274,7 @@ spec: your worst-case drain plus a buffer. The readiness probe's `periodSeconds × failureThreshold` sets how fast Kubernetes stops routing after you report `DOWN` — roughly ten seconds here — which your `preStop` sleep should cover. Liveness points at a *separate* -`/livez` that stays up throughout the drain, so the kubelet does not restart a pod that is +`/healthz` that stays up throughout the drain, so the kubelet does not restart a pod that is cleanly winding down. The `preStop` sleep looks crude, but it is the pragmatic fix for the asynchronous-deregistration race; align its duration with the probe timing rather than picking an arbitrary number. @@ -340,5 +324,5 @@ A clean rollout under live load, not a green integration test, is evidence that works. --- -*Verification status: verified — [`examples/21-graceful-shutdown/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/21-graceful-shutdown/) passes 10/10 checks in each of the six languages on Docker Engine (2026-10-10) -(SIGTERM handling, readiness flip to 503, the shutdown log line, restart recovery, and orders persisting across the restart. In-flight request draining is not checked by the script).* +*Verification status: verified — [`examples/21-graceful-shutdown/`](https://github.com/patterncatalyst/cloud-native-design-patterns/tree/main/examples/21-graceful-shutdown/) passes 17/17 checks in each of the six languages on Docker Engine (2026-10-10) +(one SIGTERM, readiness flip to 503, the drain log lines, exit code 0 inside the grace period, a `docker stop` timing check, restart recovery, and orders persisting across the restart. In-flight request draining is not checked by the script).* diff --git a/_docs/22-appendix-i-l7-routing.md b/_docs/22-appendix-i-l7-routing.md index a243b869..5345c76a 100644 --- a/_docs/22-appendix-i-l7-routing.md +++ b/_docs/22-appendix-i-l7-routing.md @@ -44,7 +44,7 @@ decision. A request is a chain of independent L7 decisions across them. caption="Figure I.2 — L7 routing is a four-layer stack; the mesh applies the same routing to every internal call, not just at the edge" %} The **edge / ingress** layer (HAProxy, Nginx, Envoy, Traefik) is where TLS terminates and -coarse host/path routing happens. The **API gateway** (Kong, Apigee, an Istio gateway, +coarse host/path routing happens. The **API gateway** (Kong, an Istio gateway, Spring Cloud Gateway) handles product-level concerns — authentication, rate limiting, transformation. The **service mesh** (Istio with Envoy sidecars, or Linkerd) governs east/west traffic, giving every internal call mTLS, retries, outlier detection, and @@ -186,8 +186,8 @@ experts own, separate from the dispatcher, and changes without redeploying the s {% include excalidraw.html file="22-business-rule-routing" - alt="Routing by business rules inside the service. Inputs from REST, WebSocket, or Kafka reach a FastAPI/Starlette router that enriches the order with context, evaluates an externalised durable-rules ruleset (a Rete forward-chaining engine), and dynamically dispatches to a list of destinations. The router fans out to service A on a Kafka topic, service B over HTTP, and a DLQ/audit topic. The durable-rules engine sits beside the router and is consulted per request." - caption="Figure I.6 — When routing depends on a business rule, a FastAPI front evaluates an externalised durable-rules ruleset and dispatches to services A, B, or a DLQ" %} + alt="Routing by business rules inside the service. Inputs from REST, WebSocket, or Kafka reach an in-service router that enriches the order with context, evaluates an externalised ruleset in a rule engine (Drools, NRules or durable-rules, all forward chaining; Lua in C++ and Go), and dynamically dispatches to a list of destinations. The router fans out to service A on a Kafka topic, service B over HTTP, and a DLQ/audit topic. The rule engine sits beside the router and is consulted per request." + caption="Figure I.6 — When routing depends on a business rule, the service's router evaluates an externalised ruleset and dispatches to services A, B, or a DLQ" %} Each ecosystem composes this from its own rule engine — a Rete forward-chaining engine in most cases — feeding a small dispatcher. First the **ruleset**, read as a set of "when … then …" @@ -248,7 +248,8 @@ end ``` ```csharp -// NRules — same Rete engine, fluent C# rules (NRules.Fluent). +// NRules — same Rete engine, fluent C# rules (NRules.Fluent). The runnable example +// (examples/22-l7-routing/dotnet) keeps a runtime-editable rule table instead of NRules. [Name("High-value VIP orders go to the priority queue")] public class HighValueVipRule : Rule { @@ -596,7 +597,7 @@ timeouts top-down, and give the deepest layer the strictest budget. {% include excalidraw.html file="22-hop-cost" - alt="A latency budget per L7 stage: TLS termination 0.5 to 2 ms, L7 path/header routing under 1 ms, Auth/JWT validation 1 to 5 ms, WAF inspection 2 to 5 ms, mesh sidecar 0.5 to 2 ms per hop, rate-limit Redis lookup 1 to 3 ms, body/payload inspection 5 to 50 ms, and in-app rule eval under 1 ms cached. The rule of thumb is to stay under about 10 ms of total L7 on hot paths, so every hop must pay for itself." + alt="A latency budget per L7 stage: TLS termination 0.5 to 2 ms, L7 path/header routing under 1 ms, Auth/JWT validation 1 to 5 ms, WAF inspection 2 to 5 ms, mesh sidecar 0.5 to 2 ms per hop, rate-limit Redis lookup 1 to 3 ms, body/payload inspection 5 to 50 ms, and in-app rule evaluation in a rule engine under 1 ms cached. The rule of thumb is to stay under about 10 ms of total L7 on hot paths, so every hop must pay for itself." caption="Figure I.7 — Each L7 stage adds latency; stay under ~10 ms of total L7 on hot paths, so every hop must pay for itself" %} ## East / west — L7 between services diff --git a/_docs/23-appendix-j-dotnet-toolkit.md b/_docs/23-appendix-j-dotnet-toolkit.md index 1b4dcc5b..10238993 100644 --- a/_docs/23-appendix-j-dotnet-toolkit.md +++ b/_docs/23-appendix-j-dotnet-toolkit.md @@ -23,9 +23,8 @@ separable — the cluster does not know or care that the Pods happen to run .NET ## The pinned .NET 10 toolkit Every library that appears anywhere in the .NET edition, grouped by concern. The license -column is the one to read closely: every entry is MIT, Apache 2.0, or BSD — no -commercial-only dependencies, and nothing that requires Azure or any other specific -cloud. +column is the one to read closely: every entry is MIT, Apache 2.0, BSD or (for Redis) the AGPLv3 option — no +commercial-only dependencies, and nothing that requires a specific cloud. | Concern | Library | Purpose | License | NuGet | |---|---|---|---|---| @@ -33,7 +32,7 @@ cloud. | Hosting | ASP.NET Core 10 | Web host, minimal APIs, controllers, DI, config | MIT | (built in) | | REST | Microsoft.AspNetCore.OpenApi | OpenAPI 3 spec generation from endpoints | MIT | Microsoft.AspNetCore.OpenApi | | REST | FluentValidation | Expressive request validation | Apache 2.0 | FluentValidation | -| Versioning | Asp.Versioning | Media-type / header / URI versioning | Apache 2.0 | Asp.Versioning.Http | +| Versioning | Asp.Versioning | Media-type / header / URI versioning | MIT | Asp.Versioning.Http | | gRPC | Grpc.AspNetCore | Server + Grpc.Tools build-time codegen | Apache 2.0 | Grpc.AspNetCore | | gRPC | Grpc.StatusProto | google.rpc.Status rich errors (ToRpcException) | Apache 2.0 | Grpc.StatusProto | | GraphQL | HotChocolate | Code-first server, DataLoader, federation | MIT | HotChocolate.AspNetCore | @@ -42,16 +41,16 @@ cloud. | Event store | Marten | Document + event sourcing on Postgres | MIT | Marten | | Messaging | Confluent.Kafka 2.16.0 | Kafka client: producers, consumer groups, explicit offset commits | Apache 2.0 | Confluent.Kafka | | Streaming | Streamiz.Kafka.Net 1.8.2 | .NET port of Kafka Streams | MIT | Streamiz.Kafka.Net | -| Workflow | Temporalio.Temporal | Temporal .NET SDK — durable execution | Apache 2.0 | Temporalio.Temporal | +| Workflow | Temporalio | Temporal .NET SDK — durable execution | MIT | Temporalio | | Workflow | Stateless | In-process state machine library | Apache 2.0 | Stateless | | Rules | NRules | Rete-algorithm rules engine | MIT | NRules.Fluent | | Rules · DSL | NRules.Language | External DSL for non-developer rule editing | MIT | NRules.Language | -| WebSockets | Microsoft.AspNetCore.SignalR | Hub abstraction; auto-reconnect; MessagePack | Apache 2.0 | (built in) | -| WebSockets | SignalR.StackExchangeRedis | Open-source Redis backplane (not Azure SignalR Service) | Apache 2.0 | Microsoft.AspNetCore.SignalR.StackExchangeRedis | +| WebSockets | Microsoft.AspNetCore.SignalR | Hub abstraction; auto-reconnect; MessagePack | MIT | (built in) | +| WebSockets | SignalR.StackExchangeRedis | Open-source Redis backplane (no managed SignalR service) | MIT | Microsoft.AspNetCore.SignalR.StackExchangeRedis | | UI · server | Blazor Server | BFF + WebSocket UI; SignalR-based | MIT | (built in) | | UI · client | Blazor WebAssembly | .NET in the browser; static-file hosted | MIT | (built in) | | Resilience | Polly + M.E.Resilience | Retry, circuit breaker, hedging, timeout, bulkhead | BSD-3 / MIT | Microsoft.Extensions.Resilience | -| Health | AspNetCore.HealthChecks | Postgres / Redis / Kafka / SQL Server probes (community) | MIT | AspNetCore.HealthChecks.* | +| Health | AspNetCore.HealthChecks | Postgres / Redis / Kafka / SQL Server probes (community) | Apache 2.0 | AspNetCore.HealthChecks.* | | Observability | OpenTelemetry .NET | Tracing, metrics, logs; ActivitySource is in the BCL | Apache 2.0 | OpenTelemetry.Extensions.Hosting | | Reverse proxy | YARP | In-app reverse proxy / gateway library | MIT | Yarp.ReverseProxy | | Mediator | Wolverine | Command / handler dispatch (MediatR OSS heir) | MIT | WolverineFx | @@ -61,7 +60,7 @@ cloud. `Microsoft.AspNetCore.OpenApi` and SignalR are built into ASP.NET Core 10 — no extra package. **Confluent.Kafka** is the Kafka client the examples use directly: a bus abstraction such as MassTransit would hide the consumer-group, offset-commit, and partition semantics the chapters teach, and MassTransit v9 moved to a commercial license. **Streamiz.Kafka.Net** is a .NET port of Kafka Streams, which means windowed aggregations -without standing up a Flink cluster. **Temporalio.Temporal** pairs with the Temporal Server +without standing up a Flink cluster. **Temporalio** (the Temporal .NET SDK) pairs with the Temporal Server in the platform stack for durable workflows; **Stateless** fills the lightweight in-process state-machine slot; **NRules** is the Rete rules engine (a .NET-only library — it has no JVM or C++ equivalent). And **Wolverine** is the modern OSS mediator to reach for now that @@ -89,14 +88,17 @@ the application layer. | Dashboards / alerts | Grafana | Unified panel for traces, metrics, logs | AGPL-3.0 | | CDC | Debezium | Postgres / SQL Server WAL tailing → Kafka | Apache 2.0 | | Relational DB | PostgreSQL | Primary OLTP store; CDC source | PostgreSQL | -| Key-value / cache | Redis (or Valkey) | SignalR backplane, distributed locks, caching | BSD-3 / BSD | +| Key-value / cache | Redis 8 (or Valkey) | SignalR backplane, distributed locks, caching | Redis 8: RSALv2 / SSPLv1 / AGPLv3, the examples take AGPLv3; Valkey: BSD-3 | | Workflow server | Temporal Server | Durable-execution server (paired with .NET SDK) | MIT | | Secrets | Kubernetes Secrets + external-secrets-operator | Secrets sync from your vault of choice | Apache 2.0 | | Observability pipeline | OpenTelemetry Collector | OTLP ingest, processing, fan-out | Apache 2.0 | The one license nuance to flag for your own legal review: the Grafana **LGTM** stack -(Tempo, Mimir, Loki, Grafana) is AGPL-3.0, which some organisations treat differently from -Apache/MIT. Everything else here is permissive. The bottom line is a standard CNCF-friendly +(Tempo, Mimir, Loki, Grafana) is AGPL-3.0, and so is Redis 8 under the option the examples +use (`redis:8.10.2-alpine`; RSALv2 and SSPLv1 are the other two choices and neither is open +source). Some organisations treat AGPL differently from Apache/MIT; where that rules Redis +out, Valkey (BSD-3, a Linux Foundation project forked from Redis 7.2) speaks the same +protocol and works with StackExchange.Redis unchanged. Everything else here is permissive. The bottom line is a standard CNCF-friendly stack on which .NET 10 is one workload type. ## From Windows / .NET 4.x to Kubernetes / .NET 10 @@ -134,7 +136,7 @@ open-source and cluster-native. | .NET Remoting | Remoting, MarshalByRefObject | gRPC over HTTP/2 / HTTP API | Wire boundary is explicit; serialization is contractual | | Distributed transactions UI | BizTalk orchestration | Temporal .NET workflows / Stateless (lightweight) | Orchestration is plain C#; the engine handles durability | | Routing logic | BizTalk pipelines + content-based routing | NRules + Confluent.Kafka producers | EIP patterns in code; rules in their own .csproj | -| UI · LOB | ASP.NET Web Forms / MVC + jQuery | Blazor Server (SignalR) or Blazor WebAssembly (browser .NET) | Same C# both sides of the wire; no Azure required | +| UI · LOB | ASP.NET Web Forms / MVC + jQuery | Blazor Server (SignalR) or Blazor WebAssembly (browser .NET) | Same C# both sides of the wire; no managed cloud service required | | Reverse proxy | ARR (IIS Application Request Routing) | YARP (in-app) / Istio Gateway (mesh edge) | Code-level routing or declarative platform routing | | Resilience | Hand-coded try/retry loops, Polly v6 standalone | M.E.Resilience pipelines (built on Polly) | Composable: retry + circuit breaker + timeout + hedging | | Health probes | ASP.NET app warmup, monitoring agents | M.E.Diagnostics.HealthChecks → /healthz/live + ready | Kubernetes is the monitoring agent | @@ -149,17 +151,16 @@ platform owning more and your code owning less. ## What the .NET cloud-native story lacks Every ecosystem has gaps; pretending .NET does not would erode trust. These are real, and -in each case there is a workable .NET path, a polyglot path, or a commercial path. +in each case there is a workable .NET path or an open-source polyglot path. - **No native DMN runtime.** .NET ships no open-source DMN (Decision Model and Notation) engine. If you need spreadsheet-style decision tables authored by non-developers, run - Camunda or Kogito DMN as a separate service and call it over REST from the enrichment - step. NRules covers the rule-*engine* slot but not the DMN authoring experience. + the Apache KIE DMN engine (Kogito, Apache 2.0) as a separate service and call it over REST + from the enrichment step. NRules covers the rule-*engine* slot but not the DMN authoring experience. - **No Apache Camel-scale connector ecosystem.** Nothing in .NET matches Camel's 300-plus component connectors. Routing code over plain Kafka producers covers the EIP patterns well, but for long-tail integration — HL7 over MLLP, AS2, EDI, mainframe gateways — you may still run a - Camel sidecar in a JVM Pod, or accept a commercial option like Boomi or MuleSoft if open - source is not a hard constraint. + Camel sidecar in a JVM Pod (Apache Camel, Apache 2.0) next to the .NET services. - **Thinner stream processing.** Streamiz.Kafka.Net is excellent but is a smaller project than Kafka Streams. If stream processing is central, Flink remains the heavyweight option, typically as a separate JVM service. @@ -200,7 +201,7 @@ settle data ownership, then pick a workflow tier, then let the cluster do the he rather than inventing your own scheme. Six libraries get you most of the way: **ASP.NET Core, EF Core, Confluent.Kafka, -Temporalio.Temporal, OpenTelemetry, and Polly.** Add NRules, Streamiz, HotChocolate, or +Temporalio, OpenTelemetry, and Polly.** Add NRules, Streamiz, HotChocolate, or SignalR per workload — only when the use case earns it. ### Cross-check it yourself diff --git a/_docs/24-appendix-k-monolith-to-microservices.md b/_docs/24-appendix-k-monolith-to-microservices.md index a34fe84e..1af5562f 100644 --- a/_docs/24-appendix-k-monolith-to-microservices.md +++ b/_docs/24-appendix-k-monolith-to-microservices.md @@ -274,33 +274,29 @@ async def forward(upstream, request, body): ``` ```cpp -// Content-based routing during cutover — Drogon middleware. -// (In production this typically lives in a sidecar; middleware shows the shape.) -constexpr auto MONOLITH = "http://monolith.svc.cluster.local:8080"; -constexpr auto NEW_SVC = "http://orders.svc.cluster.local:8080"; -constexpr auto EU_SVC = "http://orders-eu.svc.cluster.local:8080"; - -Task<> content_router(HttpRequestPtr req, auto next) { - if (!req->path().starts_with("/orders")) { - co_await next(req); co_return; // other routes pass through - } - auto body = req->bodyJson(); // nlohmann::json - std::string upstream; - if (body.value("tenant", "") == "acme") { - upstream = NEW_SVC; // cut Acme over - } else if (body.value("region", "") == "EU") { - upstream = EU_SVC; // regional service - } else { - upstream = MONOLITH; // default: legacy - } - cpp_httplib::Client client{upstream}; - auto r = client.Post(req->path(), req->headers(), req->body(), - "application/json"); - auto resp = HttpResponse::newHttpResponse(); - resp->setStatusCode(static_cast(r->status)); - resp->setBody(r->body); - co_yield resp; -} +// router/src/main.cpp (excerpt): route POST /orders by tenant during cutover +g_service_urls["monolith"] = env_m ? env_m : "http://monolith:8080"; +g_service_urls["new-service"] = env_n ? env_n : "http://new-service:8080"; +g_tenant_routes["acme"] = "new-service"; // cut Acme over first +g_default_service = "monolith"; // everyone else: legacy + +app().registerHandler("/orders", + [](const HttpRequestPtr& req, std::function&& cb) { + auto body = req->getJsonObject(); // decide by content + std::string tenant; + if (body && body->isMember("tenant")) tenant = (*body)["tenant"].asString(); + forward_request(resolve_upstream(tenant), Post, "/orders", + std::string(req->body()), std::move(cb)); + }, + {Post}); + +// forward_request(): drogon::HttpClient to the chosen upstream, 502 on failure +auto client = HttpClient::newHttpClient(upstream); +auto fwd = HttpRequest::newHttpRequest(); +fwd->setMethod(method); fwd->setPath(path); fwd->setBody(body); +client->sendRequest(fwd, [cb](ReqResult result, const HttpResponsePtr& resp) { + cb(result == ReqResult::Ok ? copy_of(resp) : bad_gateway()); // simplified +}); ``` ```go @@ -519,22 +515,20 @@ class OrdersDecorator : public drogon::HttpController { METHOD_ADD(OrdersDecorator::getOrder, "/orders/{oid}", Get); METHOD_ADD(OrdersDecorator::placeOrder, "/orders", Post); METHOD_LIST_END - Task<> getOrder(HttpRequestPtr req, auto cb, std::string oid) { auto key = "order:" + oid; - if (auto hit = co_await cache_.get(key)) { // 1. cache hit + if (auto hit = cache_.get(key)) { // 1. hit (sw/redis++) cb(json_response(*hit)); co_return; } auto body = co_await legacy_.get("/orders/" + oid); // 2. miss → legacy - co_await cache_.setex(key, 60, body); // 3. populate + cache_.setex(key, 60, body); // 3. populate cb(json_response(body)); } - Task<> placeOrder(HttpRequestPtr req, auto cb) { - auto body = co_await legacy_.post("/orders", req->body()); // 1. legacy first - auto order = json::parse(body); - producer_.send("order.placed", body); // 2. emit - co_await cache_.del("order:" + order["id"].get()); // 3. invalidate + auto body = co_await legacy_.post("/orders", req->body()); // 1. legacy first + auto id = json::parse(body)["id"].get(); + produce_to(*producer_, "order.placed", id, body); // 2. emit (rdkafka++) + cache_.del("order:" + id); // 3. invalidate cb(json_response(body)); } }; diff --git a/_docs/25-appendix-l-caching.md b/_docs/25-appendix-l-caching.md index 465ad00a..64c025ac 100644 --- a/_docs/25-appendix-l-caching.md +++ b/_docs/25-appendix-l-caching.md @@ -807,6 +807,11 @@ few seconds of writes" means for the business. Reserve it for cases where write constraint *and* eventual durability is acceptable — real-time game state, telemetry with a downstream aggregator, write-heavy buffering ahead of a slower store. +The API reflects the contract: a write answers `202 Accepted` because the value is queued, not yet +durable. A failed flush re-queues its entries and retries with backoff, up to a bounded number of +attempts, logging each failure; an entry that exhausts its attempts is parked on a failed set with its +value still in the cache, so an outage never drops a write silently. + {% include excalidraw.html file="24-write-back" alt="A client writes to the application, which writes to the cache and acks immediately. A background flusher drains dirty keys from the cache to the database asynchronously in batches." @@ -815,235 +820,161 @@ downstream aggregator, write-heavy buffering ahead of a slower store. {% include codetabs.html langs="Spring Boot|Quarkus|.NET|Python|C++|Go" %} ```java -// @Scheduled flusher drains a dirty-set to the DB; @SchedulerLock = one node. -@RestController -@RequestMapping("/metrics") -public class MetricController { - private static final int BATCH = 100; - private final RedisTemplate cache; - private final MetricRepository repo; - - public MetricController(RedisTemplate cache, MetricRepository repo) { - this.cache = cache; this.repo = repo; - } - - @PutMapping("/{id}") - public ResponseEntity write(@PathVariable String id, - @RequestBody Map body) { - cache.opsForHash().putAll("metric:" + id, body); - cache.opsForSet().add("metric:dirty", id); // mark for flush - return ResponseEntity.accepted().build(); // 202 — microseconds - } - - @Scheduled(fixedDelay = 1000) // every second - @SchedulerLock(name = "metrics-flush") // ShedLock: only one node - public void flush() { - Set ids = cache.opsForSet().pop("metric:dirty", BATCH); - if (ids == null || ids.isEmpty()) return; - try { - List rows = ids.stream().map(this::loadFromCache) - .filter(Objects::nonNull).toList(); - repo.saveAll(rows); // batch insert - } catch (DataAccessException e) { - cache.opsForSet().add("metric:dirty", ids.toArray()); // retry - } +@PutMapping("/write-back/metrics/{mid}") // CacheController (excerpt) +public ResponseEntity> writeBackWrite( + @PathVariable String mid, @RequestBody Map body) { + // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down) + return ResponseEntity.accepted().body(Map.of("ok", true, "pattern", "write-back", + "action", "cached-for-flush")); // 202: queued, not yet durable +} // examples/25-caching/spring-boot +@Scheduled(fixedDelayString = "${cache-service.flush-period-ms:1000}") +public void flusher() { // BackgroundTasks + if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure + Set ids = cache.spop("metric:dirty", FLUSH_BATCH); + if (ids == null || ids.isEmpty()) return; + List failed = new ArrayList<>(); + for (String mid : ids) { + try { // upsert: an id flushed twice is one row + jdbc.sql("INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " + + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()") + .param(mid).param(json).param(json).update(); // json: the cached hash + cache.hdel("metric:attempts", mid); + } catch (Exception e) { failed.add(mid); } // logged: flush failed for } + if (failed.isEmpty()) { failures = 0; return; } + nextFlushAt = System.currentTimeMillis() + Math.min(flushPeriodMs << ++failures, 8000); + requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5 } ``` ```java -// Quarkus blocking: @Scheduled flusher drains the dirty-set to the DB. -@Path("/metrics") -@ApplicationScoped -public class MetricResource { - private static final int BATCH = 100; - private final HashCommands cache; - private final SetCommands dirty; - - public MetricResource(RedisDataSource redis) { - this.cache = redis.hash(String.class); - this.dirty = redis.set(String.class); - } - - @PUT @Path("/{id}") - public Response write(String id, Map body) { - cache.hset("metric:" + id, body); // 1. cache only; return now - dirty.sadd("metric:dirty", id); // mark for flush - return Response.accepted().build(); // 202 — microseconds - } - - @Scheduled(every = "1s") @Transactional // 2. background flush - void flush() { - Set ids = dirty.spop("metric:dirty", BATCH); - if (ids == null || ids.isEmpty()) return; - try { - Metric.persistAll(loadFromCache(ids)); // batch insert - } catch (Exception e) { - dirty.sadd("metric:dirty", ids.toArray(new String[0])); // 3. retry - } +// CacheResource + BackgroundTasks (excerpt), examples/25-caching/quarkus: write-back +@PUT @Path("write-back/metrics/{mid}") // CacheResource +public Response writeBackWrite(@PathParam("mid") String mid, Map body) { + // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down) + return Response.accepted(Map.of("ok", true, "pattern", "write-back", + "action", "cached-for-flush")).build(); // 202: queued, not yet durable +} +@Scheduled(every = "${cache-service.flush-period:1s}") // concurrent runs are skipped +void flusher() { // BackgroundTasks + if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure + Set ids = cache.spop("metric:dirty", FLUSH_BATCH); + if (ids == null || ids.isEmpty()) return; + List failed = new ArrayList<>(); + for (String mid : ids) { + try (var conn = dataSource.getConnection(); var ps = conn.prepareStatement( + "INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " + + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()")) { + // ... bind mid and the cached hash as JSON; executeUpdate (one row per id) + cache.hdel("metric:attempts", mid); + } catch (Exception e) { failed.add(mid); } // Log.errorf: flush failed for } + if (failed.isEmpty()) { failures = 0; return; } + nextFlushAt = System.currentTimeMillis() + Math.min(FLUSH_PERIOD_MS << ++failures, 8000); + requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5 } ``` ```csharp -// BackgroundService flusher; IConnectionMultiplexer gives SPOP for batch dequeue. -[ApiController, Route("metrics")] -public class MetricsController(IConnectionMultiplexer redis) : ControllerBase +// Program.cs (excerpt), examples/25-caching/dotnet: write-back +app.MapPut("/write-back/metrics/{mid}", async (string mid, HttpContext ctx) => { - [HttpPut("{id}")] - public async Task Write(string id, Metric body, CancellationToken ct) - { - var r = redis.GetDatabase(); - await r.HashSetAsync($"metric:{id}", body.ToHashEntries()); // 1. Redis only - await r.SetAddAsync("metric:dirty", id); // mark for flush - return Accepted(); // microseconds - } -} - -public class MetricsFlusher( - IConnectionMultiplexer redis, - IServiceScopeFactory scopes, // EF Core needs a scoped DbContext - ILogger log) : BackgroundService + // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down) + return Results.Json(new { ok = true, pattern = "write-back", action = "cached-for-flush" }, + statusCode: StatusCodes.Status202Accepted); // 202: queued, not yet durable +}); +_ = Task.Run(async () => // the flusher, started at boot { - private const int Batch = 100; - - protected override async Task ExecuteAsync(CancellationToken ct) + var failures = 0; + while (!cts.Token.IsCancellationRequested) { - var r = redis.GetDatabase(); - while (!ct.IsCancellationRequested) - { - var ids = await r.SetPopAsync("metric:dirty", Batch); // 2. batch dequeue - if (ids.Length == 0) { await Task.Delay(1000, ct); continue; } - try - { - using var scope = scopes.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - foreach (var id in ids) - { - var hash = await r.HashGetAllAsync($"metric:{id}"); - db.Metrics.Add(Metric.FromHash(id!, hash)); - } - await db.SaveChangesAsync(ct); // batch insert - } - catch (Exception ex) - { - log.LogError(ex, "flush failed; re-marking {Count} dirty", ids.Length); - await r.SetAddAsync("metric:dirty", ids); // 3. retry - } - } + await Task.Delay(Math.Min(FlushPeriodMs << failures, MaxFlushBackoffMs), cts.Token); + var ids = await db.SetPopAsync("metric:dirty", 100); // atomic batch dequeue + if (ids.Length == 0) continue; + var failed = new List(); // Upsert: HGETALL, INSERT ... ON CONFLICT (id) + foreach (var mid in ids) + try { await Upsert(mid); await db.HashDeleteAsync("metric:attempts", mid); } + catch (Exception ex) { logger.LogError(ex, "flush failed"); failed.Add(mid); } + if (failed.Count == 0) { failures = 0; continue; } + failures = Math.Min(failures + 1, 3); // back off: 2 s, 4 s, 8 s + await Requeue(db, failed); // HINCRBY metric:attempts: dirty again, failed after 5 } -} -// Program.cs: builder.Services.AddHostedService(); +}); ``` ```python -import asyncio, json -from contextlib import asynccontextmanager -from fastapi import FastAPI -from redis.asyncio import Redis - -cache = Redis.from_url("redis://redis:6379") -BATCH, PERIOD_S = 100, 1.0 - -async def flusher(): - while True: # 2. drain the dirty set to the DB - await asyncio.sleep(PERIOD_S) - ids = await cache.spop("metric:dirty", BATCH) +MAX_FLUSH_ATTEMPTS, MAX_FLUSH_BACKOFF = 5, 8 # excerpt, examples/25-caching/python +async def flusher(): # 2. drain the dirty set to the DB + failures = 0 + while True: # back off after a failed flush + await asyncio.sleep(min(FLUSH_PERIOD * 2**failures, MAX_FLUSH_BACKOFF)) + ids = await cache.spop("metric:dirty", FLUSH_BATCH) # cache: decode_responses=True if not ids: continue - rows = [] - for mid in ids: - data = await cache.hgetall(f"metric:{mid}") - if data: rows.append((mid.decode(), json.dumps(dict(data)))) - if rows: - try: - await db.execute_many( - "INSERT INTO metrics (id, payload) VALUES ($1, $2)", rows) - except Exception: # 3. on failure, re-mark dirty - await cache.sadd("metric:dirty", *[m for m, _ in rows]) - -@asynccontextmanager -async def lifespan(app): - task = asyncio.create_task(flusher()) - yield - task.cancel() - -app = FastAPI(lifespan=lifespan) # created once, with the flusher - -@app.put("/metrics/{mid}") -async def write_metric(mid: str, body: dict): - await cache.hset(f"metric:{mid}", mapping=body) # 1. cache only; return now - await cache.sadd("metric:dirty", mid) # mark for flush - return {"ok": True} # fast — microseconds + rows = [(m, json.dumps(d)) for m in ids if (d := await cache.hgetall(f"metric:{m}"))] + try: # upsert: an id flushed twice is one row + async with pool.acquire() as conn: + await conn.executemany("INSERT INTO metrics (id, payload) VALUES ($1, $2) " + "ON CONFLICT (id) DO UPDATE SET payload = $2, ts = NOW()", rows) + failures = 0 + except Exception: # 3. re-queue, bounded by attempts + log.exception("flusher error: flush failed"); failures += 1 + for mid in ids: + n = await cache.hincrby("metric:attempts", mid, 1) + key = "metric:failed" if n >= MAX_FLUSH_ATTEMPTS else "metric:dirty" + await cache.sadd(key, mid) # parked after 5 tries, never dropped +@app.put("/write-back/metrics/{mid}") +async def write_back_write(mid: str, body: MetricIn): + mapping = {"value": str(body.value), "tags": json.dumps(body.tags)} + await cache.hset(f"metric:{mid}", mapping=mapping) # 1. cache only + await cache.sadd("metric:dirty", mid) # mark for flush + return JSONResponse(status_code=202, content={"ok": True, "action": "cached-for-flush"}) ``` ```cpp -{% raw %}// Write to cache, return immediately; a jthread flusher drains to the DB. -constexpr int BATCH = 100; -constexpr auto PERIOD = 1s; - -Task<> Metrics::write(HttpRequestPtr req, auto cb, std::string mid) { - auto body = req->bodyJson(); - cache_.hset("metric:" + mid, body_to_map(body)); // to cache - cache_.sadd("metric:dirty", mid); // mark dirty - cb(json_response({{"ok", true}})); // microseconds -} - -// Background flusher — std::jthread, exits cleanly on stop_token. -void flusher(std::stop_token st) { - while (!st.stop_requested()) { - std::this_thread::sleep_for(PERIOD); - std::vector ids; - cache_.spop("metric:dirty", BATCH, std::back_inserter(ids)); - if (ids.empty()) continue; - try { - PgTxn txn = pg_.begin(); - for (auto& mid : ids) { - auto data = cache_.hgetall("metric:" + mid); - txn.exec_params("INSERT INTO metrics VALUES ($1, $2)", - mid, to_json(data).dump()); - } - txn.commit(); - } catch (...) { // retry next cycle - for (auto& mid : ids) cache_.sadd("metric:dirty", mid); - } +// cache-service main.cpp (excerpt), examples/25-caching/cpp: write-back +app().registerHandler("/write-back/metrics/{id}", // cache_val {id, payload}; body {ok, ...} + [](const HttpRequestPtr& req, Callback&& callback, const std::string& id) { + redis_set("metric:" + id, writer.write(cache_val), std::chrono::hours(1)); // 1. cache + redis_sadd("writeback:dirty", "metric:" + id); // 2. mark dirty + auto resp = HttpResponse::newHttpJsonResponse(body); resp->setStatusCode(k202Accepted); + callback(resp); // 3. 202: queued, not yet durable + }, {Put}); +void writeback_flusher() { // std::thread, joined at shutdown + std::map attempts; int failures = 0; // per-key failure count + while (!g_shutdown) { // ... each cycle first sleeps std::min(2 << failures, 8) s + for (const auto& key : redis_smembers("writeback:dirty")) { // key -> id, cached payload + auto res = g_flush_db->exec("INSERT INTO metrics (id, payload) VALUES ($1, $2) " + "ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, ts = NOW()", + {id, payload}); + if (res && PQresultStatus(res.get()) == PGRES_COMMAND_OK) { + redis_srem("writeback:dirty", key); attempts.erase(key); continue; // DB has it + } // failed: stays dirty, retried next cycle + if (++attempts[key] >= kMaxFlushAttempts) // bounded: park after 5 attempts + { redis_sadd("writeback:failed", key); redis_srem("writeback:dirty", key); } + } // ... failures + 1 (at most 2) after a cycle with a failure, 0 after a clean one } } -std::jthread flush_thread{flusher, g_stop.get_token()};{% endraw %} ``` ```go -// write to cache, return immediately; a goroutine flushes to the DB in batches -const ( - batch = 100 - period = 1 * time.Second -) - -func (s *Server) writeMetric(w http.ResponseWriter, r *http.Request) { - mid := r.PathValue("mid") - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - s.cache.HSet(r.Context(), "metric:"+mid, body) // 1. cache only; return now - s.cache.SAdd(r.Context(), "metric:dirty", mid) // mark for flush - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) // fast — microseconds +// main.go (excerpt), examples/25-caching/go: write-back +func handleWriteBackPut(w http.ResponseWriter, r *http.Request) { + key := "metric:" + r.PathValue("id") // body decoded into parsed, id added: data + safeSet(r.Context(), key, string(data), 0) // 1. cache only, no TTL + rdb.SAdd(r.Context(), "metric:dirty", key) // mark for flush + writeJSON(w, 202, parsed) // 202: queued, not yet durable } - -// flusher drains the dirty set to the DB; started once, stops on ctx cancel -func (s *Server) flusher(ctx context.Context) { - t := time.NewTicker(period) - defer t.Stop() +func flusher(ctx context.Context) { // 2. drain the dirty set to the DB + failures := 0 for { - select { - case <-ctx.Done(): - return - case <-t.C: - ids := s.cache.SPopN(ctx, "metric:dirty", batch).Val() // 2. drain - if len(ids) == 0 { - continue - } - if err := s.persist(ctx, ids); err != nil { // 3. on failure, re-mark dirty - s.cache.SAdd(ctx, "metric:dirty", toAny(ids)...) - } - } + wait := flushPeriod // 1s, doubling to 8s after failures + if failures > 0 { wait = min(flushPeriod< -using namespace openfeature; - -// once, at startup -getApi().setProvider(std::make_unique( - FlagdOptions{.host = "flagd", .port = 8013})); -auto flags = getApi().getClient(); - -Task<> Checkout::post(HttpRequestPtr req, auto cb) { - auto cart = req->bodyJson(); - EvaluationContext ctx{ req->getHeader("X-User"), // targeting key - {{"plan", cart["plan"]}, {"region", cart["region"]}} }; - bool use_new = flags->getBooleanValue("new-checkout", false, ctx); // default off - co_await (use_new ? new_checkout(cart) : legacy_checkout(cart)); - cb(json_response({{"ok", true}})); -}{% endraw %} +// flag-service (excerpt): flagd's gRPC ResolveBoolean, called with grpc++ +bool FlagdClient::evaluateBoolean(const std::string& flagKey, bool defaultValue, + const std::string& targetingKey, + const std::string& plan) { + flagd::evaluation::v1::ResolveBooleanRequest request; + request.set_flag_key(flagKey); + auto* fields = request.mutable_context()->mutable_fields(); + (*fields)["targetingKey"].set_string_value(targetingKey); // sticky bucketing + if (!plan.empty()) (*fields)["plan"].set_string_value(plan); + flagd::evaluation::v1::ResolveBooleanResponse response; + grpc::ClientContext ctx; + ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::milliseconds(800)); + auto status = grpc::internal::BlockingUnaryCall( + channel_.get(), method_, &ctx, request, &response); + return status.ok() ? response.value() : defaultValue; // flagd down: default +} +// POST /checkout +bool useNew = flagdClient->evaluateBoolean("new-checkout", false, user, plan); ``` ```go // OpenFeature Go SDK + flagd func init() { // once, at startup - _ = openfeature.SetProviderAndWait( - flagd.NewProvider(flagd.WithHost("flagd"), flagd.WithPort(8013))) + provider, err := flagd.NewProvider(flagd.WithHost("flagd"), flagd.WithPort(8013)) + if err != nil { + log.Fatal(err) + } + _ = openfeature.SetProviderAndWait(provider) } var flags = openfeature.NewClient("checkout") @@ -223,6 +227,7 @@ and a `fractional` operator for percentage rollouts. ```json { + "$schema": "https://flagd.dev/schema/v0/flags.json", "flags": { "new-checkout": { "state": "ENABLED", @@ -311,11 +316,14 @@ async def recommend(user_id: str) -> Recommendations: ``` ```cpp -Task recommend(const std::string& user_id) { - // default true → fail open: only an explicit kill disables it - if (!flags->getBooleanValue("recommendations-enabled", true, ctx(user_id))) - co_return Recommendations::empty(); // serve the page without it - co_return co_await recs_.fetch(user_id); // pairs with a circuit breaker +// ops kill switch: default true (fail open), on unless an operator turns it off +bool enabled = flagdClient->evaluateBoolean("recommendations-enabled", true, user); +if (!enabled) { + response["recommendations"] = Json::Value(Json::arrayValue); // page still renders + response["reason"] = "killed"; +} else { + response["recommendations"] = live_recommendations(user); // with a circuit breaker + response["reason"] = "live"; } ``` diff --git a/_docs/28-appendix-o-newman.md b/_docs/28-appendix-o-newman.md index 68d4e486..a15918e6 100644 --- a/_docs/28-appendix-o-newman.md +++ b/_docs/28-appendix-o-newman.md @@ -130,12 +130,13 @@ reports; `htmlextra` (a community reporter) produces a shareable page. ```bash # install once (Node.js): npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1 +# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure newman run orders.postman_collection.json \ --environment local.postman_environment.json \ --reporters cli,junit,htmlextra \ --reporter-junit-export results/newman.xml \ - --bail \ # stop on first failure (fail fast in CI) - --timeout-request 5000 # per-request ceiling + --timeout-request 5000 \ + --bail ``` `--bail` stops at the first failed assertion, which is usually what you want in a gate; drop it @@ -206,7 +207,7 @@ jobs: api-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - run: docker compose up -d --build --wait # order, payment, inventory, … - run: ./scripts/wait-for-ready.sh # poll /readyz before testing - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1 @@ -215,7 +216,7 @@ jobs: -e ci.postman_environment.json \ --reporters cli,junit \ --reporter-junit-export results/newman.xml - - uses: actions/upload-artifact@v4 # keep the report even on failure + - uses: actions/upload-artifact@v7 # keep the report even on failure if: always() with: { name: newman-results, path: results/ } ``` diff --git a/_docs/29-appendix-p-dotnet-on-linux.md b/_docs/29-appendix-p-dotnet-on-linux.md index f6a34031..5226f303 100644 --- a/_docs/29-appendix-p-dotnet-on-linux.md +++ b/_docs/29-appendix-p-dotnet-on-linux.md @@ -204,7 +204,7 @@ The examples use **Red Hat Universal Base Images** (UBI) instead of the - **Freely redistributable** — no subscription required to pull, run, or ship them. - **Based on RHEL 9** — same packages, same security patches, same lifecycle. -- **Supported by Red Hat** — when running on OpenShift or RHEL, UBI images get full +- **Supported by Red Hat** — when running on RHEL, UBI images get full commercial support. - **Minimal by default** — `ubi9-minimal` variants use microdnf instead of yum, producing smaller images. diff --git a/_docs/29-glossary.md b/_docs/29-glossary.md index a62b5970..307352c6 100644 --- a/_docs/29-glossary.md +++ b/_docs/29-glossary.md @@ -87,7 +87,6 @@ might expect. | LTS | Long-Term Support | A release supported for years, e.g. .NET 10 | | PID | Process ID | PID 1 in a container receives `SIGTERM` on shutdown | | I/O | Input/Output | Work the domain core delegates to adapters in hexagonal architecture | -| S3 | Simple Storage Service | Object storage, e.g. the target of a valet-key upload | | DSL | Domain-Specific Language | A focused mini-language, e.g. a business-rule ruleset | | DMN | Decision Model and Notation | A decision-table standard for business-rule routing | | PR | Pull Request | The review unit a schema or contract change travels in | diff --git a/_docs/30-appendix-q-blazor-signalr.md b/_docs/30-appendix-q-blazor-signalr.md index 186f9e39..08c13408 100644 --- a/_docs/30-appendix-q-blazor-signalr.md +++ b/_docs/30-appendix-q-blazor-signalr.md @@ -96,11 +96,6 @@ publishes group messages to Redis channels and each pod's subscriber delivers them to the local circuits. The `ChannelPrefix` isolates this app's traffic from other SignalR apps sharing the same Redis instance. -For teams that want fully managed infrastructure, **Azure SignalR Service** offloads -the persistent connections entirely: the app server handles Hub logic but the -connections terminate at Azure, removing the per-pod connection limit from your -scaling equation. - ## A Blazor Server order dashboard Here is a minimal but complete example: a Blazor Server page that shows orders in diff --git a/_plans/modernization-plan.md b/_plans/modernization-plan.md index b7e77505..41b404e2 100644 --- a/_plans/modernization-plan.md +++ b/_plans/modernization-plan.md @@ -33,9 +33,12 @@ Each step gets its own branch, a PR, green CI and a squash-merge before the next | S8 | chore/infra-images | 2 | done (111/111 PASS; two Opus repair rounds; PR #66; lgtm-skills #45 + r5.x; main run green, Actions cache 2.4 GB) | | S9 | docs/minikube-docker-driver (+ 05 on minikube) | 7 | done (PR #67) | | S10 | feature/openshift-local-appendix | 8 | done (PR #68, 48e7736) | -| S11 | docs/professional-voice | 9 | validated (PR pending; plan: _plans/s11-professional-voice-plan.md) | -| S12 | docs/deck-refresh | 10 (expanded 2026-10-11) | pending | +| S11 | docs/professional-voice | 9 | done (PR #69, 2d8c2a8) | +| S12 | docs/deck-refresh | 10 (expanded 2026-10-11) | done (plan: _plans/s12-deck-refresh-plan.md; examples 01, 21, 25 fixed and verified 6/6) | +| S13 | refactor/idiomatic-persistence | 11 (added 2026-10-12) | next | + +- S13 (user, 2026-10-12): examples use each framework's idiomatic persistence: Hibernate ORM with Panache in every Quarkus example that touches a database (01, 04, 25 and any others), Spring Data JPA in the Spring Boot examples, EF Core in the .NET examples. Python (asyncpg), Go (pgx) and C++ (libpq) keep their drivers. Every changed example re-runs `verify.sh` green on Docker Engine; slides (via deck edit files) and site codetabs follow the examples, including the caching, outbox, health and write-back slides; CLAUDE.md's locked stacks name the ORMs. ## Target pins (verified 2026-10-09; re-check at the start of each branch) | Component | Version or image | diff --git a/_plans/s12-deck-refresh-plan.md b/_plans/s12-deck-refresh-plan.md new file mode 100644 index 00000000..3e67d28e --- /dev/null +++ b/_plans/s12-deck-refresh-plan.md @@ -0,0 +1,117 @@ +# S12 plan — deck refresh (lgtm-presentation/) + +Approved by the user 2026-10-12. Opus plans and validates; Sonnet executes; one commit per deck; a single push at the end. + +## User decisions (2026-10-12) + +1. Commit a Markdown text copy of every deck and docx (`lgtm-presentation/text/`) and gate it in CI (`deck_text.py --check` in `content-ok`; decks join the voice scan, the positional and old-engine checks, and a stale-stack denylist). +2. Exclude `lgtm-presentation/` from the Jekyll build (the site links to the GitHub tree). +3. Where a site codetab is stale against `examples/`, fix the site in this PR (separate `docs(site)` commit). `examples/` is the authority. +4. No PowerPoint check: rely on LibreOffice conversion and python-pptx reload checks. +5. Defaults kept: MassTransit removed from the .NET deck entirely (Appendix J included); the C++ deck gets Appendix R only (C++ is not in OpenShift Local), with one note line saying so. + +6. Manifest decisions (2026-10-12): the Spring deck matches the example's hand-rolled saga orchestrator (Spring Statemachine mentioned only as archived upstream, not offered as an option); the Go valet-key / claim-check slides follow `examples/` (golang-jwt ticket) and the site codetab is fixed to match; the C++ deck's library and compendium references are verified upstream and kept only where they hold up; `examples/21-graceful-shutdown/cpp` gets an async-signal-safe SIGTERM handler (atomic flag, log from the main loop) in this PR; StackRox / OKD are not added to the network advisory. + +7. Vendor-neutral content (user, 2026-10-12): nothing relies on ACS, OpenShift or AWS, and only maintained open-source libraries are used (`aws-sdk-go-v2`, `boto3`, the AWS SDK for .NET and any other cloud SDK are out). + - Valet key and claim check, on the site (`_docs/12-security.md`, all six codetabs and prose) and in every language deck: follow `examples/12-security/` (signed tickets from the service itself); describe object storage generically, with no S3, STS or AWS framing. + - All other AWS mentions in the decks (about 13–20 per language deck) and on the site (`_docs/12`, glossary, Appendix M) are rewritten vendor-neutrally. + - Network-Policy-Process-Advisory deck and its `.md`/`.docx`: reworked to upstream Kubernetes and open-source tools (NetworkPolicy, upstream AdminNetworkPolicy/ClusterNetworkPolicy, a CNI that enforces them such as Cilium or Calico, Kyverno or OPA Gatekeeper for admission policy, Trivy for scanning, Falco for runtime), keeping the same process and phases. + - Appendix S (OpenShift Local) stays on the site as merged in S10, but the decks get no Appendix S slides; Appendix R (minikube) is added to all six language decks. Remaining OpenShift mentions in the language decks are rewritten. + - The stale-stack denylist gains AWS SDKs (`aws-sdk`, `boto3`, `AWSSDK`, `IAmazonS3`, `software.amazon`, `@aws-sdk`), `minio-go`, and ACS / Advanced Cluster Security / OpenShift for the deck text copies. + +## Findings + +- No deck generator source exists anywhere (claude.ai builder for the language decks; python-pptx from a Red Hat template for the policy decks, whose `.md` companions are the source of their `.docx`). Precedent for in-place edits: `scripts/add_appendix_p_slides.py`. +- No CI touches `lgtm-presentation/`; `validate_site.py` skips it for the old-engine check; `voice-scan.sh` has a `# S12:` placeholder. No LFS; `.git` is 263 MB, `lgtm-presentation/` 82 MB; Jekyll publishes the decks into the Pages artifact today. +- Inventory (slides / ban / watch / main stale items): + - Python 272 / 58 / 231: Poetry (10, 14), `@app.on_event` (221, 223), "Faust" (43), Newman CI on the old container engine (261–272). + - Quarkus 272 / 54 / 232: "Quarkus 3.35". + - Spring 276 / 49 / 224: Spring Boot 4.0, Jackson 2 serializer (219), topic `order-placed` (36). + - .NET 303 / 65 / 270: MassTransit on 21 slides and 25 notes; 17 slides without notes (287–303); old-engine compose (301). + - Go 272 / 57 / 233: code almost never matches the site's Go codetabs; graphql-go and pyproject leftovers. + - C++ 272 / 38 / 150: modern-cpp-kafka (12 slides, 15 notes), c++20 chips, GCC 14 / CMake 3.27 notes. + - K8s-Environment-Access-Policies 18 / 1 / 15: kubeval (now kubeconform v0.8.0). + - Network-Policy-Process-Advisory 20 / 0 / 15: OpenShift doc URLs at 4.16/4.18 (pin is 4.22.14); ANP/BANP status to confirm. + - BUILD.md: 1 ban phrase, Go 1.26, C++20/23, sandbox paths, stale counts. +- Code drift vs site codetabs (best match ≥0.5 / ≥0.95): Spring 27/6, Quarkus 20/3, .NET 19/4, Python 28/7, C++ 25/2, Go 1/0. About 17 code slides per deck are language-agnostic (YAML, proto, JSON, Actions) and shared. Notes are largely shared across decks. +- No deck has minikube or OpenShift Local slides. Page numbers are static text boxes; R and S are appended at the end. +- Tooling: soffice, pdftoppm, pdftotext, lxml present; python-pptx, python-docx, cairosvg go in a scratchpad venv. Brand fonts (Red Hat Text, Red Hat Display, Overpass) must be installed by the user before rendering: `sudo dnf install -y redhat-text-fonts redhat-display-fonts overpass-fonts`. + +## Approach + +Edit every file in place with a small committed toolkit; new slides are clones of existing slides in the same deck. + +- `scripts/deck_text.py` (stdlib only): pptx/docx → `lgtm-presentation/text/.md` (one `## [NNN] EYEBROW · Title (sldId N)` section per slide, code shapes as fenced blocks tagged with the chip language, notes as a `>` block). `--write` regenerates; `--check` diffs in memory and fails on staleness or on notes ≠ slides. +- `scripts/deck_edit.py` (python-pptx + lxml, venv): applies `lgtm-presentation/edits/.json` (shared fixes in `_shared.json`). Operations: `replace` (scope slide or notes, exact text, expected count, fails on mismatch, logs cross-run matches), `set_notes`, `set_code` (keeps box formatting, colours comment lines like the existing comment runs, fails over ~22 lines / ~95 columns or the box capacity), `clone_slide` (by sldId; own notes slide, relationships and content types), `set_text`, `replace_picture`, `set_page_number`. Slides are addressed by sldId; dry run prints the text diff. +- `scripts/deck_render.sh`: soffice → PDF → `pdftoppm -png -r 90` for listed pages; aborts if the brand fonts are missing. + +Rejected: rebuilding pptxgenjs sources (≈1,690 slides, visual drift, notes re-entry); merging separately built R/S decks (fragile across masters); regenerating docx from md (no converter; edit docx in place and check text equality); introducing LFS (does not shrink history; Pages friction). + +## Steps + +1. Plan status (S11 done, S12 in progress) and this file. +2. Environment: fonts (user), venv, baseline text and renders of all decks under the session scratchpad `s12/before/`; record python-pptx / python-docx / cairosvg versions. +3. Toolkit (Sonnet) + baseline text copy commit; Opus validates round-trip (empty edit = same parts, same media hashes, same text), clone_slide on a scratch copy (opens, own notes), failing `replace` guard. +4. Manifest (Opus): per deck and slide (sldId, title) the category (version, stack, code, voice, positional, notes, new) and the exact target text or authoritative source; code map deck slide → site codetab → example file; watch-tier ledger; R and S outlines (R in all six language decks, S in five; figures from `assets/diagrams/31-*`, `32-*` via cairosvg; agenda gains "Appendices R–S"). +5. Shared fixes `_shared.json` (Sonnet; Opus checks match counts). +6–13. One Sonnet batch per deck, sequential (B1 K8s policies + md/docx, B2 network advisory + md/docx, B3 Python, B4 Quarkus, B5 Spring, B6 .NET, B7 Go, B8 C++): apply shared and deck edits, append R/S, complete notes, regenerate text copy, render changed and new slides, self-check (voice ban 0, validate_site, deck_text --check), commit `docs(deck): refresh (S12)`. Opus validates each batch; repairs as `git commit --fixup`, folded with `git rebase --autosquash` before the single push. +14. Surrounding docs: BUILD.md (method, toolkit, R/S, real counts), README.md and CLAUDE.md deck wording, `_config.yml` exclude, and `docs(site)` codetab fixes found during the batches. +15. Gate on: `content.yml` runs `deck_text.py --check`; `voice-scan.sh` adds `lgtm-presentation`; `validate_site.py` runs the positional check over `lgtm-presentation/*.md` and `text/*.md`, stops skipping `lgtm-presentation` for the old-engine check, and adds the stale-stack denylist for the text copy. +16. Full validation (Opus), push once, PR, three green checks, squash-merge, record evidence. + +## Acceptance criteria + +1. `deck_text.py --check` exits 0; notes = slides in all 8 decks. +2. `voice-scan.sh` 0 ban-tier with `lgtm-presentation` in scope; surviving watch hits in the S12 ledger with reasons. +3. `validate_site.py` 0 errors including positional and old-engine checks over `lgtm-presentation/`. +4. Text copies have 0 denylist hits; every version on a slide equals a pin in the modernization plan (pin table in this plan). +5. Every language-specific code slide is mapped to a codetab and/or example file with status "matches" (Opus sign-off), ≤22 lines, no overflow in the render. +6. Appendix R in all six language decks, S in five (not C++); every slide has notes, the 31-/32- figures, pins equal to the site appendices; the agenda lists R–S. +7. Every changed or new slide rendered with the brand fonts and inspected (log of slide ids in this plan). +8. docx text equals the companion `.md` text, ignoring formatting. +9. BUILD.md slide counts match; BUILD.md, README.md and CLAUDE.md describe the eight decks and the in-place method. +10. One commit per deck; PR checks `site`, `content-ok`, `examples-ok` green; squash-merged. +11. `modernization-plan.md` shows S11 done (PR #69, 2d8c2a8) and S12 done with evidence. + +## Status + +| Step | Status | +|---|---| +| 1 plan status | done | +| 2 environment | done (brand fonts installed; venv pinned, see "Tooling") | +| 3 toolkit | done (8c86d61, e4126d5, 6709b2c) | +| 4 manifest | done (`s12-manifest/`) | +| 5–13 batches | done (one commit per deck plus `fixup!` commits, folded with `git rebase --autosquash` before the push) | +| fix round A (colours, caps, accuracy, cross-deck) | done (`s12-manifest/README.md`, "Fix round A") | +| 14 surrounding docs | done (BUILD.md, README.md, CLAUDE.md, `_config.yml`, `docs(site)` codetab fixes) | +| 15 CI gate | done (`content.yml`, `voice-scan.sh`, `validate_site.py`, `scripts/deck-denylist.json`) | +| 16 final validation | done locally 2026-10-12 (`s12-manifest/README.md`, "Final validation"); push, PR, checks and merge pending | + +### Render log (acceptance criterion 7) + +Every language deck was rendered in full with the brand fonts (soffice 26.2.6.3, one PDF per deck) and checked with +`scripts/deck_fit.py` (0 flags on all six) after the final-validation fix files; earlier rounds rendered and inspected +the slides their edit files touched (`s12-manifest/README.md`, batch and fix-round sections). Slides whose text, +notes or picture changed in the final validation (slide numbers of the current decks); every one with a visible change +(code, caption, figure) was rendered and read, the rest changed only in the speaker notes: + +| Deck | Slides | +|---|---| +| Python | 28, 36, 38, 39, 55, 63, 69, 122, 125, 155, 164, 190, 221, 232, 241, 280 | +| Quarkus | 28, 38, 39, 43, 55, 63, 69, 122, 125, 155, 190, 232, 280 | +| Spring | 18, 28, 36, 38, 39, 55, 63, 69, 115, 122, 125, 159, 168, 192, 194, 236, 284 | +| .NET | 8, 30, 38, 40, 41, 65, 68, 71, 118, 123, 125, 126, 129, 163, 197, 198, 205, 207, 210, 235, 246, 293, 311 | +| Go | 28, 38, 39, 55, 63, 69, 122, 125, 155, 190, 221, 232, 280 | +| C++ | 14, 36, 38, 39, 55, 85, 128, 155, 164, 190, 221, 232, 280 | + +## Tooling + +Scratchpad venv (`/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/venv`, Python 3.14.7, never global), newest stable on PyPI 2026-10-12, pinned exactly: + +| Package | Version | +|---|---| +| python-pptx | 1.0.2 | +| python-docx | 1.2.0 | +| cairosvg | 2.9.1 | + +(lxml 6.1.3 comes in as the python-pptx dependency.) Run `deck_edit.py` with the venv python; `deck_text.py` is stdlib only and runs with the system python3 (CI: ubuntu-latest, 3.14). Set `DECK_BACKUP_DIR` to keep in-place edit backups in the scratchpad (default `~/.cache/deck_edit/backup`); `LO_PROFILE` sets the LibreOffice profile for `deck_render.sh`. `KNOWN_MISSING_NOTES` in `deck_text.py` lists the .NET slides 287-303 that lack notes; the .NET batch empties it. diff --git a/_plans/s12-manifest/Designing-Cloud-Native-APIs-Cpp.md b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Cpp.md new file mode 100644 index 00000000..327b9fa6 --- /dev/null +++ b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Cpp.md @@ -0,0 +1,295 @@ +# C++ deck: Designing-Cloud-Native-APIs-Cpp + +> Amended 2026-10-12 for user decision 7 (vendor-neutral): see the last section, which supersedes the rows here +> on the valet-key and claim-check slides, the cloud-SDK pins and Appendix S. + +272 slides at 8c86d61; gains Appendix R only (agenda, R slides: planner, `ops-90-rs`). Sources of +the deck-specific edits: `work/rewrites/30-deck-Cpp.json` (124 voice/positional/stack rewrites of +the deck-exclusive sentences) and `work/ops-30-deck/Designing-Cloud-Native-APIs-Cpp.json` (93 +ops), both merged into `lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.json` by the +builder. Generator: `fork-cpp/gen_cpp.py` in the S12 scratchpad. + +## Summary + +- Kafka moves from modern-cpp-kafka (archived upstream) to librdkafka's C++ API (`rdkafka++`) on + every slide, chip and note: 3 code slides rewritten (36, 43, 134), 2 code slides with a new + Kafka block (29, 87), the dispatcher and decorator (189, 208) on `produce_to`, and 20 prose + mentions in bullets, titles, subtitles, captions and notes. +- Toolchain on the title-slide notes: GCC 15 (gcc-toolset-15 on ubi10/ubi:10.2), CMake 4.4.4, + Ninja 1.13.2, Conan 2.33.0, static libstdc++ on ubi10/ubi-minimal, libpq direct, Docker Engine. +- C++20 → C++23 for the deck's baseline: chips `c++20` → `c++23` (85, 164, 245), `cpp · C++20` + replaced (253, 256), closing slide "C++ 20 / 23" → "C++23" (95). Feature-origin mentions become + "(since C++20)" (std::stop_token, std::jthread, std::counting_semaphore). +- Code agrees with `examples/*/cpp/` on library, API and identifiers: outbox on raw libpq (32), + stream processor on `MerchantWindows` and commit-after-delivery (43), saga lifecycle machine + and `compensation_order` (122, 124, 126), synchronous hexagonal ports and `PlaceOrderUseCase` + (146), graceful shutdown with an atomic flag, `InFlightGuard` and `registerBeginningAdvice` + (164, 245), tenant router on Drogon's `HttpClient` (206), flagd gRPC `ResolveBoolean` (253, + 256), OTLP/HTTP exporter (64), Apicurio 3.3's HTTP 400 for an incompatible schema (55), the + AWS SDK `GeneratePresignedUrl(bucket, key, method, expiry)` signature (78), `httplib::` + namespace (237). +- Voice: all 136 deck-exclusive units handled: 124 rewritten, 8 folded into rewritten notes + (units 0, 26, 36, 37, 38, 99, 101, 129 on sldIds 256, 291, 298, 377, 379, 419), 4 kept (ledger). + Positional phrases outside the units fixed on 278, 329, 368, 369, 441, 483 notes and the 494 + code comment; "turn 1" process language removed (294, 331 notes). + +## Change table + +Slide numbers are those at 8c86d61. + +| Slide | sldId | Title | Category | Change | +|---|---|---|---|---| +| 1 | 256 | Designing Cloud-Native APIs | stack, version | notes rewritten: toolchain pins, rdkafka++, libpq, Docker Engine, C++23 (folds unit 0) | +| 4, 5, 6 | 259–261 | Setting up / running example | voice | units 3–6 | +| 8 | 265 | Design-first | voice | bullet unit 7; notes units 8, 9 | +| 9–12 | 266–269 | Twelve factors | voice, positional | units 10–13 | +| 13 | 268 | Twelve factors, mapped | stack | notes: Kafka via librdkafka (rdkafka++) | +| 16 | 271 | Pick the protocol | stack, voice | notes unit 14 (rdkafka++) | +| 18, 19 | 273, 274 | gRPC / sync vs async | voice, stack | units 15, 16; notes: process-scoped librdkafka producer | +| 21 | 276 | Resilience as configuration | voice | notes unit 17 (code: shared-code fork) | +| 23 | 278 | One query, many services | positional | notes: "next two slides" removed | +| 24, 25 | 279, 280 | Composition | voice, positional | units 18, 20 | +| 28 | 283 | CQRS | voice | unit 21 | +| 29 | 284 | CQRS in practice | code, stack | set_code: projection on rdkafka++, outbox topic `order.placed` (was `order.created`); notes unit 22 | +| 30 | 285 | Log tailing | voice | unit 24 | +| 32 | 287 | The outbox write | code, notes | set_code: libpq excerpt from example 04; set_notes | +| 35 | 290 | Event backbone | stack | notes: RdKafka::Producer | +| 36 | 291 | Producing and consuming | stack, code, notes | title, chip `c++ · rdkafka++`, set_code (rdkafka++, group per example), set_notes (folds unit 26) | +| 37, 38, 40 | 292, 293, 295 | Event-driven | voice | units 29, 30, 31, 33 (27 kept) | +| 39 | 294 | Tools landscape | voice | "turn 1" removed (unit 32 kept: survey term) | +| 42 | 297 | Derive new streams | stack, voice, positional | notes: librdkafka; units 34, 35 | +| 43 | 298 | In-process windowed aggregation | code, stack, notes | chip, set_code from example 06, caption, set_notes (folds units 36–38) | +| 44 | 299 | KEDA | voice | unit 39 | +| 48 | 303 | Choosing between the two | stack | bullet: rdkafka++ | +| 51 | 306 | Four planes | voice | unit 42 | +| 54 | 309 | Apicurio | voice | unit 43 | +| 55 | 310 | Gate breaking changes in CI | code | Apicurio 3.3 rejects with HTTP 400 (comment and status check) | +| 58 | 313 | Catalog | voice | unit 44 | +| 60–64 | 315–319 | Observability | voice, stack, code | units 45–49; 318 bullet and notes: librdkafka; 319 code: OTLP/HTTP exporter | +| 67–69 | 322–324 | Correlation, sampling | voice | units 50–53 | +| 73–76 | 328–331 | Security | voice | units 54–62; 331 notes "turn 1" removed | +| 74 | 329 | Seven patterns | positional | notes: "in the next slides" removed | +| 78 | 333 | S3 pre-signed URL | code, voice | set_code: SDK signature, Python leftover comment removed; notes unit 63 | +| 79–83 | 334–338 | Strangler, zero-trust, policy | voice, positional | units 65–69 | +| 84, 85 | 339, 340 | Bulkhead | voice, version | unit 70, 71; chip `c++23`, code comment and notes "(C++20)" wording | +| 87 | 342 | Claim-check code | code, stack, voice | chip, set_code (rdkafka++), notes librdkafka; unit 72 | +| 88 | 343 | Choosing the pattern | voice | units 73, 75 | +| 91, 92 | 346, 347 | Anti-patterns | voice | units 76–78 | +| 93 | 348 | In closing | stack | bullet and notes: librdkafka | +| 95 | 350 | Thank you | version | "C++ 20 / 23" → "C++23" | +| 96, 97 | 351, 352 | Glossary | voice | units 81, 84, 85 | +| 99, 102, 103 | 354, 357, 358 | Appendix A | voice, stack | units 88, 90; 357 bullet and notes: rdkafka++ | +| 113–119 | 368–374 | Appendix C | voice, positional, stack | units 93–98; 368, 369 notes positional; 371 notes and 374 bullet: rdkafka++ | +| 122 | 377 | Saga state machine | code, notes | set_code (example 17 Lifecycle machine), caption, set_notes (folds unit 99) | +| 123 | 378 | Compensating transactions | voice | unit 100 | +| 124 | 379 | Compensations in reverse | code, notes | set_code (compensation_order), set_notes (folds unit 101) | +| 126 | 381 | Context forward | code | set_code (jsoncpp context per example 17); notes: jsoncpp | +| 128, 130, 134 | 383, 385, 389 | Appendix E | voice, stack, code | units 102–104; 389 title, chip, set_code (site 18 C++ tab, rdkafka++) | +| 141–146 | 396–401 | Appendix F | voice, code | units 106–112; 401 set_code (example 19 ports and use case), caption, notes `PlaceOrderUseCase` | +| 150–158 | 405–413 | Appendix G | voice, positional | units 113–122 | +| 159–162 | 414–417 | Appendix H | voice, version | units 123–128; 415 bullet and 417 notes: stop_token wording, request_stop not in the handler | +| 164 | 419 | Graceful shutdown in Drogon | code, version, notes | title, chip `c++23`, set_code (example 21), caption, set_notes (folds unit 129) | +| 167 | 422 | Shutdown checklist | code, version | handler bullet (flag only), "(since C++20)" | +| 168–175 | 423–430 | Appendix I | voice, positional | units 130–140 | +| 178, 180 | 433, 435 | TLS, sticky sessions | voice | units 142, 145 | +| 186, 187 | 441, 442 | Business-rule routing | voice, positional, stack | units 147–149; 441 notes positional; 442 subtitle and notes: librdkafka | +| 189 | 444 | The dispatcher | code, stack | set_code (site 22 C++ tab, `produce_to`) | +| 191, 192 | 446, 447 | L7 pitfalls, east/west | voice, positional | units 152, 153 | +| 201–204 | 456–459 | Appendix K | voice | units 154–156 | +| 206 | 461 | Content-based routing | code | set_code (example 24 router, Drogon HttpClient), caption, notes | +| 207, 208 | 462, 463 | Decorator | stack, code | notes librdkafka; set_code (rdkafka++ `produce_to`, synchronous sw/redis++), caption, notes | +| 215, 222, 224 | 470, 477, 479 | Appendix L | voice | units 162, 163, 165 | +| 221 | 476 | Write-back | version | notes: "leans on the standard library" | +| 228 | 483 | Failure taxonomy | positional | notes: "the matrix on slide 247" → "the failure-mode matrix" | +| 231–248 | 486–503 | Appendix M | voice, version | units 170–178 (171 kept); 497 notes "(since C++20)" | +| 237 | 492 | Timeouts | code | `cpp_httplib::` → `httplib::` | +| 239 | 494 | Retry | positional | code comment "(next slide)" removed | +| 245 | 500 | Shutdown recap | code, version | chip, set_code (aligned with 164), caption, notes | +| 253 | 508 | Evaluating a flag | code, stack, notes | chip `c++ · flagd gRPC`, set_code (example 27), set_notes | +| 256 | 511 | A kill switch | code, stack | chip `c++ · flagd`, set_code (example 27 /recommendations) | + +## Code map (every code slide) + +Status: `matches` (agrees with the site codetab and the example on library, API, identifiers), +`replaced` (set_code in this manifest), `edited` (token replace in the box), +`illustrative-ok` (teaching code with no example counterpart; API checked against the locked +library versions). Teaching wrappers (`PgTxn`, `Task<>` handlers, `json_response`) appear in the +site codetabs too and are accepted as illustration. + +| Slide | sldId | Title | Chip | Site codetab | Example | Status | +|---|---|---|---|---|---|---| +| 14 | 269 | A 12-factor Drogon service | c++ · Drogon | `_docs/01` twelve-factor | `01-cloud-native-principles/cpp/src/main.cpp` | matches | +| 17 | 272 | REST at the edge with Drogon | c++ · Drogon | `_docs/02` REST at the edge | `02-communications/cpp/order-service` | matches (deck adds the `list` handler) | +| 18 | 273 | gRPC for internal calls | c++ · gRPC | `_docs/02` gRPC | `02-communications/cpp/inventory-service` (`InventoryServiceImpl`) | matches (deck name `InventoryServicer`, illustrative) | +| 21 | 276 | Resilience as configuration | yaml · istio | — | — | owner: shared-code | +| 24 | 279 | Composing resolver | c++ · cppgraphqlgen | `_docs/03` resolver | `03-composition/cpp/gateway` | matches | +| 29 | 284 | CQRS in practice | c++ · libpq + kafka | `_docs/05` local views | `05-event-driven/cpp` (rdkafka++) | replaced | +| 32 | 287 | The outbox write | c++ · libpq | `_docs/04` (stale, site fix) | `04-data/cpp/order-service/src/main.cpp` | replaced | +| 36 | 291 | Producing and consuming | c++ · rdkafka++ | `_docs/05` Producing and consuming | `05-event-driven/cpp/{order-service,notification-consumer}` | replaced | +| 43 | 298 | Windowed aggregation | c++ · rdkafka++ | `_docs/06` (stale, site fix) | `06-stream-processing/cpp/stream-processor` | replaced | +| 45 | 300 | Scale on lag and RPS | yaml · KEDA | — | — | owner: shared-code | +| 55 | 310 | Gate breaking changes in CI | c++ · Apicurio | `_docs/09` (cpr) | none (09 is configuration only) | edited (HTTP 400) | +| 64 | 319 | Auto-instrument Drogon | c++ · OpenTelemetry | `_docs/11` (stale, site fix) | `11-observability/cpp/*/src/telemetry.hpp` | edited (OTLP/HTTP) | +| 66 | 321 | Baggage in practice | c++ · OpenTelemetry | `_docs/11` correlation | none | illustrative-ok (opentelemetry-cpp 1.26 baggage API) | +| 78 | 333 | S3 pre-signed URL | c++ · AWS SDK | `_docs/12` valet key | `12-security/cpp` uses jwt-cpp tokens | replaced (illustrative pattern; SDK call fixed) | +| 83 | 338 | OPA Gatekeeper | yaml · Gatekeeper | — | — | owner: shared-code | +| 85 | 340 | Per-tenant bulkheads | c++23 · semaphore | `_docs/12` bulkhead | none | edited (comment), illustrative-ok | +| 87 | 342 | Claim-check | c++ · S3 + rdkafka++ | `_docs/12` (stale, site fix) | none | replaced | +| 106 | 361 | Media-type versioning | c++ · Drogon | none | none | illustrative-ok | +| 108 | 363 | Evolving a .proto | protobuf | — | — | owner: shared-code | +| 110 | 365 | Deprecating a field | graphql + c++ | `_docs/15` GraphQL | none | matches (deck adds a span attribute) | +| 113 | 368 | WebSocket endpoint | c++ · Drogon | `_docs/16` endpoint | `16-websockets/cpp/ws-server` (`WsCtrl`, `/ws`) | matches site; illustrative names | +| 117 | 372 | Binary framing | protobuf + c++ | `_docs/16` binary framing | none | matches | +| 122 | 377 | Saga state machine | c++ · boost::sml | `_docs/17` saga state | `17-sagas/cpp/saga-orchestrator/src/saga_machine.hpp` | replaced | +| 124 | 379 | Compensations in reverse | c++ · boost::sml | `_docs/17` (stale, site fix) | `17-sagas/cpp/.../main.cpp` | replaced | +| 126 | 381 | Context forward | c++ · boost::sml | none | `17-sagas/cpp/.../main.cpp` | replaced (B reading A's output shown as the pattern on the example's context) | +| 130 | 385 | REST errors | c++ · Drogon | `_docs/18` REST | `18-errors/cpp` | matches | +| 132 | 387 | gRPC rich status | c++ · gRPC | `_docs/18` gRPC | `18-errors/cpp/inventory-service` | matches | +| 134 | 389 | Kafka retry + DLQ | c++ · rdkafka++ | `_docs/18` Kafka | none | replaced (site tab) | +| 136 | 391 | GraphQL field errors | c++ · cppgraphqlgen | `_docs/18` GraphQL | none | matches | +| 146 | 401 | Ports & adapters | c++ · hexagonal | `_docs/19` (stale, site fix) | `19-ddd-hexagonal/cpp/order-service/src/domain` | replaced | +| 164 | 419 | Graceful shutdown | c++23 · Drogon | `_docs/21` (stale, site fix) | `21-graceful-shutdown/cpp/src/main.cpp` | replaced | +| 175 | 430 | HAProxy | haproxy.cfg | — | — | owner: shared-code | +| 176 | 431 | Nginx | nginx.conf | — | — | owner: shared-code | +| 177 | 432 | Istio VirtualService | yaml · Istio | — | — | owner: shared-code | +| 182 | 437 | Canary | yaml · Istio | — | — | owner: shared-code | +| 183 | 438 | Header-based steering | yaml · Istio | — | — | owner: shared-code | +| 185 | 440 | Header injection | yaml · Istio | — | — | owner: shared-code | +| 188 | 443 | The rules: Lua | lua | `_docs/22` Lua | `22-l7-routing/cpp/router-service/routing.lua` (simpler `route(order, rules)`) | matches site; illustrative-ok | +| 189 | 444 | The dispatcher | c++ · Drogon + sol2 | `_docs/22` dispatcher | `22-l7-routing/cpp/router-service` | replaced (site tab) | +| 194 | 449 | Istio east/west | yaml · Istio | — | — | owner: shared-code | +| 206 | 461 | Content-based routing | c++ · Drogon | `_docs/24` (stale, site fix) | `24-monolith-to-microservices/cpp/router` | replaced | +| 208 | 463 | Decorating collaborator | c++ · Drogon | `_docs/24` (stale, site fix) | `24-monolith-to-microservices/cpp/decorator` | replaced | +| 213 | 468 | Cache-aside | c++ · Drogon | `_docs/25` cache-aside | `25-caching/cpp/cache-service` | matches | +| 215 | 470 | Read-through | c++ · template | `_docs/25` read-through | `25-caching/cpp/cache-service` | matches | +| 217 | 472 | Write-through | c++ · Drogon | `_docs/25` write-through | `25-caching/cpp/cache-service` | matches (deck adds a fast variant) | +| 219 | 474 | Write-around | c++ · Drogon | `_docs/25` write-around | `25-caching/cpp/cache-service` | matches | +| 221 | 476 | Write-back | c++ · jthread | `_docs/25` write-back | `25-caching/cpp/cache-service` | matches | +| 223 | 478 | Refresh-ahead | c++ · jthread | `_docs/25` refresh-ahead | `25-caching/cpp/cache-service` | matches | +| 237 | 492 | Timeouts + deadlines | c++ · gRPC | `_docs/26` timeouts | `26-failure-modes/cpp/edge-service` (libcurl) | edited (`httplib::`) | +| 239 | 494 | Retry with backoff | c++ | `_docs/26` retry | `26-failure-modes/cpp/edge-service` | edited (comment) | +| 241 | 496 | Circuit breaker | c++ · atomic | `_docs/26` circuit breaker | `26-failure-modes/cpp/edge-service` | matches | +| 245 | 500 | Shutdown recap | c++23 · Drogon | `_docs/21` (stale) | `21-graceful-shutdown/cpp` | replaced | +| 253 | 508 | Evaluating a flag | c++ · flagd gRPC | `_docs/27` (stale, site fix) | `27-feature-flags/cpp/flag-service` | replaced | +| 254 | 509 | flagd targeting | json · flagd | — | — | owner: shared-code | +| 256 | 511 | A kill switch | c++ · flagd | `_docs/27` (stale, site fix) | `27-feature-flags/cpp/flag-service` | replaced | +| 264, 266, 267, 269 | 519, 521, 522, 524 | Appendix O | json, bash, yaml | `_docs/28` | `28-newman` | owner: planner (App O) | + +Counts over the 43 C++-owned code slides: replaced 21, edited 5, matches 13, illustrative-ok 4 +(321, 340, 361, 443; 333 and 340 are also counted under replaced/edited). Owned by others: 12 +shared-code slides, 4 Appendix O slides. + +## Version strings left in the deck after the edits + +| String | Where | Pin | +|---|---|---| +| C++23 | chips, closing slide, title notes | C++23 (`CMAKE_CXX_STANDARD 23`) | +| GCC 15 / gcc-toolset-15, CMake 4.4.4, Ninja 1.13.2, Conan 2.33.0 | slide 1 notes | MP S7 | +| ubi10/ubi:10.2, ubi10/ubi-minimal | slide 1 notes | MP pins table (builder ubi10/ubi:10.2-1791444044, runtime ubi10/ubi-minimal:10.2-1791444377) | +| librdkafka 2.14.2 | slide 36 notes | MP S7 (ConanCenter newest; upstream 2.16.0) | +| .NET 10 | slide 1 notes (sibling decks) | .NET 10.0.12 | +| since C++20 (stop_token, jthread, counting_semaphore), C++17 (PMR) | slides 85, 160, 167, 221, 242, 352 | history, not pins | +| Apicurio `/apis/registry/v3` | slide 55 code | Apicurio 3.3.3 | +| TLS 1.3, RFC 9457, 10.1.0.10/11 | various | not software pins | + +## Watch-tier hits kept (deck-exclusive) + +| Unit | Slide | Pattern | Reason | +|---|---|---|---| +| 27 | 37 notes | silently | machine-subject (a producer corrupts consumers) | +| 32 | 39 title | landscape | survey-term | +| 91 | 107 notes | silently | machine-subject (an old client misreads data) | +| 171 | 232 notes | lies | machine-subject (gray failure) | + +Remaining watch hits in the C++ text after these edits sit in sentences shared with other decks +(voice forks) or are notes paragraphs whose em dashes are spread over sentences with one dash +pair at most (`notes-paragraph`). + +## Reference and library verification (user decision 6) + +Checked 2026-10-12 with the GitHub API (`gh api repos/...`, releases, commits, contents), the live sites (HTTP 200) +and, for the compendiums, a clone of each repository. Locked-stack libraries (CLAUDE.md) are not repeated here. +Every rewrite is a `replace` op tagged `_amend` in `lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.json`, +inserted before the agenda and Appendix R ops; sources in `tools/amend_decisions.py` (`cpp_pairs`). + +### Projects and libraries + +| Named in the deck | Exists? Official URL | Maintained (2026-10-12) | License | Verdict and action | +|---|---|---|---|---| +| "cpp-stateless compendium" / "cpp-stateless deck" / "cpp-statelessness compendium" (28 mentions) | yes: the author's companion project `github.com/patterncatalyst/cpp-statelessness`, site https://patterncatalyst.github.io/cpp-statelessness/ (Doc 01–11) | last commit 2026-10-11 | Apache-2.0 (`LICENSE`; GitHub shows NOASSERTION) | kept, renamed to its real name **cpp-statelessness** and cited by Doc number with the chapter URL. The deck's "§N" numbers do not match the site's chapters (for example "§6 PMR" is Doc 03, "§3 Conan" is Doc 11, "§11 stdout" is Doc 08), so each reference names the Doc that holds the content; claims the compendium does not make are rewritten (table below) | +| "cpp-container compendium" (1, references notes) | yes: `github.com/patterncatalyst/cpp-container-optimization-tutorial`, site https://patterncatalyst.github.io/cpp-container-optimization-tutorial/ | last commit 2026-10-11 | Apache-2.0 | kept, named "the C++ container optimization tutorial" with its URL (slide 94) | +| hedge-cpp (slide 137 notes) | no retry library by that name: `github.com/photex/hedge-cpp` is a half-edge mesh experiment (3 stars, no release) | last push 2019-09-23 | none | dropped: "no widely adopted C++ retry library exists; write capped exponential backoff with jitter, as Example 26 does (`retry_with_backoff`)" | +| Hystrix-CPP (slide 240 notes) | only `github.com/dragosbucur2001/hystrix-cpp` (0 stars, no description, no release) | last push 2023-06-06 | none | dropped | +| breaker-cpp (slide 240 notes) | no such repository (GitHub search returns unrelated brick-breaker games) | — | — | dropped: "C++ has no widely adopted circuit-breaker library; Example 26 hand-rolls one (`CircuitBreaker` in edge-service)" | +| openapi-generator-cli, `cpp-pistache-server` (slide 10 bullet) | yes: OpenAPI Generator `github.com/OpenAPITools/openapi-generator`, generator docs list `cpp-pistache-server` and `cpp-httplib-server` as STABLE server generators; Pistache `github.com/pistacheio/pistache` | generator v7.26.0 (2026-10-06); Pistache last commit 2026-07-11, last release v0.4.25 (2024-12-21) | Apache-2.0 / Apache-2.0 | "template is most mature" is not verifiable: rewritten to "stable C++ server generators: cpp-pistache-server, cpp-httplib-server; none targets Drogon" | +| cpp-httplib (slides 16, 17, 55, 207, 237, 242) | yes: `github.com/yhirose/cpp-httplib` | v0.60.1 (2026-10-08) | MIT | kept; slide 55 notes name the repository (and drop "picked back in §2") | +| libcurl (slides 55, 63) | yes: `github.com/curl/curl`, https://curl.se; used by `examples/26-failure-modes/cpp` | curl 8.22.0 (2026-09-02) | curl license (MIT-style) | kept | +| AWS SDK for C++ (slides 78, 87, 89, 93) | yes: `github.com/aws/aws-sdk-cpp` | 1.11.909 (2026-10-09) | Apache-2.0 | removed by decision 7 (a vendor cloud SDK); slides 78/87 follow `examples/12-security/cpp` (jwt-cpp), 89/93 name jwt-cpp | +| OpenTelemetry C++ SDK (slide 60 notes "BSL/Apache 2.0") | yes: `github.com/open-telemetry/opentelemetry-cpp` | v1.29.0 upstream; 1.26.0 pinned (ConanCenter) | Apache-2.0 only | license corrected to "Apache 2.0" | +| opentelemetry-cpp-contrib (slides 63, 64) | yes: `github.com/open-telemetry/opentelemetry-cpp-contrib`; `instrumentation/` holds boost_log, glog, httpd, log4cxx, nginx, otel-webserver-module, spdlog | active (push 2026-10-12) | Apache-2.0 | the deck's "gRPC, libcurl, MySQL client instrumentations" and "OTel interceptor for gRPC" do not exist: slide 63 bullet and notes and slide 64 notes rewritten (log bridges and web-server modules only; gRPC context injected by hand with the W3C propagator, as opentelemetry-cpp's `examples/grpc` does); slide 62 notes name the contrib spdlog sink | +| protobuf `Arena::CreateMessage` (slide 117 notes) | not public in protobuf 6.33.5 (`src/google/protobuf/arena.h` at v33.5 has `Arena::Create` only) | protobuf v36.2 upstream; 6.33.5 pinned | BSD-3-Clause | rewritten to `google::protobuf::Arena::Create(&arena)` | +| Boost.Beast (slide 113 notes) | yes: `github.com/boostorg/beast` | Boost 1.92.0 (2026-08-12) | BSL-1.0 | kept | +| jsoncpp (slide 126 code comment and notes) | yes: `github.com/open-source-parsers/jsoncpp` (Drogon's `Json::Value`) | 1.9.8 (2026-06-14) | MIT or public domain | kept | +| OpenSSL (slide 178 notes) | yes: `github.com/openssl/openssl` | 4.0.3 (2026-09-29) | Apache-2.0 | kept | +| Unleash (slide 260 footnote and notes) | yes: `github.com/Unleash/unleash` | v8.2.0 (2026-09-08) | AGPL-3.0 (open source; paid tiers exist) | kept as an open-source backend; "behind the same OpenFeature SDK" corrected: `open-feature/cpp-sdk-contrib` ships only a flagd provider (C++ SDK v0.1.4), so a C++ service calls Unleash's own API | + +### Compendium references, one by one + +"Holds" means the named cpp-statelessness chapter covers the claim; the deck then cites it by Doc number and URL +(`patterncatalyst.github.io/cpp-statelessness/docs//`). Slide numbers are those at 8c86d61. + +| Slide | sldId | Original reference | Verified against | Action | +|---|---|---|---|---| +| 21 | 276 | 'budget-aware' retry rule | Doc 07 ("Cap the retry budget at the request deadline") | holds; cited | +| 24 | 279 | composition root (DI by reference) | Doc 04 (`main()` as composition root, pass by reference) | holds; cited | +| 27 | 282 | ScopedConnection (RAII checkout, invalidate on broken) | Doc 07 (`ScopedConn`, `invalidate()`) | holds; real name `ScopedConn`, cited | +| 29 | 284 | "cpp-stateless idioms: PgTxn" | not in the compendium (no `PgTxn`) | rewritten: PgTxn described as a small RAII wrapper over a libpq transaction; Example 04 uses libpq calls directly | +| 60 | 315 | "the cpp-stateless deck has the configuration" (Batch span processor) | Doc 04 (`BatchSpanProcessorFactory`) | holds; cited | +| 62 | 317 | "§11 logs to stdout, not a file" | Doc 08 (`basic_logger_mt`, read-only root filesystem) | holds under Doc 08; cited | +| 84 | 339 | "§6 PMR per-request arenas" | Doc 03 | holds; cited; ALL-CAPS "CONCURRENCY" lowercased | +| 91 | 346 | per-service in-process state (PMR arenas, RAII pools) | Docs 03, 04 | holds; cited (Doc 04) | +| 94 | 349 | "cpp-stateless / cpp-container compendiums" | both repositories and sites | holds; both named with URLs | +| 99, 102 | 354, 357 | "port/adapter pattern" | not in the compendium (no ports/adapters or hexagonal chapter) | rewritten to Appendix F and Example 19 | +| 111 | 366 | "§3 Conan lockfile pinning" | Doc 11 (Conan profiles, one lockfile) | holds; cited; "(e.g., 3.21 → 4.0)" protobuf example dropped (protobuf C++ versions are 6.x now) | +| 117 | 372 | "§5 PMR integrates cleanly with protobuf arenas" | Doc 03 says the opposite emphasis: keep protobuf on its own allocator, the PMR arena for handler internals, boundary explicit in review | rewritten to the Doc 03 position; cited | +| 118, 119 | 373, 374 | "§6 PMR arenas" per connection | Doc 03 (per-request arenas) | holds as an extension; worded "the per-request arena of Doc 03, applied to a connection's lifetime" | +| 140, 148 | 395, 403 | "§4 composition root + adapter pattern is hexagonal" (slide bullets) | Doc 04 has the composition root, not adapters | rewritten to "a composition root in main() wiring adapters into ports (Example 19)" | +| 146 | 401 | "§4 idiom" (DI by reference) | Doc 04 | holds; cited | +| 147 | 402 | "§6 PMR-arena pattern" for aggregates | Doc 03 | holds; cited | +| 154 | 409 | "Conan + lockfile (cpp-stateless §3)" (slide bullet) | Doc 11; every C++ example commits `conan.lock` | holds; bullet now "Conan + a committed conan.lock" | +| 160 | 415 | "§9 covers this in detail; the cpp-stateless deck is the implementation reference" | Doc 09 (readiness flip on SIGTERM, signal handling) | holds for the readiness side; cited with Example 21 as the runnable C++ version | +| 163 | 418 | "§9 complete sequence" | Docs 09, 10, 05 (signal handler, stop_source, pool join) | holds in substance; cited Doc 09 | +| 180 | 435 | "§11 … stickiness" | Doc 01 (deployment posture: request- and process-scoped state only) | holds under Doc 01; cited | +| 188 | 443 | "§7 watcher pattern … atomically swaps the Lua state" | not in the compendium (no watcher or hot-reload); `examples/22-l7-routing/cpp` compiles the script once at startup and takes rule inputs through PUT /rules | rewritten to what the example does; a file watcher named as an extension | +| 213 | 468 | "PgConn from the §5 pool" | Doc 07 (`ScopedConn` pool) | holds under Doc 07; cited | +| 220 | 475 | "§9 graceful-shutdown discipline" | Appendix H of this deck covers it directly | rewritten to cite Appendix H | +| 227 | 482 | "cpp-stateless idioms (RAII unwinds, std::stop_token)" | Docs 02, 05 | holds; cited | + +Not changed: the CQRS slide 29 code still uses the illustrative `PgTxn` wrapper (the notes now say so); the +cache-aside slide 213 code uses `PgConn`/`co_await pg_.acquire()` the same way. + +## Unresolved or for the planner + +- `examples/21-graceful-shutdown/cpp/src/main.cpp` calls `spdlog::info` inside the SIGTERM + handler, which is not async-signal-safe; the deck (and the site) state the rule that the + handler only stores an atomic flag. The slide excerpt omits the log call. Fix the example + (outside S12 scope) or accept the excerpt. +- Resolved (user decision 6): the compendium and third-party library references are verified upstream; see + "Reference and library verification". 37 replace ops (tag `_amend`, `tools/amend_decisions.py`). +- Slide 211 bullet "the next six slides" (shared sentence, voice forks) and slide 113 caption + wording are left to the shared pass. +- The site codetab fixes listed in `site-fixes.md` (C++ rows) are needed for the deck code and + the site to agree; the deck already carries the example-aligned code. + +## Decision 7 amendment (vendor-neutral, 2026-10-12) + +Generator: `tools/amend_vendor.py` (ops tagged `_vendor`, inserted just before the agenda op; run after `amend_decisions.py`). +Find strings and counts come from the pre-decision text copy (`verify_all.py` output of the edit files at 39a5b1e). + +- Valet key (sldId 333): title "Valet key — scoped, time-bound, op-restricted", chip `c++ · Drogon + jwt-cpp`, `set_code` = `examples/12-security/cpp/order-service/src/valet.hpp` (`mint`, `verify`), 22 lines, notes rewritten (HMAC or HS256 ticket the service mints and verifies; generic object store). Code map row: matches `examples/12-security` (condensed excerpt, first comment line says so). +- Claim check (sldId 342): chip `c++ · rdkafka++ + jwt-cpp`, `set_code` = rdkafka++ produce/consume with a generic `store_`, ticket from `cndp::valet::mint` (15 minutes), 21 lines; caption rewritten; notes rewritten. Same lines as the site claim-check codetab after the site fix (`site-fixes.md`, decision-7 section). No example implements the claim check (illustrative, token from Example 12). +- Removed: AWS SDK for C++ (slides 78, 87, 89, 93); it was kept by the decision-6 amendment and now goes; the S3 pre-signed URL, STS AssumeRole, Azure SAS, GCS framing. +- Shared rewrites (where present in this deck): 4C's notes and bullet (CloudTrail, "S3 bucket open"); event-driven tools notes ("to S3" → "to an object store"); valet-key concept notes (one generic implementation sentence instead of S3/Azure/GCS/STS); strangler-fig IdPs (Keycloak, Dex instead of Auth0, Azure AD); zero-trust edge (Pomerium or OAuth2 Proxy in front of Keycloak instead of Google IAP, Cloudflare Access, AWS Verified Access; licence note Pomerium Apache-2.0, OAuth2 Proxy MIT); claim-check concept notes; choosing-the-pattern notes; glossary SAS entry and note removed; Appendix I gateway list without Apigee; Appendix M references: gRFC A6 instead of the AWS Builders' Library. +- C++ only: valet notes (332) say signed ticket and object store instead of pre-signed URL, S3 or MinIO; claim notes (341) use `https://objects.internal/claims/abc-123` and per-object access policies; take-aways (344) and closing notes (348) name jwt-cpp instead of the AWS SDK for C++; agenda notes and Appendix R notes (528, 536) drop the OpenShift Local sentences (the C++ deck never had Appendix S). +- Kept (not vendor services): Heroku as the origin of the twelve-factor methodology (history), Google SRE Book (reference), gRPC / Google.Protobuf / Google.Rpc (open-source libraries), Netflix concurrency-limits (Apache-2.0 library), Red Hat UBI and the Red Hat brand frame. +- Figures (`replace_picture`, `tools/vendor_figures.py`): sldIds 332, 335. The valet-key figure named S3, Azure SAS, GCS and STS; the zero-trust figure listed Google IAP, Cloudflare Access and AWS Verified Access; both become the neutral site figures (`12-valet-key.png` with "object store" labels, `12-zero-trust.png`). diff --git a/_plans/s12-manifest/Designing-Cloud-Native-APIs-DotNet.md b/_plans/s12-manifest/Designing-Cloud-Native-APIs-DotNet.md new file mode 100644 index 00000000..52d10d0d --- /dev/null +++ b/_plans/s12-manifest/Designing-Cloud-Native-APIs-DotNet.md @@ -0,0 +1,279 @@ +# Designing-Cloud-Native-APIs-DotNet — S12 manifest + +> Amended 2026-10-12 for user decision 7 (vendor-neutral): see the last section, which supersedes the rows here +> on the valet-key and claim-check slides, the cloud-SDK pins and Appendix S. + +Deck: `lgtm-presentation/Designing-Cloud-Native-APIs-DotNet.pptx`, 303 slides at 8c86d61. Slide numbers here +are the 8c86d61 numbers; the edit files address sldIds. + +## Summary + +- MassTransit removed from every slide and note (user decision during S4): Kafka producers and consumers are + Confluent.Kafka 2.16.0, stream processing is Streamiz.Kafka.Net 1.8.2, the in-app router is a Confluent.Kafka + consumer with NRules, the decorator uses StackExchange.Redis + Confluent.Kafka. Three notes keep MassTransit + as history on purpose (see the ledger). +- Appendix C now follows `examples/16-websockets/dotnet` (raw ASP.NET Core WebSockets and protobuf frames); + SignalR stays the subject of Appendix Q and of the Blazor slides. +- 22 code boxes rewritten (site codetab or example excerpt), 3 fixed by token (outbox comment, Stateless + `OnEntryAsync`, `KafkaEventPublisher`); every replacement fits the box capacity deck_edit computes and 95 columns. +- Appendix P: UBI 9 reason (no UBI 10 .NET image) and the pinned image tags; Containerfile wording. +- Speaker notes written for the 17 slides that had none (sldIds 542–558, Appendices P and Q); remove the deck + from `KNOWN_MISSING_NOTES` in `scripts/deck_text.py` after applying. +- 63 deck-exclusive voice/positional/engine rewrites (by category {'voice': 54, 'positional': 8, 'engine': 1}); the other + 26 exclusive units sit on notes or text that an op rewrites (ledger: `rewrites/ledger-deck-DotNet.tsv`). +- Ops in `ops-30-deck` by category: {'stack': 39, 'code': 38, 'notes': 42, 'version': 5, 'voice': 1}. Dry run of the ops alone on a copy of the deck: OK + (`deck_edit.py --dry-run`, 125 ops); applied together with the exclusive rewrites, the text keeps 0 ban-tier, + positional or old-engine hits outside sentences owned by the voice forks, Appendix O and the agenda. + +## Code map + +Status counts: {'matches': 18, 'replaced': 22, 'owner: shared-code': 14, 'owner: planner (App O)': 4} + +| Slide | sldId | Title | Chip (before) | Site doc · codetab | Example | Status | +|---|---|---|---|---|---|---| +| 15 | 270 | A 12-factor ASP.NET Core service, ready for the platform | `csharp · .NET 10 + ASP.NET Core + AspNetCore.HealthChecks` | _docs/01-cloud-native-principles.md · A twelve-factor service | examples/01-cloud-native-principles/dotnet | matches | +| 18 | 273 | REST at the edge with ASP.NET Core | `csharp · .NET 10 + ASP.NET Core minimal APIs` | _docs/02-communications.md · REST at the edge | examples/02-communications/dotnet | replaced (site codetab; WithOpenApi dropped, record moved after top-level statements) | +| 19 | 274 | gRPC for internal service-to-service calls | `protobuf + csharp · .NET 10 + Grpc.AspNetCore` | _docs/02-communications.md · gRPC for internal calls | examples/02-communications/dotnet | matches | +| 22 | 277 | Resilience as configuration, not per-call code | `yaml · istio` | see shared-code | | owner: shared-code | +| 25 | 280 | A composing resolver in HotChocolate | `csharp · .NET 10 + HotChocolate` | _docs/03-composition.md · The resolver makes it concrete | examples/03-composition/dotnet | matches | +| 31 | 286 | CQRS in practice | `csharp · ASP.NET Core 10 + EF Core + MassTransit` | _docs/04-data.md (no CQRS codetab) | examples/04-data/dotnet | replaced (Confluent.Kafka projection; illustrative) | +| 34 | 289 | The outbox write — one transaction, no lost events | `csharp · EF Core + Debezium CDC` | _docs/04-data.md · The code, in your language | examples/04-data/dotnet | matches (last comment line now Wolverine, as the site) | +| 38 | 293 | Producing and consuming with MassTransit | `csharp · .NET 10 + MassTransit + Confluent.Kafka` | _docs/05-event-driven.md · Producing and consuming | examples/05-event-driven/dotnet/{OrderService,NotificationConsumer}/Program.cs | replaced | +| 45 | 300 | A windowed aggregation with Streamiz.Kafka.Net | `csharp · .NET 10 + Streamiz.Kafka.Net` | _docs/06-stream-processing.md · A windowed aggregation | examples/06-stream-processing/dotnet/StreamProcessor/Program.cs | replaced (excerpt of the example; site fix listed) | +| 47 | 302 | Scale on lag (events) and on RPS (HTTP) | `yaml · KEDA` | see shared-code | | owner: shared-code | +| 57 | 312 | Gate breaking changes in CI | `yaml · CI + Apicurio v3 REST` | see shared-code | | owner: shared-code | +| 66 | 321 | Auto-instrument with OpenTelemetry .NET | `csharp · .NET 10 + OpenTelemetry.NET` | _docs/11-observability.md · Auto-instrument your service | examples/11-observability/dotnet/OrderService/Program.cs | replaced (example's exporter config; site fix listed) | +| 68 | 323 | Baggage in practice — carry request context downstream | `csharp · .NET System.Diagnostics.Activity + OpenTelemetry` | _docs/11-observability.md · How correlation works | examples/05-event-driven/dotnet (header propagation) | replaced | +| 80 | 335 | S3 pre-signed URL — scoped, time-bound, op-restricted | `csharp · ASP.NET Core 10 + AWS SDK for .NET` | _docs/12-security.md · The valet key hands out direct access | examples/12-security/dotnet | matches | +| 85 | 340 | OPA Gatekeeper ConstraintTemplate (Rego) | `yaml · OPA Gatekeeper (language-neutral)` | see shared-code | | owner: shared-code | +| 87 | 342 | Polly concurrency limiters in ASP.NET Core | `csharp · ASP.NET Core 10 + Polly v8` | _docs/12-security.md · Bulkhead: bound the blast radius | examples/12-security/dotnet | replaced (site codetab; the Polly version did not type-check) | +| 89 | 344 | Producer mints claim, consumer fetches payload | `csharp · ASP.NET Core 10 + AWS SDK for .NET + MassTransit` | _docs/12-security.md · Claim-check | examples/12-security/dotnet | replaced (site codetab) | +| 108 | 363 | Media-type versioning with ASP.NET Core | `csharp · .NET 10 + Asp.Versioning` | _docs/15-appendix-b-versioning.md (no REST media-type codetab) | none | replaced (illustrative-ok: Asp.Versioning template reader, NewVersionedApi) | +| 110 | 365 | Evolving a .proto safely | `protobuf` | see shared-code | | owner: shared-code | +| 112 | 367 | Deprecating a field in HotChocolate | `csharp + graphql · .NET 10 + HotChocolate` | _docs/15-appendix-b-versioning.md · GraphQL evolves, it doesn't version | examples/03-composition/dotnet | replaced (site codetab) | +| 115 | 370 | A WebSocket endpoint with SignalR | `csharp · .NET 10 + SignalR` | _docs/16-appendix-c-websockets.md · A WebSocket endpoint | examples/16-websockets/dotnet/WsServer/Program.cs | replaced (raw WebSockets, as the example; was SignalR) | +| 119 | 374 | Binary framing with MessagePack — fast and versioned | `csharp · .NET 10 + SignalR + MessagePack` | _docs/16-appendix-c-websockets.md · Binary framing with protobuf + Receiving and dispatching | examples/16-websockets/dotnet | replaced (protobuf envelope; was SignalR MessagePack) | +| 126 | 381 | A persisted saga state machine (EF Core) | `csharp · .NET 10 + EF Core` | _docs/17-appendix-d-sagas.md · Where the saga keeps its state | examples/17-sagas/dotnet (Npgsql, same SELECT ... FOR UPDATE) | matches | +| 128 | 383 | Running compensations in reverse | `csharp · .NET 10 + EF Core` | _docs/17-appendix-d-sagas.md · Compensation unwinds a failed saga | examples/17-sagas/dotnet | matches | +| 132 | 387 | Temporal .NET workflow + activity, in ASP.NET Core | `csharp · Temporalio.Temporal + ASP.NET Core` | none (Temporal) | none | replaced (illustrative-ok: no Saga helper in the .NET SDK, StartToCloseTimeout on every call) | +| 134 | 389 | Stateless: configure and drive | `csharp · Stateless package + EF Core` | none (Stateless) | none | matches after token fix (OnEntry → OnEntryAsync; illustrative-ok) | +| 138 | 393 | REST errors with ASP.NET Core ProblemDetails | `csharp · .NET 10 + IExceptionHandler` | _docs/18-appendix-e-errors.md · REST | examples/18-errors/dotnet | replaced (problem+json media type passed to WriteAsJsonAsync; site fix listed) | +| 140 | 395 | gRPC errors with rich status details | `csharp · grpc-dotnet + Google.Rpc` | _docs/18-appendix-e-errors.md · gRPC | examples/18-errors/dotnet | matches | +| 142 | 397 | Kafka retry + DLQ with MassTransit | `csharp · .NET 10 + MassTransit + Confluent.Kafka` | _docs/18-appendix-e-errors.md · Kafka | none (18-errors/dotnet has no Kafka) | replaced (site codetab, condensed) | +| 144 | 399 | GraphQL field errors with HotChocolate | `csharp · .NET 10 + HotChocolate` | _docs/18-appendix-e-errors.md · GraphQL | none | matches | +| 154 | 409 | Ports & adapters in code | `csharp · ports & adapters in .NET` | _docs/19-appendix-f-ddd-hexagonal.md · Hexagonal architecture | examples/19-ddd-hexagonal/dotnet | matches after token fix (KafkaEventPublisher) | +| 172 | 427 | Graceful shutdown in ASP.NET Core | `csharp · .NET 10 + ASP.NET Core` | _docs/21-appendix-h-shutdown.md · The application side | examples/21-graceful-shutdown/dotnet | matches | +| 173 | 428 | The Kubernetes side: probes, preStop, grace period | `yaml · Kubernetes` | see shared-code | | owner: shared-code | +| 183 | 438 | Content-based routing with HAProxy | `haproxy.cfg` | see shared-code | | owner: shared-code | +| 184 | 439 | Content-based routing with Nginx | `nginx.conf` | see shared-code | | owner: shared-code | +| 185 | 440 | Content-based routing in the mesh: Istio VirtualService | `yaml · Istio VirtualService` | see shared-code | | owner: shared-code | +| 190 | 445 | Canary release: weighted Istio routing | `yaml · Istio canary` | see shared-code | | owner: shared-code | +| 191 | 446 | Header-based steering: dark launches | `yaml · Istio header-based` | see shared-code | | owner: shared-code | +| 193 | 448 | Header injection and URL rewrites | `yaml · Envoy / Istio` | see shared-code | | owner: shared-code | +| 196 | 451 | A MassTransit RoutingSlip in .NET, calling NRules | `csharp · .NET 10 + MassTransit + NRules` | _docs/22-appendix-i-l7-routing.md · Routing inside the app (NRules + Confluent.Kafka) | examples/22-l7-routing/dotnet/RouterService (rule table) | replaced (site codetab, condensed) | +| 197 | 452 | An NRules rule: the decision, externalised | `csharp · NRules.Fluent (or external NRules.Language DSL)` | _docs/22-appendix-i-l7-routing.md · Routing inside the app (NRules rules) | examples/22-l7-routing/dotnet | matches | +| 202 | 457 | Istio for east/west: DestinationRule + VirtualService | `yaml · Istio east/west` | see shared-code | | owner: shared-code | +| 220 | 475 | Content-based routing during migration | `csharp · ASP.NET Core 10 minimal API + IHttpClientFactory` | _docs/24-appendix-k-monolith-to-microservices.md · Content-based routing | examples/24-monolith-to-microservices/dotnet | matches | +| 222 | 477 | A decorating collaborator in ASP.NET Core | `csharp · ASP.NET Core 10 + IDistributedCache + MassTransit + IHttpClientFactory (no fence language in the text copy)` | _docs/24-appendix-k-monolith-to-microservices.md · The decorating collaborator | examples/24-monolith-to-microservices/dotnet | replaced (site codetab) | +| 227 | 482 | Cache-aside in ASP.NET Core with Redis | `csharp · ASP.NET Core 10 + EF Core + IDistributedCache` | _docs/25-appendix-l-caching.md · Cache-aside | examples/25-caching/dotnet | replaced (site codetab, fitted; `?? return` did not compile) | +| 229 | 484 | Read-through in ASP.NET Core with FusionCache | `csharp · ASP.NET Core 10 + FusionCache (MIT, open-source)` | _docs/25-appendix-l-caching.md · Read-through | examples/25-caching/dotnet | replaced (site codetab, fitted) | +| 233 | 488 | Write-around in ASP.NET Core | `csharp · ASP.NET Core 10 + EF Core + IDistributedCache` | _docs/25-appendix-l-caching.md · Write-around | examples/25-caching/dotnet | replaced (site codetab) | +| 251 | 506 | Timeouts + deadlines | `csharp · ASP.NET Core 10 + HttpClient` | _docs/26-appendix-m-failure-modes.md · Timeouts | examples/26-failure-modes/dotnet | matches | +| 253 | 508 | Retry with backoff + jitter | `csharp · ASP.NET Core 10 + Polly v8` | _docs/26-appendix-m-failure-modes.md · Retry | examples/26-failure-modes/dotnet | matches | +| 255 | 510 | Circuit breaker + fallback | `csharp · ASP.NET Core 10 + Polly v8` | _docs/26-appendix-m-failure-modes.md · Circuit breaker | examples/26-failure-modes/dotnet | matches | +| 259 | 514 | Graceful shutdown (see App. H) | `csharp · ASP.NET Core 10` | _docs/21-appendix-h-shutdown.md (concept) | examples/21-graceful-shutdown/dotnet/OrderService/Program.cs | replaced (excerpt of the example) | +| 267 | 522 | Evaluating a flag | `csharp · .NET` | _docs/27-appendix-n-feature-flags.md · Evaluating a flag | examples/27-feature-flags/dotnet/FlagService/Program.cs | replaced (site API as a minimal API) | +| 268 | 523 | Targeting and rollout live in config | `json · flagd` | see shared-code | | owner: shared-code | +| 270 | 525 | A kill switch | `csharp · .NET` | _docs/27-appendix-n-feature-flags.md · The kill switch | examples/27-feature-flags/dotnet | matches | +| 278 | 533 | Tests live in the request | `json · Postman` | _docs/28-appendix-o-newman.md | examples/28-newman | owner: planner (App O) | +| 280 | 535 | Launch the service under test | `bash · .NET` | _docs/28-appendix-o-newman.md | examples/28-newman | owner: planner (App O) | +| 281 | 536 | Running Newman & shaping output | `bash` | _docs/28-appendix-o-newman.md | examples/28-newman | owner: planner (App O) | +| 283 | 538 | Newman in CI | `yaml · GitHub Actions` | _docs/28-appendix-o-newman.md | examples/28-newman | owner: planner (App O) | + +## Change table (ops-30-deck, in apply order) + +| Slide | sldId | Category | Change | +|---|---|---|---| +| 1 | [256] | stack | replace `MassTransit + Confluent.Kafka for events, Streamiz.Kafka.Net for strea` | +| 18 | 273 | code | set_code (site 02 codetab; WithOpenApi is obsolete in .NET 10, record moved after top-level statemen) | +| 27 | [282] | stack | replace `gRPC client, MassTransit consumer)` | +| 31 | 286 | stack | set_text csharp · ASP.NET Core 10 + EF Core + Confluent.Kafka | +| 31 | 286 | code | set_code (projection on a Confluent.Kafka consumer; no site codetab (illustrative, CQRS)) | +| 31 | 286 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 34 | [289] | code | replace `// MassTransit has a built-in transactional outbox if you prefer the f` | +| 34 | [289] | stack | replace `OR use MassTransit's built-in transactional outbox, which gives you th` | +| 38 | 293 | stack | set_text Producing and consuming with Confluent.Kafka | +| 38 | 293 | stack | set_text csharp · .NET 10 + Confluent.Kafka 2.16.0 | +| 38 | 293 | code | set_code (site 05 codetab, examples/05-event-driven/dotnet (topic order.placed, group notification-g) | +| 38 | 293 | stack | set_text Confluent.Kafka keeps the Kafka semantics visible: keys, par | +| 38 | 293 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 45 | 300 | version | set_text csharp · Streamiz.Kafka.Net 1.8.2 | +| 45 | 300 | code | set_code (excerpt of examples/06-stream-processing/dotnet/StreamProcessor/Program.cs) | +| 45 | 300 | version | set_notes (Streamiz 1.8.2; matches the example's contract) | +| 66 | 321 | code | set_code (examples/11-observability/dotnet/OrderService/Program.cs (env-configured OTLP to lgtm:4318) | +| 66 | 321 | code | set_text ASP.NET Core requests become spans with no code; ActivitySou | +| 66 | 321 | notes | set_notes (describes the example's setup) | +| 68 | 323 | stack | set_text csharp · OpenTelemetry Baggage + Confluent.Kafka | +| 68 | 323 | code | set_code (OpenTelemetry Baggage API (site 11 codetab) + header extraction as in Appendix S / example) | +| 68 | 323 | code | set_text Baggage.SetBaggage at the edge; extract from the record head | +| 68 | 323 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 87 | 342 | code | set_text Per-tenant bulkheads in ASP.NET Core | +| 87 | 342 | code | set_text csharp · ASP.NET Core 10 + SemaphoreSlim | +| 87 | 342 | code | set_code (site 12 codetab (the Polly version returned the wrong type: PostAsJsonAsync yields HttpRes) | +| 87 | 342 | notes | set_notes (matches the new code) | +| 89 | 344 | stack | set_text csharp · ASP.NET Core 10 + Confluent.Kafka | +| 89 | 344 | code | set_code (site 12 codetab (claim-check) plus the consumer commit) | +| 89 | 344 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 96 | [351] | stack | replace `Temporal .NET SDK (docs.temporal.io/dev-guide/dotnet), MassTransit, Ho` | +| 108 | 363 | code | set_code (Asp.Versioning: a media-type template reader and NewVersionedApi/MapToApiVersion (the old ) | +| 108 | 363 | notes | set_notes (matches the new code) | +| 112 | 367 | code | set_code (site 15 codetab (class with [GraphQLDeprecated]; the record form needed a property: target) | +| 114 | 369 | code | set_text Raw WebSockets, pod pinning, the backplane, binary framing, | +| 114 | 369 | notes | set_notes (Appendix C now matches the example (raw WebSockets); SignalR is Appendix Q) | +| 115 | 370 | code | set_text A WebSocket endpoint in ASP.NET Core | +| 115 | 370 | code | set_text csharp · ASP.NET Core 10 WebSockets | +| 115 | 370 | code | set_code (site 16 codetab and examples/16-websockets/dotnet/WsServer/Program.cs (route /ws/{clientId) | +| 115 | 370 | code | set_text Raw ASP.NET Core WebSockets, as in the running example. The | +| 115 | 370 | notes | set_notes (matches the new code) | +| 119 | 374 | code | set_text Binary framing with protobuf — fast and versioned | +| 119 | 374 | code | set_text csharp · Google.Protobuf + WebSockets | +| 119 | 374 | code | set_code (site 16 codetabs (push and receive/dispatch)) | +| 119 | 374 | code | set_text Protobuf frames are smaller and faster than JSON; the versio | +| 119 | 374 | notes | set_notes (matches the new code) | +| 128 | [383] | code | replace `If you use Temporal .NET instead, its Saga utility (Saga.AddCompensati` | +| 130 | [385] | stack | replace ` MassTransit Sagas is a closely-related alternative if you're already ` | +| 130 | [385] | stack | replace `on NuGet as Temporalio.Temporal)` | +| 130 | 385 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 132 | 387 | stack | set_text csharp · Temporalio + ASP.NET Core | +| 132 | 387 | code | set_code (Temporal .NET SDK: no Saga helper class; StartToCloseTimeout required on every activity ca) | +| 132 | 387 | code | set_text Workflow and activities are plain C# classes; the workflow k | +| 132 | 387 | notes | set_notes (matches the new code) | +| 133 | [388] | stack | replace ` The MassTransit Sagas callout is for shops already using MassTransit:` | +| 134 | [389] | code | replace `.OnEntry(() =>` | +| 134 | 389 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 141 | [396] | stack | replace `With MassTransit this is declarative configuration (UseMessageRetry + ` | +| 142 | 397 | stack | set_text Kafka retry + DLQ with Confluent.Kafka | +| 142 | 397 | stack | set_text csharp · Confluent.Kafka + Polly 8 | +| 142 | 397 | code | set_code (site 18 codetab (Kafka), condensed) | +| 142 | 397 | stack | set_text Bounded in-place retries, a delayed-retry topic, then a DLQ; | +| 142 | 397 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 154 | [409] | code | replace `MassTransitEventPublisher : IEventPublisher.` | +| 154 | [409] | stack | replace `(EF Core repo, MassTransit publisher)` | +| 154 | [409] | stack | replace `(EfCoreOrderRepository for persistence, MassTransitEventPublisher for ` | +| 175 | [430] | stack | replace `MassTransit consumer Stop semantics` | +| 177 | [432] | stack | replace `Traefik, YARP, MassTransit, NRules` | +| 180 | [435] | stack | replace `(.NET + MassTransit RoutingSlip + NRules)` | +| 182 | [437] | stack | replace `MassTransit RoutingSlip + NRules: when the routing logic is a business` | +| 194 | [449] | stack | replace `(MassTransit RoutingSlip, Wolverine routing, a custom dispatcher)` | +| 194 | 449 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 195 | 450 | stack | set_text .NET + Confluent.Kafka + NRules: rule-based routing | +| 195 | 450 | stack | set_text A Kafka consumer owns the plumbing; NRules owns the decision | +| 195 | 450 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 196 | 451 | stack | set_text A Kafka consumer in .NET, calling NRules | +| 196 | 451 | stack | set_text csharp · .NET 10 + Confluent.Kafka + NRules | +| 196 | 451 | code | set_code (site 22 codetab (NRules + Confluent.Kafka router)) | +| 196 | 451 | stack | set_text enrich → NRules evaluate → dispatch to Kafka or HTTP: the co | +| 196 | 451 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 197 | [452] | stack | replace `setting the WhereTo property the RoutingSlip will consume` | +| 204 | [459] | stack | replace `In-app router (MassTransit RoutingSlip + NRules)` | +| 205 | [460] | stack | replace `MassTransit documentation (RoutingSlip activities + receive endpoints)` | +| 205 | [460] | stack | replace `plus the MassTransit RoutingSlip / NRules samples (the canonical .NET ` | +| 207 | 462 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client); pinned versions) | +| 209 | 464 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 210 | 465 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 211 | [466] | stack | replace `EF Core, MassTransit, Temporalio.Temporal, OpenTelemetry` | +| 211 | 466 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 221 | 476 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 222 | 477 | stack | set_text csharp · ASP.NET Core 10 + StackExchange.Redis + Confluent.K | +| 222 | 477 | code | set_code (site 24 codetab (decorator); examples/24-monolith-to-microservices/dotnet uses StackExchan) | +| 222 | 477 | notes | set_notes (MassTransit removed (Confluent.Kafka is the Kafka client)) | +| 212 | [467] | stack | replace `IHttpClientFactory + IDistributedCache + MassTransit + FusionCache` | +| 215 | [470] | stack | replace `(typically via MediatR INotificationHandler for in-process events, or ` | +| 229 | 484 | voice | set_text GetOrSetAsync is read-through. Factory runs only on miss. Bu | +| 227 | 482 | code | set_code (site 25 codetab (the old code had `?? return NotFound()`, which does not compile)) | +| 229 | 484 | code | set_code (site 25 codetab (same `?? return` fix)) | +| 233 | 488 | code | set_code (site 25 codetab) | +| 233 | 488 | notes | set_notes (matches the new code) | +| 259 | 514 | code | set_code (excerpt of examples/21-graceful-shutdown/dotnet/OrderService/Program.cs (no Thread.Sleep i) | +| 259 | 514 | notes | set_notes (matches the new code) | +| 267 | 522 | code | set_code (site 27 codetab API, as a minimal API (examples/27-feature-flags/dotnet: SetProviderAsync,) | +| 138 | 393 | code | set_code (site 18 codetab; WriteAsJsonAsync sets the content type, so the problem+json media type is) | +| 294 | [549] | version | replace `UBI images are freely redistributable, based on RHEL 9, and supported ` | +| 294 | 549 | version | set_text ubi9/dotnet-100:9.8-1791421718 (SDK, build only) → ubi9/dotn | +| 296 | [551] | stack | replace `Multi-stage Dockerfiles separate` | +| 296 | [551] | version | replace `Red Hat UBI images are the same .NET runtime on a RHEL 9 base` | +| 287 | 542 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 288 | 543 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 289 | 544 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 290 | 545 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 291 | 546 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 292 | 547 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 293 | 548 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 294 | 549 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 295 | 550 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 296 | 551 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 297 | 552 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 298 | 553 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 299 | 554 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 300 | 555 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 301 | 556 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 302 | 557 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | +| 303 | 558 | notes | set_notes (slide had no speaker notes (KNOWN_MISSING_NOTES)) | + +## MassTransit ledger + +Before: 21 slides and 25 notes in the text copy (46 lines). After the exclusive rewrites and these ops: 3 lines, +all intentional history: + +| Slide | Where | Sentence kept | +|---|---|---| +| 38 (sldId 293) | notes | "MassTransit was dropped: its abstraction hides the Kafka semantics this section teaches ... and version 9 moved to a commercial license." | +| 207 (sldId 462) | notes | "(MassTransit was dropped: it hides those semantics and moved to a commercial license in version 9)" | +| 210 (sldId 465) | notes | "MediatR and MassTransit both moved to commercial licenses; ... this deck uses Confluent.Kafka directly instead of MassTransit." | + +Fate of every other occurrence: slide 1 notes (stack list), 27 bullet, 31 chip/code/notes, 34 code comment and +notes, 38 title/chip/code/caption/notes, 68 code and notes, 89 chip/code/notes, 96 references, 130 bullet and +notes, 133 notes, 134 notes, 141 notes, 142 title/chip/code/caption/notes, 154 code/caption/notes, 175 +references, 176 notes, 177 bullet, 180 notes, 182 bullet, 194 bullet/notes, 195 title/caption/notes, 196 +title/chip/code/caption/notes, 197 notes, 204 bullet, 205 references/notes, 207/209/210/211 notes and bullets, +212 notes, 215 notes, 221 notes, 222 chip/code/notes: rewritten as listed in the change table. + +Images that still show MassTransit (owned by the image audit, not by this file): `58-appI-masstransit-routingslip-dn` +(slide 195, sldId 450) and the Appendix J tables `70/71/72-appJ-*-dn` (sldIds 462–464), plus any MassTransit +Sagas callout in the Stateless figure (slide 133, sldId 388). The text on those slides no longer names MassTransit. + +## Version strings left in the deck + +| String | Pin | Source | +|---|---|---| +| .NET 10 / ASP.NET Core 10 / net10.0 | .NET 10.0.12 (SDK 10.0.112) | modernization plan S4 | +| Confluent.Kafka 2.16.0 | 2.16.0 | S4 | +| Streamiz.Kafka.Net 1.8.2 | 1.8.2 | S4 | +| ubi9/dotnet-100:9.8-1791421718, ubi9/dotnet-100-aspnet:9.8-1791363520 | same | pins table | +| Polly 8 / Polly v8 | major label | (no pin; not used by the examples) | +| Apicurio v3 | apicurio-registry 3.3.3 | pins table (shared-code slide) | +| C# 12 (primary constructors) | history | — | +| .NET 4.x, 5, 6, 8, 9 | history ("since .NET 6", ".NET 8 added ...") | — | +| OpenFeature, flagd provider (no version on slide) | 2.14.1, 0.5.0 | S4 | + +## Unresolved + +- Images with MassTransit content (listed under the ledger) need the image audit's replacements; no site SVG + exists for the .NET-only Appendix J tables, so they need regenerated images or removal. +- Slide 3 and slide 97 notes still say "four deep-dive appendices": a fact for the voice/agenda owners. +- Temporal .NET SDK license: notes now say "an open-source .NET SDK"; slides 130/131 still say "Apache 2.0" + for the .NET SDK. The SDK repository (temporalio/sdk-dotnet) is MIT; not verified online in this pass. + +## Decision 7 amendment (vendor-neutral, 2026-10-12) + +Generator: `tools/amend_vendor.py` (ops tagged `_vendor`, inserted just before the agenda op; run after `amend_decisions.py`). +Find strings and counts come from the pre-decision text copy (`verify_all.py` output of the edit files at 39a5b1e). + +- Appendix S removed: the `clone_slide` ops `s0`–`s9` (and `s7n`) and every `set_*` op on `$s*` are dropped (79 ops); Appendix R (9 slides) stays. Agenda line "Appendices P–R · .NET on Linux, Blazor, minikube" (same line count, so the agenda fit is unchanged); agenda notes, the title and overview notes and the R0/R1 notes no longer mention OpenShift Local ("carries over to any conformant Kubernetes cluster"). +- Valet key (sldId 335): title "Valet key — scoped, time-bound, op-restricted", chip `csharp · ASP.NET Core 10 + HMACSHA256`, `set_code` = `examples/12-security/dotnet/OrderService/Program.cs` (`/valet-key`, `/verify-valet`, `ComputeHmac`), 20 lines, notes rewritten (HMAC or HS256 ticket the service mints and verifies; generic object store). Code map row: matches `examples/12-security` (condensed excerpt, first comment line says so). +- Claim check (sldId 344): chip `csharp · ASP.NET Core 10 + Confluent.Kafka`, `set_code` = the earlier generic-store version with `https://objects.internal/` and a `ComputeHmac` ticket (`resource`, `operation`, `expires`, `token` in the claim), 15 lines; the AWS SDK for .NET note is gone; notes rewritten. Same lines as the site claim-check codetab after the site fix (`site-fixes.md`, decision-7 section). No example implements the claim check (illustrative, token from Example 12). +- Removed: AWS SDK for .NET / AWSSDK.Extensions.NETCore.Setup / IAmazonS3 (slides 80, 89); the S3 pre-signed URL, STS AssumeRole, Azure SAS, GCS framing. +- Shared rewrites (where present in this deck): 4C's notes and bullet (CloudTrail, "S3 bucket open"); event-driven tools notes ("to S3" → "to an object store"); valet-key concept notes (one generic implementation sentence instead of S3/Azure/GCS/STS); strangler-fig IdPs (Keycloak, Dex instead of Auth0, Azure AD); zero-trust edge (Pomerium or OAuth2 Proxy in front of Keycloak instead of Google IAP, Cloudflare Access, AWS Verified Access; licence note Pomerium Apache-2.0, OAuth2 Proxy MIT); claim-check concept notes; choosing-the-pattern notes; glossary SAS entry and note removed; Appendix I gateway list without Apigee; Appendix M references: gRFC A6 instead of the AWS Builders' Library. +- .NET only: Blazor/SignalR slides and notes (282, 373, 377, 378) drop Azure SignalR Service / App Service / Static Web Apps ("no managed SignalR service"); toolkit notes (462, 463) drop "Azure-only" and "not Confluent Cloud"; Appendix P UBI slides (549, 551) say UBI runs on any Kubernetes cluster instead of "supported on OpenShift" (Red Hat UBI itself stays: Red Hat-led open source). Agenda: "Appendices P–R · .NET on Linux, Blazor, minikube". +- Kept (not vendor services): Heroku as the origin of the twelve-factor methodology (history), Google SRE Book (reference), gRPC / Google.Protobuf / Google.Rpc (open-source libraries), Netflix concurrency-limits (Apache-2.0 library), Red Hat UBI and the Red Hat brand frame. +- Figures (`replace_picture`, `tools/vendor_figures.py`): sldIds 334 (valet-key concept), 337 (zero-trust); the Appendix J tables 462 and 464 keep their earlier replace_picture ops, whose PNGs are re-rendered without "Azure". The valet-key figure named S3, Azure SAS, GCS and STS; the zero-trust figure listed Google IAP, Cloudflare Access and AWS Verified Access; both become the neutral site figures (`12-valet-key.png` with "object store" labels, `12-zero-trust.png`). diff --git a/_plans/s12-manifest/Designing-Cloud-Native-APIs-Go.md b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Go.md new file mode 100644 index 00000000..e0d597e5 --- /dev/null +++ b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Go.md @@ -0,0 +1,172 @@ +# Designing-Cloud-Native-APIs-Go — S12 manifest + +> Amended 2026-10-12 for user decision 7 (vendor-neutral): see the last section, which supersedes the rows here +> on the valet-key and claim-check slides, the cloud-SDK pins and Appendix S. + +The Go deck was an in-place retarget of the Python deck, so most Go code slides predated the site's Go codetabs +and many notes still described Python code (Faust, `aiohttp`, `tenacity`, `uvicorn` lifespan, `durable-rules`, +`redis-py`, `grpcio-status`). This pass brings every Go code slide in line with the site codetab and +`examples/*/go/` (Go 1.27.2, the locked libraries), rewrites the notes that described the old code, and removes +the graphql-go and Python leftovers from captions, bullets and the glossary. + +Edit files (built into `lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.json`): + +- deck content ops: 85 (set_code 26, set_notes 28, set_text 13, replace 18); by category code 41, notes 28, + stack 12, voice 3, version 1. +- deck-exclusive voice rewrites: 9 (units 507, 508, 526, 544, 555); units 501, 502, 511, 528, 529 are superseded + by a set_notes or set_text on the same slide (ledger `rewrites/ledger-deck-Go.tsv`). + +Owned elsewhere: Appendix O (sldIds 516–527, planner), language-agnostic code slides (shared-code fork: 276, 300, +338, 363, 420, 430, 431, 432, 437, 438, 440, 449, 509), images, agenda (257) and Appendices R/S (planner), shared +voice sentences (voice forks). + +## Changes + +| Slide | sldId | Title | Category | Change | +|---|---|---|---|---| +| 1 | 256 | Designing Cloud-Native APIs | version | notes: "Go with net/http" → "Go 1.27.2 with net/http" | +| 10 | 265 | Design-first | stack | bullet: gqlgen generates Go resolvers from the SDL (it is schema-first; it does not generate SDL) | +| 14 | 269 | A 12-factor net/http service | code, notes | site 01 tab; version from `SERVICE_VERSION`; notes rewritten | +| 17 | 272 | REST at the edge with net/http | code, notes | site 02 REST tab (excerpt, `place` handler); caption and notes follow the excerpt | +| 18 | 273 | gRPC for internal calls | code, notes | proto from the example, server from the site 02 gRPC tab (embedded `UnimplementedInventoryServer`, generated getters); notes rewritten | +| 24 | 279 | A composing resolver in gqlgen | code, stack, notes | site 03 tab / example 03 (`Orders`, `attachStock`, one batched gRPC call); graphql-go removed from caption and notes | +| 29 | 284 | CQRS in practice | code, voice | topic `order.created` → `order.placed` (code twice); `SEPARATE` → `separate` | +| 32 | 287 | The outbox write | code, notes | site 04 tab; notes rewritten (unit 501 superseded) | +| 36 | 291 | Producing and consuming with franz-go | code, notes | site 05 tab with the example's group `notification-group`; notes rewritten (unit 502 superseded) | +| 43 | 298 | A windowed aggregation with goka | code, notes | site 06 tab / example 06 (two-stage group, `revenue.by-merchant`); Faust and "Python" removed from code and notes | +| 55 | 310 | Gate breaking changes in CI | code, notes | Apicurio 3.3 answers 400 (was 409); site 09 tab | +| 63 | 318 | Auto-injection vs. instrumentation | stack | bullet: Go OTel API names (`tracer.Start`, `span.SetAttributes`, `span.AddEvent`) | +| 64 | 319 | Instrument net/http with OpenTelemetry | code, notes | site 11 tab; span `reserve-stock` as in example 11; notes: context injected into record headers by the propagator | +| 66 | 321 | Baggage in practice | code, notes | site 11 tab key `tenant.id`; caption and notes | +| 78 | 333 | Valet key — scoped, time-bound, op-restricted | stack, code, notes | decision 6: title (was "S3 pre-signed URL …"), chip `go · net/http + golang-jwt v5`, code = example 12 `valetClaims`/`mintValet` + the `verifyValet` parse options (22 lines, 93 columns), notes rewritten (HS256 only, exp required, issuer, res/op/exp match; object-store pre-signed URLs named as the native equivalent); minio-go removed | +| 84 | 339 | Bulkhead | stack | notes: "x/sync/semaphore (Python)" → "buffered channels per tenant (Go)" | +| 87 | 342 | Producer mints claim, consumer fetches payload | stack, code, notes | decision 6: chip `go · franz-go + golang-jwt v5`; code = the site 12 Go claim-check tab shape (generic encrypted `store`, franz-go `ProduceSync`, topic `order.documents`) with the token from example 12's `mintValet`; notes rewritten (ticket lifetime vs consumer lag); minio-go removed | +| 85 | 340 | Per-tenant bulkheads | code, notes | buffered-channel bulkhead (site 12 tab, example 12); chip `go · buffered channel`; notes rewritten | +| 106 | 361 | Media-type versioning | stack | notes: "hand-building dicts" → "building maps by hand" | +| 110 | 365 | Deprecating a field in gqlgen | code, notes | SDL comments use `#` (the box had `//` inside SDL); site 15 tab field shape; notes rewritten | +| 113 | 368 | A WebSocket endpoint in Go | code, voice, notes | site 16 tab; caption rewritten (unit 511); notes rewritten | +| 117 | 372 | Binary framing with protobuf | code, notes | box held Python code: now envelope.proto + the site 16 Go `push`; chip `protobuf + go · coder/websocket` | +| 122 | 377 | A persisted saga state machine | code | site 17 tab; statuses RUNNING / COMPENSATING / COMPLETED as in example 17 | +| 124 | 379 | Running compensations in reverse | code, notes | terminal status `FAILED` → `COMPENSATED` (example 17); notes rewritten | +| 126 | 381 | Carrying context forward | stack | notes: `step_index` → `StepIndex`, `shipping.book` → `shipping.Book` | +| 130 | 385 | REST errors with net/http | code, notes | site 18 tab (unknown errors become a 500 instead of being dropped) | +| 132 | 387 | gRPC errors with rich status details | notes | Python grpcio-status wording replaced by the Go `status`/`errdetails` description | +| 134 | 389 | Kafka retry + DLQ with franz-go | code, notes | site 18 tab (retry and DLQ headers, traceId header); notes rewritten | +| 136 | 391 | GraphQL field errors with gqlgen | code, notes | site 18 tab (adds `Path: graphql.GetPath(ctx)`); caption and notes | +| 146 | 401 | Ports & adapters in code | code, notes | example 19 domain (`Save(ctx, *Order)`, `Publish(OrderPlaced)`, `PlaceOrder.Execute`) | +| 164 | 419 | Graceful shutdown in Go | code, notes | site 21 tab; caption without the uvicorn flag; notes without lifespan | +| 187 | 442 | Go + gopher-lua: routing by business rules | notes | durable-rules / Rete / Python description replaced (unit 528 superseded) | +| 188 | 443 | The rules, externalised | code, notes | site 22 Lua tab (`route(order, ctx)` returning a list); caption and notes (unit 529 superseded) | +| 189 | 444 | The dispatcher, in Go | code, notes | site 22 Go dispatcher (excerpt); notes describe the pooled sandboxed `LState` | +| 206 | 461 | Content-based routing during migration | code, notes | site 24 tab (`http.Client` with a 5 s timeout, as example 24); chip `go · net/http` | +| 207 | 462 | Decorating collaborator | stack | notes: "Common Python implementations" → "A common Go implementation" | +| 213 | 468 | Cache-aside in Go | notes | redis-py idioms (`from_url`, try/except) replaced | +| 215 | 470 | Read-through in Go | stack | caption `.get()` → `Get`; notes `products.Get(ctx, pid)` | +| 219 | 474 | Write-around in Go | voice | code comment without "deliberately NOT" | +| 221 | 476 | Write-back in Go | stack | notes: goroutine on a ticker, `CopyFrom` (was PERIOD_S / execute_many) | +| 223 | 478 | Refresh-ahead in Go | stack | notes: `REFRESH_BEFORE_S` → `refreshBefore` (2) | +| 237 | 492 | Timeouts + deadlines | voice | notes (unit 555): `context.WithTimeout` replaces `goroutines.wait_for`; "for free" removed | +| 239 | 494 | Retry with backoff + jitter | code, notes | cenkalti/backoff v4 API → v7 (`backoff.Retry(ctx, op, WithBackOff, WithMaxTries)`); chip `v7` | +| 241 | 496 | Circuit breaker + fallback | code, notes | sony/gobreaker v1 → v2 generic API (`NewCircuitBreaker[Receipt]`); chip `v2` | +| 245 | 500 | Graceful shutdown (App. M) | stack | notes: Python lifespan wording replaced | +| 253 | 508 | Evaluating a flag | code | `flagd.NewProvider` returns `(provider, error)` (the box did not compile) | +| 96 | 351 | Glossary 1 | stack, voice | unit 507: go-playground/validator row → struct tags; four em dashes → colons | +| 97 | 352 | Glossary 2 | stack | unit 508: no WSGI/ASGI or GIL; goroutine described on its own terms | +| 168 | 423 | Appendix I divider (notes) | voice | unit 526: "actually" removed | +| 215 | 470 | Read-through (notes) | positional | unit 544: "the wrapper above" → "the wrapper on this slide" | + +## Code map (every code slide) + +Status: `replaced` = the box now carries the named source (excerpt allowed); `matches` = already agrees with the +source on library, API and identifiers (token fixes listed); `illustrative-ok` = no Go codetab or example covers it; +API checked against the locked versions. + +| Slide | sldId | Title | Chip | Site codetab | Example file | Status | +|---|---|---|---|---|---|---| +| 14 | 269 | A 12-factor net/http service | go · net/http | 01 "A twelve-factor service" | 01-cloud-native-principles/go/cmd/server/main.go | replaced | +| 17 | 272 | REST at the edge | go · net/http | 02 "REST at the edge" | 02-communications/go/order-service/cmd/server/main.go | replaced | +| 18 | 273 | gRPC for internal calls | protobuf + go · grpc | 02 "gRPC for internal calls" | 02-communications/go/proto/inventory.proto, inventory-service/cmd/server/main.go | replaced | +| 21 | 276 | Resilience as configuration | yaml · istio | — | — | owner: shared-code | +| 24 | 279 | A composing resolver in gqlgen | go · gqlgen | 03 "The resolver makes it concrete" | 03-composition/go/gateway/graph/schema.resolvers.go, resolver.go | replaced | +| 29 | 284 | CQRS in practice | go · net/http + pgx + franz-go | 04 (outbox write side) | 04-data/go/order-service/cmd/server/main.go | matches (topic fixed) | +| 32 | 287 | The outbox write | go · pgx + Debezium | 04 "The code, in your language" | 04-data/go/order-service/cmd/server/main.go | replaced | +| 36 | 291 | Producing and consuming | go · franz-go | 05 "Producing and consuming" | 05-event-driven/go/{order-service,notification-consumer} | replaced | +| 43 | 298 | Windowed aggregation | go · goka | 06 "A windowed aggregation" | 06-stream-processing/go/stream-processor/cmd/processor/main.go | replaced | +| 45 | 300 | Scale on lag and RPS | yaml · KEDA | — | — | owner: shared-code (its comment still says "scale FastAPI") | +| 55 | 310 | Gate breaking changes in CI | go · net/http | 09 "register a new version" | 09-api-registry (config only; 400 per S8) | replaced | +| 64 | 319 | Instrument net/http with OpenTelemetry | go · OpenTelemetry | 11 "Auto-instrument your service" | 11-observability/go/order-service/cmd/server/main.go | replaced | +| 66 | 321 | Baggage in practice | go · OpenTelemetry baggage | 11 "How correlation works" | — | replaced | +| 78 | 333 | Valet key | go · net/http + golang-jwt v5 | 12 "The valet key" (site fix: aws-sdk-go-v2 → the example's golang-jwt ticket) | 12-security/go/cmd/server/main.go (`mintValet`, `verifyValet`) | replaced | +| 83 | 338 | OPA Gatekeeper ConstraintTemplate | yaml · OPA Gatekeeper | — | — | owner: shared-code | +| 85 | 340 | Per-tenant bulkheads | go · buffered channel | 12 "Bulkhead" | 12-security/go/cmd/server/main.go | replaced | +| 87 | 342 | Claim-check | go · franz-go + golang-jwt v5 | 12 "Claim-check" (generic `store`, franz-go) | — (token: 12-security/go `mintValet`) | replaced (sketch; first comment line says so) | +| 106 | 361 | Media-type versioning | go · net/http | — (no Go REST tab in 15) | — | illustrative-ok | +| 108 | 363 | Evolving a .proto safely | protobuf | — | — | owner: shared-code | +| 110 | 365 | Deprecating a field in gqlgen | graphql · gqlgen | 15 "GraphQL evolves" | — | replaced | +| 113 | 368 | A WebSocket endpoint | go · coder/websocket | 16 "A WebSocket endpoint" | 16-websockets/go/ws-server/cmd/server/main.go | replaced | +| 117 | 372 | Binary framing with protobuf | protobuf + go · coder/websocket | 16 "Binary framing" | — | replaced | +| 122 | 377 | A persisted saga state machine | go · pgx | 17 "Where the saga keeps its state" | 17-sagas/go/saga-orchestrator/cmd/server/main.go | replaced | +| 124 | 379 | Running compensations in reverse | go · pgx | 17 "Compensation unwinds a failed saga" | 17-sagas/go | matches (status fixed) | +| 126 | 381 | Carrying context forward | go · pgx | 17 | 17-sagas/go (`executeStep`) | matches | +| 130 | 385 | REST errors | go · net/http | 18 "REST" | 18-errors/go/order-service/cmd/server/main.go | replaced | +| 132 | 387 | gRPC errors with rich status | protobuf + go · grpc | 18 "gRPC" | 18-errors/go | matches | +| 134 | 389 | Kafka retry + DLQ | go · franz-go | 18 "Kafka" | — | replaced (header literals instead of `append`; the site uses `append` only to avoid Liquid braces) | +| 136 | 391 | GraphQL field errors | go · gqlgen | 18 "GraphQL" | — | replaced | +| 146 | 401 | Ports & adapters | go · ports & adapters | 19 "Hexagonal architecture" (stale, see site fixes) | 19-ddd-hexagonal/go/order-service/domain | replaced (from the example) | +| 164 | 419 | Graceful shutdown | go · net/http | 21 "The application side" | 21-graceful-shutdown/go/cmd/server/main.go | replaced | +| 165 | 420 | The Kubernetes side | yaml · Kubernetes | — | — | owner: shared-code | +| 175–177 | 430–432 | HAProxy, Nginx, Istio VirtualService | — | — | — | owner: shared-code | +| 182, 183, 185 | 437, 438, 440 | Istio canary, header-based, Envoy | — | — | — | owner: shared-code | +| 188 | 443 | The rules, externalised | lua · gopher-lua | 22 Lua tab | 22-l7-routing/go/router-service (its `routing.lua` is a threshold variant) | replaced | +| 189 | 444 | The dispatcher | go · gopher-lua + franz-go | 22 Go dispatcher | 22-l7-routing/go/router-service/cmd/server/main.go | replaced | +| 194 | 449 | Istio east/west | yaml · Istio east/west | — | — | owner: shared-code | +| 206 | 461 | Content-based routing | go · net/http | 24 "Content-based routing" | 24-monolith-to-microservices/go/router | replaced | +| 208 | 463 | A decorating collaborator | go · net/http + go-redis + franz-go | 24 "The decorating collaborator" | 24-monolith-to-microservices/go/decorator | matches | +| 213 | 468 | Cache-aside | go · go-redis + pgx | 25 "Cache-aside" | 25-caching/go/cache-service | matches | +| 215 | 470 | Read-through | go · go-redis (generics) | 25 "Read-through" | 25-caching/go/cache-service | matches (loader returns `(T, error)`; the site's adds a found flag) | +| 217 | 472 | Write-through | go · go-redis + pgx | 25 "Write-through" | 25-caching/go | matches | +| 219 | 474 | Write-around | go · go-redis + pgx | 25 "Write-around" | 25-caching/go | matches (comment reworded) | +| 221 | 476 | Write-back | go · go-redis + goroutine | 25 "Write-back" | 25-caching/go (`flusher`, `metric:dirty`) | matches | +| 223 | 478 | Refresh-ahead | go · go-redis + goroutine | 25 "Refresh-ahead" | 25-caching/go (`refresher`, `product:hot`) | matches | +| 237 | 492 | Timeouts + deadlines | go · net/http + context | 26 "Timeouts" (gRPC variant) | 26-failure-modes/go | illustrative-ok | +| 239 | 494 | Retry with backoff + jitter | go · cenkalti/backoff v7 | 26 "Retry" | 26-failure-modes/go/edge-service | replaced | +| 241 | 496 | Circuit breaker + fallback | go · sony/gobreaker v2 | 26 "Circuit breaker" (stale, see site fixes) | 26-failure-modes/go/edge-service | replaced (v2 API from the example) | +| 245 | 500 | Graceful shutdown (App. M) | go · net/http | 21 | 21-graceful-shutdown/go | matches | +| 253 | 508 | Evaluating a flag | go · OpenFeature + flagd | 27 "Evaluating a flag" (stale, see site fixes) | 27-feature-flags/go/flag-service | replaced | +| 254 | 509 | Targeting and rollout | json · flagd | — | — | owner: shared-code | +| 256 | 511 | A kill switch | go · OpenFeature | 27 "The kill switch" | 27-feature-flags/go | matches | +| 264, 266, 267, 269 | 519, 521, 522, 524 | Appendix O code | — | — | — | owner: planner (App O) | + +Counts for the 43 Go-owned code slides: replaced 26, matches 13, illustrative-ok 4. + +## Version strings left in the deck + +| String | Pin | +|---|---| +| Go 1.27.2 (cover notes) | Go 1.27.2 (modernization plan) | +| cenkalti/backoff v7 (chip) | v7.0.1 | +| sony/gobreaker v2 (chip) | v2.4.0 | +| golang-jwt v5 (chips) | v5.3.1 | +| Go 1.22 / 1.22+ (ServeMux, `PathValue`) | history: the release that added method patterns, not a pin | +| Apicurio `/apis/registry/v3` | API path; Apicurio 3.3.3 | + +## Unresolved + +- Resolved (user decision 6): the valet-key and claim-check slides follow `examples/12-security/go` (golang-jwt + v5 HS256 ticket); minio-go is gone from the deck, and the site's Go valet-key tab is fixed to the same code + (`site-fixes.md`, Go deck pass). Ops: `tools/amend_decisions.py` (tag `_amend`). +- Slide 1 notes (shared sentence) say "four appendices go deep"; the deck has many more. Fact fix belongs to the + shared voice pass. +- Slide 45 (sldId 300, shared-code owner) carries the comment "scale FastAPI on concurrent requests". +- set_code on sldId 377: the box has no comment line, so new comment lines take the code colour (deck_edit log). + +## Decision 7 amendment (vendor-neutral, 2026-10-12) + +Generator: `tools/amend_vendor.py` (ops tagged `_vendor`, inserted just before the agenda op; run after `amend_decisions.py`). +Find strings and counts come from the pre-decision text copy (`verify_all.py` output of the edit files at 39a5b1e). + +- Appendix S removed: the `clone_slide` ops `s0`–`s9` and every `set_*` op on `$s*` are dropped (71 ops); Appendix R (9 slides) stays. Agenda line "Appendix R · Example 05 on minikube" (same line count, so the agenda fit is unchanged); agenda notes, the title and overview notes and the R0/R1 notes no longer mention OpenShift Local ("carries over to any conformant Kubernetes cluster"). +- Shared rewrites (where present in this deck): 4C's notes and bullet (CloudTrail, "S3 bucket open"); event-driven tools notes ("to S3" → "to an object store"); valet-key concept notes (one generic implementation sentence instead of S3/Azure/GCS/STS); strangler-fig IdPs (Keycloak, Dex instead of Auth0, Azure AD); zero-trust edge (Pomerium or OAuth2 Proxy in front of Keycloak instead of Google IAP, Cloudflare Access, AWS Verified Access; licence note Pomerium Apache-2.0, OAuth2 Proxy MIT); claim-check concept notes; choosing-the-pattern notes; glossary SAS entry and note removed; Appendix I gateway list without Apigee; Appendix M references: gRFC A6 instead of the AWS Builders' Library. +- Go: the valet-key and claim-check ops stay in `tools/amend_decisions.py` (decision 6) and were made vendor-neutral there: claim URI `https://objects.internal/`, notes without S3/Azure SAS/GCS. No set_* ops from decision 7 on slides 78 and 87. +- Kept (not vendor services): Heroku as the origin of the twelve-factor methodology (history), Google SRE Book (reference), gRPC / Google.Protobuf / Google.Rpc (open-source libraries), Netflix concurrency-limits (Apache-2.0 library), Red Hat UBI and the Red Hat brand frame. +- Figures (`replace_picture`, `tools/vendor_figures.py`): sldIds 332, 335. The valet-key figure named S3, Azure SAS, GCS and STS; the zero-trust figure listed Google IAP, Cloudflare Access and AWS Verified Access; both become the neutral site figures (`12-valet-key.png` with "object store" labels, `12-zero-trust.png`). diff --git a/_plans/s12-manifest/Designing-Cloud-Native-APIs-Python.md b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Python.md new file mode 100644 index 00000000..8a7b33ac --- /dev/null +++ b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Python.md @@ -0,0 +1,154 @@ +# Designing-Cloud-Native-APIs-Python + +> Amended 2026-10-12 for user decision 7 (vendor-neutral): see the last section, which supersedes the rows here +> on the valet-key and claim-check slides, the cloud-SDK pins and Appendix S. + +272 slides at 8c86d61. Deck-specific edits live in `lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.json` +(built from the work files `rewrites/30-deck-Python.json` and `ops-30-deck/Designing-Cloud-Native-APIs-Python.json`); +shared voice, Appendix O, shared code, images, agenda and Appendices R and S come from the other parts of the +manifest (see `README.md`). + +## Summary + +- Stack: Poetry is gone (slides 10 and 14: pip with a full `==` freeze in `requirements.txt`, as every Python + example does); `@app.on_event("startup")` is replaced by a FastAPI lifespan (slides 221, 223); Faust becomes + faust-streaming 0.15.3 (slides 43, 186). +- Code: 11 Python code boxes rewritten or patched to match the site codetab and `examples/` (Pydantic 2 + `model_dump*`, the `order.placed` topic, Apicurio 3.3's 400, the saga statuses of example 17, the protobuf + envelope field numbers of Appendix C, the gRPC servicer of example 02, a Python syntax error on slide 78). +- Notes: notes rewritten where the code changed (14, 43) and token fixes (55, 123); the cover, overview and + thank-you notes no longer say "four appendices". +- Voice: 10 deck-exclusive units (2 ban `for free`, 1 ban `deliberately`, ALL-CAPS, em-dash pairs, `actually`, + `Easy`). + +## Changes + +| Slide | sldId | Title | Category | Change | +|---|---|---|---|---| +| 1 | 256 | Designing Cloud-Native APIs | notes | "four appendices go deep on…" → the appendices A–S, incl. Example 05 on minikube and OpenShift Local | +| 3 | 258 | What we'll build | notes | "four deep-dive appendices" → appendices A to S | +| 10 | 265 | Design-first | stack | bullet "Poetry for reproducible Python builds" → "pip with a fully pinned requirements.txt…" | +| 14 | 269 | A 12-factor FastAPI service | code, notes | code from `examples/01-cloud-native-principles/python/main.py` (Settings with defaults, version from SERVICE_VERSION, lifespan, readyz runs `SELECT 1`); notes follow | +| 17 | 272 | REST at the edge | voice | "for free" (unit 556) | +| 18 | 273 | gRPC for internal calls | code | servicer is the sync `grpc.server` servicer of `_docs/02` / example 02 (`remaining = stock.reserve(...)`) | +| 24 | 279 | A composing resolver in Strawberry | code | `ReserveStock_view` → `reserve_stock_view` (site tab) | +| 29 | 284 | CQRS in practice | code | `order.json()` → `order.model_dump_json()`; topic `order.created` → `order.placed` (code and comment) | +| 32 | 287 | The outbox write | code, voice | site tab (`model_dump_json`); notes "deliberately" (unit 557) | +| 36 | 291 | Producing and consuming with aiokafka | voice | "commit AFTER" (unit 558) | +| 43 | 298 | A windowed aggregation with faust-streaming | stack, code, notes | title, chip `python · faust-streaming`, code from `_docs/06` (memory store, `revenue.by-merchant`, `relative_to_stream`), notes rewritten | +| 55 | 310 | Gate breaking changes in CI | code, version | 409 → 400 (Apicurio 3.3.3) in code and notes | +| 78 | 333 | S3 pre-signed URL | code | parameter order (`body` before the `Depends` default; was a SyntaxError) | +| 96, 97 | 351, 352 | Glossary | voice | em-dash pairs in the WAL/TTL/LRU/SETEX and WSGI/ASGI entries | +| 106 | 361 | Media-type versioning | code | `order.dict()` → `order.model_dump()` (2) | +| 113 | 368 | A WebSocket endpoint | voice | caption "Easy to write — but note" (unit 561) | +| 117 | 372 | Binary framing with protobuf | code | envelope field numbers and `type="OrderPlaced"` as in `_docs/16`; `push()` from the site tab | +| 122 | 377 | A persisted saga state machine | code | status `DONE` → `COMPLETED` (example 17) | +| 123 | 378 | Compensating transactions (diagram) | notes | "persisted FAILED state" → "persisted COMPENSATED state" | +| 124 | 379 | Running compensations in reverse | code | status `FAILED` → `COMPENSATED` | +| 168 | 423 | Appendix I divider | voice | "actually" (unit 562) | +| 186 | 441 | Beyond the edge: routing inside the app | stack | "a Faust agent" → "a faust-streaming agent" | +| 187, 188 | 442, 443 | durable-rules | voice | em-dash pairs (units 563, 564) | +| 221 | 476 | Write-back | code | lifespan task instead of `@app.on_event("startup")` (site tab, 25-line excerpt) | +| 223 | 478 | Refresh-ahead | code | lifespan task instead of `@app.on_event("startup")` (site tab, read path shortened to the hot-list touch) | +| 237 | 492 | Timeouts + deadlines | voice | "for free" (unit 565) | +| 95 | 350 | Thank you | notes | "The four appendices" → "The appendices" | + +## Code map (every code slide) + +Status: `matches` (agrees with the site codetab and the example), `replaced` (rewritten or patched by this +manifest), `illustrative-ok` (no site tab or example for this exact code; checked against the locked stack +and current APIs), or the owner when another part of the manifest handles the slide. + +| Slide | sldId | Title | Chip | Site codetab | Example | Status | +|---|---|---|---|---|---|---| +| 14 | 269 | A 12-factor FastAPI service | python · FastAPI | `_docs/01` "A twelve-factor service" (stale: site fix 1) | `examples/01-cloud-native-principles/python/main.py` | replaced | +| 17 | 272 | REST at the edge with FastAPI | python · FastAPI | `_docs/02` "REST at the edge" | `examples/02-communications/python/order-service/main.py` | matches | +| 18 | 273 | gRPC for internal calls | protobuf + python · grpc | `_docs/02` "gRPC for internal calls" | `examples/02-communications/python/{proto/inventory.proto,inventory-service/main.py}` | replaced | +| 21 | 276 | Resilience as configuration | yaml · istio | — | — | owner: shared-code | +| 24 | 279 | A composing resolver in Strawberry | python · Strawberry | `_docs/03` "The resolver makes it concrete" | `examples/03-composition/python/gateway/main.py` (adds a DataLoader) | replaced | +| 29 | 284 | CQRS in practice | python · FastAPI + asyncpg + aiokafka | none (04 has only the outbox tab) | `examples/04-data/python/order-service/main.py` (outbox, `order.placed`) | replaced | +| 32 | 287 | The outbox write | python · asyncpg + Debezium | `_docs/04` "The code, in your language" | `examples/04-data/python/order-service/main.py` | replaced | +| 36 | 291 | Producing and consuming with aiokafka | python · aiokafka | `_docs/05` "Producing and consuming" | `examples/05-event-driven/python/*` | matches | +| 43 | 298 | A windowed aggregation | python · faust-streaming | `_docs/06` "A windowed aggregation" | `examples/06-stream-processing/python/stream-processor/main.py` | replaced | +| 45 | 300 | Scale on lag and on RPS | yaml · KEDA | — | — | owner: shared-code | +| 55 | 310 | Gate breaking changes in CI | python · Apicurio v3 | `_docs/09` registry client tab | `examples/09-api-registry/verify.sh` (400 + RuleViolationException) | replaced | +| 64 | 319 | Auto-instrument FastAPI | python · OpenTelemetry | `_docs/11` "Auto-instrument your service" | `examples/11-observability/python/*` | matches | +| 66 | 321 | Baggage in practice | python · OpenTelemetry baggage API | `_docs/11` "How correlation works" (shorter) | — | illustrative-ok (opentelemetry-api 1.45.1 baggage/context API) | +| 78 | 333 | S3 pre-signed URL | python · FastAPI + boto3 | `_docs/12` "The valet key…" (shorter, same `generate_presigned_url`) | `examples/12-security/python` (HMAC valet key, no S3) | replaced (syntax) | +| 83 | 338 | OPA Gatekeeper ConstraintTemplate | yaml · OPA Gatekeeper | — | — | owner: shared-code | +| 85 | 340 | Per-tenant bulkheads in asyncio | python · asyncio + aiohttp | `_docs/12` "Bulkhead" (Semaphore per tenant) | `examples/12-security/python/order-service/main.py` (`_tenant_sem`) | illustrative-ok | +| 87 | 342 | Claim-check | python · boto3 + aiokafka | `_docs/12` "Claim-check" (store/token variant) | — | illustrative-ok | +| 106 | 361 | Media-type versioning | python · FastAPI | none (http/proto blocks only) | — | replaced (Pydantic 2) | +| 108 | 363 | Evolving a .proto safely | protobuf | — | — | owner: shared-code | +| 110 | 365 | Deprecating a field in Strawberry | python · Strawberry | `_docs/15` "GraphQL evolves" | — | matches | +| 113 | 368 | A WebSocket endpoint in FastAPI | python · FastAPI | `_docs/16` "A WebSocket endpoint" | `examples/16-websockets/python/ws-server/main.py` | matches | +| 117 | 372 | Binary framing with protobuf | protobuf + python | `_docs/16` envelope.proto + "Binary framing" tab | — (example 16 uses JSON frames) | replaced | +| 122 | 377 | A persisted saga state machine | python · asyncpg | `_docs/17` "Where the saga keeps its state" | `examples/17-sagas/python/saga-orchestrator/main.py` | replaced | +| 124 | 379 | Running compensations in reverse | python · asyncpg | `_docs/17` "Compensation unwinds" | same | replaced | +| 126 | 381 | Carrying context forward | python · asyncpg | — | same (context accumulated per step) | illustrative-ok | +| 130 | 385 | REST errors with FastAPI | python · FastAPI | `_docs/18` REST tab | `examples/18-errors/python/order-service` | matches | +| 132 | 387 | gRPC errors with rich status | python · grpc + grpcio-status | `_docs/18` gRPC tab | `examples/18-errors/python/inventory-service` | matches | +| 134 | 389 | Kafka retry + DLQ | python · aiokafka | `_docs/18` Kafka tab | — | matches | +| 136 | 391 | GraphQL field errors | python · Strawberry | `_docs/18` GraphQL tab | — | matches | +| 146 | 401 | Ports & adapters in code | python · ports & adapters | `_docs/19` "Hexagonal architecture" | `examples/19-ddd-hexagonal/python` | matches | +| 164 | 419 | Graceful shutdown in FastAPI / uvicorn | python · FastAPI + uvicorn | `_docs/21` "The application side" | `examples/21-graceful-shutdown/python` | matches | +| 165 | 420 | The Kubernetes side | yaml · Kubernetes | — | — | owner: shared-code | +| 175 | 430 | HAProxy | haproxy.cfg | — | — | owner: shared-code | +| 176 | 431 | Nginx | nginx.conf | — | — | owner: shared-code | +| 177 | 432 | Istio VirtualService | yaml | — | — | owner: shared-code | +| 182 | 437 | Canary | yaml | — | — | owner: shared-code | +| 183 | 438 | Header-based steering | yaml | — | — | owner: shared-code | +| 185 | 440 | Header injection | yaml | — | — | owner: shared-code | +| 188 | 443 | The rules, externalised | python · durable-rules | `_docs/22` rules tab | — (example 22 routes in plain code) | matches | +| 189 | 444 | The dispatcher, in FastAPI | python · FastAPI + aiokafka | `_docs/22` dispatcher tab | — | matches | +| 194 | 449 | Istio east/west | yaml | — | — | owner: shared-code | +| 206 | 461 | Content-based routing during migration | python · FastAPI + httpx | `_docs/24` routing tab (comments differ only) | `examples/24-monolith-to-microservices/python` | matches | +| 208 | 463 | A decorating collaborator | python · FastAPI + httpx + redis + aiokafka | `_docs/24` decorator tab (line wrap only) | same | matches | +| 213 | 468 | Cache-aside | python · FastAPI + redis.asyncio + Postgres | `_docs/25` cache-aside tab | `examples/25-caching/python` | matches | +| 215 | 470 | Read-through | python · read-through wrapper | `_docs/25` read-through tab (assignment style only) | same | matches | +| 217 | 472 | Write-through | python · write-through | `_docs/25` write-through tab (slide adds the "fast" variant) | same | matches | +| 219 | 474 | Write-around | python · write-around | `_docs/25` write-around tab (slide adds the invalidate variant) | same | matches | +| 221 | 476 | Write-back | python · asyncio background task | `_docs/25` write-back tab | same | replaced | +| 223 | 478 | Refresh-ahead | python · Redis sorted set | `_docs/25` refresh-ahead tab | same | replaced | +| 237 | 492 | Timeouts + deadlines | python · httpx + asyncio | `_docs/26` timeouts tab | `examples/26-failure-modes/python/edge-service/main.py` | matches | +| 239 | 494 | Retry with backoff + jitter | python · tenacity | `_docs/26` retry tab | same (hand-written retry) | matches | +| 241 | 496 | Circuit breaker + fallback | python · purgatory | `_docs/26` breaker tab | same (hand-written breaker) | matches | +| 245 | 500 | Graceful shutdown (App. H) | python · FastAPI lifespan | `_docs/26` / `_docs/21` | `examples/21-graceful-shutdown/python` | illustrative-ok (lifespan, current) | +| 253 | 508 | Evaluating a flag | python · FastAPI | `_docs/27` OpenFeature tab | `examples/27-feature-flags/python` (openfeature-sdk 0.10.0, provider-flagd 0.5.2) | matches | +| 254 | 509 | Targeting and rollout | json · flagd | — | — | owner: shared-code | +| 256 | 511 | A kill switch | python · FastAPI | `_docs/27` kill-switch tab | same | matches | +| 264 | 519 | Tests live in the request | json · Postman | — | — | owner: planner (App O) | +| 266 | 521 | Launch the service under test | bash · Python | — | — | owner: planner (App O) | +| 267 | 522 | Running Newman | bash | — | — | owner: planner (App O) | +| 269 | 524 | Newman in CI | yaml · GitHub Actions | — | — | owner: planner (App O) | + +Counts (the 43 Python-owned code slides): matches 24, replaced 14, illustrative-ok 5. Owned elsewhere: 17 +(shared-code 13, planner 4). + +## Version strings left in the deck + +None of the Python deck's own slides or notes name a library or runtime version after the edits (the code +uses names, not versions). `Apicurio v3` (chip, slide 55) agrees with the pin 3.3.3; `TLS 1.3` and +`Apache 2.0` are standards and licences, not pins. + +## Unresolved + +- Slide 13 image `42-twelve-factor-map-py.png` probably shows "Poetry" like the site's `01-factors-mapped` + figure; the image-audit part owns it (site fix 2 regenerates the site figure). +- The "four appendices" correction (slides 1, 3, 95 notes) is a fact shared with the other language decks; each + deck part must make the same change. +- Slides 85 (aiohttp sessions) and 87 (boto3 calls inside async code) stay as illustrations: neither library is + in the Python lock, both APIs are current, and the site teaches the same pattern with asyncio only. + +## Decision 7 amendment (vendor-neutral, 2026-10-12) + +Generator: `tools/amend_vendor.py` (ops tagged `_vendor`, inserted just before the agenda op; run after `amend_decisions.py`). +Find strings and counts come from the pre-decision text copy (`verify_all.py` output of the edit files at 39a5b1e). + +- Appendix S removed: the `clone_slide` ops `s0`–`s9` and every `set_*` op on `$s*` are dropped (71 ops); Appendix R (9 slides) stays. Agenda line "Appendix R · Example 05 on minikube" (same line count, so the agenda fit is unchanged); agenda notes, the title and overview notes and the R0/R1 notes no longer mention OpenShift Local ("carries over to any conformant Kubernetes cluster"). +- Valet key (sldId 333): title "Valet key — scoped, time-bound, op-restricted", chip `python · FastAPI + hmac (stdlib)`, `set_code` = `examples/12-security/python/order-service/main.py` (`_mint_valet`, `_verify_valet`, `/valet-key`), 22 lines, notes rewritten (HMAC or HS256 ticket the service mints and verifies; generic object store). Code map row: matches `examples/12-security` (condensed excerpt, first comment line says so). +- Claim check (sldId 342): chip `python · aiokafka + Example 12 valet key`, `set_code` = aiokafka producer/consumer with a generic `store` and `**_mint_valet(key, "GET", ttl=900)` in the claim, 18 lines; notes rewritten. Same lines as the site claim-check codetab after the site fix (`site-fixes.md`, decision-7 section). No example implements the claim check (illustrative, token from Example 12). +- Removed: boto3 (slides 78, 87); the S3 pre-signed URL, STS AssumeRole, Azure SAS, GCS framing. +- Shared rewrites (where present in this deck): 4C's notes and bullet (CloudTrail, "S3 bucket open"); event-driven tools notes ("to S3" → "to an object store"); valet-key concept notes (one generic implementation sentence instead of S3/Azure/GCS/STS); strangler-fig IdPs (Keycloak, Dex instead of Auth0, Azure AD); zero-trust edge (Pomerium or OAuth2 Proxy in front of Keycloak instead of Google IAP, Cloudflare Access, AWS Verified Access; licence note Pomerium Apache-2.0, OAuth2 Proxy MIT); claim-check concept notes; choosing-the-pattern notes; glossary SAS entry and note removed; Appendix I gateway list without Apigee; Appendix M references: gRFC A6 instead of the AWS Builders' Library. +- Kept (not vendor services): Heroku as the origin of the twelve-factor methodology (history), Google SRE Book (reference), gRPC / Google.Protobuf / Google.Rpc (open-source libraries), Netflix concurrency-limits (Apache-2.0 library), Red Hat UBI and the Red Hat brand frame. +- Figures (`replace_picture`, `tools/vendor_figures.py`): sldIds 332 (valet-key concept), 335 (zero-trust), 427 (Appendix I layered stack, Apigee). The valet-key figure named S3, Azure SAS, GCS and STS; the zero-trust figure listed Google IAP, Cloudflare Access and AWS Verified Access; both become the neutral site figures (`12-valet-key.png` with "object store" labels, `12-zero-trust.png`); the layered stack becomes the site `22-l7-layers.png`, as in the Go, C++ and .NET decks. diff --git a/_plans/s12-manifest/Designing-Cloud-Native-APIs-Quarkus.md b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Quarkus.md new file mode 100644 index 00000000..163d18a9 --- /dev/null +++ b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Quarkus.md @@ -0,0 +1,169 @@ +# Designing-Cloud-Native-APIs-Quarkus — S12 manifest + +> Amended 2026-10-12 for user decision 7 (vendor-neutral): see the last section, which supersedes the rows here +> on the valet-key and claim-check slides, the cloud-SDK pins and Appendix S. + +272 slides at 8c86d61. Content ops: `lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.json` +(assembled from the deck-exclusive voice rewrites and the content ops listed here; the shared voice, +Appendix O, image, language-agnostic code and R/S ops are documented in the README and their own +sections). + +## Summary + +- Version: Quarkus 3.35 (slide 1 notes) and Quarkus 3.32 (Appendix H code comment and notes) become + Quarkus 3.40.1 (LTS, Java 25). +- Stack: Poetry comparison dropped (slide 14 notes), "corollary to Faust" becomes faust-streaming + (slide 43 notes), RESTEasy Reactive becomes Quarkus REST (slide 130 notes). +- Code: 22 code boxes changed (21 `set_code`, one token replace in the Appendix H comment). The main drift was in Appendices K and L, where the deck showed + reactive Mutiny variants (ReactiveRedisDataSource, Panache Reactive) while the site and + `examples/25-caching/quarkus` use the blocking RedisDataSource; in slide 85, which used a + programmatic SmallRye API that does not exist (`FaultToleranceProvider.bulkhead`); in the gRPC + service base class (the example implements the generated Mutiny interface); in topic and + identifier names (`revenue.by-merchant`, artifact `order-placed`, saga states `COMPLETED` / + `COMPENSATED`); and in feature flags (the example registers the flagd provider itself). +- Notes: 8 notes rewritten where they described code that changed; 29 deck-exclusive voice and + positional rewrites (`rewrites/30-deck-Quarkus.json` in the work tree, folded into the deck file). + +## Changes + +| Slide | sldId | Title | Category | Change | +|---|---|---|---|---| +| 1 | 256 | Designing Cloud-Native APIs | version, voice | notes: "on Quarkus 3.35" → "on Quarkus 3.40.1 (LTS, Java 25)"; em-dash sentence rewritten | +| 14 | 269 | A 12-factor Quarkus service | code, stack, voice | `PgPool` → `Pool`, readiness runs `SELECT 1` with `executeAndAwait()`; notes drop the Poetry comparison and "for free" | +| 17 | 272 | REST at the edge with Quarkus REST | code, voice | box = site 02 codetab (blocking Quarkus REST, all lines in Red Hat Mono again); notes: Uni sentence describes the blocking method, "for free" rewritten | +| 18 | 273 | gRPC for internal service-to-service calls | code | `implements Inventory` (generated Mutiny interface) instead of `extends InventoryGrpc.InventoryImplBase`; notes token | +| 24 | 279 | A composing resolver in SmallRye GraphQL | code | box = site 03 codetab | +| 29 | 284 | CQRS in practice | code | write side blocking (`Order create`), read side `findByIdOptional`, channel `order-created` → topic `order.created`; notes token | +| 32 | 287 | The outbox write | voice | notes: "deliberately" removed | +| 36 | 291 | Producing and consuming with Reactive Messaging | code, voice, positional | channel `order-placed` bound to topic `order.placed` (comment), as the examples; notes: ALL-CAPS and "the next slide" rewritten | +| 43 | 298 | A windowed aggregation with Kafka Streams | code, stack | output topic `revenue.by-merchant` (site 06 codetab, example 06); notes: topic name, faust-streaming | +| 46 | 301 | Workflows and Jobs divider | voice | notes em dashes | +| 55 | 310 | Gate breaking changes in CI with Maven | code, version | plugin `3.3.3`, artifact `order-placed`, file `order-placed.avsc` | +| 85 | 340 | SmallRye Fault Tolerance bulkheads | code, notes | `@AsynchronousNonBlocking` for the Uni method; per-tenant semaphore bulkhead from site 12 / example 12; notes rewritten | +| 96, 97 | 351, 352 | Glossary | voice | em-dash definitions use colons; AOT mentions Mandrel | +| 110 | 365 | Deprecating a field in SmallRye GraphQL | voice | notes em dashes | +| 113 | 368 | A WebSocket endpoint with WebSockets Next | voice | caption: "Declarative and easy" → "Declarative" | +| 117 | 372 | Binary framing with protobuf | code, positional | `sendBinaryAndAwait(Buffer.buffer(...))` as site 16; codec comment; notes: codec sentence, "the next slide" | +| 122 | 377 | A persisted saga state machine | code, positional, voice | status `COMPLETED`; notes: "next-but-one slide" and "for free" | +| 124 | 379 | Running compensations in reverse | code | terminal status `COMPENSATED` | +| 126 | 381 | Carrying context forward with Quarkus Flow | voice | notes: ALL-CAPS IS | +| 130 | 385 | REST errors with Quarkus REST | stack | notes: RESTEasy Reactive → Quarkus REST | +| 146 | 401 | Ports & adapters in code | voice | notes em dashes | +| 164 | 419 | Graceful shutdown in Quarkus | version, voice, positional | code comment and notes: Quarkus 3.32 → 3.40.1; ALL-CAPS NOT, "the next slide" | +| 168 | 423 | Appendix I divider | voice | notes "actually" | +| 188, 189 | 443, 444 | Camel route / Drools rule | positional, voice | notes "on the next slide", "actually" | +| 204, 205 | 459, 460 | Strangler fig | voice | "earns its keep", ALL-CAPS NOT | +| 206 | 461 | Content-based routing during migration | code, notes | chip, box = site 24 codetab (the old route lines ran to 150 columns), caption, notes | +| 208 | 463 | A decorating collaborator in Quarkus | code, notes | chip, box = site 24 codetab (blocking RedisDataSource), notes | +| 209 | 464 | Choosing the right migration pattern | voice | "genuinely" | +| 211 | 466 | Five things a cache earns you | voice | notes "very", "actually" | +| 213 | 468 | Cache-aside in Quarkus with Redis | code, notes | chip, box = site 25 cache-aside, notes | +| 215 | 470 | Read-through with @CacheResult | code | chip, box = site 25 read-through (resource reduced to a comment) | +| 217 | 472 | Write-through in Quarkus | code, notes | chip, box = site 25 write-through, caption, notes | +| 219 | 474 | Write-around in Quarkus | code, notes | chip, box = site 25 write-around, notes | +| 221 | 476 | Write-back with @Scheduled | code, notes | chip, box = site 25 write-back, notes | +| 222 | 477 | Refresh-ahead | voice | notes "actually" | +| 223 | 478 | Refresh-ahead in Quarkus | code, notes | chip, box = site 25 refresh-ahead, notes (also removes the positional "below") | +| 253 | 508 | Evaluating a flag | code | provider registered at StartupEvent, `OpenFeatureAPI.getInstance().getClient()`, SDK 1.23.0 / flagd 0.14.2, as example 27 | + +## Code map + +Status: `matches` (agrees with the site codetab and the example on library, API and identifiers), +`replaced` (rewritten by this manifest to match), `illustrative-ok` (no site or example counterpart; +API checked for Quarkus 3.40.1), or owned elsewhere. + +| Slide | sldId | Title | Chip | Site codetab | Example | Status | +|---|---|---|---|---|---|---| +| 14 | 269 | A 12-factor Quarkus service | java · Quarkus + SmallRye Health | 01-cloud-native-principles §A twelve-factor service | 01/quarkus (custom /healthz, /readyz) | replaced (PgPool → Pool; site fix filed) | +| 17 | 272 | REST at the edge | java · Quarkus REST | 02-communications §REST at the edge | 01/quarkus OrderResource | replaced | +| 18 | 273 | gRPC for internal calls | protobuf + java · Quarkus gRPC | 02-communications §gRPC for internal calls | 02/quarkus InventoryService | replaced | +| 21 | 276 | Resilience as configuration | yaml · istio | | | owner: shared-code | +| 24 | 279 | A composing resolver | java · SmallRye GraphQL | 03-composition §The resolver makes it concrete | 03/quarkus gateway OrderGraphql | replaced | +| 29 | 284 | CQRS in practice | java · Quarkus REST + Panache + Reactive Messaging | none | none | illustrative-ok (after fix) | +| 32 | 287 | The outbox write | java · Hibernate Panache + Debezium | 04-data §The code, in your language | 04/quarkus OutboxResource | matches | +| 36 | 291 | Producing and consuming | java · SmallRye Reactive Messaging | 05-event-driven §Producing and consuming | 05/quarkus | replaced (channel order-placed; site fix filed) | +| 43 | 298 | A windowed aggregation | java · Quarkus Kafka Streams | 06-stream-processing §A windowed aggregation | 06/quarkus RevenueTopology | replaced | +| 45 | 300 | Scale on lag and on RPS | yaml · KEDA | | | owner: shared-code | +| 55 | 310 | Gate breaking changes in CI with Maven | xml · Maven + Apicurio | 09-api-registry (REST client tabs; no Maven tab) | 09 (curl, group orders, artifact order-placed) | replaced | +| 64 | 319 | Auto-instrument with Quarkus OpenTelemetry | java · Quarkus OpenTelemetry | 11-observability §Auto-instrument your service | 11/quarkus (OTLP 4317) | matches (site fix filed: Uni return) | +| 66 | 321 | Baggage in practice | java · Quarkus + OpenTelemetry baggage API | 11-observability §How correlation works (shorter) | none | illustrative-ok | +| 78 | 333 | S3 pre-signed URL | java · Quarkus REST + AWS SDK for Java v2 | 12-security §The valet key hands out direct access | 12/quarkus ValetKeyService (HMAC token) | matches (site and slide use the S3 presigner; the example uses HMAC tokens) | +| 83 | 338 | OPA Gatekeeper ConstraintTemplate | yaml · OPA Gatekeeper | | | owner: shared-code | +| 85 | 340 | SmallRye Fault Tolerance bulkheads | java · Quarkus + SmallRye Fault Tolerance | 12-security §Bulkhead | 12/quarkus BulkheadService | replaced | +| 87 | 342 | Producer mints claim, consumer fetches payload | java · Quarkus + quarkus-amazon-s3 + Reactive Messaging | 12-security §Claim-check (generic store) | none | illustrative-ok | +| 106 | 361 | Media-type versioning | java · Quarkus REST | none | none | illustrative-ok | +| 108 | 363 | Evolving a .proto safely | protobuf | | | owner: shared-code | +| 110 | 365 | Deprecating a field | java · SmallRye GraphQL | 15-appendix-b-versioning §GraphQL evolves | none | matches (record form of the same @Deprecated mechanism) | +| 113 | 368 | A WebSocket endpoint | java · Quarkus WebSockets Next | 16-appendix-c-websockets §A WebSocket endpoint | 16/quarkus WsEndpoint | matches | +| 117 | 372 | Binary framing with protobuf | protobuf + java | 16-appendix-c-websockets §Binary framing | 16/quarkus | replaced | +| 122 | 377 | A persisted saga state machine | java · Hibernate Panache | 17-appendix-d-sagas §Where the saga keeps its state | 17/quarkus SagaService | replaced | +| 124 | 379 | Running compensations in reverse | java · Hibernate Panache | 17-appendix-d-sagas §Compensation | 17/quarkus SagaService | replaced | +| 126 | 381 | Carrying context forward with Quarkus Flow | java · Quarkus Flow | none | none | illustrative-ok (io.quarkiverse.flow.Flow#descriptor() checked in quarkus-flow 1.2.0) | +| 130 | 385 | REST errors | java · Quarkus REST | 18-appendix-e-errors §REST | 18/quarkus | matches | +| 132 | 387 | gRPC errors with rich status | java · Quarkus gRPC | 18-appendix-e-errors §gRPC | 18/quarkus InventoryServiceImpl | matches (reactive form of the site's blocking tab) | +| 134 | 389 | Kafka retry + DLQ | java · SmallRye Reactive Messaging | 18-appendix-e-errors §Kafka | none | matches | +| 136 | 391 | GraphQL field errors | java · SmallRye GraphQL | 18-appendix-e-errors §GraphQL | none | matches | +| 146 | 401 | Ports & adapters in code | java · ports & adapters | 19-appendix-f-ddd-hexagonal §Hexagonal architecture | 19/quarkus | matches | +| 164 | 419 | Graceful shutdown in Quarkus | java · Quarkus | 21-appendix-h-shutdown §The application side | 21/quarkus | replaced (version in comment) | +| 165 | 420 | The Kubernetes side | yaml · Kubernetes | | | owner: shared-code | +| 175–177 | 430–432 | HAProxy / Nginx / Istio VirtualService | | | | owner: shared-code | +| 182, 183, 185 | 437, 438, 440 | Istio canary / header / Envoy | | | | owner: shared-code | +| 188 | 443 | A Camel route calling Drools | java · Quarkus + Camel | 22-appendix-i-l7-routing (line 360) | 22/quarkus | matches | +| 189 | 444 | A Drools rule | drl · Drools rules | 22-appendix-i-l7-routing (line 196) | none | matches | +| 194 | 449 | Istio east/west | yaml · Istio east/west | | | owner: shared-code | +| 206 | 461 | Content-based routing during migration | java · Quarkus REST + REST Client | 24-appendix-k §Content-based routing | 24/quarkus router | replaced | +| 208 | 463 | A decorating collaborator | java · Quarkus REST + REST Client + Redis + Reactive Messaging | 24-appendix-k §The decorating collaborator | 24/quarkus decorator | replaced | +| 213 | 468 | Cache-aside | java · Quarkus REST + Panache + Redis | 25-appendix-l §Cache-aside | 25/quarkus CacheResource | replaced | +| 215 | 470 | Read-through | java · Quarkus Cache + quarkus-redis-cache | 25-appendix-l §Read-through | 25/quarkus | replaced | +| 217 | 472 | Write-through | java · Quarkus REST + Panache + Redis | 25-appendix-l §Write-through | 25/quarkus | replaced | +| 219 | 474 | Write-around | java · Quarkus REST + Panache + Redis | 25-appendix-l §Write-around | 25/quarkus | replaced | +| 221 | 476 | Write-back | java · Quarkus + @Scheduled + Redis + Panache | 25-appendix-l §Write-back | 25/quarkus BackgroundTasks | replaced | +| 223 | 478 | Refresh-ahead | java · Quarkus + @Scheduled + Redis sorted set | 25-appendix-l §Refresh-ahead | 25/quarkus BackgroundTasks | replaced | +| 237 | 492 | Timeouts + deadlines | java · Quarkus + MicroProfile Fault Tolerance | 26-appendix-m §Timeouts | 26/quarkus | matches (reactive form) | +| 239 | 494 | Retry with backoff + jitter | java · Quarkus + MicroProfile Fault Tolerance | 26-appendix-m §Retry | 26/quarkus | matches | +| 241 | 496 | Circuit breaker + fallback | java · Quarkus + MicroProfile Fault Tolerance | 26-appendix-m §Circuit breaker | 26/quarkus CircuitBreaker | matches | +| 245 | 500 | Graceful shutdown (see App. H) | java · Quarkus + SmallRye Health | none (21 covers the config side) | 21/quarkus | illustrative-ok | +| 253 | 508 | Evaluating a flag | java · Quarkus | 27-appendix-n §Evaluating a flag | 27/quarkus OpenFeatureSetup, FlagResource | replaced (site fix filed) | +| 254 | 509 | Targeting and rollout live in config | json · flagd | | | owner: shared-code | +| 256 | 511 | A kill switch | java · Quarkus | 27-appendix-n §The kill switch | 27/quarkus | matches | +| 264, 266, 267, 269 | 519, 521, 522, 524 | Appendix O code slides | | | | owner: planner (App O) | + +Counts (Quarkus-specific code slides, 43): replaced 21, matches 16, illustrative-ok 6 (slide 29 after its fix); +shared-code 13 boxes and App O 4 boxes are owned elsewhere (not counted). + +## Version strings left in the deck + +| String | Where | Pin | +|---|---|---| +| Quarkus 3.40.1 (LTS, Java 25) | slide 1 notes | MP: Quarkus 3.40.1, Java 25 | +| Quarkus 3.40.1 | slide 164 code comment and notes | MP: Quarkus 3.40.1 | +| apicurio-registry-maven-plugin 3.3.3 | slide 55 | MP: Apicurio 3.3.3 | +| OpenFeature SDK 1.23.0, flagd provider 0.14.2 | slide 253 | MP S2 evidence: OpenFeature sdk 1.23.0, flagd 0.14.2 | +| AWS SDK for Java v2 | slides 78, 87 chips and notes | major label, no pin | +| Bean Validation annotations | slide 17 notes | no version | + +## Unresolved + +- Slide 1 notes still say "re-examine it from twelve angles, then four appendices go deep on + protocol internals, versioning, WebSockets and saga mechanics": the deck has thirteen sections and + appendices A to S. The sentence is shared by the language decks, so the voice/shared pass owns it. +- Slide 66 (baggage) uses the key `tenant_id`; the site snippet uses `tenant.id`. No example + exercises baggage, so the slide keeps `tenant_id` (caption and notes use it too). +- Slide 87 (claim check) uses S3 through quarkus-amazon-s3, the site uses a generic store; no + example implements the claim check. Kept as illustrative. +- The Apicurio v3 dry-run fix in `site-fixes.md` affects all six codetabs of + `_docs/09-api-registry.md`; it should be checked against a running 3.3.3 registry before the + `docs(site)` commit. + +## Decision 7 amendment (vendor-neutral, 2026-10-12) + +Generator: `tools/amend_vendor.py` (ops tagged `_vendor`, inserted just before the agenda op; run after `amend_decisions.py`). +Find strings and counts come from the pre-decision text copy (`verify_all.py` output of the edit files at 39a5b1e). + +- Appendix S removed: the `clone_slide` ops `s0`–`s9` and every `set_*` op on `$s*` are dropped (71 ops); Appendix R (9 slides) stays. Agenda line "Appendix R · Example 05 on minikube" (same line count, so the agenda fit is unchanged); agenda notes, the title and overview notes and the R0/R1 notes no longer mention OpenShift Local ("carries over to any conformant Kubernetes cluster"). +- Valet key (sldId 333): title "Valet key — scoped, time-bound, op-restricted", chip `java · Quarkus REST + javax.crypto (HmacSHA256)`, `set_code` = `examples/12-security/quarkus/.../ValetKeyService.java` (`mint`, `verify`), 22 lines, notes rewritten (HMAC or HS256 ticket the service mints and verifies; generic object store). Code map row: matches `examples/12-security` (condensed excerpt, first comment line says so). +- Claim check (sldId 342): chip `java · Quarkus + SmallRye Reactive Messaging`, `set_code` = Emitter/`@Incoming` on channels bound to `order.documents`, generic `ObjectStore`, ticket from `ValetKeyService.mint`, 24 lines (box held 31); notes rewritten. Same lines as the site claim-check codetab after the site fix (`site-fixes.md`, decision-7 section). No example implements the claim check (illustrative, token from Example 12). +- Removed: AWS SDK for Java v2, quarkus-amazon-s3 (slides 78, 87); the S3 pre-signed URL, STS AssumeRole, Azure SAS, GCS framing. +- Shared rewrites (where present in this deck): 4C's notes and bullet (CloudTrail, "S3 bucket open"); event-driven tools notes ("to S3" → "to an object store"); valet-key concept notes (one generic implementation sentence instead of S3/Azure/GCS/STS); strangler-fig IdPs (Keycloak, Dex instead of Auth0, Azure AD); zero-trust edge (Pomerium or OAuth2 Proxy in front of Keycloak instead of Google IAP, Cloudflare Access, AWS Verified Access; licence note Pomerium Apache-2.0, OAuth2 Proxy MIT); claim-check concept notes; choosing-the-pattern notes; glossary SAS entry and note removed; Appendix I gateway list without Apigee; Appendix M references: gRFC A6 instead of the AWS Builders' Library. +- Kept (not vendor services): Heroku as the origin of the twelve-factor methodology (history), Google SRE Book (reference), gRPC / Google.Protobuf / Google.Rpc (open-source libraries), Netflix concurrency-limits (Apache-2.0 library), Red Hat UBI and the Red Hat brand frame. +- Figures (`replace_picture`, `tools/vendor_figures.py`): sldIds 332, 335, 427 (as Python). The valet-key figure named S3, Azure SAS, GCS and STS; the zero-trust figure listed Google IAP, Cloudflare Access and AWS Verified Access; both become the neutral site figures (`12-valet-key.png` with "object store" labels, `12-zero-trust.png`); the layered stack becomes the site `22-l7-layers.png`, as in the Go, C++ and .NET decks. diff --git a/_plans/s12-manifest/Designing-Cloud-Native-APIs-Spring.md b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Spring.md new file mode 100644 index 00000000..f71d4c34 --- /dev/null +++ b/_plans/s12-manifest/Designing-Cloud-Native-APIs-Spring.md @@ -0,0 +1,181 @@ +# Designing-Cloud-Native-APIs-Spring — S12 changes + +> Amended 2026-10-12 for user decision 7 (vendor-neutral): see the last section, which supersedes the rows here +> on the valet-key and claim-check slides, the cloud-SDK pins and Appendix S. + +Deck: `lgtm-presentation/Designing-Cloud-Native-APIs-Spring.pptx` (276 slides at 8c86d61; sldIds run 4 ahead of +the Python deck from Appendix E on). Edit file: `lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.json` +(assembled from the deck-exclusive voice rewrites and the content ops listed here; shared voice, Appendix O, +language-agnostic code, images, agenda and Appendices R and S come from the other parts of the manifest). + +## Summary + +- Versions: Spring Boot 4.0 → 4.1.1, Spring Framework 7.0 → 7.0.9, Java 25, Jackson 3 (cover notes); "Spring 4.0 + idiomatic style" and "Spring 6 RestClient" lose their stale version labels; the Temporal "1.35 in April 2026" + claim is dropped (the SDK is at 1.40.0). +- Stack: gRPC on plain grpc-java (the examples do not use grpc-spring-boot-starter); outbox on `JdbcClient` + + Jackson 3 (example 04); Kafka Streams topology as in example 06 (`JacksonJsonSerde`, `revenue.by-merchant`); + tracing by the OpenTelemetry Java agent 2.32.0 with `@WithSpan` and the OpenTelemetry Baggage API (Micrometer + Tracing is not used by the examples); Redis cache values with `RedisSerializer.json()` (Jackson 3) instead of + `GenericJackson2JsonRedisSerializer`; topic `order-placed` → `order.placed`; Reactor Kafka noted as + discontinued; Temporal `Saga.Options.Builder()`. +- Appendix D (user decision 6, 2026-10-12): the Spring Statemachine slides now present the hand-rolled saga + orchestrator that `examples/17-sagas/spring-boot` runs (sagas row + saga_log, `JdbcClient`, + `TransactionTemplate`, `SELECT ... FOR UPDATE`, resume at startup). Spring Statemachine appears once, as + archived upstream, and is not offered as an option. Ops: `tools/amend_decisions.py` (tag `_amend`). +- Saga status names follow example 17 (`COMPLETED`, `COMPENSATED`). +- Voice: 37 deck-exclusive units rewritten (ban 5, positional 2, watch 30); none kept. + +## Change table + +| Slide | sldId | Title | Category | Change | +|---|---|---|---|---| +| 1 | 256 | Designing Cloud-Native APIs | version, notes | notes rewritten: Spring Boot 4.1.1, Framework 7.0.9, Java 25, Jackson 3, grpc-java, Kafka Streams, OTel Java agent, appendix list incl. R and S | +| 11 | 266 | The twelve-factor app, revisited | voice | subtitle em-dash pair (unit 600) | +| 14 | 269 | A 12-factor Spring Boot service | voice, version | notes: "Spring 4.0 idiomatic style … trivially testable" (unit 601) | +| 17 | 272 | REST at the edge with Spring Web | voice, version | notes: units 602, 603 ("for free"); "the Spring 4.0 idiomatic style" → "the idiomatic Spring style" | +| 18 | 273 | gRPC for internal service-to-service calls | stack, code, notes | chip `protobuf + java · Spring Boot 4 + grpc-java`; code = proto + example 02 grpc-java server; notes rewritten (Spring Integration 7.1 gateway mention removed) | +| 24 | 279 | A composing resolver in Spring for GraphQL | code | code = site 03 tab (`inventory.remaining(...)`) + `@BatchMapping` comment | +| 29 | 284 | CQRS in practice | positional | notes "from the next two slides" → "shown in the outbox write" | +| 32 | 287 | The outbox write | stack, code, notes, voice | chip `java · Spring Boot 4 + JdbcClient + Debezium CDC`; code = example 04 excerpt; notes rewritten (unit 604 also rewritten first) | +| 36 | 291 | Producing and consuming with Spring Kafka | code, stack, voice | `"order-placed"` → `"order.placed"` (2); notes: Reactor Kafka discontinued; unit 605 (AFTER) | +| 43 | 298 | A windowed aggregation with Kafka Streams | code, notes | code = example 06 excerpt; notes rewritten (window.seconds, BigDecimal, HALF_UP, revenue.by-merchant) | +| 57, 72 | 312, 327 | (metadata, security notes) | voice | "actually" (units 606, 607) | +| 64 | 319 | Auto-instrument with the OpenTelemetry Java agent | stack, code, notes | title, chip, code (site 11 tab), caption and notes: OTel Java agent 2.32.0 + `@WithSpan` | +| 66 | 321 | Baggage in practice | stack, code, notes | chip, code (OTel Baggage API filter + consumer), caption, notes | +| 85 | 340 | Resilience4j bulkheads in Spring Boot | voice, stack | notes unit 608: resilience4j-spring-boot4 starter named, em dashes | +| 96, 97 | 351, 352 | Glossary of acronyms | voice | em-dash pairs (units 609, 610) | +| 110 | 365 | Deprecating a field in Spring for GraphQL | voice | units 611 (IS), 612 (simply), 613 | +| 113 | 368 | A WebSocket endpoint with Spring WebSocket | voice | caption "Declarative and easy" (unit 614) | +| 117 | 372 | Binary framing with protobuf | voice | unit 615 ("critically for what comes next") | +| 46 | 301 | Workflows and Jobs divider | stack | notes: Appendix D sets the hand-rolled orchestrator (Example 17) beside Temporal; Spring State Machine dropped | +| 94 | 349 | References | stack | bullet: "Spring State Machine" dropped from the docs list | +| 121 | 376 | Where a saga keeps its state | stack | figure (names "Temporal / State Machine") → `17-saga-state.png` (site figure); notes: Example 17 runs the first option | +| 122 | 377 | A persisted saga state machine | code, voice | `setStatus("DONE")` → `"COMPLETED"`; notes unit 616 ("remember that … slide"); notes: "Temporal, in this appendix, … Example 17 runs the hand-rolled version with JdbcClient" | +| 124 | 379 | Running compensations in reverse | code | `setStatus("FAILED")` → `"COMPENSATED"` | +| 125 | 380 | When step B needs data only step A produced | stack | notes: Spring State Machine sentence → Example 17 stores each step's result in the JSONB context | +| 126 | 381 | Two ways to make a saga durable | stack | title "Two ways to keep a saga durable"; bullets and notes rewritten: hand-rolled orchestrator (Example 17) vs Temporal; one bullet and one notes paragraph state that Spring Statemachine is archived upstream and not offered (replaces the unit 617 and archived-repository ops) | +| 127 | 382 | Temporal: your saga is plain Java | voice | unit 618 | +| 128 | 383 | Temporal saga workflow + activity | code | `new Saga.Options()` → `new Saga.Options.Builder().build()` | +| 129 | 384 | The hand-rolled orchestrator: one row, one log, no extra cluster | stack | title, caption and notes rewritten; figure `63-appD-statemachine` → `17-sagas-architecture.png` (`examples/17-sagas/architecture.svg`, cairosvg 2.9.1, 1400 px); replaces the unit 619/620 and archived-repository ops | +| 130 | 385 | The hand-rolled orchestrator: one step per transaction | code, stack | title, chip `java · Spring Boot 4 + JdbcClient`, caption, notes; code = `SagaService.advance()` excerpt (22 lines, 94 columns; was 35 lines over the box capacity of 26, with lines over 95 columns) | +| 134 | 389 | REST errors with Spring 7 ProblemDetail | voice | unit 621 ("deliberately absent") | +| 136 | 391 | gRPC errors with rich status details | stack | chip `java · grpc-java + StatusProto` | +| 150 | 405 | Ports & adapters in code | voice | units 622, 623 | +| 172 | 427 | Appendix I divider notes | voice | unit 624 ("actually") | +| 192, 193 | 447, 448 | Camel route / Drools rule | voice | units 625, 626 | +| 202 | 457 | Appendix K divider | version | notes "RestClient (Spring 6+)" → "RestClient" | +| 208, 209 | 463, 464 | Strangler fig slides | voice | units 627 (earns its keep), 628 (NOT) | +| 210 | 465 | Content-based routing during migration | version, voice | code comment, caption and notes drop "Spring 6"; unit 629 (powerful) | +| 213 | 468 | Modular monolith with decomposed DBs | voice | units 630 (genuinely), 631 (powerful) | +| 217 | 472 | Cache-aside in Spring Boot | positional, voice | notes "(write-through, two slides later)" → "(write-through)"; "OUTSIDE" lowercased | +| 219 | 474 | Read-through with @Cacheable | stack, code, notes | code: `RedisSerializer.json()` (Jackson 3), 25 lines (fits the box); notes name the serializer; unit 632 | +| 222 | 477 | Write-around | voice | unit 633 | +| 223 | 478 | Write-around in Spring Boot | code, notes | code = site 25 tab (single write path); notes rewritten | +| 226, 227 | 481, 482 | Refresh-ahead | voice, positional | units 634 (BEFORE), 635 ("TTL is below" → "drops under") | + +## Code map (every code slide) + +Status: `matches` (agrees with the site codetab and, where one exists, the example), `replaced` (set_code in this +file), `illustrative-ok` (no site or example counterpart, or the example is a minimal variant; API checked current +for Spring Boot 4.1.1 / Framework 7.0.9 / Jackson 3), `owner: shared-code`, `owner: planner (App O)`. + +| Slide | sldId | Title | Chip | Site codetab | Example file | Status | +|---|---|---|---|---|---|---| +| 14 | 269 | A 12-factor Spring Boot service | java · Spring Boot 4 + Actuator | 01 "A twelve-factor service" | examples/01-cloud-native-principles/spring-boot (minimal variant) | matches | +| 17 | 272 | REST at the edge with Spring Web | java · Spring Boot 4 + Spring Web | 02 "REST at the edge" | examples/02-communications/spring-boot/order-service/OrderController.java (minimal) | matches | +| 18 | 273 | gRPC for internal calls | protobuf + java · Spring Boot 4 + grpc-java | 02 "gRPC for internal calls" (stale, site fix) | examples/02-communications/spring-boot/inventory-service/InventoryServiceApplication.java | replaced | +| 21 | 276 | Resilience as configuration | yaml · istio | — | — | owner: shared-code | +| 24 | 279 | A composing resolver | java · Spring for GraphQL | 03 "The resolver makes it concrete" | examples/03-composition/spring-boot (gateway) | replaced | +| 29 | 284 | CQRS in practice | java · Spring Boot 4 + Spring Data JPA + Spring Kafka | — | — | illustrative-ok | +| 32 | 287 | The outbox write | java · Spring Boot 4 + JdbcClient + Debezium CDC | 04 "The code, in your language" (stale, site fix) | examples/04-data/spring-boot/order-service/OrderController.java | replaced | +| 36 | 291 | Producing and consuming with Spring Kafka | java · Spring Boot 4 + Spring Kafka | 05 "Producing and consuming" | examples/05-event-driven/spring-boot (order.placed) | replaced (topic) | +| 43 | 298 | A windowed aggregation with Kafka Streams | java · Spring Kafka Streams | 06 "A windowed aggregation" (stale, site fix) | examples/06-stream-processing/spring-boot/stream-processor/RevenueTopology.java | replaced | +| 45 | 300 | Scale on lag and on RPS | yaml · KEDA | — | — | owner: shared-code | +| 55 | 310 | Gate breaking changes in CI with Maven | xml · Maven + Apicurio | 09 uses RestClient + dry run | examples/09-api-registry (config only) | illustrative-ok (apicurio-registry-maven-plugin 3.x documents `test-update`, `/apis/registry/v3`) | +| 64 | 319 | Auto-instrument with the OpenTelemetry Java agent | java · Spring Boot 4 + OpenTelemetry Java agent | 11 "Auto-instrument your service" | examples/11-observability/spring-boot (agent 2.32.0) | replaced | +| 66 | 321 | Baggage in practice | java · Spring Boot 4 + OpenTelemetry API | 11 "How correlation works" (Baggage API) | — | replaced | +| 78 | 333 | S3 pre-signed URL | java · Spring Boot 4 + Spring Security + AWS SDK for Java v2 | 12 "The valet key" (same API) | examples/12-security ValetKeyService (HMAC variant) | illustrative-ok | +| 83 | 338 | OPA Gatekeeper ConstraintTemplate | yaml · OPA Gatekeeper | — | — | owner: shared-code | +| 85 | 340 | Resilience4j bulkheads | java · Spring Boot 4 + Resilience4j | 12 "Bulkhead" (programmatic Resilience4j) | examples/12-security BulkheadService (Semaphore variant) | illustrative-ok (resilience4j-spring-boot4 2.4.0 exists) | +| 87 | 342 | Claim-check producer/consumer | java · Spring Boot 4 + AWS SDK for Java v2 + Spring Kafka | 12 "Claim-check" | — | illustrative-ok | +| 106 | 361 | Media-type versioning | java · Spring Boot 4 + Spring Web | — | — | illustrative-ok (Framework 7 API versioning mentioned correctly) | +| 108 | 363 | Evolving a .proto safely | protobuf | — | — | owner: shared-code | +| 110 | 365 | Deprecating a field | graphql + java · Spring for GraphQL | 15 "GraphQL evolves" | — | illustrative-ok | +| 113 | 368 | A WebSocket endpoint | java · Spring Boot 4 + spring-boot-starter-websocket | 16 "A WebSocket endpoint" | examples/16-websockets/spring-boot/ws-server | matches | +| 117 | 372 | Binary framing with protobuf | protobuf + java · Spring WebSocket | 16 "Binary framing" + "Receiving and dispatching" | — | matches | +| 122 | 377 | A persisted saga state machine | java · Spring Data JPA | 17 "Where the saga keeps its state" | examples/17-sagas/spring-boot/saga-orchestrator (JdbcClient variant; statuses) | replaced (status) | +| 124 | 379 | Running compensations in reverse | java · Spring Data JPA | 17 "Compensation unwinds a failed saga" | examples/17-sagas (statuses) | replaced (status) | +| 128 | 383 | Temporal saga workflow + activity | java · io.temporal:temporal-sdk + Spring Boot | — | — | replaced (Saga.Options builder); otherwise illustrative-ok | +| 130 | 385 | The hand-rolled orchestrator: one step per transaction | java · Spring Boot 4 + JdbcClient | 17 "Where the saga keeps its state" | examples/17-sagas/spring-boot/saga-orchestrator/.../service/SagaService.java (`advance`) | replaced (excerpt: `lockRow`, `saveProgress`, `logStep`, `markStatus` name the condensed JdbcClient calls, as the first comment line says) | +| 134 | 389 | REST errors with Spring 7 ProblemDetail | java · Spring Boot 4 + Spring 7 ProblemDetail | 18 "REST" | examples/18-errors/spring-boot/order-service | matches | +| 136 | 391 | gRPC errors with rich status details | java · grpc-java + StatusProto | 18 "gRPC" | examples/18-errors/spring-boot/inventory-service (plain grpc-java) | matches (chip replaced) | +| 138 | 393 | Kafka retry + DLQ | java · Spring Boot 4 + Spring Kafka | 18 "Kafka" | — | matches | +| 140 | 395 | GraphQL field errors | java · Spring for GraphQL | 18 "GraphQL" | — | matches | +| 150 | 405 | Ports & adapters in code | java · ports & adapters in Spring Boot | 19 "Hexagonal architecture" | examples/19-ddd-hexagonal/spring-boot | matches | +| 168 | 423 | Graceful shutdown in Spring Boot | java · Spring Boot 4 | 21 "The application side" | examples/21-graceful-shutdown/spring-boot | matches | +| 169 | 424 | The Kubernetes side | yaml · Kubernetes | — | — | owner: shared-code | +| 179–189, 198 | 434, 435, 436, 441, 442, 444, 453 | HAProxy, nginx, Istio | haproxy.cfg / nginx.conf / yaml | — | — | owner: shared-code | +| 192 | 447 | A Camel route calling Drools | java · Spring Boot 4 + Apache Camel | 22 "Routing inside the app" (Camel) | examples/22-l7-routing/spring-boot (minimal router) | matches | +| 193 | 448 | A Drools rule | drl · Drools rules | 22 "Routing inside the app" (drl) | — | matches | +| 210 | 465 | Content-based routing during migration | java · Spring Boot 4 + Spring Web + RestClient | 24 "Content-based routing during migration" | examples/24-monolith-to-microservices/spring-boot/router | matches (comment label fixed) | +| 217 | 472 | Cache-aside with Redis | java · Spring Boot 4 + Spring Data JPA + Spring Data Redis | 25 "Cache-aside" | examples/25-caching/spring-boot (minimal) | matches | +| 219 | 474 | Read-through with @Cacheable | java · Spring Boot 4 + Spring Cache + Spring Data Redis | 25 "Read-through" | — | replaced | +| 223 | 478 | Write-around | java · Spring Boot 4 + Spring Cache + Spring Data Redis | 25 "Write-around" | — | replaced | +| 241 | 496 | Timeouts + deadlines | java · Spring Boot 4 + Resilience4j | 26 "Timeouts and deadline propagation" | examples/26-failure-modes (hand-rolled variant) | matches | +| 243 | 498 | Retry with backoff + jitter | java · Spring Boot 4 + Resilience4j | 26 "Retry" | — | matches | +| 245 | 500 | Circuit breaker + fallback | java · Spring Boot 4 + Resilience4j | 26 "Circuit breaker" | examples/26-failure-modes CircuitBreaker.java (hand-rolled) | matches | +| 249 | 504 | Graceful shutdown | java · Spring Boot 4 + Actuator | 21 | examples/21-graceful-shutdown | matches | +| 257 | 512 | Evaluating a flag | java · Spring Boot | 27 "Evaluating a flag" | examples/27-feature-flags/spring-boot FlagController (new-checkout) | matches | +| 258 | 513 | Targeting and rollout | json · flagd | — | — | owner: shared-code | +| 260 | 515 | A kill switch | java · Spring Boot | 27 "The kill switch" | examples/27-feature-flags (recommendations-enabled) | matches (chip is already Spring Boot) | +| 268–273 | 523, 525, 526, 528 | Appendix O code | json / bash / yaml | — | — | owner: planner (App O) | + +Counts (Spring-owned code slides, 40 incl. 385): replaced 13, matches 20, illustrative-ok 7; shared-code 13 +slides and App O 4 slides owned elsewhere. + +## Version strings left in the deck (after all edits) and their pins + +| String | Pin / source | +|---|---| +| Spring Boot 4.1.1 (slide 1 notes), "Spring Boot 4" (chips, notes) | MP: Spring Boot 4.1.1 | +| Spring Framework 7.0.9 (slide 1), "Spring 7" / "Spring Framework 7" (App E) | spring-boot-dependencies-4.1.1.pom `spring-framework.version` 7.0.9 | +| Java 25 | MP | +| Jackson 3 | MP; jackson-bom 3.1.5 in the Boot 4.1.1 BOM | +| OpenTelemetry Java agent 2.32.0 (slide 64 notes) | MP S3 evidence; examples/11 Containerfile | +| Bean Validation 3.1 (slide 17 notes) | Jakarta Validation 3.1 in the Boot 4.1.1 BOM (unchanged) | +| Apicurio `/apis/registry/v3` | MP: Apicurio 3.3.3 | +| "Spring Framework 6 introduced ProblemDetail" (slide 134 notes) | history, not a pin | +| AWS SDK for Java v2 | major label of the SDK (illustrative slides) | + +## Unresolved + +- Resolved (user decision 6): Appendix D presents the example's hand-rolled orchestrator. Spring Statemachine status, + checked 2026-10-12: `github.com/spring-projects/spring-statemachine` is archived (read-only); the README + ("Spring Statemachine is no longer maintained", commit 718d0705, 2026-07-03) links the announcement; the last + commit before the archive is b78817dd, 2026-07-05 ("Create LICENSE prior to archive"); last release v4.0.2, + 2026-06-11; license Apache-2.0. spring.io, "Spring Cloud Data Flow commercial" (2025-04-21, + https://spring.io/blog/2025/04/21/spring-cloud-data-flow-commercial): "Spring Statemachine 4.0.x will be the last + open-source lines"; later releases go to Tanzu Spring customers only. The slides state this once (slide 126 bullet + and notes); the deck offers no state-machine framework. +- Slides 122 and 124 (sldIds 377, 379) stay the illustrative JPA/EntityManager coordinator with example-17 status + names; the example code itself is on slide 130. +- Slide 43 excerpt condenses the example's 14-line JSON mapping lambda into `toJson(window, acc)`; the manifest + says "excerpt" in the first line. Slide 18 and 32 excerpts elide variable declarations (marked in comments). +- Unit 275 (slide 63 notes, "Spring — Micrometer Tracing + the OTel bridge, or the Java agent.") is a shared voice + unit; it stays factually correct, so no Spring-only replace was added (it would collide with the voice rewrite). +- Images (e.g. `58-appI-camel-drools-sb`, `62-appD-temporal`) are the image-audit fork's; `63-appD-statemachine` and + the Spring copy of `33-appD-state-options` are replaced here (decision 6). + +## Decision 7 amendment (vendor-neutral, 2026-10-12) + +Generator: `tools/amend_vendor.py` (ops tagged `_vendor`, inserted just before the agenda op; run after `amend_decisions.py`). +Find strings and counts come from the pre-decision text copy (`verify_all.py` output of the edit files at 39a5b1e). + +- Appendix S removed: the `clone_slide` ops `s0`–`s9` and every `set_*` op on `$s*` are dropped (71 ops); Appendix R (9 slides) stays. Agenda line "Appendix R · Example 05 on minikube" (same line count, so the agenda fit is unchanged); agenda notes, the title and overview notes and the R0/R1 notes no longer mention OpenShift Local ("carries over to any conformant Kubernetes cluster"). +- Valet key (sldId 333): title "Valet key — scoped, time-bound, op-restricted", chip `java · Spring Boot 4 + javax.crypto (HmacSHA256)`, `set_code` = `examples/12-security/spring-boot/.../ValetKeyService.java` (`mint`, `verify`), 22 lines, notes rewritten (HMAC or HS256 ticket the service mints and verifies; generic object store). Code map row: matches `examples/12-security` (condensed excerpt, first comment line says so). +- Claim check (sldId 342): chip `java · Spring Boot 4 + Spring Kafka`, `set_code` = KafkaTemplate/`@KafkaListener`, generic `ObjectStore`, ticket from `ValetKeyService.mint`, 22 lines (box held 33); notes rewritten. Same lines as the site claim-check codetab after the site fix (`site-fixes.md`, decision-7 section). No example implements the claim check (illustrative, token from Example 12). +- Removed: AWS SDK for Java v2 (slides 78, 87); the S3 pre-signed URL, STS AssumeRole, Azure SAS, GCS framing. +- Shared rewrites (where present in this deck): 4C's notes and bullet (CloudTrail, "S3 bucket open"); event-driven tools notes ("to S3" → "to an object store"); valet-key concept notes (one generic implementation sentence instead of S3/Azure/GCS/STS); strangler-fig IdPs (Keycloak, Dex instead of Auth0, Azure AD); zero-trust edge (Pomerium or OAuth2 Proxy in front of Keycloak instead of Google IAP, Cloudflare Access, AWS Verified Access; licence note Pomerium Apache-2.0, OAuth2 Proxy MIT); claim-check concept notes; choosing-the-pattern notes; glossary SAS entry and note removed; Appendix I gateway list without Apigee; Appendix M references: gRFC A6 instead of the AWS Builders' Library. +- Kept (not vendor services): Heroku as the origin of the twelve-factor methodology (history), Google SRE Book (reference), gRPC / Google.Protobuf / Google.Rpc (open-source libraries), Netflix concurrency-limits (Apache-2.0 library), Red Hat UBI and the Red Hat brand frame. +- Figures (`replace_picture`, `tools/vendor_figures.py`): sldIds 332, 335, 431 (Appendix I layered stack, Apigee). The valet-key figure named S3, Azure SAS, GCS and STS; the zero-trust figure listed Google IAP, Cloudflare Access and AWS Verified Access; both become the neutral site figures (`12-valet-key.png` with "object store" labels, `12-zero-trust.png`); the layered stack becomes the site `22-l7-layers.png`, as in the Go, C++ and .NET decks. diff --git a/_plans/s12-manifest/K8s-Environment-Access-Policies.md b/_plans/s12-manifest/K8s-Environment-Access-Policies.md new file mode 100644 index 00000000..4ab77a3e --- /dev/null +++ b/_plans/s12-manifest/K8s-Environment-Access-Policies.md @@ -0,0 +1,66 @@ +# K8s-Environment-Access-Policies.pptx + +18 slides (sldIds 256–273). Edit file: `lgtm-presentation/edits/K8s-Environment-Access-Policies.json` +(no `_shared.json`). 26 ops: 18 `replace` (voice 11, positional 1, stack 6, all `expect` 1 with +`sldIds`) and 8 `replace_picture`. No slides added, no notes missing. + +Before → after (voice scanner over the text copy): ban 1 → 0, watch 16 → 8. The 8 survivors are +"em-dash pileup" on notes paragraphs whose dashes sit in separate sentences, one per sentence +(`notes-paragraph`; notes of slides 3, 5, 6, 7, 12, 13, 15, 17). Positional 1 → 0. kubeval 4 → 0. + +## Changes + +| Slide | sldId | Title | Category | Change | +|---|---|---|---|---| +| 1 | 256 | Kubernetes Environment Access Policies | voice | notes: "This deck covers …" opener → "The subject is …" | +| 2 | 257 | Why environment governance matters | voice | body and notes: two-dash aside → parentheses | +| 3 | 258 | Three environments, three risk profiles | voice | title → "Risk profiles by environment" (parallel-number title); notes "in the rest of the deck" (ban) → "in the model" | +| 3 | 258 | | stack | figure → `k8s-policy-env-rbac.png` (ASCII arrows; the embedded PNG shows missing-glyph boxes) | +| 4 | 259 | RBAC: the Kubernetes gate | voice, positional | notes: two-dash aside rewritten; "The next slide maps these verbs …" → "The environment-specific RBAC matrix maps these verbs to each environment." | +| 7 | 262 | NetworkPolicy by environment | stack | figure → `k8s-policy-namespace-isolation.png` | +| 8 | 263 | Policy-as-code with OPA Gatekeeper | stack | figure → `k8s-12-policy-as-code.png` (current site wording) | +| 9 | 264 | GitOps-gated production changes | stack | body "CI validates (kubeval, conftest)" → kubeconform; notes: kubeconform v0.8.0 against the Kubernetes and CRD schemas; figure → `k8s-policy-gitops-flow.png` (CI Lint box: kubeconform, conftest) | +| 10 | 265 | The four-layer L7 stack | voice, stack | notes: two dash asides → parentheses; figure → `k8s-22-l7-layers.png` (S11 wording "the path is observable") | +| 11 | 266 | Istio AuthorizationPolicy | voice | notes: "particularly powerful" sentence → "In production this guarantees …" | +| 12 | 267 | Traffic steering for safe rollouts | stack | figure → `k8s-22-traffic-steering.png` | +| 13 | 268 | Namespace isolation architecture | stack | figure → `k8s-policy-namespace-isolation.png` | +| 14 | 269 | CI/CD validation pipeline | stack, voice | body "Schema validation (kubeval)" → kubeconform; notes: dash aside → parentheses, "kubeval or kubeconform" → "kubeconform v0.8.0 … including custom resources through the CRDs-catalog schemas"; figure → `k8s-policy-ci-pipeline.png` (Schema Check: kubeconform v0.8.0; last box "Prod"; caption "Fail at any stage: PR blocked.") | +| 15 | 270 | Audit & compliance | voice | notes: "— allow or deny —" → commas | +| 18 | 273 | References | voice | "ArgoCD — … · Flux — …" → colons; notes: PSP sentence now says PodSecurityPolicy was removed in Kubernetes 1.25 | + +Eyebrows (`THREE ENVIRONMENTS`, `CI/CD PIPELINE`, …) stay: `design-label`. + +## Figures + +Every content figure of this deck has a source in `assets/diagrams/` (`policy-env-rbac`, +`policy-namespace-isolation`, `12-policy-as-code`, `policy-gitops-flow`, `22-l7-layers`, +`22-traffic-steering`, `policy-ci-pipeline`). The embedded PNGs were rendered without a font that +has `→`/`←`/`✓`, so they show boxes (for example "RoleBinding ▮ view only", "PR ▮ CI ▮ review ▮ +sync"), and two of them name kubeval. The replacements are rendered with cairosvg 2.9.1 at 1400 px +from copies in `lgtm-presentation/edits/png/src/k8s-*.svg`: arrows written as `->` / `<-` / `<->`, +`✓` as "Prod", and the kubeval labels changed. `replace_picture` fits each into the old frame. +`pptx_bg.png` and the logos are unchanged. + +Site follow-up (for the `docs(site)` commit, not applied here): `assets/diagrams/policy-ci-pipeline.{svg,excalidraw}` +("kubeval / kubeconform" → "kubeconform v0.8.0") and `assets/diagrams/policy-gitops-flow.{svg,excalidraw}` +("kubeval, conftest" → two lines "kubeconform", "conftest", as in `edits/png/src/k8s-policy-gitops-flow.svg`) +still name kubeval in both the `text` and `originalText` of the Excalidraw files. No chapter +includes these two figures; they exist for the deck. + +## Sources + +- kubeconform v0.8.0 and CRDs-catalog commit 63669a570e231d4f1f8396d229a1de512bcf0a34: + `_plans/modernization-plan.md`, Target pins. kubeval is unmaintained upstream; the slides no longer + name it. +- PodSecurityPolicy removed in Kubernetes 1.25 (Kubernetes deprecation guide). + +## Unresolved + +None. + +## Decision 7 check (vendor-neutral, 2026-10-12) + +Checked for ACS, OpenShift (Routes, SCCs, OperatorHub, `oc`, OpenShift GitOps), AWS, Azure and Google Cloud +reliance in the deck text, notes, figures (`k8s-*` PNGs) and `k8s-environment-policies.{md,docx}`: none. The deck +is built on RBAC, NetworkPolicy, OPA Gatekeeper, conftest, kubeconform, Argo CD and Istio. No change; the +decision-7 denylist entries report 0 hits on its text copy. diff --git a/_plans/s12-manifest/Network-Policy-Process-Advisory.md b/_plans/s12-manifest/Network-Policy-Process-Advisory.md new file mode 100644 index 00000000..da67d8e8 --- /dev/null +++ b/_plans/s12-manifest/Network-Policy-Process-Advisory.md @@ -0,0 +1,93 @@ +# Network-Policy-Process-Advisory.pptx + +20 slides (sldIds 256–275). Edit file: `lgtm-presentation/edits/Network-Policy-Process-Advisory.json` +(no `_shared.json`), generated by `tools/gen_advisory.py`. Reworked for user decision 7 (2026-10-12): the +deck and its companion no longer rely on Red Hat Advanced Cluster Security (ACS) or OpenShift. They keep the +same process, the same four phases, the same three-tier model and the same slide order, built on upstream +Kubernetes APIs and open-source tools. This file supersedes the 0aeddfc version (OpenShift 4.22 and ACS 4.11 +documentation links, the ANP/BANP status sentence, StackRox/OKD question). + +55 ops, all tagged `_vendor`: 33 `set_text` (eyebrow, title, body, caption), 19 `set_notes`, 3 +`replace_picture`. Every slide except 10 (templates, vendor-neutral already) gets its text and notes set; the +earlier 17 ops (voice, link updates, two pictures) are superseded and removed. Slide 3 keeps its title "The +four-phase maturity path" (the earlier voice rename). Rendered with the brand fonts (scratch +`s12/manifest/work/advr/`): no overflow; the page-number wrap on slides 10 and 20 is the template's. + +## Rework outline (slide → what replaces what) + +| Slide | sldId | Before | After | +|---|---|---|---| +| 1 | 256 | "Red Hat Advisory"; Red Hat's approach on OpenShift | "Advisory"; a recommended approach on Kubernetes with upstream APIs and open-source tools | +| 2 | 257 | NetworkPolicy, AdminNetworkPolicy, EgressFirewall; "Red Hat's recommended approach"; Palo Alto | NetworkPolicy, ClusterNetworkPolicy, egress rules; neutral caption; "the perimeter appliance" | +| 3 | 258 | Observe with the ACS network graph; ACS baselines; ANP/BANP | CNI flow logs (Hubble or Calico); policies generated from observed flows; CNP Admin and Baseline tiers | +| 4 | 259 | Observe with ACS (network baseline, Network Graph) | "Observe the flows": Hubble (Cilium), Goldmane/Whisker flow logs (Calico 3.30+), Inspektor Gadget `advise_networkpolicy` | +| 5 | 260 | ACS-generated `stackrox-generated-` policies | `advise_networkpolicy` writes one NetworkPolicy per workload (or CiliumNetworkPolicy); Calico StagedNetworkPolicy previews a policy | +| 6 | 261 | ANP/BANP guardrails, ACS baselines | CNP Admin/Baseline tiers, generated baselines | +| 7 | 262 | OVN-Kubernetes three tiers: ANP priority 0-99 Allow/Deny/Pass, BANP singleton | CNP Admin tier priority 0-1000 Accept/Deny/Pass, NetworkPolicy, CNP Baseline tier; figure `net-advisory-tier-evaluation.png` | +| 8 | 263 | "ANP · BANP"; empty selector matches `openshift-*`; status in OpenShift 4.22 | "ADMIN · BASELINE", "ClusterNetworkPolicy: Admin and Baseline tiers"; empty selector matches `kube-system` and the CNI's namespaces; status paragraph (v1alpha2 alpha, v0.2.0, ANP/BANP previous, Calico 3.32, Cilium 1.20 flag) | +| 9 | 264 | ACS-generated baselines; EgressFirewall in the golden path | generated baselines; egress rules; Kyverno generate rule for default-deny | +| 11 | 266 | `oc apply --dry-run`, ACS build-time, OVN-K flow logs, ArgoCD | kubeconform + `kubectl apply --dry-run`, conftest / `kyverno test` / `gator verify`, `trivy config` (KSV-0038), baseline diff, Argo CD, flow logs + Falco; figure `net-advisory-ci-pipeline.png` | +| 12 | 267 | `platform-baselines/` = ANP, BANP, EgressFirewall | CNP Admin and Baseline tiers and egress rules | +| 13 | 268 | firewall team authors EgressFirewall, reviews ANP/BANP; Palo Alto | egress rules, Admin/Baseline review, perimeter firewall; figure `net-advisory-team-ownership.png` | +| 14 | 269 | ACS graph, OVN-K flow logs, `oc adm must-gather`, Red Hat support | baseline in Git, `hubble observe --verdict DROPPED` or Calico flow logs, `cilium sysdump` / `calicoctl cluster diags`, "your support provider" | +| 15 | 270 | temporary ANP Pass; ArgoCD; ACS baseline drift | temporary Admin-tier Pass; Argo CD (self-heal); flow logs and Falco | +| 16 | 271 | EgressFirewall authorship; ANP/BANP review; Palo Alto; "customers" | egress rules; Admin and Baseline tier review; perimeter firewall; "teams" | +| 17 | 272 | "EGRESSFIREWALL": per-project OpenShift CRD, CIDR and DNS rules | "EGRESS": CNP `networks` peers (standard), `domainNames` (experimental, Accept only), Cilium `toFQDNs`; Calico OSS has no DNS policy | +| 18 | 273 | ACS baselines active, EgressFirewall per project, `oc apply` | baselines reviewed, egress rules, `kubectl apply`, baseline diff | +| 19 | 274 | "What customers wish they'd done", ACS observation | "What teams wish they'd done", flow observation, egress work | +| 20 | 275 | OpenShift ANP/BANP/EgressFirewall and ACS documentation | network-policy-api site and v0.2.0 release, Calico v3.32 notes, Cilium policy docs, Inspektor Gadget, Kubernetes Network Policies, companion | + +Red Hat attribution: the deck and companion said "Red Hat's recommended approach", "What Red Hat typically +sees" and "lessons Red Hat hears". With every recommended tool now an upstream project, keeping those lines +would attribute recommendations to a vendor that did not make them, so they are neutral ("a recommended +approach", "field teams", "teams"). The Red Hat slide master and logo stay (house style). Question for the +user: keep the deck branded "Red Hat Advisory" in the title, or the neutral "Advisory" used here. + +## Verification (upstream, checked 2026-10-12) + +| Fact used | Source | +|---|---| +| ClusterNetworkPolicy is the only "Active" API; AdminNetworkPolicy and BaselineAdminNetworkPolicy are "Previous (APIs that have evolved to the next stage and are no longer actively developed)" | https://network-policy-api.sigs.k8s.io/ | +| network-policy-api v0.2.0 (2026-04-21): "introduces v1alpha2 version of the API with a single unified resource … ClusterNetworkPolicy (CNP)", "combines the previously separate AdminNetworkPolicy and BaselineAdminNetworkPolicy resources into a single CRD with a `tier` field (Admin or Baseline)", "The `Allow` action has been renamed to `Accept`." | https://github.com/kubernetes-sigs/network-policy-api/releases/tag/v0.2.0 | +| CNP group/version `policy.networking.k8s.io/v1alpha2`; `tier` Enum Admin, Baseline; `priority` 0–1000; actions Accept, Deny, Pass; egress peers `namespaces`, `pods`, `networks` standard, `nodes` and `domainNames` experimental ("DomainNames is only supported for Accept rules") | `apis/v1alpha2/clusternetworkpolicy_types.go` in the same repo; NPEP-285 https://network-policy-api.sigs.k8s.io/npeps/npep-285-combine-crds | +| Implementations (page dated 21 April 2026): CNP in kube-network-policies (v0.2.0 conformance), Calico ("Standard support in Calico v3.32"), Kube-OVN (experimental); Cilium "(tracking issue)" | https://network-policy-api.sigs.k8s.io/implementations/ | +| Calico v3.32.0 (2026-04-30) "implements the standard fields of upstream Kubernetes ClusterNetworkPolicy API, including the experimental network peers and named ports"; "Calico's previous support for the older AdminNetworkPolicy (ANP) and BaselineAdminNetworkPolicy (BANP) APIs has been removed"; latest Calico v3.33.0 (2026-10-01), Apache-2.0 | https://github.com/projectcalico/calico/blob/release-v3.32/release-notes/v3.32.0-release-notes.md | +| Calico 3.30: BANP support; Whisker web console and Goldmane flow logs; StagedNetworkPolicy in open source; DNS/FQDN policy only in Calico Cloud/Enterprise; `calicoctl cluster diags` | https://github.com/projectcalico/calico/blob/release-v3.30/release-notes/v3.30.0-release-notes.md; https://docs.tigera.io/calico/latest/about/product-comparison; https://docs.tigera.io/calico/latest/reference/calicoctl/cluster/diags | +| Cilium v1.20.2 (2026-09-16), Apache-2.0: "Support for the Kubernetes ClusterNetworkPolicy (KCNP) is available starting with Cilium 1.20", enabled with `k8sClusterNetworkPolicy.enabled=true`; ANP/BANP never implemented; `toFQDNs` egress; `hubble observe --verdict DROPPED`; `cilium sysdump` | https://docs.cilium.io/en/stable/network/kubernetes/policy/; https://docs.cilium.io/en/stable/security/policy/language/; https://docs.cilium.io/en/stable/operations/troubleshooting/ | +| Inspektor Gadget v0.57.0 (2026-10-05), CNCF Sandbox, Apache-2.0: `advise_networkpolicy` "monitors the network activity in the specified namespaces and records a summary of TCP and UDP traffic. This is then used to generate Kubernetes network policies"; `--policy-format k8s` or `cilium` | https://inspektor-gadget.io/docs/latest/gadgets/advise_networkpolicy | +| Kyverno v1.19.1 (2026-09-10), CNCF Graduated (2026-03-16); `kyverno apply`, `kyverno test`; sample `add-network-policy` generates a `default-deny` NetworkPolicy on namespace creation | https://www.cncf.io/projects/kyverno/; https://kyverno.io/docs/kyverno-cli/; https://kyverno.io/policies/best-practices/add-network-policy/add-network-policy/ | +| OPA Gatekeeper v3.23.1 (2026-08-27); `gator verify` (first called `gator test`) | https://open-policy-agent.github.io/gatekeeper/website/docs/gator | +| Trivy v0.75.0 (2026-10-01), Apache-2.0; `trivy config`; KSV-0038 "Selector usage in network policies" (the only NetworkPolicy-content check) | https://github.com/aquasecurity/trivy-checks/blob/main/checks/kubernetes/selector_usage_in_network_policies.rego | +| Falco 0.45.0 (2026-09-21), CNCF Graduated; "Network Connection outside Local Subnet" (incubating rules) for unexpected egress ("Unexpected outbound connection destination" was removed in 2023) | https://github.com/falcosecurity/rules/tree/main/rules | +| Argo CD v3.5.5 (2026-10-12), CNCF Graduated; `selfHeal`, `prune` | https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/ | + +Not used, and why: network-policy-api's `policy-assistant` (only release v0.0.1-policy-assistant, 2024-11-19; +no ClusterNetworkPolicy support found); the Cilium/Isovalent network policy editor (hosted, no public source +found); StackRox and OKD (decision 6). OVN-Kubernetes (v1.4.0) still implements ANP/BANP v1alpha1 only, so it +is not named. + +## Figures + +`tools/vendor_figures.py` writes corrected copies of the site SVGs to `lgtm-presentation/edits/png/src/` and +renders them (cairosvg 2.9.1, 1400 px, ASCII arrows): + +| PNG | From | Label changes | +|---|---|---| +| `net-advisory-tier-evaluation.png` (new; slide 7 and docx image1) | `advisory-tier-evaluation.svg` | tiers named CNP Admin / CNP Baseline, priority 0-1000, Allow → Accept, singleton → cluster-scoped, title "ClusterNetworkPolicy three-tier evaluation" | +| `net-advisory-ci-pipeline.png` (re-rendered; slide 11, docx image2) | `advisory-ci-pipeline.svg` | `oc apply --dry-run` → kubeconform, ACS Build-time → Baseline Diff (vs observed flows), "ACS + OVN-K verify" → "flow logs + Falco" | +| `net-advisory-team-ownership.png` (re-rendered; slide 13, docx image3) | `advisory-team-ownership.svg` | EgressFirewall → Egress rules, ANP/BANP → CNP Admin / Baseline tier, Palo Alto → Perimeter firewall, ACS baselines → Observed baselines | + +The same label changes are site fixes (`site-fixes.md`, decision-7 Figures table). The ownership figure's +title is still clipped at the right edge in the source SVG (unchanged, pre-existing). + +## Companion + +`lgtm-presentation/network-policy-process-advisory.md` becomes +`_plans/s12-manifest/companions/network-policy-process-advisory.md`; the docx gets the generated +paragraph-level edits and the three images (`companions.md`). + +## Scans + +After the edits (dry run on a fresh copy of the committed deck): ban 0, watch 7 (notes-paragraph), positional 0, +old engine 0, denylist 0 including the decision-7 entries (`acs`, `openshift`, `vendor-cloud`, `cloud-sdk`). The +companion md: voice scan 0 hits, positional 0, denylist 0. diff --git a/_plans/s12-manifest/README.md b/_plans/s12-manifest/README.md new file mode 100644 index 00000000..9c336c38 --- /dev/null +++ b/_plans/s12-manifest/README.md @@ -0,0 +1,734 @@ +# S12 deck edit manifest + +Step 4 of `_plans/s12-deck-refresh-plan.md`. The Sonnet batches apply these files mechanically; +nothing here needs judgment at apply time. Author: Opus planner, 2026-10-12, branch +`docs/deck-refresh` at 8c86d61. + +## Files + +| File | What | +|---|---| +| `lgtm-presentation/edits/_shared.json` | `replace` ops (no `sldIds`) that match the same number of times in all six language decks: voice, positional and old-engine fixes, including Appendix O | +| `lgtm-presentation/edits/.json` | everything else for one deck, in apply order (see "Op order") | +| `lgtm-presentation/edits/png/*.png` | figures for `replace_picture`: `31-*` (Appendix R), `27-black-box`, `27-ci-gate` (Appendix O), the image-audit replacements, and the decision-7 figures `12-valet-key`, `12-zero-trust`, `net-advisory-*` (`tools/vendor_figures.py`), rendered from `assets/diagrams/` with cairosvg 2.9.1 at 1400 px; the `32-*` Appendix S figures are removed | +| `_plans/s12-manifest/.md` | per deck: what changes and why, the code map (slide → site codetab → example file → status), image audit, notes | +| `_plans/s12-manifest/site-fixes.md` | site codetabs that are stale against `examples/` (the `docs(site)` commit) | +| `_plans/s12-manifest/companions.md` | the `.md` and `.docx` companions of the two policy decks | +| `_plans/s12-manifest/companions/` | decision 7: the exact new text of `network-policy-process-advisory.md` and the generated docx paragraph operations | +| `_plans/s12-manifest/watch-ledger.md` | watch-tier hits that stay, with reason codes | +| `_plans/s12-manifest/tools/` | the generators and checks used to build the edit files (scratch copies; reproducible) | +| `_plans/s12-manifest/fit/.json` | layout-fit plan per language deck (font fits, excerpts, frame changes, title shortenings); `tools/gen_fit.py apply` folds it into the deck file (see "Layout fit") | +| `scripts/deck_fit.py` | the rendered-layout check: text outside its frame, over the page number or logo, off the slide, or on top of other text | + +## How to apply (per batch) + +Run from the repository root (the `png` paths in the edit files are relative to it), with the S12 +venv python: + +```bash +V=/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/venv/bin/python +export DECK_BACKUP_DIR=/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/backup +D=lgtm-presentation/Designing-Cloud-Native-APIs-Python.pptx +$V scripts/deck_edit.py "$D" lgtm-presentation/edits/_shared.json \ + lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.json --dry-run # review the diff +$V scripts/deck_edit.py "$D" lgtm-presentation/edits/_shared.json \ + lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.json # apply in place +``` + +The policy decks take only their own file (no `_shared.json`). Every `replace` carries the exact +`expect` count measured on the deck at 8c86d61 in this order; a mismatch means the deck changed +and the batch stops. Apply each deck's files exactly once, to the committed deck. + +After applying: `python3 scripts/deck_text.py --write`, then remove the deck from +`KNOWN_MISSING_NOTES` (.NET batch), run `scripts/voice-scan.sh --tier ban lgtm-presentation/text` +(0 ban), `python3 scripts/validate_site.py`, `python3 scripts/deck_text.py --check`, and render +every slide whose sldId appears in the edit file plus every new slide. + + +## Summary (build of 2026-10-12) + +`_shared.json`: 58 `replace` ops (engine 10, positional 4, stack 4, voice 40). + +| Deck file | Ops | By category | Dry run | Ban after | Watch after | Positional | Old engine | Denylist | +|---|---|---|---|---|---|---|---|---| +| `Designing-Cloud-Native-APIs-Python.json` | 464 | code 33, fit 53, new 67, notes 5, positional 37, stack 36, version 1, voice 232 | ok | 0 | 54 | 0 | 0 | 0 | +| `Designing-Cloud-Native-APIs-Quarkus.json` | 474 | code 53, fit 55, new 67, notes 10, positional 34, stack 32, version 3, voice 220 | ok | 0 | 51 | 0 | 0 | 0 | +| `Designing-Cloud-Native-APIs-Spring.json` | 491 | code 39, fit 83, new 67, notes 2, positional 9, stack 58, version 7, voice 226 | ok | 0 | 54 | 0 | 0 | 0 | +| `Designing-Cloud-Native-APIs-DotNet.json` | 631 | code 56, engine 2, fit 88, new 66, notes 43, positional 36, stack 93, version 5, voice 242 | ok | 0 | 63 | 0 | 0 | 0 | +| `Designing-Cloud-Native-APIs-Go.json` | 517 | code 58, fit 40, new 67, notes 30, positional 38, stack 52, version 1, voice 231 | ok | 0 | 55 | 0 | 0 | 0 | +| `Designing-Cloud-Native-APIs-Cpp.json` | 399 | code 50, fit 29, new 65, notes 8, positional 17, stack 91, version 12, voice 127 | ok | 0 | 45 | 0 | 0 | 0 | +| `K8s-Environment-Access-Policies.json` | 26 | positional 1, stack 12, voice 13 | ok | 0 | 8 | 0 | 0 | 0 | +| `Network-Policy-Process-Advisory.json` | 55 | stack 55 (decision 7 rework: set_text 33, set_notes 19, replace_picture 3) | ok | 0 | 0 | 0 | 0 | 0 | + +Amended 2026-10-12 for user decision 6 (plan): Spring Appendix D presents the hand-rolled saga orchestrator of +`examples/17-sagas/spring-boot` (Spring Statemachine only as archived upstream); Go valet key and claim-check follow +`examples/12-security/go` (golang-jwt ticket, minio-go removed; site fix in `site-fixes.md`); C++ compendium and +library references verified upstream (table in the C++ file). Generator: `tools/amend_decisions.py` (ops tagged +`_amend`, inserted before the agenda and Appendix R ops); new figure `png/17-sagas-architecture.png`. The dry runs +and scans were re-run for all eight decks after the amendment. + +Amended 2026-10-12 for user decision 7 (plan; vendor-neutral, house dependency and platform policy): nothing relies on +AWS, Azure, Google Cloud, ACS or OpenShift, and only maintained open-source libraries appear. + +- No Appendix S in any deck: `tools/amend_vendor.py` drops the `s0`-`s9` clone ops and every `$s*` op (71 ops per + deck, 79 in .NET; the C++ deck never had them); the agenda line is "Appendix R · Example 05 on minikube" (.NET: + "Appendices P–R · .NET on Linux, Blazor, minikube"), so the agenda keeps 13 lines and its fit; agenda, title, + overview and Appendix R notes no longer mention OpenShift Local. Appendix R stays in all six. The `32-*` PNGs are + deleted. `tools/gen_rs.py` still emits S; `amend_vendor.py` removes it, so run it last. +- Valet key and claim check (slides 78 and 87; .NET 80 and 89) follow `examples/12-security/`: an HMAC-SHA256 + ticket (HS256 JWT in Go and C++) the service mints and verifies itself, a generic encrypted object store and no + `s3://` URI; boto3, the AWS SDK for Java v2 / quarkus-amazon-s3, the AWS SDK for .NET and the AWS SDK for C++ are + gone (the Go slides were fixed in `amend_decisions.py`). The same code is the site fix (`site-fixes.md`). +- About 20 vendor mentions per deck are rewritten by shared find/replace pairs (4C's, valet and claim-check concept + notes, strangler-fig IdPs, zero-trust edge proxies, glossary SAS, Appendix I Apigee, Appendix M AWS Builders' + Library → gRFC A6) and deck pairs (.NET Azure SignalR / App Service / "supported on OpenShift"; C++ AWS SDK and + MinIO). Figures: the valet-key and zero-trust concept figures become the neutral site figures, the Python, + Quarkus and Spring Appendix I layered-stack figure (Apigee) becomes the site `22-l7-layers`, and the .NET + Appendix J tables are re-rendered without "Azure". +- The network advisory deck and companion are reworked to upstream Kubernetes (ClusterNetworkPolicy Admin and + Baseline tiers, NetworkPolicy, Calico or Cilium, Inspektor Gadget, Kyverno / Gatekeeper, Trivy, Falco, Argo CD); + see `Network-Policy-Process-Advisory.md` and `companions.md`. The K8s policies deck needed no change. +- Generators: `tools/amend_vendor.py` (ops tagged `_vendor`, before the agenda op; replace finds and counts from + the pre-decision text copies), `tools/gen_advisory.py`, `tools/vendor_figures.py`, `tools/companion_docx.py`, + `tools/watch_ledger_md.py`. Run order after `build.py`: `amend_decisions.py`, `amend_vendor.py`, `verify_all.py`. +- Dry runs against fresh copies of the committed decks: all `ok`; slides after: Python, Quarkus, Go, C++ 281, + Spring 285, .NET 312, K8s 18, advisory 20; ban 0, positional 0, old engine 0, denylist 0 with the decision-7 + entries; `AWS|Amazon|S3\b|STS|Azure|GCS|Google Cloud|OpenShift|\bOCP\b|\bACS\b` matches only `ALREADY_EXISTS` + (a gRPC status code) in the gRPC-errors notes. +- BUILD.md (step 14) must drop its minio-go / "AWS SDK" line (lgtm-presentation/BUILD.md lines 46-47), which the + new `minio` and `cloud-sdk` denylist entries flag. + +Watch-after counts are scanner lines in the post-edit text copy; every one is in `watch-ledger.md` with a reason. Dry runs: `deck_edit.py --dry-run` with `_shared.json` (language decks) and the deck file, against a copy of each deck at 8c86d61, all `ok`. + +## Conventions + +- Slides are addressed by sldId. Slide numbers appear only in `_slide` comments and in the per-deck + notes; they are the numbers of the deck at 8c86d61 and shift after the R and S slides are added + only at the end. +- Extra keys in an op (`cat`, `_slide`, `_why`, `_src`, `_keep`, `_amend`, `_amended`) are documentation; deck_edit + ignores them. `cat` is one of `version`, `stack`, `code`, `voice`, `positional`, `notes`, `new` + (plus `engine` for the old container engine, a kind of `stack` change kept separate for the + report). +- JSON files spell the old container engine's name with a `o` escape, so the repository text + never contains the word and `validate_site.py` can scan `lgtm-presentation/` (step 15). +- Code lines: at most 22 per box (or the box's current count when larger) and 95 columns. Code + slides agree with `examples/` (library, API, identifiers, topic names); excerpts say so in their + first comment line. Layout fit (2026-10-12): every code box must also fit its frame in the + render; long boxes are excerpted with `// ...` / `# ...` ellipsis comment lines (verbatim lines + kept, the cut summarised on the ellipsis line) and sized down, never under 9 pt (bullets never + under 12 pt). +- Every new or rewritten sentence follows the voice rules (lgtm-professional-voice): no ban-tier + phrase, no positional words (`above`, `below`, `next slide`, `previous slide`, + `previous section`, `following section`, `see slide N`, `preceding`; numeric "above 50%" and + "at or above" stay), no old engine name, no ALL-CAPS emphasis, no "actually/simply/very/really", + one em dash per sentence at most. + +## Op order (per deck file) + +1. `_shared.json` (language decks only). +2. Deck-specific voice, positional and engine rewrites (`replace`, with `sldIds`). +3. Appendix O ops (code box, figures, titles). +4. Image replacements from the image audit. +5. Language-agnostic code slides (YAML, proto, JSON, HAProxy, nginx, Actions). +6. Deck content: version, stack, code, notes (including the 17 .NET slides without notes). +7. Agenda, then Appendix R (all six), appended at the end (Appendix S removed by decision 7). +8. Layout-fit ops (`cat: fit`, from `_plans/s12-manifest/fit/.json`), last. A fitted box whose + code a `set_code` op writes gets `pt` (and `line_spacing`) on that op, and its excerpt in its + `lines`, instead of a separate op. + +## Pin table + +Every version string on a slide or in the notes after the edits, and the pin it equals. Source: +`_plans/modernization-plan.md` "Target pins" (MP) and the S2–S10 evidence sections, or the file +named. A major-only label ("Spring Boot 4", ".NET 10", "Java 25", "C++23", "Apicurio v3", "Kafka +4") is allowed when it agrees with the pin; a minor without the patch ("Spring Boot 4.0", +"Quarkus 3.35") is not. Versions in a historical sentence ("since .NET 6", ".NET 8 added +IExceptionHandler", "from .NET 4.x") are history, not pins, and stay. + +| Component (as written on slides) | Pin | Source | +|---|---|---| +| Quarkus | 3.40.1 (LTS), Java 25 | MP pins; S2 | +| Spring Boot | 4.1.1, Java 25 | MP pins; S3 | +| Spring Framework | 7.0.9 | spring-boot-dependencies 4.1.1 BOM (`spring-framework.version`), the parent of every `examples/*/spring-boot/**/pom.xml` | +| Spring Kafka / Spring Integration / Spring for GraphQL / Spring Security | 4.1.1 / 7.1.1 / 2.0.5 / 7.1.1 | same BOM | +| Jackson | 3 (`tools.jackson`; BOM 3.1.5) | S3; same BOM (`jackson-bom.version`) | +| OpenTelemetry Java agent | 2.32.0 | S3 | +| JVM images | `ubi10/openjdk-25:1.24-15`, `ubi10/openjdk-25-runtime:1.24-15` | MP pins | +| .NET | 10.0.12 (SDK 10.0.112), `net10.0`, C# 14 | MP pins; S4 | +| .NET images | `ubi9/dotnet-100:9.8-1791421718`, `ubi9/dotnet-100-aspnet:9.8-1791363520` (no ubi10 .NET image exists) | MP pins; S4 | +| Confluent.Kafka / Streamiz.Kafka.Net | 2.16.0 / 1.8.2 | S4 | +| OpenTelemetry .NET / Npgsql / Grpc.* / Google.Protobuf / StackExchange.Redis / HotChocolate / OpenFeature / flagd provider / SignalR | 1.19.1 / 10.0.3 / 2.84.0 / 3.36.2 / 3.4.0 / 16.6.8 / 2.14.1 / 0.5.0 / 10.0.12 | S4 | +| Python | 3.14; `ubi10/python-314-minimal:10.2-1791464217` (fallback `ubi9/python-314:9.8-1791466446`) | MP pins; S5 | +| FastAPI / pydantic / aiokafka / grpcio / strawberry / redis-py / OTel Python | 0.143.0 / 2.14.0 / 0.14.0 / 1.84.0 / 0.333.0 / 8.1.0 / 1.45.1 (contrib 0.66b1) | S5 | +| faust-streaming | 0.15.3 | S5 | +| Go | 1.27.2; builder `docker.io/library/golang:1.27.2`; runtime `ubi10/ubi-minimal:10.2-1791444377` | MP pins; S6 | +| Go libraries | franz-go 1.22.1, pgx 5.11.0, go-redis 9.23.0, gqlgen 0.17.95, golang-jwt 5.3.1, OTel Go 1.47.0 (otelhttp/otelgrpc 0.72.0), gobreaker v2.4.0, cenkalti/backoff v7.0.1, gopher-lua 1.1.2, goka 1.1.17, coder/websocket 1.8.15, OpenFeature go-sdk 1.19.0 + flagd 0.7.1, grpc 1.84.0, protobuf 1.36.12 | S6 | +| C++ | C++23; GCC 15 (`gcc-toolset-15`) on `ubi10/ubi:10.2-1791444044`; CMake 4.4.4; Ninja 1.13.2; Conan 2.33.0; runtime `ubi10/ubi-minimal:10.2-1791444377` | MP pins; S7; CLAUDE.md | +| C++ libraries | drogon 1.9.13, grpc 1.84.0, protobuf 6.33.5, librdkafka 2.14.2 (`rdkafka++`), libpq 17.11, redis-plus-plus 1.3.15, jwt-cpp 0.7.2, sml 1.2.0, sol2 3.5.0, lua 5.4.8, opentelemetry-cpp 1.26.0, spdlog 1.17.0, nlohmann_json 3.12.0, cppgraphqlgen 4.5.9 | S7 | +| Kafka (compose, minikube) | 4.3.1 (`apache/kafka:4.3.1`; Strimzi `quay.io/strimzi/kafka:1.2.0-kafka-4.3.1`) | MP pins; S8; S9 | +| Kafka (OpenShift Local) | 4.2.0 (AMQ Streams `amqstreams.v3.2.1-14`, channel `amq-streams-3.2.x`) | S10; Appendix S | +| Postgres | 18 (`postgres:18.6-alpine`; OpenShift: `registry.redhat.io/rhel10/postgresql-18`) | MP pins; Appendix S | +| Redis | `redis:8.10.2-alpine` (AGPLv3 option) | MP pins | +| Debezium / Apicurio / flagd / LGTM / Envoy | `quay.io/debezium/connect:3.7.0.Final` / `apicurio-registry:3.3.3` (REST v3) / `flagd:v0.17.0` / `grafana/otel-lgtm:0.36.0` (Grafana 13.2.3) / `envoyproxy/envoy:v1.39.3` | MP pins | +| Test tools | newman 6.2.3, newman-reporter-htmlextra 1.23.1, hey v0.1.5, ghz v0.121.0, conftest 0.71.1, kcat 1.7.1 | MP pins; S8 | +| GitHub Actions | `actions/checkout@v7`, `actions/upload-artifact@v7` | `.github/workflows/*.yml`; upload-artifact v7.0.2 (2026-10-07) | +| minikube / Kubernetes / kubectl / Helm | v1.39.0 / v1.36.5 / v1.36.5 (kustomize v5.8.1) / v4.3.0 | MP pins; S9; Appendix R | +| Strimzi / CNPG / KEDA / Istio | 1.2.0 / v1.30.1 / v2.21.0 / 1.31.1 | MP pins | +| kubeconform | v0.8.0 (CRDs-catalog 63669a570e231d4f1f8396d229a1de512bcf0a34) | MP pins | +| OpenShift Local | crc 2.64.0, OpenShift 4.22.14, Kubernetes v1.35.6; 12 vCPU, 32768 MiB | MP pins; S10; Appendix S | + +Not pins (stay as written): `Apache 2.0` and other license names, `TLS 1.3`, HTTP/1.1 and HTTP/2, RFC numbers, IP addresses such as `10.1.0.10`, `.NET 6`/`.NET 8`/`.NET 9`/`.NET 4.x` and `C# 12` in historical sentences, `AWS SDK for Java v2` (a product name), Bean Validation 3.1 (the Jakarta specification level in Spring Boot 4), `Apicurio v3` (the REST API major). + +## Stale-stack denylist (for `validate_site.py`, step 15) + +Applied to every line of `lgtm-presentation/text/*.md` (code blocks included) and to +`lgtm-presentation/*.md`. A hit is an error unless the exemption regex matches the same line. +Python `re` syntax; flags as given. The old engine is written `p[o]dman` so this file does not +contain the word. + +Machine-readable copy: `_plans/s12-manifest/denylist.json` (`id`, `regex`, `flags`, `exempt`, `files`, `why`). + +| id | regex | flags | exemption | scope | why | +|---|---|---|---|---|---| +| old-engine | `p[o]dman` | i | | all | Docker Engine / docker compose is the runtime (S1) | +| poetry | `\bPoetry\b\|\bpoetry (install\|add\|lock\|run)\b` | | | all | Python examples use pip with a full == requirements.txt freeze (S5) | +| fastapi-on-event | `@app\.on_event\b\|\.on_event\(` | | | all | FastAPI on_event replaced by lifespan (S5) | +| faust | `\bfaust\b(?![-.])` | i | `faust-streaming` | all | stream processing is faust-streaming 0.15.3 (S5); its Python module is `faust` (faust.App), which stays | +| modern-cpp-kafka | `modern-cpp-kafka\|kafka::clients` | i | `archived` | all | C++ Kafka is librdkafka's C++ API rdkafka++ (S7) | +| masstransit | `MassTransit` | i | `dropped\|commercial licen[cs]e` | all | .NET Kafka is Confluent.Kafka; MassTransit dropped (S4) | +| kafka-ui | `kafka-ui` | i | | all | kafka-ui removed; kcat or kafka-console-consumer.sh (S8) | +| kubeval | `\bkubeval\b` | i | `kubeconform` | all | kubeval is unmaintained; kubeconform v0.8.0 | +| libpqxx | `\b(lib)?pqxx\b` | | `\bnot (lib)?pqxx\b` | all | C++ uses libpq directly (S7) | +| cxx-old-standard | `[Cc]\+\+ ?(17\|20)\b\|c\+\+2a\|-std=c\+\+(17\|20)\|CMAKE_CXX_STANDARD (17\|20)\b` | | `\b(since\|in\|from) [Cc]\+\+ ?(17\|20)\b` | all | C++23 (S7); history ('since C++20') stays | +| gcc-old | `\bGCC 1[0-4]\b\|gcc-toolset-1[0-4]\b` | | | all | GCC 15 via gcc-toolset-15 (S7) | +| cmake-old | `\bCMake 3\.\d+` | | `UBI ships` | all | CMake 4.4.4 (S7) | +| quarkus-old | `\bQuarkus 3\.(?!40\b)\d+` | | | all | Quarkus 3.40.1 LTS (S2) | +| spring-boot-old | `\bSpring Boot (3\.\d+\|3\.x\|4\.0)\b` | | `migrat\|upgrad\|from Spring Boot 3` | all | Spring Boot 4.1.1 (S3) | +| go-old | `\bGo 1\.(1\d\|2[0-6])\b\|golang:1\.(1\d\|2[0-6])\b` | | `go-toolset\|Go 1\.22\b\|since Go 1\.\d+` | all | Go 1.27.2 (S6); Go 1.22 (method-aware ServeMux patterns) is the version a feature arrived in and stays | +| python-old | `\bPython 3\.([0-9]\|1[0-3])\b\|python:3\.([0-9]\|1[0-3])\b\|python-3(9\|1[0-3])\b` | | | all | Python 3.14 (S5) | +| dotnet-old-tfm | `\bnet[5-9]\.0\b\|dotnet-[5-9]0\b\|dotnet/(sdk\|aspnet):[5-9]\.` | | | all | .NET 10 (net10.0, ubi9/dotnet-100) (S4) | +| ubi8 | `\bubi8\b` | i | | all | UBI 10 (UBI 9 only for .NET) | +| confluent-kafka-version | `Confluent\.Kafka[\" ]*(Version=\")?[ @]?2\.(?!16\.0)\d+\.\d+` | | | all | Confluent.Kafka 2.16.0 (S4) | +| streamiz-version | `Streamiz(\.Kafka\.Net)?[\" ]*(Version=\")?[ @]?1\.(?!8\.2)\d+\.\d+` | | | all | Streamiz.Kafka.Net 1.8.2 (S4) | +| jackson2 | `com\.fasterxml\.jackson\.(databind\|core)\b\|\bJackson 2\b` | | | all | Jackson 3 (tools.jackson) with Spring Boot 4 (S3) | +| spring-kafka-json-serde | `support\.serializer\.Json(Serializer\|Deserializer)\b` | | | all | Spring Kafka 4 uses the Jackson 3 JacksonJson serializers (S3) | +| topic-order-placed | `\border-placed\b` | | `artifact\|@(Channel\|Incoming\|Outgoing)\(\|mp\.messaging\.\|channel\|\.avsc` | all | the Kafka topic is order.placed; the Quarkus channel name order-placed (bound to topic order.placed, as in every Quarkus example) and the Apicurio artifact id order-placed (Example 09) stay | +| graphql-go | `\bgraphql-go\b\|github\.com/graphql-go/` | | | all | Go GraphQL is gqlgen (S6) | +| go-deck-python-leftovers | `pyproject\|requirements\.txt\|\bpip install\b\|uvicorn\|FastAPI` | | | Designing-Cloud-Native-APIs-Go | the Go deck was retargeted from the Python deck; no Python tooling belongs in it | +| ocp-docs-old | `openshift_container_platform/4\.(1\d\|2[01])/` | | | all | OpenShift 4.22 (CRC 2.64.0 / OCP 4.22.14); superseded by `openshift` (decision 7) | +| kafka-image | `apache/kafka:(?!4\.3\.1\b)\d+\.\d+` | | | all | apache/kafka:4.3.1 (S8) | +| postgres-image | `\bpostgres:(?!18\.6-alpine\b)\d+\.\d+` | | | all | postgres:18.6-alpine (S8) | +| redis-image | `\bredis:(?!8\.10\.2-alpine\b)\d+\.\d+` | | | all | redis:8.10.2-alpine (S8) | +| otel-lgtm-image | `otel-lgtm:(?!0\.36\.0\b)\d` | | | all | grafana/otel-lgtm:0.36.0 (S8) | +| envoy-image | `envoyproxy/envoy:v(?!1\.39\.3\b)\d` | | | all | envoyproxy/envoy:v1.39.3 (S8) | +| apicurio-image | `apicurio-registry:(?!3\.3\.3\b)\d` | | | all | apicurio-registry:3.3.3 (S8) | +| flagd-image | `flagd:v(?!0\.17\.0\b)\d` | | | all | ghcr.io/open-feature/flagd:v0.17.0 (S8) | +| debezium-image | `debezium/connect:(?!3\.7\.0\.Final\b)\d` | | | all | quay.io/debezium/connect:3.7.0.Final (S8) | +| five-implementations | `\b(five\|5) (implementations\|backends)\b` | i | | all | six language implementations (Go added) | +| latest-tag | `[a-z0-9]:latest\b` | | `never\|no \|not \|nothing uses\|sketchy\|\bblock\b` | all | every image is pinned; :latest appears only as the thing not to do | +| cloud-sdk | `aws-sdk\|\bboto3\b\|AWSSDK\|IAmazonS3\|software\.amazon\|@aws-sdk\|AWS SDK\|azure-sdk\|Azure\.Storage\|google-cloud-\|cloud\.google\.com/go` | i | | all | decision 7: no vendor cloud SDKs; valet key and claim check follow examples/12-security (a ticket the service signs itself) | +| vendor-cloud | `\bAWS\b\|\bAmazon\b\|\bS3\b\|\bSTS\b\|Azure\|\bGCS\b\|Google Cloud\|Google IAP\|\bEKS\b\|\bGKE\b\|\bAKS\b\|CloudTrail\|AWS Lambda\|Cloudflare Access\|Apigee\|Confluent Cloud` | | | all | decision 7: no AWS, Azure, Google Cloud or other managed-service framing in examples or recommendations (object storage is described generically) | +| minio | `minio-go\|\bminio\b` | i | | all | decision 7: minio-go removed (Go valet key is a golang-jwt ticket) | +| acs | `\bACS\b\|[Aa]dvanced [Cc]luster [Ss]ecurity\|StackRox\|roxctl` | | | all | decision 7: nothing relies on ACS; the network advisory uses CNI flow logs, Inspektor Gadget, Kyverno/Gatekeeper, Trivy and Falco | +| openshift | `OpenShift\|\bOCP\b\|EgressFirewall\|OperatorHub\|\bSCCs?\b\|OVN-K\|\boc (apply\|adm\|get\|create\|new-project)\b` | | | all | decision 7: decks carry no Appendix S and no OpenShift-only feature; generic Kubernetes (Ingress/Gateway API, Pod Security Standards, Helm/upstream operators) | + +## Watch-tier triage policy (slides and notes) + +S11 rule 10 (`_plans/s11-professional-voice-plan.md`), adapted to decks: + +1. Rewrite by default: `actually` (unless it contrasts with a stated wrong belief), `simply`, + `obviously`, `trivially`, `of course`, `very`, `really`, `truly`, `crucially`, `notably`, + `importantly`, `incredibly`, hedges (`arguably`, `perhaps`, `somewhat`, `fairly`, + `essentially`, `basically`, `kind of`, `sort of`), `note that` / `keep in mind`, `let's ...`, + summary markers, exclamation marks, soft hype (`powerful`, `seamless`, `leverage` → use, + `robust` → name the failure, prose `journey` → path), "isn't X — it's Y" unless X is a + misconception the audience holds, and "this slide/section covers" (notes describe the subject). +2. ALL-CAPS emphasis in prose, titles, bullets and notes becomes lowercase (or the sentence is + restructured so the word lands at the end). ALL-CAPS labels that are part of the deck design + stay: the red eyebrow lines (`APPENDIX L · CACHE-ASIDE`), divider letters, the failure-mode + taxonomy labels (`NETWORK`, `COMPONENT`, `LOAD`), SQL keywords and constants. They are + identifier-like (reason `design-label`); the scanner's ALL-CAPS pattern matches none of them + except where an eyebrow or title contains an emphasis word, and those titles are rewritten. +3. Em dashes: a sentence with two or more em dashes (including a parenthetical pair) is rewritten + to one or none. A notes paragraph whose three or more em dashes sit in different sentences, each + with at most one, stays (reason `notes-paragraph`): the scanner counts per line and a notes + paragraph is one line in the text copy. Empty table cells and code stay. +4. `quietly` / `silently` stay with a machine subject (`machine-subject`); drama is rewritten. + `lie` with a machine subject stays. +5. `minikube`, `OpenShift Local`, `k3d` stay where the environment is the subject (Appendices R + and S, tool tables): `literal-subject`. `laptop` becomes "workstation" or "locally". +6. `deliberate` (adjective) stays when it names a planned stage ("a deliberate intermediate + state"): `planned-stage`. The ban-tier `deliberately` is always rewritten. +7. Recorded results and unit lists ("10 passed, 0 failed", "12 vCPU, 32768 MiB") match the + parallel-number pattern and stay (`table-data`). Titles built as synthetic parallels ("Nine + capabilities, nine real services") are rewritten. +8. `landscape` for a survey of tools stays (`survey-term`). + +Reason codes: `machine-subject`, `literal-subject`, `table-data`, `survey-term`, `idiom`, +`identifier`, `misconception-contrast`, `design-label`, `notes-paragraph`, `planned-stage`, +`quoted`. Surviving watch hits are listed per deck in `watch-ledger.md`. + +## Appendices R and S + +> Decision 7 (2026-10-12): the decks carry Appendix R only. The Appendix S design that follows is kept as the +> record of the earlier manifest; `tools/amend_vendor.py` removes its ops. + +Source: `_docs/31-appendix-r-minikube.md`, `_docs/32-appendix-s-openshift-local.md`, +`examples/05-event-driven/k8s/` and `openshift/`. Generator: `tools/gen_rs.py` (writes the ops that +end each deck file). All new slides are clones of the same deck's Appendix O slides, appended at the +end (after Appendix O; .NET: after Appendix Q), each with its own notes; content slides get the next +static page number. + +### Templates (cloned per deck) + +| Role | Python, Quarkus, Go, C++ | Spring | .NET | Template slide | +|---|---|---|---|---| +| Divider | 516 | 520 | 530 | "API Testing with Newman" divider (letter, title, subtitle, white logo) | +| Bullets | 527 | 531 | 541 | Appendix O "Take-aways" (eyebrow, title, body, footnote line) | +| Figure | 525 | 529 | 539 | Appendix O "The gate on ..." (eyebrow, title, picture, caption) | +| Code | 521 | 525 | 535 | Appendix O "Launch the service under test" (eyebrow, title, chip, code box, caption) | +| Agenda | 257 | 257 | 257 | second column (`set_text`) and notes (`set_notes`) | + +Shape ids are read from each template by role (eyebrow at y 0.42 in, title 0.74 in, caption 6.50 in, +code box = the tall monospace shape, chip = the 11 pt monospace line at y 1.06 in). The figures are +`lgtm-presentation/edits/png/31-minikube-topology.png`, `31-minikube-image-flow.png`, +`32-openshift-topology.png`, `32-openshift-image-flow.png` (cairosvg 2.9.1, 1400 px wide, from +`assets/diagrams/`). + +### Agenda + +Second column becomes (Python, Quarkus, Spring, Go): 07 to 13 unchanged, then +"Appendices A–D · Protocols, Versioning, WebSockets, Sagas", "Appendices E–I · Errors, DDD, +Coupling, Shutdown, L7 Routing" (shortened so it no longer wraps), "Appendices K–L · Monolith → +Microservices, Caching Patterns", "Appendices M–O · Failure modes, Feature Flags, API Testing", +"Appendices R–S · Example 05 on minikube and OpenShift Local", "Glossary of acronyms" (13 lines, the +same visual height as before). C++: the R–S line reads "Appendix R · Example 05 on minikube". .NET (the +box has no autofit and overflowed with 15 lines): "Appendices J–L · .NET 10 Toolkit, Monolith → +Microservices, Caching" and "Appendices P–S · .NET on Linux, Blazor, minikube, OpenShift Local" replace +the separate J, P and Q lines, so .NET also has 13 lines. The agenda notes are rewritten to name +Appendices R and S (C++: R, and one line saying OpenShift Local covers the other five languages). + +### Appendix R (all six decks): 9 slides, 8 page-numbered + +| # | Template | Title | Content | +|---|---|---|---| +| R0 | divider | Example 05 on minikube | letter R; subtitle "The same services and the same verify.sh on a single-node Kubernetes cluster" | +| R1 | bullets | From compose to a cluster | what a cluster adds; scope (Example 05, three services, six languages, one node); not production; the contract that carries over; same verify.sh, ten checks. Footnote: site and code paths | +| R2 | bullets | The minikube runtime | profile cndp-k8s, docker driver, containerd, Kubernetes v1.36.5; 8 GB / 4 CPUs / 40 GB; NodePorts 30080 and 30300 on 127.0.0.1; no port-forward/tunnel/service; `--context cndp-k8s`. Footnote: minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 | +| R3 | figure | One node, two namespaces | `31-minikube-topology.png` | +| R4 | bullets | Compose to Kubernetes | kafka → Strimzi (Kafka 4.3.1); postgres → StatefulSet; Secret; docker build + minikube image load, `imagePullPolicy: Never`; probes; NodePort 30080 | +| R5 | code | One overlay per language | `yaml · kustomize`: the deck language's `k8s/overlays//kustomization.yaml` images block and the variables it appends (the .NET connection string uses a YAML escaped line break to stay within 95 columns) | +| R6 | figure | Images built on the host | `31-minikube-image-flow.png` | +| R7 | code | Run it | `bash · minikube`: setup-profile.sh, build-images.sh , deploy.sh , `RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh`, verify-matrix.sh, teardown.sh --operator, minikube stop | +| R8 | bullets | Recorded results | 10 passed, 0 failed in six languages; per-language times; Tempo saw the three services; scale-to-zero check; stop the profile | + +### Appendix S (Python, Quarkus, Spring, .NET, Go): 10 slides (.NET 11), 9 (10) page-numbered + +| # | Template | Title | Content | +|---|---|---|---| +| S0 | divider | Example 05 on OpenShift Local | letter S; subtitle "Operators from OLM, restricted-v2 pods, Routes and an internal registry" | +| S1 | bullets | What OpenShift changes | OLM Subscription with pinned CSV and Manual approval; restricted-v2; edge-TLS Routes; skopeo to the internal registry, `imagePullPolicy: Always`; Kafka 4.2.0, rhel10/postgresql-18 | +| S2 | bullets | The OpenShift Local runtime | crc 2.64.0 / OpenShift 4.22.14 / Kubernetes v1.35.6; 12 vCPU, 32768 MiB, 80 GB; `--context crc-admin`; no secret printed or stored; one cluster at a time | +| S3 | figure | Topology on OpenShift Local | `32-openshift-topology.png` | +| S4 | code | Pinning an operator with OLM | `yaml · OLM`: `openshift/operators/subscription.yaml` (labels omitted) | +| S5 | bullets | restricted-v2 without exceptions | base security context; no runAsUser/fsGroup/SCC binding; numeric USER 185/1001 and group-0 files; otel-lgtm with three emptyDirs; every pod restricted-v2 | +| S6 | figure | Images through the internal registry | `32-openshift-image-flow.png` | +| S7 | code | A registry push without a token on disk | `bash · skopeo`: the token-in-a-pipe `skopeo copy` from the appendix, with the deck language's image name | +| S7n | code | Trace context through Kafka headers (.NET only) | `csharp · Confluent.Kafka`: inject in `OrderService/Program.cs` and extract in `ShippingConsumer/Program.cs` (excerpts; the long `PropagationContext` argument is wrapped) | +| S8 | code | Run it | `bash · OpenShift Local`: setup-crc.sh, build-push-images.sh , deploy.sh , the verify.sh call over HTTPS with the ingress CA, verify-matrix.sh, teardown.sh | +| S9 | bullets | Recorded results | 10/10 in five languages, every pod restricted-v2; times; one trace across Kafka; 302 to HTTPS and Grafana 13.2.3; second teardown exits 0 | + +The exact slide text and notes are in the deck files (`set_text` and `set_notes` ops after the +`clone_slide` ops); `tools/gen_rs.py` is their source. + +New page numbers: Python, Quarkus, Go 244–260; Spring 248–264; .NET 272–289; C++ 244–251. + +## Layout fit (amendment 2026-10-12) + +The first language batch (Python) showed an overflow the edits caused (slide 45, sldId 300: the +KEDA `ScaledObject` + `HTTPScaledObject` code is longer than the old code and its last line was +clipped at the box bottom, which `set_code`'s capacity estimate of 1.1 x the font size missed) and +pre-existing defects (slide 88 "Choosing the right pattern" running into the page number and logo, +slides 176 and 221 with code clipped at the bottom). `scripts/deck_fit.py` checks the render for +every slide: each `pdftotext -bbox-layout` line of the soffice PDF is matched to its shape (by text, +then geometry) and compared with the shape's frame (12700 EMU per PDF point). It flags text past its +frame (bottom, right, top, left), text over the page-number box or the logo picture, text off the +slide, and text on top of another shape's text. Validation: on the Python deck it flags 45 after the +edits (`overflow-bottom` 22.6 pt), 88, 176 and 221 in both states, and passes known-good slides +such as the section dividers (50) and the agenda. + +Calibration (soffice 26.2.6.3 PDFs of the committed Python and .NET decks, 248 multi-line text +boxes): the line pitch of Red Hat Mono and Red Hat Text is 1.20 x the size x `spcPct` (median +1.1995, range 1.1985-1.2020), a rendered line box is 1.32 x the size, and Red Hat Mono advances +0.5985 x the size per character. `box_capacity` in `deck_edit.py` now uses these numbers +(n lines fit when the top inset + (n - 1) pitches + one line box stay inside the frame), so a code +box that would overflow fails at apply time. + +New `deck_edit.py` ops: `fit_font` (size every run of a shape to `pt`, or `scale` all sizes; floors +9 pt for code, 12 pt for text; a code box must then fit), `resize_shape` (frame change that must stay +on the slide and clear of the page number and logo), `cut_code` (excerpt a code box in place, +keeping each remaining line's runs and colours; each cut may leave one ellipsis comment line), and +`pt` / `line_spacing` on `set_code`. + +Flags per deck (`deck_fit.py`, flags on slides; "after edits" is `_shared.json` + the pre-fit deck +file applied to a copy of the committed deck; edit-caused = not present in the committed render): + +| Deck | Committed | After edits: pre-existing | After edits: edit-caused | After fit | +|---|---|---|---|---| +| Python | 107 on 43 | 102 on 42 | 6 on 4 (45 KEDA, 24 and 43 code; 43 and 221 chips) | 0 | +| Quarkus | 129 on 54 | 106 on 54 | 2 on 2 (45 KEDA, 213 chip) | 0 | +| Spring | 183 on 60 | 166 on 60 | 5 on 3 (45 KEDA, 64 chip, 129 title) | 0 | +| .NET | 231 on 65 | 182 on 64 | 9 on 8 (47 KEDA; 38, 45, 68, 87, 142, 196 chips; 122 caption) | 0 | +| Go | 75 on 39 | 65 on 34 | 5 on 3 (45 KEDA, 117 and 241 chips) | 0 | +| C++ | 51 on 31 | 45 on 26 | 8 on 4 (78, 87 chips, 146, 206 captions) | 0 | + +The edits themselves removed 5 to 49 flags per deck (rewritten code that fits). The minikube divider +cloned for Appendix R carries the template's 1.7 pt descent into its frame, inside the tolerance. + +Fixes (plan entries per deck in `fit/.json`; each op carries `_fit` with the reason): + +| Deck | Chips widened | Code font fits | Code excerpts | Text frames grown | Text font fits | Titles shortened | +|---|---|---|---|---|---|---| +| Python | 28 | 9 | 8 | 10 | 2 | 1 | +| Quarkus | 37 | 7 | 6 | 9 | 3 | 1 | +| Spring | 44 | 12 | 17 | 8 | 3 | 3 | +| .NET | 46 | 17 | 20 | 13 | 6 | 3 | +| Go | 19 | 9 | 2 | 9 | 2 | 1 | +| C++ | 8 | 14 | 8 | 3 | 0 | 1 | + +- Chips (the `yaml · KEDA` labels) that wrapped are widened to the left to one line (.NET slide 237: + two lines, so the chip stays clear of the first code line). +- Code: the largest size (0.5 pt steps, at least 9 pt) at which the box fits, with line spacing + 1.0 where 1.04 does not fit at 9 pt. Boxes that do not fit at 9 pt are excerpted: blank lines + first, then imports, field and constructor boilerplate, or a second variant, each replaced by one + ellipsis comment line that names what was cut; every remaining line is verbatim. The OPA + Gatekeeper template (all six decks) and the Istio east/west pair (Python, Quarkus, Spring, .NET, + Go) get the same excerpt everywhere. +- Text frames (captions, bullets) grow into free space, never past the next shape, the page + number or the logo. +- Text that cannot grow: "Choosing the right pattern" 17 -> 15 pt; references 17 -> 14 pt (and + 6 pt paragraph spacing in Quarkus, Spring and .NET, which carry a runtime-docs line); caching + pitfalls (Quarkus, Spring, .NET) 17 -> 16 pt; .NET known gaps 15 pt, recipe scaled 0.85 (17/14 -> + 14.5/12 pt), migration take-aways 15.5 pt. +- Titles: "Glossary of acronyms (2 of 2) — platform, security, telemetry" (all six); Spring + "Temporal: a plain-Java saga that the Server makes durable", "A hand-rolled orchestrator: one + row, one log, no cluster"; .NET "Temporal .NET: a plain-C# saga the Server makes durable", + divider "Appendix J · .NET 10 Toolkit & Migration". + +Regenerate: `tools/gen_fit.py suggest ` proposes chip, +code-font and frame entries from the flags; excerpts, text fonts and titles are decided by hand; +`tools/gen_fit.py apply` rebuilds each deck file from its committed pre-fit version +(`FIT_BASE`, default c70ab3a) plus `fit/.json`. The dry runs and scans in "Summary" were +re-run after the amendment (ban 0, positional 0, old engine 0, denylist 0, same watch counts). +The policy decks are not in this amendment. + +## Batch checks + +Every batch, after applying its edit files and before committing, renders its deck and runs the +layout check; the batch does not commit until it reports 0 flags: + +```bash +V=/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/venv/bin/python +LO_PROFILE=/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/lo-profile \ + $V scripts/deck_fit.py lgtm-presentation/.pptx --keep-pdf +# "deck_fit: .pptx: 0 flag(s) on 0 slide(s)", exit 0 +``` + +A flag is fixed in the deck's `fit/.json` plan (then `tools/gen_fit.py apply`) or, for a +new op, in the deck file with `cat: fit`, never by editing the deck by hand. The renders of the +changed slides are still inspected (`deck_render.sh`); `deck_fit.py` does not judge legibility, +image content or colour. + +## Fix round A (2026-10-12): colours, caps, accuracy, cross-deck + +Found by the Quarkus validation (code lines coloured as comments, ALL-CAPS emphasis, code accuracy) and +applied to every language deck. Inputs: `fixA/` (the validator's `Q.fix1`/`Q.fix2` for Quarkus and the +cross-deck `capsfix` files). Generator: `tools/fixA_build.py` with the rules in `tools/fixA_rules.py`. +Checks: `tools/fixA_verify.py`, `tools/colour_audit.py`, `tools/caps_audit.py`, +`tools/test_set_code_colours.py`. + +Tool fix (`scripts/deck_edit.py`): `code_lines()` took the run that ended the previous line +(`"# comment\n"`) as the template of the next line, so `set_code` wrote code lines in the comment green +8FB98F (41-60 lines on the Appendix O/R code slides of each committed deck, plus Quarkus/Spring/.NET slides +with a leading comment). A line's template is now the first run that put non-blank text on it. `set_code` +then colours the box by line type (`code_rgb`, default the box's code colour or E6E6E6; `comment_rgb`, +default the box's comment colour or 8FB98F; `"colour": false` keeps the template colours); the new +`recolour_code` op does the same for a box in place (splits multi-line runs, keeps third colours unless +`force`, fails on a no-op unless `allow_noop`); `cut_code` ellipsis lines are comment-coloured; +`set_geometry` works on the XML, so it also finds pictures on slides cloned earlier in the same run. +`tools/test_set_code_colours.py` reproduces the bug on the old tool and passes on the new one. + +Fixup files (apply after the deck's earlier fix files, to the committed deck; replace counts measured on +the committed deck with its existing fixes; the recolour ops are last and carry `allow_noop`, because a +replay of the whole chain with the fixed tool already colours most boxes): + +| Deck | File | Ops | From | +|---|---|---|---| +| Python | `Designing-Cloud-Native-APIs-Python.fix1.json` | 137 | capsfix 89, content 10, caps 29, recolour 9 | +| Quarkus | `Designing-Cloud-Native-APIs-Quarkus.fix1.json` | 167 | Q.fix1 101, Q.fix2 5, content 7, caps 17, recolour 37 | +| Spring | `Designing-Cloud-Native-APIs-Spring.fix2.json` | 165 | capsfix 88, content 9, caps 24, recolour 44 | +| .NET | `Designing-Cloud-Native-APIs-DotNet.fix2.json` | 184 | capsfix 84, content 15, caps 37, recolour 48 | +| Go | appended to `Designing-Cloud-Native-APIs-Go.json` (`_fixA`) | 120 | capsfix 89, content 9, caps 21, recolour 1 | +| C++ | appended to `Designing-Cloud-Native-APIs-Cpp.json` (`_fixA`) | 115 | capsfix 24, content 6, caps 84, recolour 1 | + +Content ops: the outbox figure re-rendered without "is a lie" (`png/src/04-dual-write-outbox.svg`, 2048x972; +C++ 1181x561); bulkhead 5 as `examples/12-security` (Python, Spring comment, .NET, Go, C++; Quarkus in Q.fix1); +Kafka Streams `.to(..., Produced.with(windowSerde, Serdes.Double()))` on the Quarkus slide (Spring already +maps the window to JSON and passes serdes); OAuth2 Proxy / Pomerium no longer "check device posture"; +"Microsoft Cloud Adoption Framework" replaced by the OWASP Microservices Security Cheat Sheet (denylist entry +`vendor-framework`); .NET saga ends in COMPENSATED and maps `/healthz` and `/readyz` as the examples do; +the OpenTelemetry manual-span API on the JVM and .NET slides uses the language's API (it was Python's); +title spacing on three shared titles; the Appendix R topology figure enlarged as in Q.fix2. Caps ops lower +every ALL-CAPS emphasis word outside the `caps_audit.py` allowlist and remove the soft positional +references ("covered next", "next slides", "we'll see next", ...). + +Verification (scratch copies, fixed tool): each committed deck + its new fixup applies `ok`, and the full +chain `_shared` + deck JSON + all fix files from the pre-S12 parent gives the same text; Go and C++ apply +`ok` from the committed pre-S12 decks; `deck_fit` 0 flags on all six; `colour_audit` 0 hits (no code line +coloured as a comment, no comment line in code colour, no third colour) on all six; `caps_audit` 0 words +outside the allowlist; voice ban 0 (also `--raw`); positional 0 (soft references included); old engine 0; +denylist 0. Every slide a fixup touches (331 slides) rendered and inspected. + +Not changed (figures carry baked-in ALL-CAPS: gray failure "PERCEIVED DIFFERENTLY / NOT fully down / +OBSERVE FROM THE CLIENT", event sourcing vs streaming "ARE / IS / NOT / ABOUT", tools landscape +"COMPLEMENT", coupling "strong OR distant"); `caps_audit` scans text only. + +## Watch-tier ledger (final validation, 2026-10-12) + +Every watch-tier line that `bash scripts/voice-scan.sh --tier watch --hits all lgtm-presentation` still reports after the +final-validation voice fixes (`edits/.fix*.json`, `cat: voice`, `_why` "watch triage (final validation)"). Before: 356 +lines; fixed: 17 (em-dash pileups of five to seven per notes paragraph in the tools-landscape, auto-instrumentation, CQRS, +.NET saga-state and write-back and C++ error-facts notes; in the same notes, two leaked authoring requests, the garbled +"covered in this appendix.NET" sentence and the soft positional references "we cover next", "we'll show side by side", +"that follow"); kept: 339. The content fixes of the same round add three SQL clauses to the write-back notes +(C++, Go, Python slide 221), so the scanner now reports 342 lines, all kept. Slide numbers are those of the committed decks after the fixes (they do not shift: no slide +is added or removed). Ban tier is 0. One row per distinct line; a deck name is followed by its slide numbers. + +### environment-specific noun (155 kept) + +Reason: literal-subject (minikube is the subject: Appendix R and the agenda line that names it). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 2; .NET 2-3; Go 1-3; Python 1-3; Quarkus 1-3; Spring 1-3 | title, agenda and overview (the agenda line and notes that name Appendix R) | minikube | +| C++ 273; .NET 304; Go 273; Python 273; Quarkus 273; Spring 277 | Appendix R divider (Example 05 on minikube) | minikube | +| C++ 274-281; .NET 305-312; Go 274-281; Python 274-281; Quarkus 274-281; Spring 278-285 | Appendix R slides and notes | minikube | +| lgtm-presentation/BUILD.md:27 | Every language deck ends with Appendix R (Example 05 on minikube: a divider and | minikube | +| lgtm-presentation/BUILD.md:83 | - Kubernetes: minikube v1.39.0 with Kubernetes v1.36.5, Strimzi 1.2.0, Helm v4.3 | minikube | + +### em-dash pileup (3+ per line) (84 kept) + +Reason `notes-paragraph`: a speaker-notes paragraph (one line in the text copy) whose em dashes sit in different +sentences, at most one per sentence (checked per sentence for every line), or a table row whose cells each carry one +dash (`table-data`). Grouped by the paragraph's opening words; the deck columns give the slide number. + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 20 | The Istio sidecar handles wire concerns: mTLS, bounded retries with ba… | 3 dashes | +| C++ 39 | Three layers of the event-driven stack: (1) substrate — Kafka (Strimzi… | 4 dashes | +| C++ 49 | Three Kubernetes-native patterns for non-request work. (1) Job: a one-… | 3 dashes | +| C++ 66 | Baggage is a separate concern from trace context — they ride together… | 3 dashes | +| C++ 108 | The four rules of safe proto evolution, in one example: (1) add new fi… | 3 dashes | +| C++ 121 | Three options for saga state durability. (1) Database row — one row pe… | 3 dashes | +| C++ 131 | gRPC's error model is two layers: (1) one of ~16 canonical codes (OK,… | 3 dashes | +| C++ 137 | The retry storm is the classic distributed-systems amplification failu… | 3 dashes | +| C++ 151 | Three dimensions, each measurable. STRENGTH: how intensely are the two… | 3 dashes | +| C++ 202 | Martin Fowler's strangler fig pattern is the safest large-migration ap… | 3 dashes | +| .NET 25 | The resolver makes federation concrete in HotChocolate. The top-level… | 3 dashes | +| .NET 34 | For the WCF/COM+ audience this is the most important pattern. You used… | 3 dashes | +| .NET 63; Go 61; Python 61; Quarkus 61; Spring 61 | Expand the 'three pillars' framing into the five signals modern observ… | 4 dashes | +| .NET 67; Go 65; Python 65; Quarkus 65; Spring 65 | Demystify the magic word 'correlation.' It is not a backend join — it'… | 4 dashes | +| .NET 71; Go 69; Python 69; Quarkus 69; Spring 69 | The decision guide for sampling. Probabilistic is the simplest and the… | 4 dashes | +| .NET 94; Go 92; Python 92; Quarkus 92; Spring 92 | These are the five tests to apply in design and code review. (1) If tw… | 3 dashes | +| .NET 125 | First problem: the orchestrator must survive a crash mid-flow, so in-m… | 3 dashes | +| .NET 126 | This is the DB-backed option made concrete with EF Core — the one I'd… | 3 dashes | +| .NET 129; Go 125; Python 125; Quarkus 125; Spring 125 | This case is a common source of bad designs. The orchestrator calls se… | 3 dashes | +| .NET 133 | Walk the picture. The top row is the saga as a state machine: Created… | 3 dashes | +| .NET 138 | ASP.NET Core has had ProblemDetails (RFC 9457) built into Microsoft.As… | 3 dashes | +| .NET 140 | This is grpc-dotnet's standard rich-error model — the same wire shape… | 4 dashes | +| .NET 144 | Throwing from a single field's resolver is exactly right: HotChocolate… | 4 dashes | +| .NET 154 | Walk the three blocks. Ports: the domain declares plain C# interfaces,… | 3 dashes | +| .NET 158; Go 150; Python 150; Quarkus 150; Spring 154 | Frame the appendix. The central, slightly contrarian idea: coupling is… | 3 dashes | +| .NET 161; Go 153; Python 153; Quarkus 153; Spring 157 | Integration strength has four levels, worst to best. Intrusive couplin… | 3 dashes | +| .NET 163; Go 155; Python 155; Quarkus 155; Spring 159 | This brings strength and distance together into the design rule. Read… | 3 dashes | +| .NET 205; Spring 201 | Leave the checklist up at the end. The first point is the most common… | 3 dashes | +| .NET 220 | The .NET shape. Three named HttpClients registered via IHttpClientFact… | 3 dashes | +| .NET 231 | Two write-through flavours. The first uses IDistributedCache directly:… | 3 dashes | +| Go 121; Python 121; Quarkus 121; Spring 121 | First problem: the orchestrator must survive a crash mid-flow, so in-m… | 4 dashes | +| Go 203; Python 203 | The pre-condition for incremental migration: clients hit the proxy, th… | 3 dashes | +| Go 215 | The wrapper is small but the discipline is significant. Callers can't… | 3 dashes | +| Go 219; Python 219 | Two write variants and the standard cache-aside read for context. The… | 4 dashes | +| Spring 136 | This is grpc-java's standard rich-error model via StatusProto — the sa… | 3 dashes | +| Spring 140 | Throwing from a single field's resolver is exactly right: Spring for G… | 4 dashes | +| K8s policies deck 3 | Walk through the three environments as a risk gradient. Dev is where d… | 3 dashes | +| K8s policies deck 5 | Walk the RBAC matrix column by column. In dev, developers get full CRU… | 4 dashes | +| K8s policies deck 6 | This is the single highest-impact security measure you can apply to a… | 3 dashes | +| K8s policies deck 7 | The same default-deny baseline applies everywhere, but the allow rules… | 3 dashes | +| K8s policies deck 12 | L7 routing enables environment-safe deployments without modifying RBAC… | 3 dashes | +| K8s policies deck 13 | A well-governed namespace is a blast-radius boundary with four control… | 4 dashes | +| K8s policies deck 15 | Governance requires more than access control — it requires proof that… | 3 dashes | +| K8s policies deck 17 | Close with the priority-ordered checklist. First, apply default-deny N… | 3 dashes | +| text/k8s-environment-policies.md (docx copy):37 | Change velocity / High — deploy on every commit / Moderate — deploy on… | 3 dashes | +| lgtm-presentation/k8s-environment-policies.md:33 | Change velocity** / High — deploy on every commit / Moderate — deploy… | 3 dashes | + +### quietly / silently (28 kept) + +Reason: machine-subject (a producer, schema change or client that breaks or corrupts data without an error). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 37 | it, a producer can ship a breaking schema change and silently corrupt consumers across the fleet. With it, br | and silently | +| C++ 107 | es tag 4 with new semantics will misinterpret the data silently. For C++ specifically: protoc-generated C++ cl | data silently | +| C++ 111; .NET 113; Go 111; Python 111; Quarkus 111; Spring 111 | - Never break silently: deprecate with a reason, a replacement and a | break silently | +| .NET 110; Go 108; Python 108; Quarkus 108; Spring 108 | ) and its name permanently: nobody can reuse them and silently corrupt old data on the wire. We add currency a | and silently | +| .NET 113; Go 111; Python 111; Quarkus 111; Spring 111 | hecked in CI, never a reviewer remembering. Never break silently: deprecate with a reason, a replacement, a su | break silently | +| .NET 165; Go 157; Python 157; Quarkus 157; Spring 161 | pling stable: compatibility rules stop a producer from silently raising the integration strength on its consum | from silently | +| .NET 165; Go 157; Python 157; Quarkus 157; Spring 161 | lity gate is, in this language, a guard against a producer silently increasing integration strength on consume | producer silently | + +### ALL-CAPS emphasis (27 kept) + +Reasons: `design-label` (the red eyebrow `WORKFLOWS AND JOBS`; the scanner's list now includes AND) and `identifier` +(the SQL clause `ON CONFLICT ... DO NOTHING` / `DO UPDATE`). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 29 | cceeds; at-least-once delivery with an idempotent ON CONFLICT DO UPDATE is the standard pattern. PgTxn stands | ON CONFLICT DO UPDATE | +| C++ 31 | interesting part. Pair with idempotent consumers (ON CONFLICT DO NOTHING / DO UPDATE) and you get exactly-once | ON CONFLICT DO NOTHING | +| C++ 221; .NET 235; Go 221; Python 221; Quarkus 221; Spring 225 | write-back notes: the flusher upserts with INSERT ... ON CONFLICT (id) DO UPDATE | ON CONFLICT DO UPDATE | +| C++ 47; Go 47; Python 47; Quarkus 47; Spring 47 | ## [047] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 302) | AND JOBS | +| C++ 48; Go 48; Python 48; Quarkus 48; Spring 48 | ## [048] WORKFLOWS AND JOBS · Choosing between the two (sldId 303) | AND JOBS | +| C++ 49; Go 49; Python 49; Quarkus 49; Spring 49 | ## [049] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 304) | AND JOBS | +| .NET 49 | ## [049] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 304) | AND JOBS | +| .NET 50 | ## [050] WORKFLOWS AND JOBS · Choosing between the two (sldId 305) | AND JOBS | +| .NET 51 | ## [051] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 306) | AND JOBS | +| Go 36 | e idempotent. The example's consumer inserts with ON CONFLICT (order_id) DO NOTHING, so a duplicate is a no-op | ON CONFLICT | + +### lie / lies / lying (person vs machine) (13 kept) + +Reason: machine-subject (failures and remote calls that lie: gray failure). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 227; .NET 241; Go 227; Python 227; Quarkus 227; Spring 231 | - Some failures lie: a node passes its own health check while failing real t | lie | +| C++ 232 | > Gray failure is the failure that lies. The /healthz endpoint returns 200; real traffic is tim | lies | +| C++ 248; .NET 262; Go 248; Python 248; Quarkus 248; Spring 252 | rt: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often infinite. | lie | + +### deliberate (adjective) (11 kept) + +Reason: planned-stage (a planned transition strategy / intermediate state). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 205; Go 205; Python 205; Quarkus 205 | IX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) | deliberate | +| .NET 90; Go 88; Python 88; Quarkus 88; Spring 88 | eration and the expiry. Strangler fig (auth) is a deliberate transition strategy; the gateway running translat | deliberate | +| .NET 219 | IX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 474) | deliberate | +| Spring 209 | IX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 464) | deliberate | + +### exclamation in prose (11 kept) + +Reason: quoted (book title 'Release It!', Nygard). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 248; .NET 262; Go 248; Python 248; Quarkus 248; Spring 252 | ddressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkhead origin); t | 'Release It!' | +| .NET 262; Go 248; Python 248; Quarkus 248; Spring 252 | e the single best free resource; Michael Nygard's 'Release It!' is where the circuit breaker and bulkhead patt | 'Release It!' | + +### parallel-number title (7 kept) + +Reason: table-data (recorded result / a limit, not a synthetic parallel). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 281; .NET 312; Go 281; Python 281; Quarkus 281; Spring 285 | - 10 passed, 0 failed in all six languages (Fedora 44, Docker | 10 passed, 0 failed | +| lgtm-presentation/BUILD.md:51 | (at most 22 lines, 95 columns) and names the example in its first com | 22 lines, 95 columns | + +### soft hype (6 kept) + +Reason: survey-term ('tools landscape' is a survey of tools). + +| Where (deck slide, or file:line) | Text | Kept as | +|---|---|---| +| C++ 39; Go 39; Python 39; Quarkus 39; Spring 39 | ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) | landscape | +| .NET 41 | ## [041] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 296) | landscape | + + +### Kept by decision: section numbers with the concept named + +About 120 notes and slide lines cross-reference a section by number and name ("section 11 (Observability)", "Covered in +depth in section 12 (Security)"). The decks number their sections on the agenda and every divider, and each reference +names the concept, so these are references to a labelled part of the deck, not positional language ("above", "next +slide"). They stay; the positional check in `validate_site.py` and `fixA_verify.py` reports 0. + +## Final validation (2026-10-12) + +Opus validation of the branch against main (2d8c2a8). Fix files, applied once to the committed decks after the earlier +ones: `Designing-Cloud-Native-APIs-Python.fix3.json`, `Quarkus.fix3`, `Spring.fix4`, `DotNet.fix4`, `Go.fix2`, +`Cpp.fix2` (each op keeps `_src`: `watch`, `tone`, `content`, `shutdown`, `registry`, `mine`, `figures`, `hop`). + +- Content (examples/ is the authority): Apicurio v3 CreateVersion body on the CI-gate slides (Python, Go, C++; the raw + schema body is the v2 shape) and `register` + `dryRun` instead of the deprecated `test-update` goal (Quarkus, + Spring; apicurio-registry-maven-plugin 3.3.3); Python write-back, circuit-breaker import and consumer group id; Go + write-back upsert; Quarkus Kafka Streams serdes; Spring gRPC stock update and saga class body, Undertow removed (Spring + Boot 4); .NET saga COMPLETED, Kafka consumer notes, Apache KIE instead of Camunda, no commercial alternatives, + SignalR backplane MIT, Polly BSD-3; C++ 12-factor service, librdkafka slide, bulkhead, shutdown, write-back rewritten + from the examples; the Python shutdown slide follows `examples/21-graceful-shutdown/python`, whose SIGTERM handler now + chains to uvicorn's (before, uvicorn never drained and the lifespan exit never ran). +- Appendix S: the one dangling reference (.NET notes, sldId 323) now names `examples/05-event-driven/dotnet`. +- Figures: gray failure, event sourcing vs streaming, tools landscape and coupling take the site figures (no baked-in + ALL-CAPS emphasis); the hop-cost footer and the .NET Appendix J library table (Temporalio, MIT rows) re-rendered. +- Voice: 17 watch lines fixed (em-dash pileups, leaked authoring requests, soft positional references), first-person + tone in the saga notes; survivors in "Watch-tier ledger (final validation)". +- Checks on the result: `deck_fit` 0 flags, `colour_audit` 0, `caps_audit` 0, `fixA_verify` all 0 on the six language + decks; `validate_site.py` 0 errors; voice scan 0 ban-tier; `deck_text.py --check` ok. +- Site (`docs(site)`): the optional rows of `site-fixes.md` were real defects and are fixed (`02-pick-protocol`, + `02-mesh-handles-wire`, `22-business-rule-routing`, `22-hop-cost` language-neutral, with their alt text and captions; + `29-container-layers` label overlap via `scripts/gen_29_diagrams.py`); `_docs/09` codetabs and CI YAML send the v3 + CreateVersion body; codetabs in `_docs/01`, `02`, `05`, `06`, `12`, `21`, `23`, `24`, `25`, `26` follow the examples; + `_docs/23` keeps Redis 8 (AGPLv3 option, as the examples deploy) and names Valkey as the BSD-3 drop-in; ALL-CAPS + emphasis lowered in `11-signals-in-depth`, `11-sampling`, `25-cascading`, `25-amplification`. + +## Example alignment (2026-10-12) + +Three example fixes landed after the final validation: 937f8d7 (Example 01: `/readyz` answers 503 with the same +`"status": "down"` body when the database is down), 9676f32 (Example 21: one SIGTERM drives the whole drain: +readiness 503, `DRAIN_DELAY_SECONDS` = 5, stop accepting, in-flight drain bounded at 20 s, cleanup, exit 0, inside +the 30 s grace period) and ebca295 (Example 25: `PUT /write-back/metrics/{id}` answers 202 Accepted; the flusher +upserts and re-queues failures with a `metric:attempts` hash, a `metric:failed` set after five attempts and a +backoff of 2, 4 and 8 s). Two older divergences in the Quarkus deck go with them: the health slide used SmallRye +Health and the outbox slide the Debezium outbox extension, while `examples/01/quarkus` hand-writes `/healthz` and +`/readyz` and `examples/04-data/quarkus` inserts into the outbox table over JDBC. `examples/` is the authority. + +Generator: `tools/align_build.py` (code, captions, chips and notes per slide; checks 95 columns and each box's +line limit, max(22, current lines) capped by the box capacity at its current font, so no font was shrunk) and +`tools/align_site.py` (the same line lists into the site codetabs; Go with tab indentation). Fix files, applied +once to the committed decks after the earlier ones (each op carries `_src: align`): + +| Deck | File | Slides (number / sldId) | +|---|---|---| +| Python | `Designing-Cloud-Native-APIs-Python.fix4.json` | 14/269 (503 line, notes), 164/419, 221/476, 245/500 | +| Go | `Designing-Cloud-Native-APIs-Go.fix3.json` | 14/269 (now the example's handlers; the Kafka check and `errors.Join` were not in it), 164/419, 221/476, 245/500 | +| C++ | `Designing-Cloud-Native-APIs-Cpp.fix3.json` | 14/269, 164/419 (watcher thread, drain delay, bounded wait, `app().quit()`), 221/476, 245/500 | +| Quarkus | `Designing-Cloud-Native-APIs-Quarkus.fix4.json` | 14/269 (hand-written `HealthResource`), 32/287 (JDBC outbox), 164/419 (`Signal` handler, `Quarkus.asyncExit`), 221/476 (JDBC per-id upsert), 245/500 | +| Spring | `Designing-Cloud-Native-APIs-Spring.fix5.json` | 14/269 (hand-written `HealthController`), 168/423 (`Signal` handler, `System.exit`), 225/480 (`JdbcClient`, no ShedLock), 249/504 | +| .NET | `Designing-Cloud-Native-APIs-DotNet.fix5.json` | 15/270 (`MapGet` probes), 172/427 (`ManualLifetime`, `PosixSignalRegistration`, `StopApplication`), 234/489 (notes), 235/490 (title, the example's `Task.Run` flusher), 259/514 | + +The Appendix M shutdown recaps (245, Spring 249, .NET 259) follow Example 21 as well: they showed the old budgets, +a readiness flip in the lifespan exit (Python), SmallRye Health (Quarkus) or Actuator (Spring). The other Appendix H +slides and notes already describe one SIGTERM and stay. `caps_audit.py` gains HINCRBY, HDEL and SREM (Redis +commands) and JTA (an acronym) on its allowlist. + +Checks on scratch copies, then on the committed decks: dry run and apply `ok`; `deck_fit` 0 flags on all six (after +two fit fixes in the generator: the Quarkus outbox chip shortened to one line, the first Spring write-back line +kept clear of the chip); `colour_audit` 0, `caps_audit` 0, `fixA_verify` all 0; every changed slide rendered +(`deck_render.sh`) and read; the committed decks have the same parts as the verified scratch copies; +`validate_site.py` 0 errors; voice scan 0 ban-tier; `deck_text.py --check` ok. Watch tier: 342 lines, unchanged in +number; the em-dash pileups of the .NET 172 and Go/Python 221 notes are gone and the write-back notes of .NET 235, +Quarkus 221 and Spring 225 join the `ON CONFLICT` identifier row (ledger updated). + +Site: `_docs/01` (all six codetabs follow Example 01: 503 on the down path, hand-written probes in Spring, Quarkus +and .NET, the Go handlers; the prose says the probe reads the status code), `_docs/21` (Spring, Quarkus, .NET, C++ +and Go codetabs follow Example 21; the prose describes the single SIGTERM, the drain delay and the 20 s bound), +`_docs/25` (the six write-back codetabs follow Example 25), and the Example 01 C++ README (503 on the down path). +Open: the `_docs/01` footer still says 10/10 checks, while `examples/01-cloud-native-principles/verify.sh` now has +14; the count needs a green run before it changes. + diff --git a/_plans/s12-manifest/companions.md b/_plans/s12-manifest/companions.md new file mode 100644 index 00000000..c51fd3c6 --- /dev/null +++ b/_plans/s12-manifest/companions.md @@ -0,0 +1,121 @@ +# Companion documents: edits + +The two policy decks have a Markdown companion and a Word document built from it: +`lgtm-presentation/k8s-environment-policies.{md,docx}` and +`lgtm-presentation/network-policy-process-advisory.{md,docx}`. The `.md` is edited directly; the +`.docx` is edited in place with python-docx 1.2.0 (S12 venv). No converter regenerates the docx. + +## Equality today + +Word-level comparison (docx paragraphs and table cells in body order against the md with Markdown +markers, link targets and fence tags removed) shows only formatting differences today: list and +heading numbers (`1.` … rendered as numbered headings), fence language tags, the `!` of image +markers and `[ ]` checkboxes. The docx keeps the md's hard line wraps: one md line is one docx +paragraph, so each md line edit has a docx paragraph edit. The docx has no hyperlinks (the +References list holds the labels only), so URL edits apply to the md alone. After the edits the +comparison shows the same formatting-only differences (24 and 23 blocks, none textual), so +acceptance criterion 8 holds. Proof: the edits were applied to scratch copies +(`s12/manifest/fork-policy/comp/`) and compared; every md find matched exactly once and every docx +anchor matched its expected old text. + +Voice scan of the patched md copies: 0 ban; one watch hit left, the `Change velocity` table row in +`k8s-environment-policies.md` (three em dashes in three cells, `table-data`). Positional terms 0; +kubeval, OpenShift 4.16/4.18 links and ACS 3.69 links 0. + +Em dashes in the companions follow S11's line rule (split prose lines with three or more), not the +deck's sentence rule, because the companion is hard-wrapped prose and the docx mirrors each line. + +## k8s-environment-policies.md (3 edits) + +| # | Find (whole line, exactly once) | Replace with | Why | +|---|---|---|---| +| 1 | `## 2. Three environments, three risk profiles` | `## 2. Risk profiles by environment` | parallel-number title; matches deck slide 3 | +| 2 | ``2. **CI validates** — `kubeval` checks schema, `conftest` runs OPA policies, a`` | ``2. **CI validates** — `kubeconform` checks schema, `conftest` runs OPA policies, a`` | kubeconform v0.8.0 pin | +| 3 | `\| **Schema validation** \| kubeval, kubeconform \| Invalid YAML, unknown fields, wrong API versions \|` | `\| **Schema validation** \| kubeconform v0.8.0 \| Invalid YAML, unknown fields, wrong API versions \|` | kubeconform v0.8.0 pin | + +## k8s-environment-policies.docx (3 edits) + +Indices are `Document.paragraphs` / `Document.tables` positions in the committed file. Replace the +text of the named run only; the run keeps its formatting. + +| # | Location | Run | Old run text | New run text | +|---|---|---|---|---| +| 1 | paragraph 15 (Heading 2) | 0 | `2. Three environments, three risk profiles` | `2. Risk profiles by environment` | +| 2 | paragraph 91 (List Number) | 2 (Consolas) | `kubeval` | `kubeconform` | +| 3 | table 3, row 1, cell 1, paragraph 0 | 0 | `kubeval, kubeconform` | `kubeconform v0.8.0` | + +## network-policy-process-advisory.md (decision 7: reworked to upstream Kubernetes and open source) + +Supersedes the nine find/replace edits and the docx edits of the 0aeddfc manifest. The companion is rewritten +to the same process, phases and section structure with upstream APIs and open-source tools (facts and +citations: `Network-Policy-Process-Advisory.md`): + +- ACS (network graph, baselines, `stackrox-generated-*` policies, build-time tools, anomalous-flow alerts) → + the CNI's flow logs (Hubble with Cilium; Goldmane/Whisker with Calico), Inspektor Gadget's + `advise_networkpolicy` gadget for generated NetworkPolicy, Calico StagedNetworkPolicy for a preview, + a baseline diff in CI, Falco for runtime egress alerts. +- AdminNetworkPolicy / BaselineAdminNetworkPolicy on OVN-Kubernetes → upstream ClusterNetworkPolicy + (`policy.networking.k8s.io/v1alpha2`, network-policy-api v0.2.0) in its Admin and Baseline tiers; priority + 0-1000; Accept/Deny/Pass. One paragraph records that ANP/BANP (v1alpha1) are previous upstream APIs and that + Calico 3.32 no longer enforces them. +- EgressFirewall → egress rules: Admin-tier CNP `networks` peers (standard), `domainNames` (experimental, + Accept only), Cilium `toFQDNs` for DNS-based egress. +- `oc apply`, `oc adm must-gather`, OpenShift GitOps → `kubectl apply`, `cilium sysdump` / + `calicoctl cluster diags`, Argo CD (self-heal, prune). +- CI lint adds `kyverno test`, `gator verify` and `trivy config`; golden-path provisioning names Kyverno's + `add-network-policy` generate rule. +- "Red Hat's recommended approach", "What Red Hat typically sees", "Red Hat recommends", "lessons Red Hat + hears", "escalation to Red Hat support" and the "Red Hat Advisory" title → neutral wording (the + recommendations now name upstream projects, so they are not attributed to a vendor); "Palo Alto, + Fortinet" → "a traditional perimeter firewall appliance". +- References: the OpenShift and ACS documentation links → network-policy-api (site, v0.2.0 release, + implementations), Calico v3.32 release notes, Cilium network policy, Inspektor Gadget, Kyverno + add-network-policy; the Kubernetes, CNCF and RBAC links stay. + +The exact target text is the file `_plans/s12-manifest/companions/network-policy-process-advisory.md`: copy it +over `lgtm-presentation/network-policy-process-advisory.md` (whole-file replacement; same section order, same +fenced blocks, same three figure references). + +## network-policy-process-advisory.docx (decision 7: paragraph-level edits, generated) + +The docx keeps one paragraph per md line, so the md change maps to paragraph operations. +`tools/companion_docx.py` derives them: it parses both md revisions into the docx paragraph model, checks that +the committed md reproduces the committed docx text exactly (353 blocks before: 351 paragraphs and 2 tables; 371 +after), aligns old and new with difflib and applies: + +| Operation | Count | What | +|---|---|---| +| `set` | 128 | paragraph text replaced; runs rebuilt from the md's `**bold**`, `` `code` `` and `*italic*` spans with the formatting of the paragraph's own runs of that kind (or the first run of that kind in the document) | +| `insert_after` | 18 | new paragraph, a clone of the first paragraph of the same style (Normal, List Bullet, List Number), inserted after the preceding block | +| `delete` | 0 | | +| `set_cell` | 4 | table cells (ownership table row 1; governance table rows Network segmentation, GitOps, Audit) | +| `replace_image` | 3 | `rId9` (image1, tier evaluation), `rId10` (image2, CI/CD pipeline), `rId11` (image3, ownership) get `lgtm-presentation/edits/png/net-advisory-*.png`; the inline extent height is rescaled to the new aspect ratio, width unchanged | + +The full list (old block index, style, new text) is +`_plans/s12-manifest/companions/network-policy-process-advisory.docx-ops.json`. Apply with the S12 venv python: + +```bash +V=/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/venv/bin/python +P=lgtm-presentation/edits/png +$V _plans/s12-manifest/tools/companion_docx.py \ + lgtm-presentation/network-policy-process-advisory.md \ + _plans/s12-manifest/companions/network-policy-process-advisory.md \ + lgtm-presentation/network-policy-process-advisory.docx lgtm-presentation/network-policy-process-advisory.docx \ + --png rId9=$P/net-advisory-tier-evaluation.png rId10=$P/net-advisory-ci-pipeline.png \ + rId11=$P/net-advisory-team-ownership.png +cp _plans/s12-manifest/companions/network-policy-process-advisory.md lgtm-presentation/network-policy-process-advisory.md +``` + +Run the docx step before copying the md (it reads the committed md as the old revision). The script re-reads +its output and fails unless every docx paragraph and table cell equals the new md's text (acceptance +criterion 8); on the scratch copy it printed `{"ok": true, "set": 128, "set_cell": 4, "insert": 18, +"delete": 0, "images": 3, "blocks": 371}`, and a LibreOffice PDF of the result (15 pages) contains no +OpenShift, ACS, OVN or EgressFirewall. Known formatting carry-over (as in the original): a `**bold**` span +that crosses an md line break stays as literal asterisks in the docx (`**default-deny at` / +`the Baseline tier**`), exactly like the committed docx's `**default-deny at` / `the BANP tier**`. + +## After applying + +`python3 scripts/deck_text.py --write` regenerates `lgtm-presentation/text/k8s-environment-policies.md` +and `network-policy-process-advisory.md`; then run the voice scan, `validate_site.py` (positional +check over `lgtm-presentation/*.md` from step 15) and the word-level md/docx comparison. diff --git a/_plans/s12-manifest/companions/network-policy-process-advisory.docx-ops.json b/_plans/s12-manifest/companions/network-policy-process-advisory.docx-ops.json new file mode 100644 index 00000000..f65e0120 --- /dev/null +++ b/_plans/s12-manifest/companions/network-policy-process-advisory.docx-ops.json @@ -0,0 +1,925 @@ +[ + { + "op": "set", + "block": 0, + "style": "Heading 1", + "text": "Network Policy Process — Advisory" + }, + { + "op": "set", + "block": 2, + "style": "Normal", + "text": "on Kubernetes, from perimeter-only to developer-owned NetworkPolicies." + }, + { + "op": "set", + "block": 4, + "style": "Normal", + "text": "This is a recommended approach to building a network policy process" + }, + { + "op": "set", + "block": 7, + "style": "Normal", + "text": " traditional perimeter firewall appliance today." + }, + { + "op": "set", + "block": 8, + "style": "List Bullet", + "text": "Wants to shift network segmentation left into the Kubernetes cluster using" + }, + { + "op": "set", + "block": 9, + "style": "Normal", + "text": " NetworkPolicy, ClusterNetworkPolicy, and egress rules." + }, + { + "op": "set", + "block": 15, + "style": "Normal", + "text": "it with the teams and tools they already have. The tools named are upstream" + }, + { + "op": "set", + "block": 16, + "style": "Normal", + "text": "open-source options: a CNI that enforces NetworkPolicy and ClusterNetworkPolicy" + }, + { + "op": "insert_after", + "after_text": "Normal:open-source options: a CNI that enforces NetworkPolicy and ClusterNetwork", + "style": "Normal", + "text": "(Calico or Cilium), Kyverno or OPA Gatekeeper for admission policy, Trivy for" + }, + { + "op": "insert_after", + "after_text": "Normal:(Calico or Cilium), Kyverno or OPA Gatekeeper for admission policy, Trivy", + "style": "Normal", + "text": "manifest scanning, Falco for runtime detection, and Argo CD for GitOps." + }, + { + "op": "insert_after", + "after_text": "Normal:manifest scanning, Falco for runtime detection, and Argo CD for GitOps.", + "style": "Heading 2", + "text": "1. Where organizations typically start" + }, + { + "op": "set", + "block": 21, + "style": "Normal", + "text": "capacity, and risk tolerance. The four phases are sequential, each building" + }, + { + "op": "set", + "block": 22, + "style": "Normal", + "text": "on the previous, but the pace is agreed upon by the organization." + }, + { + "op": "set", + "block": 24, + "style": "Normal", + "text": "Before writing any policy, understand what traffic flows today." + }, + { + "op": "set", + "block": 25, + "style": "Normal", + "text": "The CNI records the network flows between pods, namespaces, and external" + }, + { + "op": "set", + "block": 26, + "style": "Normal", + "text": "destinations: Hubble with Cilium (hubble observe), or Calico's flow logs" + }, + { + "op": "set", + "block": 27, + "style": "Normal", + "text": "(Goldmane, viewed in the Whisker console). Inspektor Gadget's" + }, + { + "op": "set", + "block": 28, + "style": "Normal", + "text": "advise_networkpolicy gadget records the TCP and UDP traffic of chosen" + }, + { + "op": "set", + "block": 29, + "style": "Normal", + "text": "namespaces. Together they give a network baseline, a map of every" + }, + { + "op": "insert_after", + "after_text": "Normal:namespaces. Together they give a network baseline, a map of every", + "style": "Normal", + "text": "connection the cluster has seen, which becomes the source of truth for what" + }, + { + "op": "insert_after", + "after_text": "Normal:connection the cluster has seen, which becomes the source of truth for wh", + "style": "Normal", + "text": "traffic is expected." + }, + { + "op": "set", + "block": 31, + "style": "Normal", + "text": "and platform team review the observed flows together and identify:" + }, + { + "op": "set", + "block": 36, + "style": "Normal", + "text": "What field teams typically see: organizations are surprised by the volume of" + }, + { + "op": "set", + "block": 45, + "style": "Normal", + "text": "Simultaneously, generate NetworkPolicy YAML from the observed baseline." + }, + { + "op": "set", + "block": 46, + "style": "Normal", + "text": "Inspektor Gadget's advise_networkpolicy writes one policy per workload, with" + }, + { + "op": "set", + "block": 47, + "style": "Normal", + "text": "ingress and egress rules based on the observed peers (Kubernetes NetworkPolicy," + }, + { + "op": "set", + "block": 48, + "style": "Normal", + "text": "or CiliumNetworkPolicy on Cilium). These generated policies are a starting" + }, + { + "op": "insert_after", + "after_text": "Normal:point — they capture what traffic did flow, not what traffic should flow.", + "style": "Normal", + "text": "On Calico, a StagedNetworkPolicy shows in the flow logs what a policy would" + }, + { + "op": "insert_after", + "after_text": "Normal:On Calico, a StagedNetworkPolicy shows in the flow logs what a policy wou", + "style": "Normal", + "text": "deny before it is enforced." + }, + { + "op": "set", + "block": 53, + "style": "Normal", + "text": "What field teams typically see: the first default-deny rollout in a non-prod" + }, + { + "op": "set", + "block": 60, + "style": "Normal", + "text": "guardrails using the upstream ClusterNetworkPolicy (CNP) API, in its" + }, + { + "op": "set", + "block": 61, + "style": "Normal", + "text": "Admin and Baseline tiers." + }, + { + "op": "set", + "block": 71, + "style": "Normal", + "text": "A CNI that implements ClusterNetworkPolicy evaluates traffic against three" + }, + { + "op": "set", + "block": 74, + "style": "Heading 3", + "text": "Tier 1 — ClusterNetworkPolicy, Admin tier" + }, + { + "op": "set", + "block": 77, + "style": "List Bullet", + "text": "Priority: 0 (highest) to 1000 (lowest)" + }, + { + "op": "set", + "block": 78, + "style": "List Bullet", + "text": "Actions: Accept, Deny, or Pass (hand evaluation to the next tier)" + }, + { + "op": "set", + "block": 79, + "style": "List Bullet", + "text": "Override: cannot be overridden by NetworkPolicy or the Baseline tier" + }, + { + "op": "set", + "block": 80, + "style": "List Bullet", + "text": "Status: policy.networking.k8s.io/v1alpha2 (alpha, network-policy-api v0.2.0); enforced by Calico 3.32 and later, and by Cilium 1.20 when enabled" + }, + { + "op": "set", + "block": 81, + "style": "Normal", + "text": "The Admin tier is where the platform team enforces non-negotiable rules: deny" + }, + { + "op": "set", + "block": 82, + "style": "Normal", + "text": "traffic to the control plane from application namespaces, accept monitoring" + }, + { + "op": "insert_after", + "after_text": "Normal:traffic to the control plane from application namespaces, accept monitori", + "style": "Normal", + "text": "agents scraping all pods, deny cross-environment traffic (dev → prod). A Pass" + }, + { + "op": "set", + "block": 85, + "style": "Normal", + "text": "Critical caution: an Admin-tier policy with an empty namespace selector" + }, + { + "op": "set", + "block": 86, + "style": "Normal", + "text": "({}) matches all namespaces, including kube-system and the CNI's own" + }, + { + "op": "set", + "block": 87, + "style": "Normal", + "text": "namespaces. Use workload-specific label selectors. An overly broad Admin-tier" + }, + { + "op": "insert_after", + "after_text": "Normal:namespaces. Use workload-specific label selectors. An overly broad Admin-", + "style": "Normal", + "text": "policy can lock the cluster." + }, + { + "op": "set", + "block": 91, + "style": "List Bullet", + "text": "Override: can be overridden by the Admin tier (which takes precedence)" + }, + { + "op": "set", + "block": 94, + "style": "Normal", + "text": "which ports. Traffic that matches an Admin-tier Accept or Deny rule never reaches" + }, + { + "op": "set", + "block": 95, + "style": "Normal", + "text": "this tier — it is only evaluated if the Admin tier passed the traffic through." + }, + { + "op": "set", + "block": 96, + "style": "Heading 3", + "text": "Tier 3 — ClusterNetworkPolicy, Baseline tier" + }, + { + "op": "set", + "block": 97, + "style": "List Bullet", + "text": "Scope: cluster-wide (one default-deny policy is the recommended shape)" + }, + { + "op": "set", + "block": 99, + "style": "List Bullet", + "text": "Actions: Deny for the safety net, Accept for cluster-wide defaults such as DNS" + }, + { + "op": "set", + "block": 101, + "style": "Normal", + "text": "The Baseline tier is the safety net. It catches traffic that the Admin tier" + }, + { + "op": "set", + "block": 102, + "style": "Normal", + "text": "passed and no NetworkPolicy matched. The recommended use is **default-deny at" + }, + { + "op": "set", + "block": 103, + "style": "Normal", + "text": "the Baseline tier**: if no Admin-tier rule and no NetworkPolicy allows it, the" + }, + { + "op": "set", + "block": 104, + "style": "Normal", + "text": "Baseline tier denies it. This means that a namespace with no NetworkPolicies at all still has a" + }, + { + "op": "insert_after", + "after_text": "Normal:default-deny policy themselves.", + "style": "Normal", + "text": "The earlier releases of the API split these two tiers into" + }, + { + "op": "insert_after", + "after_text": "Normal:The earlier releases of the API split these two tiers into", + "style": "Normal", + "text": "AdminNetworkPolicy and BaselineAdminNetworkPolicy (v1alpha1). Upstream now" + }, + { + "op": "insert_after", + "after_text": "Normal:AdminNetworkPolicy and BaselineAdminNetworkPolicy (v1alpha1). Upstream no", + "style": "Normal", + "text": "lists them as previous APIs, and Calico 3.32 no longer enforces them, so new" + }, + { + "op": "insert_after", + "after_text": "Normal:lists them as previous APIs, and Calico 3.32 no longer enforces them, so ", + "style": "Normal", + "text": "policies use ClusterNetworkPolicy." + }, + { + "op": "set", + "block": 108, + "style": "Normal", + "text": "Diagram: advisory-tier-evaluation — ClusterNetworkPolicy three-tier evaluation" + }, + { + "op": "set", + "block": 111, + "style": "Normal", + "text": "The value proposition for the organization: the platform team controls tiers" + }, + { + "op": "set", + "block": 113, + "style": "Normal", + "text": "policies). Even if a developer writes no NetworkPolicies at all, the Baseline" + }, + { + "op": "set", + "block": 120, + "style": "Normal", + "text": "unrealistic at the start. Four complementary approaches close the gap:" + }, + { + "op": "set", + "block": 121, + "style": "Heading 3", + "text": "a) Generated baselines — developers review, not author" + }, + { + "op": "set", + "block": 122, + "style": "Normal", + "text": "Inspektor Gadget's advise_networkpolicy observes traffic for a chosen window," + }, + { + "op": "insert_after", + "after_text": "Normal:Inspektor Gadget's advise_networkpolicy observes traffic for a chosen win", + "style": "Normal", + "text": "then generates" + }, + { + "op": "set", + "block": 146, + "style": "Heading 3", + "text": "c) The Baseline tier as a safety net" + }, + { + "op": "set", + "block": 147, + "style": "Normal", + "text": "With a Baseline-tier policy that denies traffic no other policy allows, a" + }, + { + "op": "set", + "block": 156, + "style": "List Bullet", + "text": "A default-deny NetworkPolicy (a Kyverno generate rule can create it for every new namespace)" + }, + { + "op": "set", + "block": 157, + "style": "List Bullet", + "text": "Egress rules allowing only approved external destinations" + }, + { + "op": "set", + "block": 174, + "style": "List Bullet", + "text": "Schema check — kubeconform and kubectl apply --dry-run=client (valid YAML, correct API version)" + }, + { + "op": "set", + "block": 175, + "style": "List Bullet", + "text": "Policy lint — conftest / kyverno test / gator verify (organizational rules: no allow-all," + }, + { + "op": "set", + "block": 176, + "style": "Normal", + "text": " required labels, port range limits); trivy config scans the manifests" + }, + { + "op": "set", + "block": 177, + "style": "List Bullet", + "text": "Baseline diff — compare the change with the policy generated from observed flows" + }, + { + "op": "set", + "block": 178, + "style": "Normal", + "text": " and flag rules that would allow traffic outside the baseline" + }, + { + "op": "set", + "block": 179, + "style": "List Bullet", + "text": "Dry-run apply — kubectl apply --dry-run=server against a staging cluster" + }, + { + "op": "set", + "block": 183, + "style": "Normal", + "text": " ClusterNetworkPolicies and egress rules (firewall/security reviews)." + }, + { + "op": "set", + "block": 184, + "style": "List Number", + "text": "Merge triggers GitOps sync — Argo CD syncs to the" + }, + { + "op": "set", + "block": 187, + "style": "List Number", + "text": "Post-deploy validation — Hubble or Calico flow logs confirm no unexpected" + }, + { + "op": "set", + "block": 188, + "style": "Normal", + "text": " flows and the expected deny counts; Falco alerts on unexpected egress at runtime." + }, + { + "op": "set", + "block": 189, + "style": "Heading 3", + "text": "What generated baselines add" + }, + { + "op": "set", + "block": 190, + "style": "Normal", + "text": "Inspektor Gadget's advise_networkpolicy gadget records the traffic of the" + }, + { + "op": "set", + "block": 191, + "style": "Normal", + "text": "selected namespaces and writes the NetworkPolicy that matches it. This closes the loop between" + }, + { + "op": "set", + "block": 192, + "style": "Normal", + "text": "\"what traffic does my app need\" (observed by the CNI) and \"what policy should I" + }, + { + "op": "set", + "block": 193, + "style": "Normal", + "text": "write\" (generated, then validated in CI). The developer never needs to author" + }, + { + "op": "set", + "block": 197, + "style": "Normal", + "text": "A three-layer repository model separates platform-" + }, + { + "op": "set", + "block": 200, + "style": "Normal", + "text": "cluster-policies/\n│\n├── platform-baselines/ # Platform team owns\n│ ├── admin-tier/\n│ │ ├── deny-cross-env.yaml # Block dev ↔ prod traffic\n│ │ ├── accept-monitoring.yaml # Prometheus/OTel can scrape all pods\n│ │ ├── deny-control-plane.yaml # Apps cannot reach API server directly\n│ │ └── pass-app-traffic.yaml # Delegate app-to-app decisions to NP tier\n│ ├── baseline-tier/\n│ │ └── default-deny-baseline.yaml # Safety net: deny anything not explicitly allowed\n│ ├── egress/\n│ │ ├── prod-egress.yaml # Production: allow only approved external CIDRs/DNS\n│ │ ├── test-egress.yaml # Test: same as prod (mirrors topology)\n│ │ └── dev-egress.yaml # Dev: broader access, still no direct internet\n│ └── kustomization.yaml\n│\n├── onboarding/ # Platform team maintains, app teams consume\n│ ├── web-service/\n│ │ ├── default-deny.yaml\n│ │ ├── allow-ingress-controller.yaml\n│ │ ├── allow-egress-db.yaml\n│ │ └── kustomization.yaml\n│ ├── internal-api/\n│ │ ├── default-deny.yaml\n│ │ ├── allow-from-consumers.yaml\n│ │ ├── allow-egress-kafka.yaml\n│ │ └── kustomization.yaml\n│ └── batch-job/\n│ ├── default-deny.yaml\n│ ├── allow-egress-db.yaml\n│ └── kustomization.yaml\n│\n├── app-policies/ # App teams own, platform reviews\n│ ├── orders/\n│ │ ├── base/\n│ │ │ ├── order-service-np.yaml\n│ │ │ ├── inventory-service-np.yaml\n│ │ │ └── kustomization.yaml\n│ │ └── overlays/\n│ │ ├── dev/\n│ │ ├── test/\n│ │ └── prod/\n│ ├── payments/\n│ │ └── ...\n│ └── notifications/\n│ └── ...\n│\n├── CODEOWNERS # Enforces review rules\n│ # /platform-baselines/ @platform-team @firewall-team\n│ # /onboarding/ @platform-team\n│ # /app-policies/orders/ @orders-team @platform-team\n│\n└── .tekton/ or .github/ # CI pipeline definitions\n └── policy-validation.yaml" + }, + { + "op": "set_cell", + "block": 202, + "text": "`platform-baselines/` (Admin tier, Baseline tier, egress rules)" + }, + { + "op": "set", + "block": 211, + "style": "List Number", + "text": "Compare with the baseline — check whether the connection is in the" + }, + { + "op": "set", + "block": 212, + "style": "Normal", + "text": " observed baseline (the generated policies in Git). If it is not, it was" + }, + { + "op": "set", + "block": 213, + "style": "Normal", + "text": " likely never policy-allowed. Decide whether to add" + }, + { + "op": "set", + "block": 215, + "style": "List Number", + "text": "Flow logs — hubble observe --verdict DROPPED (Cilium) or the Calico" + }, + { + "op": "set", + "block": 216, + "style": "Normal", + "text": " flow logs show the dropped flow with source pod, destination and port." + }, + { + "op": "set", + "block": 217, + "style": "Normal", + "text": " Match it with the policies that select the pod to find which tier" + }, + { + "op": "insert_after", + "after_text": "Normal: Match it with the policies that select the pod to find which tier", + "style": "Normal", + "text": " denied the traffic (Admin, NP, or Baseline), which" + }, + { + "op": "set", + "block": 219, + "style": "List Number", + "text": "Diagnostics bundle — cilium sysdump or calicoctl cluster diags" + }, + { + "op": "set", + "block": 220, + "style": "Normal", + "text": " collects network policy state, CNI configuration, and logs for offline" + }, + { + "op": "set", + "block": 221, + "style": "Normal", + "text": " analysis. Use it for an upstream issue or for escalation to your support" + }, + { + "op": "insert_after", + "after_text": "Normal: analysis. Use it for an upstream issue or for escalation to your suppo", + "style": "Normal", + "text": " provider." + }, + { + "op": "set", + "block": 223, + "style": "Normal", + "text": "regularly takes longer, the observability tooling (flow logs, baselines) is not" + }, + { + "op": "set", + "block": 228, + "style": "List Number", + "text": "A platform team member creates a temporary Admin-tier policy with a Pass rule at a" + }, + { + "op": "set", + "block": 230, + "style": "Normal", + "text": " NetworkPolicy tier. If no NetworkPolicy exists for the target, the Baseline tier" + }, + { + "op": "set", + "block": 231, + "style": "Normal", + "text": " (if configured as default-deny) will still deny, so the break-glass" + }, + { + "op": "set", + "block": 240, + "style": "List Bullet", + "text": "Argo CD reports sync status for every policy" + }, + { + "op": "set", + "block": 241, + "style": "Normal", + "text": " manifest. If someone manually applies a policy via kubectl apply instead" + }, + { + "op": "set", + "block": 242, + "style": "Normal", + "text": " of the PR pipeline, Argo CD detects the drift and either alerts or" + }, + { + "op": "set", + "block": 243, + "style": "Normal", + "text": " auto-reverts (self-heal, depending on configuration)." + }, + { + "op": "set", + "block": 244, + "style": "List Bullet", + "text": "Baseline drift — if new traffic flows appear that are not in" + }, + { + "op": "set", + "block": 245, + "style": "Normal", + "text": " the observed baseline, the flow logs and Falco flag them. This catches" + }, + { + "op": "set", + "block": 251, + "style": "List Bullet", + "text": "Namespaces with reviewed baselines vs. those still in observation." + }, + { + "op": "set", + "block": 252, + "style": "List Bullet", + "text": "Admin-tier and Baseline-tier rules in effect and their last-modified dates." + }, + { + "op": "set", + "block": 257, + "style": "Normal", + "text": "It changes their scope and moves their role from gatekeeper to architect." + }, + { + "op": "set", + "block": 259, + "style": "List Bullet", + "text": "North-south edge controls — the perimeter firewall" + }, + { + "op": "set", + "block": 263, + "style": "List Bullet", + "text": "Egress rule authorship — the firewall team authors and reviews" + }, + { + "op": "set", + "block": 264, + "style": "Normal", + "text": " the egress rules that control which external destinations cluster" + }, + { + "op": "set", + "block": 267, + "style": "List Bullet", + "text": "Admin and Baseline tier review — the firewall team reviews all cluster-scoped" + }, + { + "op": "set", + "block": 268, + "style": "Normal", + "text": " network policies (the Admin and Baseline tiers) because these have cluster-wide blast" + }, + { + "op": "set", + "block": 291, + "style": "Normal", + "text": " default-deny NetworkPolicy or Baseline-tier coverage." + }, + { + "op": "set", + "block": 292, + "style": "List Bullet", + "text": "Baselines reviewed — every production namespace has a reviewed" + }, + { + "op": "set", + "block": 293, + "style": "Normal", + "text": " baseline in Git. New flows outside it raise alerts." + }, + { + "op": "set", + "block": 294, + "style": "List Bullet", + "text": "Admin-tier guardrails enforced — cross-environment traffic blocked," + }, + { + "op": "set", + "block": 296, + "style": "List Bullet", + "text": "Baseline-tier default-deny — the cluster-wide safety net catches any" + }, + { + "op": "set", + "block": 297, + "style": "Normal", + "text": " traffic not explicitly allowed by the Admin tier or NetworkPolicy." + }, + { + "op": "set", + "block": 299, + "style": "Normal", + "text": " NetworkPolicy changes through the CI/CD pipeline, not kubectl apply." + }, + { + "op": "set", + "block": 300, + "style": "List Bullet", + "text": "CI validates before merge — schema checks, policy linting, baseline" + }, + { + "op": "set", + "block": 301, + "style": "Normal", + "text": " diff, and dry-run apply run on every PR." + }, + { + "op": "set", + "block": 302, + "style": "List Bullet", + "text": "Egress rules per namespace — outbound traffic is restricted to" + }, + { + "op": "set", + "block": 304, + "style": "List Bullet", + "text": "Firewall team reviews, does not block — turnaround on Admin and Baseline tier" + }, + { + "op": "set", + "block": 306, + "style": "List Bullet", + "text": "Denied-flow triage under 30 minutes — flow logs and Falco alerts" + }, + { + "op": "set", + "block": 307, + "style": "Normal", + "text": " provide immediate root-cause." + }, + { + "op": "set", + "block": 309, + "style": "Normal", + "text": " stale policies removed, baselines refreshed, RBAC bindings" + }, + { + "op": "set", + "block": 311, + "style": "Heading 3", + "text": "What organizations wish they'd done differently" + }, + { + "op": "set", + "block": 312, + "style": "Normal", + "text": "These are the five lessons heard most often from organizations that have" + }, + { + "op": "set", + "block": 313, + "style": "Normal", + "text": "completed this transition:" + }, + { + "op": "set", + "block": 314, + "style": "List Number", + "text": "\"We should have started with observation, not enforcement.\" Teams" + }, + { + "op": "set", + "block": 315, + "style": "Normal", + "text": " that jumped to default-deny without first observing the flows" + }, + { + "op": "set", + "block": 321, + "style": "List Number", + "text": "\"We underestimated the egress work.\" Cataloging every" + }, + { + "op": "set", + "block": 324, + "style": "Normal", + "text": " alongside the flow observation." + }, + { + "op": "set", + "block": 325, + "style": "List Number", + "text": "\"We should have involved the firewall team earlier.\" Teams that" + }, + { + "op": "set", + "block": 327, + "style": "Normal", + "text": " resistance. Teams that positioned it as \"the firewall team's" + }, + { + "op": "set_cell", + "block": 338, + "text": "NetworkPolicy, ClusterNetworkPolicy (Admin and Baseline tiers), egress rules: the focus here" + }, + { + "op": "set_cell", + "block": 338, + "text": "Argo CD sync and drift detection for policies" + }, + { + "op": "set_cell", + "block": 338, + "text": "Flow logs, Falco alerts, break-glass tracking" + }, + { + "op": "set", + "block": 344, + "style": "List Bullet", + "text": "Kubernetes Network Policy API: ClusterNetworkPolicy" + }, + { + "op": "set", + "block": 345, + "style": "List Bullet", + "text": "network-policy-api v0.2.0 release notes" + }, + { + "op": "set", + "block": 346, + "style": "List Bullet", + "text": "Network Policy API implementations" + }, + { + "op": "set", + "block": 347, + "style": "List Bullet", + "text": "Calico v3.32 release notes: ClusterNetworkPolicy" + }, + { + "op": "set", + "block": 348, + "style": "List Bullet", + "text": "Cilium: Kubernetes network policy" + }, + { + "op": "insert_after", + "after_text": "List Bullet:Cilium: Kubernetes network policy", + "style": "List Bullet", + "text": "Inspektor Gadget: advise_networkpolicy" + }, + { + "op": "insert_after", + "after_text": "List Bullet:Inspektor Gadget: advise_networkpolicy", + "style": "List Bullet", + "text": "Kyverno policy: add-network-policy" + }, + { + "op": "replace_image", + "rId": "rId9", + "png": "lgtm-presentation/edits/png/net-advisory-tier-evaluation.png", + "size": [ + 1400, + 747 + ] + }, + { + "op": "replace_image", + "rId": "rId10", + "png": "lgtm-presentation/edits/png/net-advisory-ci-pipeline.png", + "size": [ + 1400, + 476 + ] + }, + { + "op": "replace_image", + "rId": "rId11", + "png": "lgtm-presentation/edits/png/net-advisory-team-ownership.png", + "size": [ + 1400, + 653 + ] + } +] diff --git a/_plans/s12-manifest/companions/network-policy-process-advisory.md b/_plans/s12-manifest/companions/network-policy-process-advisory.md new file mode 100644 index 00000000..dde84e93 --- /dev/null +++ b/_plans/s12-manifest/companions/network-policy-process-advisory.md @@ -0,0 +1,589 @@ +# Network Policy Process — Advisory + +A recommended approach for operationalizing in-cluster network segmentation +on Kubernetes, from perimeter-only to developer-owned NetworkPolicies. + +--- + +## What this document is + +This is a recommended approach to building a network policy process +for an organization that: + +- Runs perimeter and east-west controls through a firewall team on a + traditional perimeter firewall appliance today. +- Wants to shift network segmentation left into the Kubernetes cluster using + NetworkPolicy, ClusterNetworkPolicy, and egress rules. +- Has developers who are not security specialists — most have never written + a firewall rule and do not inherently know what traffic their applications + require. + +**What this document is not:** a tool selection exercise or an ownership +assignment. It describes a process and a maturity path. Organizations apply +it with the teams and tools they already have. The tools named are upstream +open-source options: a CNI that enforces NetworkPolicy and ClusterNetworkPolicy +(Calico or Cilium), Kyverno or OPA Gatekeeper for admission policy, Trivy for +manifest scanning, Falco for runtime detection, and Argo CD for GitOps. + +--- + +## 1. Where organizations typically start + +Most organizations move through a four-phase maturity path. Trying to skip +phases — jumping straight to developer-authored micro-segmentation — is the +most common cause of stalled adoption. + +The timeline for each phase depends on the organization's complexity, team +capacity, and risk tolerance. The four phases are sequential, each building +on the previous, but the pace is agreed upon by the organization. + +### Phase 1 — Observe + +Before writing any policy, understand what traffic flows today. + +The CNI records the network flows between pods, namespaces, and external +destinations: Hubble with Cilium (`hubble observe`), or Calico's flow logs +(Goldmane, viewed in the Whisker console). Inspektor Gadget's +`advise_networkpolicy` gadget records the TCP and UDP traffic of chosen +namespaces. Together they give a **network baseline**, a map of every +connection the cluster has seen, which becomes the source of truth for what +traffic is expected. + +At this stage, nothing is denied. The goal is visibility. The firewall team +and platform team review the observed flows together and identify: + +- Which services talk to each other (and which shouldn't). +- Which services make outbound calls to external endpoints. +- Which connections cross namespace boundaries. +- Which traffic patterns were previously invisible behind the perimeter. + +**What field teams typically see:** organizations are surprised by the volume of +east-west traffic they did not know existed — service-to-service connections +that were never documented because the perimeter firewall was the only +control plane. + +### Phase 2 — Baseline + +Apply **default-deny** NetworkPolicy in every namespace. This is the single +highest-impact security measure — without it, any pod can reach any other +pod in the cluster. + +```yaml +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +``` + +Simultaneously, **generate NetworkPolicy YAML** from the observed baseline. +Inspektor Gadget's `advise_networkpolicy` writes one policy per workload, with +ingress and egress rules based on the observed peers (Kubernetes NetworkPolicy, +or CiliumNetworkPolicy on Cilium). These generated policies are a starting +point — they capture what traffic *did* flow, not what traffic *should* flow. +On Calico, a StagedNetworkPolicy shows in the flow logs what a policy would +deny before it is enforced. + +The platform team reviews the generated policies, removes any connections +that should not have existed (the audit value of this step is significant), +and applies the cleaned-up policies alongside the default-deny. + +**What field teams typically see:** the first default-deny rollout in a non-prod +namespace immediately reveals undocumented dependencies — a monitoring agent +that scrapes every pod, a legacy batch job that connects to an external +database, a sidecar that phones home to a SaaS endpoint. Discovering these +in dev or test is the point. + +### Phase 3 — Guardrails + +With baseline policies in place, the platform team introduces cluster-scoped +guardrails using the upstream **ClusterNetworkPolicy (CNP)** API, in its +**Admin** and **Baseline** tiers. + +These are the policies the platform team owns — they cannot be overridden by +developers, and they establish the security boundaries that all applications +inherit. + +### Phase 4 — Developer ownership (ongoing) + +App teams begin authoring and owning their own namespace-scoped +NetworkPolicies. They do this via pull request, with CI validation and +platform team review. The CI/CD flow section describes +this workflow in detail. + +--- + +## 2. The three-tier policy model + +A CNI that implements ClusterNetworkPolicy evaluates traffic against three +tiers of policy, in order. Understanding this model is critical — it +determines who controls what and where overrides are possible. + +### Tier 1 — ClusterNetworkPolicy, Admin tier + +- **Scope:** cluster-wide (not namespace-scoped) +- **Controlled by:** platform / infrastructure team +- **Priority:** 0 (highest) to 1000 (lowest) +- **Actions:** Accept, Deny, or **Pass** (hand evaluation to the next tier) +- **Override:** cannot be overridden by NetworkPolicy or the Baseline tier +- **Status:** `policy.networking.k8s.io/v1alpha2` (alpha, network-policy-api v0.2.0); enforced by Calico 3.32 and later, and by Cilium 1.20 when enabled + +The Admin tier is where the platform team enforces non-negotiable rules: deny +traffic to the control plane from application namespaces, accept monitoring +agents scraping all pods, deny cross-environment traffic (dev → prod). A **Pass** +rule explicitly delegates the decision to the next tier (NetworkPolicy), +which is how the platform team says "app teams, this one is yours to decide." + +**Critical caution:** an Admin-tier policy with an empty namespace selector +(`{}`) matches *all* namespaces, including `kube-system` and the CNI's own +namespaces. Use workload-specific label selectors. An overly broad Admin-tier +policy can lock the cluster. + +### Tier 2 — NetworkPolicy (NP) + +- **Scope:** namespace-scoped +- **Controlled by:** app teams (developers) +- **Override:** can be overridden by the Admin tier (which takes precedence) + +This is the standard Kubernetes NetworkPolicy API. App teams use it to +define which pods in their namespace accept traffic from which sources, on +which ports. Traffic that matches an Admin-tier Accept or Deny rule never reaches +this tier; it is evaluated unless an Admin-tier rule accepted or denied the traffic. + +### Tier 3 — ClusterNetworkPolicy, Baseline tier + +- **Scope:** cluster-wide (one default-deny policy is the recommended shape) +- **Controlled by:** platform / infrastructure team +- **Actions:** Deny for the safety net, Accept for cluster-wide defaults such as DNS +- **Override:** *can* be overridden by NetworkPolicy + +The Baseline tier is the safety net. It decides traffic that no Admin-tier rule +accepted or denied and no NetworkPolicy covers. The recommended use is **default-deny at +the Baseline tier**: if no Admin-tier rule and no NetworkPolicy allows it, the +Baseline tier denies it. This means that a namespace with no NetworkPolicies at all still has a +default-deny posture, without requiring the app team to create the +default-deny policy themselves. + +The earlier releases of the API split these two tiers into +AdminNetworkPolicy and BaselineAdminNetworkPolicy (`v1alpha1`). Upstream now +lists them as previous APIs, and Calico 3.32 no longer enforces them, so new +policies use ClusterNetworkPolicy. + +### How the tiers compose + +*Diagram: advisory-tier-evaluation — ClusterNetworkPolicy three-tier evaluation* + +![Three-tier policy evaluation](../assets/diagrams/advisory-tier-evaluation.svg) + +**The value proposition for the organization:** the platform team controls tiers +1 and 3 (the guardrails). Developers control tier 2 (their application's +policies). Even if a developer writes no NetworkPolicies at all, the Baseline +default-deny ensures the namespace is not wide open. This directly addresses +the developer skill gap — the platform provides safety by default. + +--- + +## 3. Handling the developer skill gap + +Developers are not firewall engineers. Expecting them to author network +policies from scratch — knowing which ports their services listen on, which +upstream services call them, and which external endpoints they need — is +unrealistic at the start. Four complementary approaches close the gap: + +### a) Generated baselines — developers review, not author + +Inspektor Gadget's `advise_networkpolicy` observes traffic for a chosen window, +then generates +NetworkPolicy YAML that matches the observed flows. The developer's job is +to *review* the generated policy — "does this look right? should this +connection exist?" — not to author it from scratch. This inverts the skill +requirement: reviewing a concrete policy is dramatically easier than writing +one from zero. + +Over time, as developers become familiar with the policy format, they begin +modifying and authoring policies directly. But the starting point is always +a generated baseline, not a blank file. + +### b) Platform-authored starter templates + +The platform team maintains a library of NetworkPolicy templates for common +workload archetypes: + +- **Web-facing service** — accepts ingress from the ingress controller on + port 8080/8443, allows egress to backend services and the database. +- **Internal API** — accepts ingress from specific consumer services, allows + egress to the database and Kafka. +- **Batch job** — no ingress, allows egress to the database and specific + external endpoints. +- **Database** — accepts ingress from application services on port 5432, + no egress. + +Developers pick the template that matches their workload, customize the +namespace and label selectors, and submit a PR. The template handles the +hard parts (deny-all baseline, correct policyTypes, CIDR ranges for external +services). + +### c) The Baseline tier as a safety net + +With a Baseline-tier policy that denies traffic no other policy allows, a +developer who deploys a new service without any NetworkPolicy gets +default-deny behavior automatically. Their service cannot send or receive +traffic until they (or the platform) create a policy. This is strict but +safe — it converts "I forgot to write a policy" from a security gap into a +connectivity issue that surfaces immediately in testing. + +### d) Golden-path namespace provisioning + +When a new namespace is provisioned (via GitOps, a self-service portal, or +an operator), the provisioning process automatically includes: + +- A default-deny NetworkPolicy (a Kyverno generate rule can create it for every new namespace) +- Egress rules allowing only approved external destinations +- Starter NetworkPolicy templates in a `policies/` directory +- RBAC bindings giving the app team edit access to NetworkPolicy resources + +The developer starts with a working, secured namespace. They never see the +"everything is open, now lock it down" starting point that leads to +procrastination. + +--- + +## 4. CI/CD flow end-to-end + +Network policy changes follow the same CI/CD pipeline as application code. +The principle is the same: **the CI pipeline is the only path to production.** + +### The flow + +*Diagram: advisory-ci-pipeline — network policy CI/CD pipeline* + +![Policy CI/CD pipeline](../assets/diagrams/advisory-ci-pipeline.svg) + +The pipeline stages: + +1. **Developer** authors or modifies NetworkPolicy YAML and opens a PR + against the policy repo. +2. **CI validates automatically:** + - Schema check — `kubeconform` and `kubectl apply --dry-run=client` (valid YAML, correct API version) + - Policy lint — conftest / `kyverno test` / `gator verify` (organizational rules: no allow-all, + required labels, port range limits); `trivy config` scans the manifests + - Baseline diff — compare the change with the policy generated from observed flows + and flag rules that would allow traffic outside the baseline + - Dry-run apply — `kubectl apply --dry-run=server` against a staging cluster + (catches admission denials, quota issues) +3. **Platform / security team reviews the PR** — CODEOWNERS enforces: app + team authors NetworkPolicies (platform reviews), platform authors + ClusterNetworkPolicies and egress rules (firewall/security reviews). +4. **Merge triggers GitOps sync** — Argo CD syncs to the + staging cluster, smoke tests validate connectivity, then syncs to + production. +5. **Post-deploy validation** — Hubble or Calico flow logs confirm no unexpected + flows and the expected deny counts; Falco alerts on unexpected egress at runtime. + +### What generated baselines add + +Inspektor Gadget's `advise_networkpolicy` gadget records the traffic of the +selected namespaces and writes the NetworkPolicy that matches it. This closes the loop between +"what traffic does my app need" (observed by the CNI) and "what policy should I +write" (generated, then validated in CI). The developer never needs to author +a policy from scratch — they generate one from the baseline, review it, and +submit the PR. + +--- + +## 5. Repo structure + +A three-layer repository model separates platform- +owned policies from application-owned policies, with clear ownership and +review boundaries. + +``` +cluster-policies/ +│ +├── platform-baselines/ # Platform team owns +│ ├── admin-tier/ +│ │ ├── deny-cross-env.yaml # Block dev ↔ prod traffic +│ │ ├── accept-monitoring.yaml # Prometheus/OTel can scrape all pods +│ │ ├── deny-control-plane.yaml # Apps cannot reach API server directly +│ │ └── pass-app-traffic.yaml # Delegate app-to-app decisions to NP tier +│ ├── baseline-tier/ +│ │ └── default-deny-baseline.yaml # Safety net: deny anything not explicitly allowed +│ ├── egress/ +│ │ ├── prod-egress.yaml # Production: allow only approved external CIDRs/DNS +│ │ ├── test-egress.yaml # Test: same as prod (mirrors topology) +│ │ └── dev-egress.yaml # Dev: broader access, still no direct internet +│ └── kustomization.yaml +│ +├── onboarding/ # Platform team maintains, app teams consume +│ ├── web-service/ +│ │ ├── default-deny.yaml +│ │ ├── allow-ingress-controller.yaml +│ │ ├── allow-egress-db.yaml +│ │ └── kustomization.yaml +│ ├── internal-api/ +│ │ ├── default-deny.yaml +│ │ ├── allow-from-consumers.yaml +│ │ ├── allow-egress-kafka.yaml +│ │ └── kustomization.yaml +│ └── batch-job/ +│ ├── default-deny.yaml +│ ├── allow-egress-db.yaml +│ └── kustomization.yaml +│ +├── app-policies/ # App teams own, platform reviews +│ ├── orders/ +│ │ ├── base/ +│ │ │ ├── order-service-np.yaml +│ │ │ ├── inventory-service-np.yaml +│ │ │ └── kustomization.yaml +│ │ └── overlays/ +│ │ ├── dev/ +│ │ ├── test/ +│ │ └── prod/ +│ ├── payments/ +│ │ └── ... +│ └── notifications/ +│ └── ... +│ +├── CODEOWNERS # Enforces review rules +│ # /platform-baselines/ @platform-team @firewall-team +│ # /onboarding/ @platform-team +│ # /app-policies/orders/ @orders-team @platform-team +│ +└── .tekton/ or .github/ # CI pipeline definitions + └── policy-validation.yaml +``` + +### Ownership model + +| Layer | Authors | Reviewers | Scope | +|-------|---------|-----------|-------| +| `platform-baselines/` (Admin tier, Baseline tier, egress rules) | Platform team | Firewall / security team | Cluster-wide | +| `onboarding/` (templates) | Platform team | Platform team | Reusable templates | +| `app-policies/` (NetworkPolicy) | App teams | Platform team | Per-namespace | + +### Environment promotion + +Kustomize overlays handle environment differences. The `base/` directory +contains the policy shape; overlays adjust namespace names, label selectors, +and external CIDR ranges per environment. The same PR that modifies a +policy flows through the same CI pipeline regardless of target environment. + +--- + +## 6. Day-2 operations + +### Denied-flow triage + +When a developer reports "my service can't reach X," the triage path is: + +1. **Compare with the baseline** — check whether the connection is in the + observed baseline (the generated policies in Git). If it is not, it was + likely never policy-allowed. Decide whether to add + a policy or investigate why the new connection exists. + +2. **Flow logs** — `hubble observe --verdict DROPPED` (Cilium) or the Calico + flow logs show the dropped flow with source pod, destination and port. + Match it with the policies that select the pod to find *which tier* + denied the traffic (Admin, NP, or Baseline), which + tells you who needs to update the policy. + +3. **Diagnostics bundle** — `cilium sysdump` or `calicoctl cluster diags` + collects network policy state, CNI configuration, and logs for offline + analysis. Use it for an upstream issue or for escalation to your support + provider. + +**Target:** denied-flow triage should take under 30 minutes. If it +regularly takes longer, the observability tooling (flow logs, baselines) is not +configured correctly. + +### Break-glass process + +When a denied flow is blocking a production incident and the normal PR +process is too slow: + +1. A platform team member creates a **temporary Admin-tier policy with a Pass rule** at a + high priority (low number), handing the traffic decision to the + NetworkPolicy tier. If no NetworkPolicy exists for the target, the Baseline tier + (if configured as default-deny) will still deny, so the break-glass + may need a temporary NetworkPolicy as well. + +2. The temporary rule is **time-boxed** (label it with an expiration date + annotation) and **logged** (create an incident ticket referencing the + change). + +3. Within 24 hours, the proper policy change goes through the normal PR + and CI pipeline. The temporary rule is removed. + +4. All break-glass events are reviewed in the quarterly policy review. + +### Drift detection + +- **Argo CD** reports sync status for every policy + manifest. If someone manually applies a policy via `kubectl apply` instead + of the PR pipeline, Argo CD detects the drift and either alerts or + auto-reverts (self-heal, depending on configuration). + +- **Baseline drift** — if new traffic flows appear that are not in + the observed baseline, the flow logs and Falco flag them. This catches + both policy drift (someone added a rule manually) and application + drift (a new service started making unexpected connections). + +### Policy coverage reporting + +Maintain a dashboard or periodic report showing: + +- Namespaces with default-deny in place vs. those without. +- Namespaces with reviewed baselines vs. those still in observation. +- Admin-tier and Baseline-tier rules in effect and their last-modified dates. +- Break-glass events in the current quarter. +- Denied-flow triage times (mean, p95). + +--- + +## 7. The firewall team's evolving role + +The shift to in-cluster network policy does not eliminate the firewall team. +It changes their scope and moves their role from gatekeeper to architect. + +### What stays with the firewall team + +- **North-south edge controls** — the perimeter firewall + continues to manage traffic entering and leaving the data center or + cloud VPC. This is where TLS inspection, DDoS mitigation, and + compliance logging (PCI, HIPAA) remain. + +- **Egress rule authorship** — the firewall team authors and reviews + the egress rules that control which external destinations cluster + workloads can reach. This is their domain expertise (approved CIDRs, + DNS-based rules, compliance-required blocks). + +- **Admin and Baseline tier review** — the firewall team reviews all cluster-scoped + network policies (the Admin and Baseline tiers) because these have cluster-wide blast + radius. The platform team authors them; the firewall team approves. + +### What shifts to the platform and app teams + +- **East-west traffic segmentation** — service-to-service traffic within + the cluster is segmented by NetworkPolicy, authored by app teams and + reviewed by the platform team. The firewall team does not need to + approve every pod-to-pod rule — that does not scale. + +- **NetworkPolicy authorship** — app teams own their namespace-scoped + policies. They know which services call which endpoints. The platform + team reviews for correctness and compliance; the firewall team is + consulted for cross-namespace or unusual egress patterns. + +### The new relationship + +*Diagram: advisory-team-ownership — policy ownership model* + +![Policy ownership model](../assets/diagrams/advisory-team-ownership.svg) + +The firewall team moves from "approve every rule" to "set the boundaries +and review the exceptions." This is faster for developers, safer for the +organization, and a better use of the firewall team's expertise. + +--- + +## 8. What "good" looks like at maturity + +### Maturity checklist + +After sustained effort over a maturity period agreed upon by the +organization, a mature network policy practice looks like this: + +- [ ] **Default-deny in every namespace** — no namespace exists without a + default-deny NetworkPolicy or Baseline-tier coverage. +- [ ] **Baselines reviewed** — every production namespace has a reviewed + baseline in Git. New flows outside it raise alerts. +- [ ] **Admin-tier guardrails enforced** — cross-environment traffic blocked, + monitoring access allowed, control-plane access restricted. +- [ ] **Baseline-tier default-deny** — the cluster-wide safety net catches any + traffic not explicitly allowed by the Admin tier or NetworkPolicy. +- [ ] **App teams authoring policies via PR** — developers submit + NetworkPolicy changes through the CI/CD pipeline, not `kubectl apply`. +- [ ] **CI validates before merge** — schema checks, policy linting, baseline + diff, and dry-run apply run on every PR. +- [ ] **Egress rules per namespace** — outbound traffic is restricted to + approved external destinations in every namespace. +- [ ] **Firewall team reviews, does not block** — turnaround on Admin and Baseline tier + reviews is under 48 hours. +- [ ] **Denied-flow triage under 30 minutes** — flow logs and Falco alerts + provide immediate root-cause. +- [ ] **Quarterly policy review** — all break-glass events reviewed, + stale policies removed, baselines refreshed, RBAC bindings + audited. + +### What organizations wish they'd done differently + +These are the five lessons heard most often from organizations that have +completed this transition: + +1. **"We should have started with observation, not enforcement."** Teams + that jumped to default-deny without first observing the flows + caused outages from denied traffic they did not know existed. The observe + phase pays for itself. + +2. **"We over-engineered the templates."** The first set of starter templates + should be simple — three to five archetypes, not twenty. Start broad + and refine based on actual PR volume and developer feedback. + +3. **"We underestimated the egress work."** Cataloging every + external endpoint every service needs to reach is the most + time-consuming part of the shift. Start this inventory in Phase 1 + alongside the flow observation. + +4. **"We should have involved the firewall team earlier."** Teams that + presented in-cluster policy as a replacement for the firewall team met + resistance. Teams that positioned it as "the firewall team's + expertise applied at a new layer" got buy-in. + +5. **"We needed a break-glass process on day one."** The first denied-flow + incident in production without a break-glass process caused panic and + a rollback of all policies. Define the break-glass process before you + enforce anything in production. + +--- + +## 9. Alignment with environment governance + +This network policy process fits within a broader environment governance +framework covering RBAC, admission control, GitOps, and audit. The +companion document — *Kubernetes Environment Access Policies* — covers the +full stack: + +| Governance layer | This document | Companion document | +|-----------------|---------------|-------------------| +| Network segmentation | NetworkPolicy, ClusterNetworkPolicy (Admin and Baseline tiers), egress rules: the focus here | Default-deny baseline, environment-specific allow rules | +| RBAC | Who can create/modify network policies | Full RBAC matrix by environment (dev/test/prod) | +| Admission control | Policy linting in CI (conftest, kyverno) | OPA Gatekeeper ConstraintTemplates | +| GitOps | Argo CD sync and drift detection for policies | Full GitOps-gated production change pipeline | +| Audit | Flow logs, Falco alerts, break-glass tracking | API server audit logs, OPA decision logs, Git history | +| Namespace isolation | NetworkPolicy as one of four controls | RBAC + NetworkPolicy + ResourceQuota + LimitRange | + +The network policy process described here is one vertical slice of that +broader model. Organizations should implement both together — network +segmentation without RBAC governance is incomplete, and RBAC without +network segmentation leaves east-west traffic uncontrolled. + +--- + +## References + +- [Kubernetes Network Policy API: ClusterNetworkPolicy](https://network-policy-api.sigs.k8s.io/) +- [network-policy-api v0.2.0 release notes](https://github.com/kubernetes-sigs/network-policy-api/releases/tag/v0.2.0) +- [Network Policy API implementations](https://network-policy-api.sigs.k8s.io/implementations/) +- [Calico v3.32 release notes: ClusterNetworkPolicy](https://github.com/projectcalico/calico/blob/release-v3.32/release-notes/v3.32.0-release-notes.md) +- [Cilium: Kubernetes network policy](https://docs.cilium.io/en/stable/network/kubernetes/policy/) +- [Inspektor Gadget: advise_networkpolicy](https://inspektor-gadget.io/docs/latest/gadgets/advise_networkpolicy) +- [Kyverno policy: add-network-policy](https://kyverno.io/policies/best-practices/add-network-policy/add-network-policy/) +- [Kubernetes Network Policies](https://kubernetes.io/docs/concepts/services-networking/network-policies/) +- [CNCF Cloud Native Security Whitepaper](https://github.com/cncf/tag-security/tree/main/security-whitepaper) +- [Kubernetes RBAC Good Practices](https://kubernetes.io/docs/concepts/security/rbac-good-practices/) +- Companion: [Kubernetes Environment Access Policies](k8s-environment-policies.md) diff --git a/_plans/s12-manifest/denylist.json b/_plans/s12-manifest/denylist.json new file mode 100644 index 00000000..b6c8dbff --- /dev/null +++ b/_plans/s12-manifest/denylist.json @@ -0,0 +1,236 @@ +[ + { + "id": "old-engine", + "regex": "p[o]dman", + "flags": "i", + "why": "Docker Engine / docker compose is the runtime (S1)" + }, + { + "id": "poetry", + "regex": "\\bPoetry\\b|\\bpoetry (install|add|lock|run)\\b", + "why": "Python examples use pip with a full == requirements.txt freeze (S5)" + }, + { + "id": "fastapi-on-event", + "regex": "@app\\.on_event\\b|\\.on_event\\(", + "why": "FastAPI on_event replaced by lifespan (S5)" + }, + { + "id": "faust", + "regex": "\\bfaust\\b(?![-.])", + "flags": "i", + "exempt": "faust-streaming", + "why": "stream processing is faust-streaming 0.15.3 (S5); its Python module is `faust` (faust.App), which stays" + }, + { + "id": "modern-cpp-kafka", + "regex": "modern-cpp-kafka|kafka::clients", + "flags": "i", + "exempt": "archived", + "why": "C++ Kafka is librdkafka's C++ API rdkafka++ (S7)" + }, + { + "id": "masstransit", + "regex": "MassTransit", + "flags": "i", + "exempt": "dropped|commercial licen[cs]e", + "why": ".NET Kafka is Confluent.Kafka; MassTransit dropped (S4)" + }, + { + "id": "kafka-ui", + "regex": "kafka-ui", + "flags": "i", + "why": "kafka-ui removed; kcat or kafka-console-consumer.sh (S8)" + }, + { + "id": "kubeval", + "regex": "\\bkubeval\\b", + "flags": "i", + "exempt": "kubeconform", + "why": "kubeval is unmaintained; kubeconform v0.8.0" + }, + { + "id": "libpqxx", + "regex": "\\b(lib)?pqxx\\b", + "exempt": "\\bnot (lib)?pqxx\\b", + "why": "C++ uses libpq directly (S7)" + }, + { + "id": "cxx-old-standard", + "regex": "[Cc]\\+\\+ ?(17|20)\\b|c\\+\\+2a|-std=c\\+\\+(17|20)|CMAKE_CXX_STANDARD (17|20)\\b", + "exempt": "\\b(since|in|from) [Cc]\\+\\+ ?(17|20)\\b", + "why": "C++23 (S7); history ('since C++20') stays" + }, + { + "id": "gcc-old", + "regex": "\\bGCC 1[0-4]\\b|gcc-toolset-1[0-4]\\b", + "why": "GCC 15 via gcc-toolset-15 (S7)" + }, + { + "id": "cmake-old", + "regex": "\\bCMake 3\\.\\d+", + "exempt": "UBI ships", + "why": "CMake 4.4.4 (S7)" + }, + { + "id": "quarkus-old", + "regex": "\\bQuarkus 3\\.(?!40\\b)\\d+", + "why": "Quarkus 3.40.1 LTS (S2)" + }, + { + "id": "spring-boot-old", + "regex": "\\bSpring Boot (3\\.\\d+|3\\.x|4\\.0)\\b", + "exempt": "migrat|upgrad|from Spring Boot 3", + "why": "Spring Boot 4.1.1 (S3)" + }, + { + "id": "go-old", + "regex": "\\bGo 1\\.(1\\d|2[0-6])\\b|golang:1\\.(1\\d|2[0-6])\\b", + "exempt": "go-toolset|Go 1\\.22\\b|since Go 1\\.\\d+", + "why": "Go 1.27.2 (S6); Go 1.22 (method-aware ServeMux patterns) is the version a feature arrived in and stays" + }, + { + "id": "python-old", + "regex": "\\bPython 3\\.([0-9]|1[0-3])\\b|python:3\\.([0-9]|1[0-3])\\b|python-3(9|1[0-3])\\b", + "why": "Python 3.14 (S5)" + }, + { + "id": "dotnet-old-tfm", + "regex": "\\bnet[5-9]\\.0\\b|dotnet-[5-9]0\\b|dotnet/(sdk|aspnet):[5-9]\\.", + "why": ".NET 10 (net10.0, ubi9/dotnet-100) (S4)" + }, + { + "id": "ubi8", + "regex": "\\bubi8\\b", + "flags": "i", + "why": "UBI 10 (UBI 9 only for .NET)" + }, + { + "id": "confluent-kafka-version", + "regex": "Confluent\\.Kafka[\\\" ]*(Version=\\\")?[ @]?2\\.(?!16\\.0)\\d+\\.\\d+", + "why": "Confluent.Kafka 2.16.0 (S4)" + }, + { + "id": "streamiz-version", + "regex": "Streamiz(\\.Kafka\\.Net)?[\\\" ]*(Version=\\\")?[ @]?1\\.(?!8\\.2)\\d+\\.\\d+", + "why": "Streamiz.Kafka.Net 1.8.2 (S4)" + }, + { + "id": "jackson2", + "regex": "com\\.fasterxml\\.jackson\\.(databind|core)\\b|\\bJackson 2\\b", + "why": "Jackson 3 (tools.jackson) with Spring Boot 4 (S3)" + }, + { + "id": "spring-kafka-json-serde", + "regex": "support\\.serializer\\.Json(Serializer|Deserializer)\\b", + "why": "Spring Kafka 4 uses the Jackson 3 JacksonJson serializers (S3)" + }, + { + "id": "topic-order-placed", + "regex": "\\border-placed\\b", + "exempt": "artifact|@(Channel|Incoming|Outgoing)\\(|mp\\.messaging\\.|channel|\\.avsc", + "why": "the Kafka topic is order.placed; the Quarkus channel name order-placed (bound to topic order.placed, as in every Quarkus example) and the Apicurio artifact id order-placed (Example 09) stay" + }, + { + "id": "graphql-go", + "regex": "\\bgraphql-go\\b|github\\.com/graphql-go/", + "why": "Go GraphQL is gqlgen (S6)" + }, + { + "id": "go-deck-python-leftovers", + "regex": "pyproject|requirements\\.txt|\\bpip install\\b|uvicorn|FastAPI", + "files": [ + "Designing-Cloud-Native-APIs-Go" + ], + "why": "the Go deck was retargeted from the Python deck; no Python tooling belongs in it" + }, + { + "id": "ocp-docs-old", + "regex": "openshift_container_platform/4\\.(1\\d|2[01])/", + "why": "OpenShift 4.22 (CRC 2.64.0 / OCP 4.22.14)" + }, + { + "id": "kafka-image", + "regex": "apache/kafka:(?!4\\.3\\.1\\b)\\d+\\.\\d+", + "why": "apache/kafka:4.3.1 (S8)" + }, + { + "id": "postgres-image", + "regex": "\\bpostgres:(?!18\\.6-alpine\\b)\\d+\\.\\d+", + "why": "postgres:18.6-alpine (S8)" + }, + { + "id": "redis-image", + "regex": "\\bredis:(?!8\\.10\\.2-alpine\\b)\\d+\\.\\d+", + "why": "redis:8.10.2-alpine (S8)" + }, + { + "id": "otel-lgtm-image", + "regex": "otel-lgtm:(?!0\\.36\\.0\\b)\\d", + "why": "grafana/otel-lgtm:0.36.0 (S8)" + }, + { + "id": "envoy-image", + "regex": "envoyproxy/envoy:v(?!1\\.39\\.3\\b)\\d", + "why": "envoyproxy/envoy:v1.39.3 (S8)" + }, + { + "id": "apicurio-image", + "regex": "apicurio-registry:(?!3\\.3\\.3\\b)\\d", + "why": "apicurio-registry:3.3.3 (S8)" + }, + { + "id": "flagd-image", + "regex": "flagd:v(?!0\\.17\\.0\\b)\\d", + "why": "ghcr.io/open-feature/flagd:v0.17.0 (S8)" + }, + { + "id": "debezium-image", + "regex": "debezium/connect:(?!3\\.7\\.0\\.Final\\b)\\d", + "why": "quay.io/debezium/connect:3.7.0.Final (S8)" + }, + { + "id": "five-implementations", + "regex": "\\b(five|5) (implementations|backends)\\b", + "flags": "i", + "why": "six language implementations (Go added)" + }, + { + "id": "latest-tag", + "regex": "[a-z0-9]:latest\\b", + "exempt": "never|no |not |nothing uses|sketchy|\\bblock\\b", + "why": "every image is pinned; :latest appears only as the thing not to do" + }, + { + "id": "cloud-sdk", + "regex": "aws-sdk|\\bboto3\\b|AWSSDK|IAmazonS3|software\\.amazon|@aws-sdk|AWS SDK|azure-sdk|Azure\\.Storage|google-cloud-|cloud\\.google\\.com/go", + "flags": "i", + "why": "decision 7: no vendor cloud SDKs; valet key and claim check follow examples/12-security (a ticket the service signs itself)" + }, + { + "id": "vendor-cloud", + "regex": "\\bAWS\\b|\\bAmazon\\b|\\bS3\\b|\\bSTS\\b|Azure|\\bGCS\\b|Google Cloud|Google IAP|\\bEKS\\b|\\bGKE\\b|\\bAKS\\b|CloudTrail|AWS Lambda|Cloudflare Access|Apigee|Confluent Cloud", + "why": "decision 7: no AWS, Azure, Google Cloud or other managed-service framing in examples or recommendations (object storage is described generically)" + }, + { + "id": "minio", + "regex": "minio-go|\\bminio\\b", + "flags": "i", + "why": "decision 7: minio-go removed (Go valet key is a golang-jwt ticket)" + }, + { + "id": "acs", + "regex": "\\bACS\\b|[Aa]dvanced [Cc]luster [Ss]ecurity|StackRox|roxctl", + "why": "decision 7: nothing relies on ACS; the network advisory uses CNI flow logs, Inspektor Gadget, Kyverno/Gatekeeper, Trivy and Falco" + }, + { + "id": "openshift", + "regex": "OpenShift|\\bOCP\\b|EgressFirewall|OperatorHub|\\bSCCs?\\b|OVN-K|\\boc (apply|adm|get|create|new-project)\\b", + "why": "decision 7: decks carry no Appendix S and no OpenShift-only feature; generic Kubernetes (Ingress/Gateway API, Pod Security Standards, Helm/upstream operators)" + }, + { + "id": "vendor-framework", + "regex": "Cloud Adoption Framework|Well-Architected Framework|Azure Architecture Center|AWS Prescriptive Guidance", + "why": "decision 7 (fix round A): cite vendor-neutral references (CNCF, NIST, OWASP, the books) instead of a cloud vendor's framework" + } +] diff --git a/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Cpp.json b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Cpp.json new file mode 100644 index 00000000..ac741f83 --- /dev/null +++ b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Cpp.json @@ -0,0 +1,344 @@ +{ + "entries": [ + { + "sldId": 279, + "shape_id": 270, + "resize": { + "cx": 1826230, + "keep": "right" + }, + "_why": "slide 24: chip 'c++ · cppgraphqlgen' wraps; one line at 11.0 pt" + }, + { + "sldId": 279, + "shape_id": 271, + "pt": 9.5, + "_why": "slide 24: 24 code lines, 82 columns; 10.0 pt overflows the box" + }, + { + "sldId": 279, + "shape_id": 272, + "resize": { + "y": 5712714, + "cy": 492506 + }, + "_why": "slide 24: text overflows its frame by overflow-top 3.6 pt, overflow-bottom 4.7 pt; free space around the frame" + }, + { + "sldId": 284, + "shape_id": 326, + "resize": { + "cx": 1826230, + "keep": "right" + }, + "_why": "slide 29: chip 'c++ · libpq + kafka' wraps; one line at 11.0 pt" + }, + { + "sldId": 291, + "shape_id": 407, + "pt": 9.5, + "_why": "slide 36: 24 code lines, 88 columns; 10.0 pt overflows the box" + }, + { + "sldId": 319, + "shape_id": 714, + "resize": { + "cx": 1826230, + "keep": "right" + }, + "_why": "slide 64: chip 'c++ · OpenTelemetry' wraps; one line at 11.0 pt" + }, + { + "sldId": 321, + "shape_id": 738, + "resize": { + "cx": 1826230, + "keep": "right" + }, + "_why": "slide 66: chip 'c++ · OpenTelemetry' wraps; one line at 11.0 pt" + }, + { + "sldId": 333, + "shape_id": 866, + "resize": { + "cx": 2077689, + "keep": "right" + }, + "_why": "slide 78: chip 'c++ · Drogon + jwt-cpp' wraps; one line at 11.0 pt" + }, + { + "sldId": 338, + "shape_id": 922, + "pt": 9.0, + "_why": "slide 83: 25 code lines, 85 columns; 10.0 pt overflows the box" + }, + { + "sldId": 340, + "shape_id": 946, + "pt": 9.5, + "_why": "slide 85: 24 code lines, 65 columns; 10.0 pt overflows the box" + }, + { + "sldId": 342, + "shape_id": 969, + "resize": { + "cx": 2329150, + "keep": "right" + }, + "_why": "slide 87: chip 'c++ · rdkafka++ + jwt-cpp' wraps; one line at 11.0 pt" + }, + { + "sldId": 365, + "shape_id": 1216, + "pt": 9.0, + "_why": "slide 110: 25 code lines, 73 columns; 10.0 pt overflows the box" + }, + { + "sldId": 368, + "shape_id": 1249, + "pt": 9.0, + "_why": "slide 113: 25 code lines, 79 columns; 10.0 pt overflows the box" + }, + { + "sldId": 385, + "shape_id": 1442, + "pt": 9.0, + "_why": "slide 130: 25 code lines, 78 columns; 10.0 pt overflows the box" + }, + { + "sldId": 387, + "shape_id": 1466, + "pt": 9.0, + "_why": "slide 132: 25 code lines, 80 columns; 10.0 pt overflows the box" + }, + { + "sldId": 389, + "shape_id": 1490, + "pt": 9.5, + "_why": "slide 134: 24 code lines, 87 columns; 10.0 pt overflows the box" + }, + { + "sldId": 391, + "shape_id": 1513, + "resize": { + "cx": 1826230, + "keep": "right" + }, + "_why": "slide 136: chip 'c++ · cppgraphqlgen' wraps; one line at 11.0 pt" + }, + { + "sldId": 391, + "shape_id": 1514, + "pt": 9.5, + "_why": "slide 136: 24 code lines, 76 columns; 10.0 pt overflows the box" + }, + { + "sldId": 401, + "shape_id": 1621, + "pt": 9.5, + "_why": "slide 146: 24 code lines, 85 columns; 10.0 pt overflows the box" + }, + { + "sldId": 401, + "shape_id": 1622, + "resize": { + "y": 5712714, + "cy": 492506 + }, + "_why": "slide 146: text overflows its frame by overflow-top 3.6 pt, overflow-bottom 4.7 pt; free space around the frame" + }, + { + "sldId": 444, + "shape_id": 2088, + "resize": { + "cx": 1826230, + "keep": "right" + }, + "_why": "slide 189: chip 'c++ · Drogon + sol2' wraps; one line at 11.0 pt" + }, + { + "sldId": 444, + "shape_id": 2089, + "pt": 9.5, + "_why": "slide 189: 24 code lines, 83 columns; 10.0 pt overflows the box" + }, + { + "sldId": 461, + "shape_id": 2275, + "resize": { + "y": 5712714, + "cy": 492506 + }, + "_why": "slide 206: text overflows its frame by overflow-top 3.6 pt, overflow-bottom 4.7 pt; free space around the frame" + }, + { + "sldId": 463, + "shape_id": 2298, + "pt": 9.5, + "_why": "slide 208: 24 code lines, 85 columns; 10.0 pt overflows the box" + }, + { + "sldId": 472, + "shape_id": 2400, + "pt": 9.0, + "_why": "slide 217: 25 code lines, 77 columns; 10.0 pt overflows the box" + }, + { + "sldId": 468, + "shape_id": 2352, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 18, + "to": 18, + "first": "" + } + ], + "pt": 9.5, + "_why": "slide 213: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "sldId": 470, + "shape_id": 2376, + "cuts": [ + { + "from": 5, + "to": 8, + "first": " sw::redis::Redis& redis_;", + "last": " std::string prefix_;", + "ellipsis": " // ... members: redis_, loader_, ttl_, prefix_" + } + ], + "pt": 9.5, + "_why": "slide 215: 27 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "sldId": 474, + "shape_id": 2424, + "cuts": [ + { + "from": 13, + "to": 18, + "first": "// Optional defensive: invalidate any stale entry (e.g. from a sibling read)", + "last": "}", + "ellipsis": "// ... optional variant: after the same INSERT, cache_.del(\"event:\" + id)" + } + ], + "pt": 10.0, + "_why": "slide 219: 28 code lines do not fit the box at 9 pt; excerpt of 23 lines at 10.0 pt, was 10.0 pt" + }, + { + "sldId": 476, + "shape_id": 2448, + "cuts": [ + { + "from": 18, + "to": 24, + "first": " PgTxn txn = pg_.begin();", + "last": " txn.commit();", + "ellipsis": " // ... one transaction: INSERT each metric:{id} hash into metrics, commit" + } + ], + "pt": 9.5, + "_why": "slide 221: 30 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "sldId": 478, + "shape_id": 2472, + "cuts": [ + { + "from": 7, + "to": 12, + "first": " if (auto hit = cache_.get(key)) { cb(json_response(*hit)); co_return; }", + "last": " cb(json_response(body));", + "ellipsis": " // ... then cache-aside on key with TTL_S" + } + ], + "pt": 9.5, + "_why": "slide 223: 29 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "sldId": 492, + "shape_id": 2623, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// Every C++ client has an EXPLICIT timeout; deadlines propagate downstream.", + "last": "using namespace std::chrono;", + "ellipsis": "// ... #include ; using namespace std::chrono;" + }, + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 9, + "to": 9, + "first": " // -------- gRPC: deadline propagation is built-in -------------" + } + ], + "pt": 9.0, + "_why": "slide 237: 29 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 494, + "shape_id": 2647, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "// Prefer Istio retry policy when possible; in-process retry is for when you can't." + }, + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 27, + "to": 27, + "first": "" + } + ], + "pt": 9.5, + "_why": "slide 239: 27 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "sldId": 496, + "shape_id": 2671, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "// Production C++ shops typically let Istio do this; included for reference." + }, + { + "from": 4, + "to": 9, + "first": " enum State { Closed, Open, HalfOpen };", + "last": " static constexpr seconds RESET_AFTER = 30s;", + "ellipsis": " // ... state_ (Closed, Open, HalfOpen), failures_, opened_at_; THRESHOLD 5, RESET_AFTER 30s" + } + ], + "pt": 9.5, + "_why": "slide 241: 30 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "_why": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + } + ] +} diff --git a/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-DotNet.json b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-DotNet.json new file mode 100644 index 00000000..811e3c71 --- /dev/null +++ b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-DotNet.json @@ -0,0 +1,1254 @@ +{ + "entries": [ + { + "sldId": 257, + "shape_id": 30, + "resize": { + "y": 1589592, + "cy": 4688780 + }, + "_why": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "sldId": 270, + "shape_id": 166, + "resize": { + "cx": 4577049, + "keep": "right" + }, + "_why": "slide 15: chip 'csharp · .NET 10 + ASP.NET Core + AspNetCore.HealthChecks' wraps; one line at 10.0 pt" + }, + { + "sldId": 270, + "shape_id": 167, + "pt": 10.5, + "_why": "slide 15: 19 code lines, 91 columns; 11.0 pt overflows the box" + }, + { + "sldId": 270, + "shape_id": 168, + "resize": { + "cy": 436820 + }, + "_why": "slide 15: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 273, + "shape_id": 200, + "resize": { + "cx": 3586450, + "keep": "right" + }, + "_why": "slide 18: chip 'csharp · .NET 10 + ASP.NET Core minimal APIs' wraps; one line at 10.0 pt" + }, + { + "sldId": 273, + "shape_id": 201, + "pt": 10.0, + "_why": "slide 18: 22 code lines, 86 columns; 10.5 pt overflows the box" + }, + { + "sldId": 274, + "shape_id": 213, + "resize": { + "cx": 3662650, + "keep": "right" + }, + "_why": "slide 19: chip 'protobuf + csharp · .NET 10 + Grpc.AspNetCore' wraps; one line at 10.0 pt" + }, + { + "sldId": 280, + "shape_id": 282, + "resize": { + "cx": 2595849, + "keep": "right" + }, + "_why": "slide 25: chip 'csharp · .NET 10 + HotChocolate' wraps; one line at 10.0 pt" + }, + { + "sldId": 286, + "shape_id": 348, + "resize": { + "cx": 4196050, + "keep": "right" + }, + "_why": "slide 31: chip 'csharp · ASP.NET Core 10 + EF Core + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 286, + "shape_id": 349, + "pt": 9.0, + "_why": "slide 31: 26 code lines, 89 columns; 8.0 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 289, + "shape_id": 382, + "resize": { + "cx": 2595849, + "keep": "right" + }, + "_why": "slide 34: chip 'csharp · EF Core + Debezium CDC' wraps; one line at 10.0 pt" + }, + { + "sldId": 293, + "shape_id": 427, + "resize": { + "cx": 3357849, + "keep": "right" + }, + "_why": "slide 38: chip 'csharp · .NET 10 + Confluent.Kafka 2.16.0' wraps; one line at 10.0 pt" + }, + { + "sldId": 293, + "shape_id": 428, + "pt": 9.0, + "_why": "slide 38: 24 code lines, 81 columns; 9.5 pt overflows the box" + }, + { + "sldId": 300, + "shape_id": 505, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 45: chip 'csharp · Streamiz.Kafka.Net 1.8.2' wraps; one line at 10.0 pt" + }, + { + "sldId": 300, + "shape_id": 506, + "pt": 9.0, + "_why": "slide 45: 22 code lines, 93 columns; 9.5 pt overflows the box" + }, + { + "sldId": 302, + "shape_id": 530, + "pt": 11.0, + "_why": "slide 47: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "sldId": 312, + "shape_id": 641, + "resize": { + "cx": 2367250, + "keep": "right" + }, + "_why": "slide 57: chip 'yaml · CI + Apicurio v3 REST' wraps; one line at 10.0 pt" + }, + { + "sldId": 321, + "shape_id": 737, + "resize": { + "cx": 2976849, + "keep": "right" + }, + "_why": "slide 66: chip 'csharp · .NET 10 + OpenTelemetry.NET' wraps; one line at 10.0 pt" + }, + { + "sldId": 321, + "shape_id": 738, + "pt": 9.0, + "_why": "slide 66: 25 code lines, 88 columns; 9.5 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 321, + "shape_id": 739, + "resize": { + "cy": 436820 + }, + "_why": "slide 66: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 323, + "shape_id": 761, + "resize": { + "cx": 3891250, + "keep": "right" + }, + "_why": "slide 68: chip 'csharp · OpenTelemetry Baggage + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 323, + "shape_id": 762, + "pt": 10.0, + "_why": "slide 68: 20 code lines, 81 columns; 10.5 pt overflows the box" + }, + { + "sldId": 326, + "shape_id": 795, + "resize": { + "cy": 4607500 + }, + "_why": "slide 71: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 330, + "shape_id": 836, + "resize": { + "cy": 4607500 + }, + "_why": "slide 75: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 331, + "shape_id": 846, + "resize": { + "cy": 4597340 + }, + "_why": "slide 76: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "sldId": 333, + "shape_id": 867, + "resize": { + "cy": 4607500 + }, + "_why": "slide 78: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 335, + "shape_id": 889, + "resize": { + "cx": 3053050, + "keep": "right" + }, + "_why": "slide 80: chip 'csharp · ASP.NET Core 10 + HMACSHA256' wraps; one line at 10.0 pt" + }, + { + "sldId": 340, + "shape_id": 943, + "resize": { + "cx": 3357849, + "keep": "right" + }, + "_why": "slide 85: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "sldId": 342, + "shape_id": 966, + "resize": { + "cx": 3281650, + "keep": "right" + }, + "_why": "slide 87: chip 'csharp · ASP.NET Core 10 + SemaphoreSlim' wraps; one line at 10.0 pt" + }, + { + "sldId": 342, + "shape_id": 967, + "pt": 9.0, + "_why": "slide 87: 15 code lines, 86 columns; 9.0 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 344, + "shape_id": 989, + "resize": { + "cx": 3434050, + "keep": "right" + }, + "_why": "slide 89: chip 'csharp · ASP.NET Core 10 + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 363, + "shape_id": 1185, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 108: chip 'csharp · .NET 10 + Asp.Versioning' wraps; one line at 10.0 pt" + }, + { + "sldId": 367, + "shape_id": 1233, + "resize": { + "cx": 3357849, + "keep": "right" + }, + "_why": "slide 112: chip 'csharp + graphql · .NET 10 + HotChocolate' wraps; one line at 10.0 pt" + }, + { + "sldId": 367, + "shape_id": 1234, + "pt": 10.5, + "_why": "slide 112: 18 code lines, 88 columns; 11.0 pt overflows the box" + }, + { + "sldId": 370, + "shape_id": 1266, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 115: chip 'csharp · ASP.NET Core 10 WebSockets' wraps; one line at 10.0 pt" + }, + { + "sldId": 374, + "shape_id": 1311, + "resize": { + "cx": 3053050, + "keep": "right" + }, + "_why": "slide 119: chip 'csharp · Google.Protobuf + WebSockets' wraps; one line at 10.0 pt" + }, + { + "sldId": 374, + "shape_id": 1312, + "pt": 9.5, + "_why": "slide 119: 22 code lines, 82 columns; 10.0 pt overflows the box" + }, + { + "sldId": 377, + "shape_id": 1345, + "resize": { + "y": 5817712, + "cy": 460660 + }, + "_why": "slide 122: text overflows its frame by overflow-top 2.4 pt, overflow-bottom 3.4 pt; free space around the frame" + }, + { + "sldId": 381, + "shape_id": 1387, + "resize": { + "cx": 2214850, + "keep": "right" + }, + "_why": "slide 126: chip 'csharp · .NET 10 + EF Core' wraps; one line at 10.0 pt" + }, + { + "sldId": 383, + "shape_id": 1411, + "resize": { + "cx": 2214850, + "keep": "right" + }, + "_why": "slide 128: chip 'csharp · .NET 10 + EF Core' wraps; one line at 10.0 pt" + }, + { + "sldId": 383, + "shape_id": 1412, + "pt": 9.0, + "_why": "slide 128: 24 code lines, 85 columns; 11.0 pt overflows the box" + }, + { + "sldId": 387, + "shape_id": 1456, + "resize": { + "cx": 2824450, + "keep": "right" + }, + "_why": "slide 132: chip 'csharp · Temporalio + ASP.NET Core' wraps; one line at 10.0 pt" + }, + { + "sldId": 389, + "shape_id": 1480, + "resize": { + "cx": 2976849, + "keep": "right" + }, + "_why": "slide 134: chip 'csharp · Stateless package + EF Core' wraps; one line at 10.0 pt" + }, + { + "sldId": 393, + "shape_id": 1525, + "resize": { + "cx": 2976849, + "keep": "right" + }, + "_why": "slide 138: chip 'csharp · .NET 10 + IExceptionHandler' wraps; one line at 10.0 pt" + }, + { + "sldId": 395, + "shape_id": 1549, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 140: chip 'csharp · grpc-dotnet + Google.Rpc' wraps; one line at 10.0 pt" + }, + { + "sldId": 395, + "shape_id": 1550, + "pt": 9.0, + "_why": "slide 140: 25 code lines, 83 columns; 10.5 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 397, + "shape_id": 1573, + "resize": { + "cx": 2824450, + "keep": "right" + }, + "_why": "slide 142: chip 'csharp · Confluent.Kafka + Polly 8' wraps; one line at 10.0 pt" + }, + { + "sldId": 397, + "shape_id": 1574, + "pt": 9.0, + "_why": "slide 142: 25 code lines, 95 columns; 9.0 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 399, + "shape_id": 1597, + "resize": { + "cx": 2595849, + "keep": "right" + }, + "_why": "slide 144: chip 'csharp · .NET 10 + HotChocolate' wraps; one line at 10.0 pt" + }, + { + "sldId": 409, + "shape_id": 1704, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 154: chip 'csharp · ports & adapters in .NET' wraps; one line at 10.0 pt" + }, + { + "sldId": 409, + "shape_id": 1706, + "resize": { + "cy": 436820 + }, + "_why": "slide 154: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 427, + "shape_id": 1892, + "resize": { + "cx": 2595849, + "keep": "right" + }, + "_why": "slide 172: chip 'csharp · .NET 10 + ASP.NET Core' wraps; one line at 10.0 pt" + }, + { + "sldId": 427, + "shape_id": 1894, + "resize": { + "cy": 436820 + }, + "_why": "slide 172: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 428, + "shape_id": 1907, + "resize": { + "cy": 436820 + }, + "_why": "slide 173: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 439, + "shape_id": 2025, + "pt": 11.0, + "_why": "slide 184: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "sldId": 440, + "shape_id": 2037, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 185: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "sldId": 445, + "shape_id": 2093, + "pt": 10.0, + "_why": "slide 190: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "sldId": 446, + "shape_id": 2105, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 191: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "sldId": 451, + "shape_id": 2162, + "resize": { + "cx": 3510250, + "keep": "right" + }, + "_why": "slide 196: chip 'csharp · .NET 10 + Confluent.Kafka + NRules' wraps; one line at 10.0 pt" + }, + { + "sldId": 452, + "shape_id": 2175, + "resize": { + "cx": 4577049, + "keep": "right" + }, + "_why": "slide 197: chip 'csharp · NRules.Fluent (or external NRules.Language DSL)' wraps; one line at 10.0 pt" + }, + { + "sldId": 452, + "shape_id": 2177, + "resize": { + "cy": 436820 + }, + "_why": "slide 197: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 457, + "shape_id": 2230, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 202: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "sldId": 475, + "shape_id": 2423, + "resize": { + "cx": 4577049, + "keep": "right" + }, + "_why": "slide 220: chip 'csharp · ASP.NET Core 10 minimal API + IHttpClientFactory' wraps; one line at 10.0 pt" + }, + { + "sldId": 477, + "shape_id": 2447, + "resize": { + "cx": 5110450, + "keep": "right" + }, + "_why": "slide 222: chip 'csharp · ASP.NET Core 10 + StackExchange.Redis + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 482, + "shape_id": 2501, + "resize": { + "cx": 4348450, + "keep": "right" + }, + "_why": "slide 227: chip 'csharp · ASP.NET Core 10 + EF Core + IDistributedCache' wraps; one line at 10.0 pt" + }, + { + "sldId": 484, + "shape_id": 2525, + "resize": { + "cx": 4577049, + "keep": "right" + }, + "_why": "slide 229: chip 'csharp · ASP.NET Core 10 + FusionCache (MIT, open-source)' wraps; one line at 10.0 pt" + }, + { + "sldId": 484, + "shape_id": 2526, + "pt": 9.0, + "_why": "slide 229: 25 code lines, 86 columns; 9.5 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 486, + "shape_id": 2549, + "resize": { + "cx": 5034250, + "keep": "right" + }, + "_why": "slide 231: chip 'csharp · ASP.NET Core 10 + IDistributedCache OR + FusionCache' wraps; one line at 10.0 pt" + }, + { + "sldId": 488, + "shape_id": 2573, + "resize": { + "cx": 4348450, + "keep": "right" + }, + "_why": "slide 233: chip 'csharp · ASP.NET Core 10 + EF Core + IDistributedCache' wraps; one line at 10.0 pt" + }, + { + "sldId": 490, + "shape_id": 2597, + "resize": { + "cx": 6024850, + "keep": "right" + }, + "_why": "slide 235: chip 'csharp · ASP.NET Core 10 + BackgroundService + StackExchange.Redis + EF Core' wraps; one line at 10.0 pt" + }, + { + "sldId": 494, + "shape_id": 2643, + "resize": { + "cy": 4568130 + }, + "_why": "slide 239: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 506, + "shape_id": 2772, + "resize": { + "cx": 3053050, + "keep": "right" + }, + "_why": "slide 251: chip 'csharp · ASP.NET Core 10 + HttpClient' wraps; one line at 10.0 pt" + }, + { + "sldId": 508, + "shape_id": 2795, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 253: chip 'csharp · ASP.NET Core 10 + Polly v8' wraps; one line at 10.0 pt" + }, + { + "sldId": 510, + "shape_id": 2818, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 255: chip 'csharp · ASP.NET Core 10 + Polly v8' wraps; one line at 10.0 pt" + }, + { + "sldId": 514, + "shape_id": 2861, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 259: chip 'csharp · ASP.NET Core 10' wraps; one line at 10.0 pt" + }, + { + "sldId": 274, + "shape_id": 214, + "cuts": [ + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 15, + "to": 15, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 19: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 11.0 pt" + }, + { + "sldId": 280, + "shape_id": 283, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 20, + "to": 20, + "first": "" + }, + { + "from": 27, + "to": 27, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 25: 28 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.5 pt" + }, + { + "sldId": 289, + "shape_id": 383, + "cuts": [ + { + "from": 9, + "to": 9, + "first": "" + }, + { + "from": 18, + "to": 18, + "first": "" + }, + { + "from": 24, + "to": 24, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 34: 27 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.5 pt" + }, + { + "sldId": 340, + "shape_id": 944, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 85: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "sldId": 381, + "shape_id": 1388, + "cuts": [ + { + "from": 1, + "to": 2, + "first": "// EF Core 10 + pessimistic row lock — the in-process saga state store.", + "last": "// The PostgreSQL/SQL Server commit replaces what MSDTC used to make atomic." + }, + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 12, + "to": 13, + "first": "", + "last": " // pessimistic row lock so two workers can't advance the same saga" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 126: 31 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "sldId": 389, + "shape_id": 1481, + "cuts": [ + { + "from": 3, + "to": 4, + "first": "", + "last": "// 1) Configure the machine — states, triggers, actions" + }, + { + "from": 8, + "to": 10, + "first": " public State CurrentState => _sm.State;", + "last": "", + "ellipsis": " // ... CurrentState => _sm.State; OrderId { get; }" + }, + { + "from": 18, + "to": 18, + "first": "" + }, + { + "from": 26, + "to": 31, + "first": " _sm.Configure(State.Reserved)", + "last": " .OnEntryAsync(() => CompensateAsync(orderId));", + "ellipsis": " // ... Reserved -> Shipped and Compensating -> Failed are configured the same way" + }, + { + "from": 35, + "to": 47, + "first": "", + "last": "}", + "ellipsis": "// ... drive it: FulfillmentService.OnAsync loads the saga row, builds the machine, fires, saves" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 134: 47 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.0 pt" + }, + { + "sldId": 393, + "shape_id": 1526, + "cuts": [ + { + "from": 5, + "to": 5, + "first": "// One place: domain → wire (the single global exception-to-response seam)" + } + ], + "edits": [ + { + "line": 6, + "old": "public class DomainExceptionHandler : IExceptionHandler", + "new": "public class DomainExceptionHandler : IExceptionHandler // one place: domain → wire" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 138: 26 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "sldId": 399, + "shape_id": 1598, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// HotChocolate: implement IErrorFilter to map exceptions to GraphQL errors[]", + "last": "" + }, + { + "from": 18, + "to": 23, + "first": "", + "last": "", + "ellipsis": "// ... Program.cs: AddGraphQLServer().AddErrorFilter()" + }, + { + "from": 24, + "to": 24, + "first": "// Field resolver — throws normally; the filter turns it into a GraphQL error" + }, + { + "from": 31, + "to": 35, + "first": " try { return await inventory.RemainingAsync(order.Sku); }", + "last": " }", + "ellipsis": " // ... RemainingAsync; on UpstreamException throws InventoryUnavailable (nulls the field)" + } + ], + "pt": 9.0, + "_why": "slide 144: 37 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.5 pt" + }, + { + "sldId": 409, + "shape_id": 1705, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 16, + "to": 16, + "first": "" + }, + { + "from": 25, + "to": 25, + "first": "" + }, + { + "from": 28, + "to": 28, + "first": "// Wire them up in Program.cs: builder.Services.AddScoped();" + } + ], + "pt": 9.0, + "_why": "slide 154: 28 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 427, + "shape_id": 1893, + "cuts": [ + { + "from": 5, + "to": 6, + "first": " o.BackgroundServiceExceptionBehavior = // tolerate handler errors", + "last": " BackgroundServiceExceptionBehavior.Ignore;", + "ellipsis": " // ... BackgroundServiceExceptionBehavior = Ignore (tolerate handler errors)" + }, + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 12, + "to": 16, + "first": " private volatile bool _ready = true;", + "last": " Task.FromResult(_ready ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy());", + "ellipsis": " // ... a volatile ready flag, MarkUnready(), CheckHealthAsync: Healthy or Unhealthy" + }, + { + "from": 18, + "to": 18, + "first": "" + }, + { + "from": 24, + "to": 32, + "first": " protected override async Task ExecuteAsync(CancellationToken ct)", + "last": " }", + "ellipsis": " // ... ExecuteAsync: Consume, Handle, Commit until the token is cancelled" + } + ], + "pt": 9.0, + "_why": "slide 172: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "sldId": 451, + "shape_id": 2163, + "cuts": [ + { + "from": 2, + "to": 5, + "first": "public record OrderFact(string OrderId, long AmountCents, Customer Customer)", + "last": "}", + "ellipsis": "// ... record OrderFact(OrderId, AmountCents, Customer) with a settable WhereTo" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 196: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "sldId": 452, + "shape_id": 2176, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// Rules live in their own NuGet/csproj — owned by domain experts via NRules.Language,", + "last": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 19, + "to": 19, + "first": "" + }, + { + "from": 20, + "to": 30, + "first": "[Name(\"European orders go to the EU region\")]", + "last": "", + "ellipsis": "// ... EuropeanOrderRule: Customer.Region == \"EU\" sets WhereTo = \"kafka:orders.eu\"" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 197: 40 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "sldId": 457, + "shape_id": 2231, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 202: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "sldId": 475, + "shape_id": 2424, + "cuts": [ + { + "from": 3, + "to": 5, + "first": "// In production this usually lives in a separate sidecar / YARP, but the", + "last": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 15, + "to": 15, + "first": "" + }, + { + "from": 25, + "to": 25, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 220: 30 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 477, + "shape_id": 2448, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "// cache (StackExchange.Redis) and Kafka events (Confluent.Kafka)." + }, + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 15, + "to": 15, + "first": "" + } + ], + "edits": [ + { + "line": 1, + "old": "// ASP.NET Core minimal API that wraps a legacy /orders API and adds a Redis", + "new": "// Wraps the legacy /orders API: Redis cache, Kafka events." + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 222: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "sldId": 482, + "shape_id": 2502, + "cuts": [ + { + "from": 20, + "to": 22, + "first": " if (await db.Products.FindAsync([id], ct) is not { } p) return NotFound();", + "last": " await db.SaveChangesAsync(ct);", + "ellipsis": " // ... FindAsync (404 when missing), p.Update(body), SaveChangesAsync" + } + ], + "pt": 9.0, + "_why": "slide 227: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "sldId": 486, + "shape_id": 2550, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 23, + "to": 23, + "first": "" + }, + { + "from": 26, + "to": 41, + "first": "[ApiController, Route(\"products\")]", + "last": "}", + "ellipsis": "// ... same controller on IFusionCache: await cache.SetAsync($\"product:{id}\", p, token: ct);" + } + ], + "pt": 9.0, + "_why": "slide 231: 41 code lines do not fit the box at 9 pt; excerpt of 23 lines at 9.0 pt, was 9.0 pt" + }, + { + "sldId": 488, + "shape_id": 2574, + "cuts": [ + { + "from": 12, + "to": 12, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 233: 26 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "sldId": 490, + "shape_id": 2598, + "cuts": [ + { + "from": 1, + "to": 17, + "first": "// BackgroundService is the canonical ASP.NET Core long-running task host.", + "last": "", + "ellipsis": "// ... MetricsController.Write: HSET, SADD metric:dirty, 202" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 33, + "to": 33, + "first": "" + }, + { + "from": 34, + "to": 44, + "first": " try", + "last": " }", + "ellipsis": " // ... try: add Metric.FromHash for every id in a new scope, SaveChangesAsync (batch)" + }, + { + "from": 54, + "to": 54, + "first": "// Program.cs: builder.Services.AddHostedService();" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 235: 54 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 7.5 pt" + }, + { + "sldId": 492, + "shape_id": 2622, + "cuts": [ + { + "from": 3, + "to": 4, + "first": "", + "last": "// Approach 1: explicit sorted-set tracking" + }, + { + "from": 5, + "to": 23, + "first": "public class ProductService(", + "last": "}", + "ellipsis": "// ... ProductService.FindAsync: ZADD product:hot , then cache-aside on product:{id}" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 39, + "to": 51, + "first": " using var scope = scopes.CreateScope();", + "last": " }", + "ellipsis": " // ... for each hot id with a TTL under 10 s: reload from ShopDb, SET with a fresh TTL" + }, + { + "from": 55, + "to": 55, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 237: 59 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 7.5 pt" + }, + { + "op": "fit_font", + "sldId": 345, + "shape_id": 1000, + "pt": 15, + "_why": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 351, + "shape_id": 1061, + "pt": 14, + "_why": "references: the reference lines run past the frame into the page number; the frame cannot grow", + "space_before": 6 + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 354 + ], + "_why": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "sldId": 492, + "shape_id": 2621, + "resize": { + "cx": 4191000, + "cy": 457200, + "keep": "right" + }, + "_why": "chip on two lines: one line would reach the first code line" + }, + { + "op": "fit_font", + "sldId": 493, + "shape_id": 2633, + "pt": 16, + "_why": "caching pitfalls: six long bullets run 15 pt past the frame; the frame cannot grow (page number below)" + }, + { + "op": "replace", + "scope": "slide", + "find": "Temporal .NET: your saga is plain C#, the Server makes it crash-proof", + "repl": "Temporal .NET: a plain-C# saga the Server makes durable", + "expect": 1, + "sldIds": [ + 386 + ], + "_why": "title wraps to two lines" + }, + { + "op": "replace", + "scope": "slide", + "find": "Appendix J · .NET 10 Toolkit & Migration Reference", + "repl": "Appendix J · .NET 10 Toolkit & Migration", + "expect": 1, + "sldIds": [ + 461 + ], + "_why": "divider title wraps to three lines and collides with the J letter" + }, + { + "op": "fit_font", + "sldId": 465, + "shape_id": 2315, + "pt": 15, + "_why": "known gaps: six long bullets run 36 pt past the frame into the page number" + }, + { + "op": "fit_font", + "sldId": 466, + "shape_id": 2325, + "scale": 0.85, + "_why": "recipe: five steps and a summary run 28 pt past the frame into the page number; scaled to keep the 17/14 pt hierarchy" + }, + { + "op": "fit_font", + "sldId": 478, + "shape_id": 2459, + "pt": 15.5, + "_why": "migration take-aways: seven bullets run 25 pt past the frame into the page number" + } + ] +} diff --git a/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Go.json b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Go.json new file mode 100644 index 00000000..b21f3451 --- /dev/null +++ b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Go.json @@ -0,0 +1,367 @@ +{ + "entries": [ + { + "sldId": 256, + "shape_id": 20, + "resize": { + "y": 5007611, + "cy": 467359 + }, + "_why": "slide 1: text overflows its frame by overflow-top 2.3 pt, overflow-bottom 3.3 pt; free space around the frame" + }, + { + "sldId": 257, + "shape_id": 30, + "resize": { + "y": 1589592, + "cy": 4688780 + }, + "_why": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "sldId": 284, + "shape_id": 327, + "resize": { + "cx": 2519650, + "keep": "right" + }, + "_why": "slide 29: chip 'go · net/http + pgx + franz-go' wraps; one line at 10.0 pt" + }, + { + "sldId": 291, + "shape_id": 407, + "pt": 11.0, + "_why": "slide 36: 20 code lines, 84 columns; 11.5 pt overflows the box" + }, + { + "sldId": 300, + "shape_id": 509, + "pt": 11.0, + "_why": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "sldId": 321, + "shape_id": 740, + "resize": { + "cx": 2214850, + "keep": "right" + }, + "_why": "slide 66: chip 'go · OpenTelemetry baggage' wraps; one line at 10.0 pt" + }, + { + "sldId": 324, + "shape_id": 774, + "resize": { + "cy": 4607500 + }, + "_why": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 328, + "shape_id": 815, + "resize": { + "cy": 4607500 + }, + "_why": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 329, + "shape_id": 825, + "resize": { + "cy": 4597340 + }, + "_why": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "sldId": 331, + "shape_id": 846, + "resize": { + "cy": 4607500 + }, + "_why": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 333, + "shape_id": 868, + "resize": { + "cx": 2443450, + "keep": "right" + }, + "_why": "slide 78: chip 'go · net/http + golang-jwt v5' wraps; one line at 10.0 pt" + }, + { + "sldId": 338, + "shape_id": 922, + "resize": { + "cx": 3357849, + "keep": "right" + }, + "_why": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "sldId": 340, + "shape_id": 945, + "resize": { + "cx": 1833850, + "keep": "right" + }, + "_why": "slide 85: chip 'go · buffered channel' wraps; one line at 10.0 pt" + }, + { + "sldId": 342, + "shape_id": 968, + "resize": { + "cx": 2443450, + "keep": "right" + }, + "_why": "slide 87: chip 'go · franz-go + golang-jwt v5' wraps; one line at 10.0 pt" + }, + { + "sldId": 372, + "shape_id": 1290, + "resize": { + "cx": 2595849, + "keep": "right" + }, + "_why": "slide 117: chip 'protobuf + go · coder/websocket' wraps; one line at 10.0 pt" + }, + { + "sldId": 379, + "shape_id": 1370, + "pt": 10.5, + "_why": "slide 124: 21 code lines, 90 columns; 11.5 pt overflows the box" + }, + { + "sldId": 401, + "shape_id": 1617, + "resize": { + "cx": 1833850, + "keep": "right" + }, + "_why": "slide 146: chip 'go · ports & adapters' wraps; one line at 10.0 pt" + }, + { + "sldId": 420, + "shape_id": 1820, + "resize": { + "cy": 436820 + }, + "_why": "slide 165: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 431, + "shape_id": 1938, + "pt": 11.0, + "_why": "slide 176: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "sldId": 432, + "shape_id": 1950, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 177: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "sldId": 437, + "shape_id": 2006, + "pt": 10.0, + "_why": "slide 182: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "sldId": 438, + "shape_id": 2018, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 183: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "sldId": 444, + "shape_id": 2088, + "resize": { + "cx": 2214850, + "keep": "right" + }, + "_why": "slide 189: chip 'go · gopher-lua + franz-go' wraps; one line at 10.0 pt" + }, + { + "sldId": 449, + "shape_id": 2143, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 194: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "sldId": 463, + "shape_id": 2297, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 208: chip 'go · net/http + go-redis + franz-go' wraps; one line at 10.0 pt" + }, + { + "sldId": 464, + "shape_id": 2309, + "resize": { + "cy": 4568130 + }, + "_why": "slide 209: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 470, + "shape_id": 2375, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 215: chip 'go · go-redis (generics)' wraps; one line at 10.0 pt" + }, + { + "sldId": 470, + "shape_id": 2376, + "pt": 10.5, + "_why": "slide 215: 21 code lines, 92 columns; 11.5 pt overflows the box" + }, + { + "sldId": 474, + "shape_id": 2424, + "pt": 11.0, + "_why": "slide 219: 20 code lines, 84 columns; 11.5 pt overflows the box" + }, + { + "sldId": 476, + "shape_id": 2447, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 221: chip 'go · go-redis + goroutine' wraps; one line at 10.0 pt" + }, + { + "sldId": 476, + "shape_id": 2448, + "pt": 11.0, + "_why": "slide 221: 20 code lines, 82 columns; 11.5 pt overflows the box" + }, + { + "sldId": 478, + "shape_id": 2471, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 223: chip 'go · go-redis + goroutine' wraps; one line at 10.0 pt" + }, + { + "sldId": 478, + "shape_id": 2472, + "pt": 10.0, + "_why": "slide 223: 22 code lines, 90 columns; 10.5 pt overflows the box" + }, + { + "sldId": 480, + "shape_id": 2493, + "resize": { + "cy": 4568130 + }, + "_why": "slide 225: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 492, + "shape_id": 2622, + "resize": { + "cx": 1986250, + "keep": "right" + }, + "_why": "slide 237: chip 'go · net/http + context' wraps; one line at 10.0 pt" + }, + { + "sldId": 494, + "shape_id": 2645, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 239: chip 'go · cenkalti/backoff v7' wraps; one line at 10.0 pt" + }, + { + "sldId": 496, + "shape_id": 2668, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 241: chip 'go · sony/gobreaker v2' wraps; one line at 10.0 pt" + }, + { + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "sldId": 449, + "shape_id": 2144, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 194: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "_why": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "_why": "references: the reference lines run past the frame into the page number; the frame cannot grow" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "_why": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + } + ] +} diff --git a/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Python.json b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Python.json new file mode 100644 index 00000000..163d8088 --- /dev/null +++ b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Python.json @@ -0,0 +1,626 @@ +{ + "entries": [ + { + "sldId": 256, + "shape_id": 20, + "resize": { + "y": 5007611, + "cy": 467359 + }, + "_why": "slide 1: text overflows its frame by overflow-top 2.3 pt, overflow-bottom 3.3 pt; free space around the frame" + }, + { + "sldId": 257, + "shape_id": 30, + "resize": { + "y": 1589592, + "cy": 4688780 + }, + "_why": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "sldId": 272, + "shape_id": 190, + "pt": 12.0, + "_why": "slide 17: 18 code lines, 77 columns; 12.5 pt overflows the box" + }, + { + "sldId": 273, + "shape_id": 202, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 18: chip 'protobuf + python · grpc' wraps; one line at 10.0 pt" + }, + { + "sldId": 279, + "shape_id": 272, + "pt": 12.0, + "_why": "slide 24: 18 code lines, 87 columns; 12.5 pt overflows the box" + }, + { + "sldId": 284, + "shape_id": 327, + "resize": { + "cx": 3053050, + "keep": "right" + }, + "_why": "slide 29: chip 'python · FastAPI + asyncpg + aiokafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 287, + "shape_id": 361, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 32: chip 'python · asyncpg + Debezium' wraps; one line at 10.0 pt" + }, + { + "sldId": 298, + "shape_id": 484, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 43: chip 'python · faust-streaming' wraps; one line at 10.0 pt" + }, + { + "sldId": 298, + "shape_id": 485, + "pt": 12.0, + "_why": "slide 43: 18 code lines, 91 columns; 12.5 pt overflows the box" + }, + { + "sldId": 300, + "shape_id": 509, + "pt": 11.0, + "_why": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "sldId": 319, + "shape_id": 716, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 64: chip 'python · OpenTelemetry' wraps; one line at 10.0 pt" + }, + { + "sldId": 321, + "shape_id": 740, + "resize": { + "cx": 2824450, + "keep": "right" + }, + "_why": "slide 66: chip 'python · OpenTelemetry baggage API' wraps; one line at 10.0 pt" + }, + { + "sldId": 324, + "shape_id": 774, + "resize": { + "cy": 4607500 + }, + "_why": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 328, + "shape_id": 815, + "resize": { + "cy": 4607500 + }, + "_why": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 329, + "shape_id": 825, + "resize": { + "cy": 4597340 + }, + "_why": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "sldId": 331, + "shape_id": 846, + "resize": { + "cy": 4607500 + }, + "_why": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 333, + "shape_id": 868, + "resize": { + "cx": 2672050, + "keep": "right" + }, + "_why": "slide 78: chip 'python · FastAPI + hmac (stdlib)' wraps; one line at 10.0 pt" + }, + { + "sldId": 338, + "shape_id": 922, + "resize": { + "cx": 3357849, + "keep": "right" + }, + "_why": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "sldId": 340, + "shape_id": 945, + "resize": { + "cx": 2214850, + "keep": "right" + }, + "_why": "slide 85: chip 'python · asyncio + aiohttp' wraps; one line at 10.0 pt" + }, + { + "sldId": 342, + "shape_id": 968, + "resize": { + "cx": 3281650, + "keep": "right" + }, + "_why": "slide 87: chip 'python · aiokafka + Example 12 valet key' wraps; one line at 10.0 pt" + }, + { + "sldId": 387, + "shape_id": 1462, + "resize": { + "cx": 2443450, + "keep": "right" + }, + "_why": "slide 132: chip 'python · grpc + grpcio-status' wraps; one line at 10.0 pt" + }, + { + "sldId": 401, + "shape_id": 1617, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 146: chip 'python · ports & adapters' wraps; one line at 10.0 pt" + }, + { + "sldId": 419, + "shape_id": 1805, + "resize": { + "cx": 2214850, + "keep": "right" + }, + "_why": "slide 164: chip 'python · FastAPI + uvicorn' wraps; one line at 10.0 pt" + }, + { + "sldId": 419, + "shape_id": 1807, + "resize": { + "cy": 436820 + }, + "_why": "slide 164: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 420, + "shape_id": 1820, + "resize": { + "cy": 436820 + }, + "_why": "slide 165: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 431, + "shape_id": 1938, + "pt": 11.0, + "_why": "slide 176: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "sldId": 432, + "shape_id": 1950, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 177: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "sldId": 437, + "shape_id": 2006, + "pt": 10.0, + "_why": "slide 182: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "sldId": 438, + "shape_id": 2018, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 183: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "sldId": 443, + "shape_id": 2075, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 188: chip 'python · durable-rules' wraps; one line at 10.0 pt" + }, + { + "sldId": 444, + "shape_id": 2088, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 189: chip 'python · FastAPI + aiokafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 449, + "shape_id": 2143, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 194: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "sldId": 461, + "shape_id": 2273, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 206: chip 'python · FastAPI + httpx' wraps; one line at 10.0 pt" + }, + { + "sldId": 463, + "shape_id": 2297, + "resize": { + "cx": 3510250, + "keep": "right" + }, + "_why": "slide 208: chip 'python · FastAPI + httpx + redis + aiokafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 464, + "shape_id": 2309, + "resize": { + "cy": 4568130 + }, + "_why": "slide 209: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 468, + "shape_id": 2351, + "resize": { + "cx": 3510250, + "keep": "right" + }, + "_why": "slide 213: chip 'python · FastAPI + redis.asyncio + Postgres' wraps; one line at 10.0 pt" + }, + { + "sldId": 470, + "shape_id": 2375, + "resize": { + "cx": 3738850, + "keep": "right" + }, + "_why": "slide 215: chip 'python · read-through wrapper on redis.asyncio' wraps; one line at 10.0 pt" + }, + { + "sldId": 472, + "shape_id": 2399, + "resize": { + "cx": 4196050, + "keep": "right" + }, + "_why": "slide 217: chip 'python · write-through with redis.asyncio + Postgres' wraps; one line at 10.0 pt" + }, + { + "sldId": 472, + "shape_id": 2400, + "pt": 9.0, + "_why": "slide 217: 24 code lines, 80 columns; 10.5 pt overflows the box" + }, + { + "sldId": 474, + "shape_id": 2423, + "resize": { + "cx": 4348450, + "keep": "right" + }, + "_why": "slide 219: chip 'python · write-around with selective read-side caching' wraps; one line at 10.0 pt" + }, + { + "sldId": 476, + "shape_id": 2447, + "resize": { + "cx": 3891250, + "keep": "right" + }, + "_why": "slide 221: chip 'python · write-back with asyncio background task' wraps; one line at 10.0 pt" + }, + { + "sldId": 476, + "shape_id": 2448, + "pt": 9.0, + "_why": "slide 221: 25 code lines, 93 columns; 9.5 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 478, + "shape_id": 2471, + "resize": { + "cx": 3586450, + "keep": "right" + }, + "_why": "slide 223: chip 'python · refresh-ahead with Redis sorted set' wraps; one line at 10.0 pt" + }, + { + "sldId": 478, + "shape_id": 2472, + "pt": 9.0, + "_why": "slide 223: 25 code lines, 91 columns; 9.0 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 480, + "shape_id": 2493, + "resize": { + "cy": 4568130 + }, + "_why": "slide 225: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 492, + "shape_id": 2622, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 237: chip 'python · httpx + asyncio' wraps; one line at 10.0 pt" + }, + { + "sldId": 500, + "shape_id": 2711, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 245: chip 'python · FastAPI lifespan' wraps; one line at 10.0 pt" + }, + { + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "sldId": 340, + "shape_id": 946, + "cuts": [ + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 8, + "to": 8, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 85: 28 code lines do not fit the box at 9 pt; excerpt of 26 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 449, + "shape_id": 2144, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 194: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "sldId": 461, + "shape_id": 2274, + "cuts": [ + { + "from": 2, + "to": 4, + "first": "# In production this would typically live in a separate sidecar / proxy,", + "last": "" + }, + { + "from": 6, + "to": 6, + "first": "from fastapi.responses import StreamingResponse" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 14, + "to": 14, + "first": "" + }, + { + "from": 16, + "to": 16, + "first": "" + }, + { + "from": 31, + "to": 39, + "first": "", + "last": " headers=dict(r.headers))", + "ellipsis": "# ... forward(): replays the request to upstream with httpx and streams the reply" + } + ], + "pt": 9.0, + "_why": "slide 206: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 463, + "shape_id": 2298, + "cuts": [ + { + "from": 5, + "to": 6, + "first": "# The legacy service is unchanged.", + "last": "" + }, + { + "from": 7, + "to": 10, + "first": "from fastapi import FastAPI", + "last": "import httpx, json", + "ellipsis": "# ... imports: FastAPI, redis.asyncio.Redis, aiokafka.AIOKafkaProducer, httpx, json" + }, + { + "from": 11, + "to": 11, + "first": "" + }, + { + "from": 16, + "to": 16, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 208: 32 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.5 pt" + }, + { + "sldId": 468, + "shape_id": 2352, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 14, + "to": 14, + "first": "" + }, + { + "from": 19, + "to": 19, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 213: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 11.0 pt" + }, + { + "sldId": 470, + "shape_id": 2376, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "# Wrap Redis with a loader function. Callers just call get(); the wrapper", + "last": "" + }, + { + "from": 4, + "to": 6, + "first": "from redis.asyncio import Redis", + "last": "import json", + "ellipsis": "# ... imports: redis.asyncio.Redis, typing, json" + }, + { + "from": 11, + "to": 14, + "first": " self.redis = redis", + "last": " self.prefix = prefix", + "ellipsis": " # ... keeps redis, loader (called on cache miss), ttl and prefix" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 31, + "to": 31, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 215: 34 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 474, + "shape_id": 2424, + "cuts": [ + { + "from": 13, + "to": 20, + "first": "# Optional defensive variant: invalidate any stale entry, but don't populate", + "last": "", + "ellipsis": "# ... optional variant: the same INSERT, then cache.delete(f\"event:{id}\")" + } + ], + "pt": 9.0, + "_why": "slide 219: 31 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "_why": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "_why": "references: the reference lines run past the frame into the page number; the frame cannot grow" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "_why": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + } + ] +} diff --git a/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Quarkus.json b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Quarkus.json new file mode 100644 index 00000000..d81cb943 --- /dev/null +++ b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Quarkus.json @@ -0,0 +1,610 @@ +{ + "entries": [ + { + "sldId": 257, + "shape_id": 30, + "resize": { + "y": 1589592, + "cy": 4688780 + }, + "_why": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "sldId": 269, + "shape_id": 155, + "resize": { + "cx": 2672050, + "keep": "right" + }, + "_why": "slide 14: chip 'java · Quarkus + SmallRye Health' wraps; one line at 10.0 pt" + }, + { + "sldId": 272, + "shape_id": 190, + "pt": 11.0, + "_why": "slide 17: 20 code lines, 89 columns; 11.5 pt overflows the box" + }, + { + "sldId": 273, + "shape_id": 202, + "resize": { + "cx": 2519650, + "keep": "right" + }, + "_why": "slide 18: chip 'protobuf + java · Quarkus gRPC' wraps; one line at 10.0 pt" + }, + { + "sldId": 279, + "shape_id": 271, + "resize": { + "cx": 1986250, + "keep": "right" + }, + "_why": "slide 24: chip 'java · SmallRye GraphQL' wraps; one line at 10.0 pt" + }, + { + "sldId": 284, + "shape_id": 327, + "resize": { + "cx": 4729450, + "keep": "right" + }, + "_why": "slide 29: chip 'java · Quarkus REST + Panache + SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "sldId": 287, + "shape_id": 361, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 32: chip 'java · Hibernate Panache + Debezium' wraps; one line at 10.0 pt" + }, + { + "sldId": 291, + "shape_id": 406, + "resize": { + "cx": 2824450, + "keep": "right" + }, + "_why": "slide 36: chip 'java · SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "sldId": 298, + "shape_id": 484, + "resize": { + "cx": 2367250, + "keep": "right" + }, + "_why": "slide 43: chip 'java · Quarkus Kafka Streams' wraps; one line at 10.0 pt" + }, + { + "sldId": 300, + "shape_id": 509, + "pt": 11.0, + "_why": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "sldId": 310, + "shape_id": 620, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 55: chip 'xml · Maven + Apicurio' wraps; one line at 10.0 pt" + }, + { + "sldId": 319, + "shape_id": 716, + "resize": { + "cx": 2367250, + "keep": "right" + }, + "_why": "slide 64: chip 'java · Quarkus OpenTelemetry' wraps; one line at 10.0 pt" + }, + { + "sldId": 321, + "shape_id": 740, + "resize": { + "cx": 3434050, + "keep": "right" + }, + "_why": "slide 66: chip 'java · Quarkus + OpenTelemetry baggage API' wraps; one line at 10.0 pt" + }, + { + "sldId": 324, + "shape_id": 774, + "resize": { + "cy": 4607500 + }, + "_why": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 328, + "shape_id": 815, + "resize": { + "cy": 4607500 + }, + "_why": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 329, + "shape_id": 825, + "resize": { + "cy": 4597340 + }, + "_why": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "sldId": 331, + "shape_id": 846, + "resize": { + "cy": 4607500 + }, + "_why": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 333, + "shape_id": 868, + "resize": { + "cx": 3815050, + "keep": "right" + }, + "_why": "slide 78: chip 'java · Quarkus REST + javax.crypto (HmacSHA256)' wraps; one line at 10.0 pt" + }, + { + "sldId": 338, + "shape_id": 922, + "resize": { + "cx": 3357849, + "keep": "right" + }, + "_why": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "sldId": 340, + "shape_id": 945, + "resize": { + "cx": 4500850, + "keep": "right" + }, + "_why": "slide 85: chip 'java · Quarkus + SmallRye Fault Tolerance (MicroProfile)' wraps; one line at 10.0 pt" + }, + { + "sldId": 342, + "shape_id": 968, + "resize": { + "cx": 3586450, + "keep": "right" + }, + "_why": "slide 87: chip 'java · Quarkus + SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "sldId": 365, + "shape_id": 1212, + "resize": { + "cx": 1986250, + "keep": "right" + }, + "_why": "slide 110: chip 'java · SmallRye GraphQL' wraps; one line at 10.0 pt" + }, + { + "sldId": 368, + "shape_id": 1245, + "resize": { + "cx": 2519650, + "keep": "right" + }, + "_why": "slide 113: chip 'java · Quarkus WebSockets Next' wraps; one line at 10.0 pt" + }, + { + "sldId": 377, + "shape_id": 1345, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 122: chip 'java · Hibernate Panache' wraps; one line at 10.0 pt" + }, + { + "sldId": 379, + "shape_id": 1369, + "resize": { + "cx": 2062450, + "keep": "right" + }, + "_why": "slide 124: chip 'java · Hibernate Panache' wraps; one line at 10.0 pt" + }, + { + "sldId": 389, + "shape_id": 1486, + "resize": { + "cx": 2824450, + "keep": "right" + }, + "_why": "slide 134: chip 'java · SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "sldId": 391, + "shape_id": 1510, + "resize": { + "cx": 1986250, + "keep": "right" + }, + "_why": "slide 136: chip 'java · SmallRye GraphQL' wraps; one line at 10.0 pt" + }, + { + "sldId": 401, + "shape_id": 1617, + "resize": { + "cx": 1986250, + "keep": "right" + }, + "_why": "slide 146: chip 'java · ports & adapters' wraps; one line at 10.0 pt" + }, + { + "sldId": 419, + "shape_id": 1807, + "resize": { + "cy": 436820 + }, + "_why": "slide 164: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 420, + "shape_id": 1820, + "resize": { + "cy": 436820 + }, + "_why": "slide 165: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 431, + "shape_id": 1938, + "pt": 11.0, + "_why": "slide 176: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "sldId": 432, + "shape_id": 1950, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 177: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "sldId": 437, + "shape_id": 2006, + "pt": 10.0, + "_why": "slide 182: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "sldId": 438, + "shape_id": 2018, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 183: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "sldId": 443, + "shape_id": 2075, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 188: chip 'java · Quarkus + Camel' wraps; one line at 10.0 pt" + }, + { + "sldId": 444, + "shape_id": 2089, + "pt": 9.0, + "_why": "slide 189: 24 code lines, 70 columns; 11.0 pt overflows the box" + }, + { + "sldId": 449, + "shape_id": 2143, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 194: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "sldId": 461, + "shape_id": 2273, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 206: chip 'java · Quarkus REST + REST Client' wraps; one line at 10.0 pt" + }, + { + "sldId": 461, + "shape_id": 2274, + "pt": 9.0, + "_why": "slide 206: 25 code lines, 88 columns; 9.5 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 463, + "shape_id": 2297, + "resize": { + "cx": 4958049, + "keep": "right" + }, + "_why": "slide 208: chip 'java · Quarkus REST + REST Client + Redis + Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "sldId": 464, + "shape_id": 2309, + "resize": { + "cy": 4568130 + }, + "_why": "slide 209: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 468, + "shape_id": 2351, + "resize": { + "cx": 3053050, + "keep": "right" + }, + "_why": "slide 213: chip 'java · Quarkus REST + Panache + Redis' wraps; one line at 10.0 pt" + }, + { + "sldId": 470, + "shape_id": 2375, + "resize": { + "cx": 3434050, + "keep": "right" + }, + "_why": "slide 215: chip 'java · Quarkus Cache + quarkus-redis-cache' wraps; one line at 10.0 pt" + }, + { + "sldId": 470, + "shape_id": 2376, + "pt": 9.0, + "_why": "slide 215: 22 code lines, 83 columns; 9.5 pt overflows the box" + }, + { + "sldId": 472, + "shape_id": 2399, + "resize": { + "cx": 3053050, + "keep": "right" + }, + "_why": "slide 217: chip 'java · Quarkus REST + Panache + Redis' wraps; one line at 10.0 pt" + }, + { + "sldId": 474, + "shape_id": 2423, + "resize": { + "cx": 3053050, + "keep": "right" + }, + "_why": "slide 219: chip 'java · Quarkus REST + Panache + Redis' wraps; one line at 10.0 pt" + }, + { + "sldId": 476, + "shape_id": 2447, + "resize": { + "cx": 3662650, + "keep": "right" + }, + "_why": "slide 221: chip 'java · Quarkus + @Scheduled + Redis + Panache' wraps; one line at 10.0 pt" + }, + { + "sldId": 478, + "shape_id": 2471, + "resize": { + "cx": 3738850, + "keep": "right" + }, + "_why": "slide 223: chip 'java · Quarkus + @Scheduled + Redis sorted set' wraps; one line at 10.0 pt" + }, + { + "sldId": 480, + "shape_id": 2493, + "resize": { + "cy": 4568130 + }, + "_why": "slide 225: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 492, + "shape_id": 2622, + "resize": { + "cx": 3662650, + "keep": "right" + }, + "_why": "slide 237: chip 'java · Quarkus + MicroProfile Fault Tolerance' wraps; one line at 10.0 pt" + }, + { + "sldId": 494, + "shape_id": 2645, + "resize": { + "cx": 3662650, + "keep": "right" + }, + "_why": "slide 239: chip 'java · Quarkus + MicroProfile Fault Tolerance' wraps; one line at 10.0 pt" + }, + { + "sldId": 496, + "shape_id": 2668, + "resize": { + "cx": 3662650, + "keep": "right" + }, + "_why": "slide 241: chip 'java · Quarkus + MicroProfile Fault Tolerance' wraps; one line at 10.0 pt" + }, + { + "sldId": 500, + "shape_id": 2711, + "resize": { + "cx": 2672050, + "keep": "right" + }, + "_why": "slide 245: chip 'java · Quarkus + SmallRye Health' wraps; one line at 10.0 pt" + }, + { + "sldId": 284, + "shape_id": 328, + "cuts": [ + { + "from": 1, + "to": 1, + "first": "// Write side — command handler" + }, + { + "from": 14, + "to": 14, + "first": "// Read side — query handler reads from a different, denormalised store" + }, + { + "from": 24, + "to": 24, + "first": "// Projection — consumes events, upserts the read model" + } + ], + "edits": [ + { + "line": 2, + "old": "@Path(\"/orders\")", + "new": "@Path(\"/orders\") // write side: command handler" + }, + { + "line": 15, + "old": "@Path(\"/orders\")", + "new": "@Path(\"/orders\") // read side: query handler, denormalised store" + }, + { + "line": 25, + "old": "@ApplicationScoped", + "new": "@ApplicationScoped // projection: events upsert the read model" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 29: 30 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "sldId": 449, + "shape_id": 2144, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 194: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "sldId": 463, + "shape_id": 2298, + "cuts": [ + { + "from": 1, + "to": 2, + "first": "// Quarkus REST that wraps a legacy /orders API and adds Redis caching and", + "last": "// Kafka event emission. The legacy service is unchanged." + } + ], + "pt": 9.0, + "_why": "slide 208: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "sldId": 468, + "shape_id": 2352, + "cuts": [ + { + "from": 1, + "to": 1, + "first": "// Quarkus REST + Panache + quarkus-redis-client (blocking ValueCommands)." + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 213: 26 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "sldId": 476, + "shape_id": 2448, + "cuts": [ + { + "from": 8, + "to": 11, + "first": " public MetricResource(RedisDataSource redis) {", + "last": " }", + "ellipsis": " // ... constructor: cache = redis.hash(String.class), dirty = redis.set(String.class)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 221: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "_why": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "_why": "references: the reference lines run past the frame into the page number; the frame cannot grow", + "space_before": 6 + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "_why": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "op": "fit_font", + "sldId": 479, + "shape_id": 2483, + "pt": 16, + "_why": "caching pitfalls: six long bullets run 15 pt past the frame; the frame cannot grow (page number below)" + } + ] +} diff --git a/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Spring.json b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Spring.json new file mode 100644 index 00000000..0c276603 --- /dev/null +++ b/_plans/s12-manifest/fit/Designing-Cloud-Native-APIs-Spring.json @@ -0,0 +1,1036 @@ +{ + "entries": [ + { + "sldId": 257, + "shape_id": 30, + "resize": { + "y": 1589592, + "cy": 4688780 + }, + "_why": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "sldId": 269, + "shape_id": 155, + "resize": { + "cx": 2595849, + "keep": "right" + }, + "_why": "slide 14: chip 'java · Spring Boot 4 + Actuator' wraps; one line at 10.0 pt" + }, + { + "sldId": 272, + "shape_id": 189, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 17: chip 'java · Spring Boot 4 + Spring Web' wraps; one line at 10.0 pt" + }, + { + "sldId": 272, + "shape_id": 190, + "pt": 9.0, + "_why": "slide 17: 25 code lines, 81 columns; 11.5 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 273, + "shape_id": 202, + "resize": { + "cx": 3510250, + "keep": "right" + }, + "_why": "slide 18: chip 'protobuf + java · Spring Boot 4 + grpc-java' wraps; one line at 10.0 pt" + }, + { + "sldId": 273, + "shape_id": 203, + "pt": 11.0, + "_why": "slide 18: 20 code lines, 94 columns; 11.5 pt overflows the box" + }, + { + "sldId": 279, + "shape_id": 271, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 24: chip 'java · Spring for GraphQL' wraps; one line at 10.0 pt" + }, + { + "sldId": 284, + "shape_id": 327, + "resize": { + "cx": 4272250, + "keep": "right" + }, + "_why": "slide 29: chip 'java · Spring Boot 4 + Spring Data JPA + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 287, + "shape_id": 361, + "resize": { + "cx": 3891250, + "keep": "right" + }, + "_why": "slide 32: chip 'java · Spring Boot 4 + JdbcClient + Debezium CDC' wraps; one line at 10.0 pt" + }, + { + "sldId": 291, + "shape_id": 406, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 36: chip 'java · Spring Boot 4 + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 291, + "shape_id": 407, + "pt": 10.5, + "_why": "slide 36: 21 code lines, 80 columns; 11.5 pt overflows the box" + }, + { + "sldId": 298, + "shape_id": 484, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 43: chip 'java · Spring Kafka Streams' wraps; one line at 10.0 pt" + }, + { + "sldId": 300, + "shape_id": 509, + "pt": 11.0, + "_why": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "sldId": 310, + "shape_id": 620, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 55: chip 'xml · Maven + Apicurio' wraps; one line at 10.0 pt" + }, + { + "sldId": 319, + "shape_id": 716, + "resize": { + "cx": 3815050, + "keep": "right" + }, + "_why": "slide 64: chip 'java · Spring Boot 4 + OpenTelemetry Java agent' wraps; one line at 10.0 pt" + }, + { + "sldId": 319, + "shape_id": 717, + "pt": 11.0, + "_why": "slide 64: 18 code lines, 88 columns; 11.5 pt overflows the box" + }, + { + "sldId": 321, + "shape_id": 740, + "resize": { + "cx": 3281650, + "keep": "right" + }, + "_why": "slide 66: chip 'java · Spring Boot 4 + OpenTelemetry API' wraps; one line at 10.0 pt" + }, + { + "sldId": 324, + "shape_id": 774, + "resize": { + "cy": 4607500 + }, + "_why": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 328, + "shape_id": 815, + "resize": { + "cy": 4607500 + }, + "_why": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 329, + "shape_id": 825, + "resize": { + "cy": 4597340 + }, + "_why": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "sldId": 331, + "shape_id": 846, + "resize": { + "cy": 4607500 + }, + "_why": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "sldId": 333, + "shape_id": 868, + "resize": { + "cx": 3891250, + "keep": "right" + }, + "_why": "slide 78: chip 'java · Spring Boot 4 + javax.crypto (HmacSHA256)' wraps; one line at 10.0 pt" + }, + { + "sldId": 338, + "shape_id": 922, + "resize": { + "cx": 3357849, + "keep": "right" + }, + "_why": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "sldId": 340, + "shape_id": 945, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 85: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "sldId": 342, + "shape_id": 968, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 87: chip 'java · Spring Boot 4 + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 361, + "shape_id": 1164, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 106: chip 'java · Spring Boot 4 + Spring Web' wraps; one line at 10.0 pt" + }, + { + "sldId": 365, + "shape_id": 1212, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 110: chip 'graphql + java · Spring for GraphQL' wraps; one line at 10.0 pt" + }, + { + "sldId": 368, + "shape_id": 1245, + "resize": { + "cx": 4196050, + "keep": "right" + }, + "_why": "slide 113: chip 'java · Spring Boot 4 + spring-boot-starter-websocket' wraps; one line at 10.0 pt" + }, + { + "sldId": 368, + "shape_id": 1246, + "pt": 9.5, + "_why": "slide 113: 23 code lines, 85 columns; 11.0 pt overflows the box" + }, + { + "sldId": 372, + "shape_id": 1290, + "resize": { + "cx": 2824450, + "keep": "right" + }, + "_why": "slide 117: chip 'protobuf + java · Spring WebSocket' wraps; one line at 10.0 pt" + }, + { + "sldId": 372, + "shape_id": 1291, + "pt": 10.5, + "_why": "slide 117: 21 code lines, 87 columns; 11.0 pt overflows the box" + }, + { + "sldId": 377, + "shape_id": 1345, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 122: chip 'java · Spring Data JPA' wraps; one line at 10.0 pt" + }, + { + "sldId": 379, + "shape_id": 1369, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 124: chip 'java · Spring Data JPA' wraps; one line at 10.0 pt" + }, + { + "sldId": 383, + "shape_id": 1414, + "resize": { + "cx": 3662650, + "keep": "right" + }, + "_why": "slide 128: chip 'java · io.temporal:temporal-sdk + Spring Boot' wraps; one line at 10.0 pt" + }, + { + "sldId": 385, + "shape_id": 1438, + "resize": { + "cx": 2748250, + "keep": "right" + }, + "_why": "slide 130: chip 'java · Spring Boot 4 + JdbcClient' wraps; one line at 10.0 pt" + }, + { + "sldId": 389, + "shape_id": 1483, + "resize": { + "cx": 3662650, + "keep": "right" + }, + "_why": "slide 134: chip 'java · Spring Boot 4 + Spring 7 ProblemDetail' wraps; one line at 10.0 pt" + }, + { + "sldId": 391, + "shape_id": 1507, + "resize": { + "cx": 2519650, + "keep": "right" + }, + "_why": "slide 136: chip 'java · grpc-java + StatusProto' wraps; one line at 10.0 pt" + }, + { + "sldId": 393, + "shape_id": 1531, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 138: chip 'java · Spring Boot 4 + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 393, + "shape_id": 1532, + "pt": 9.0, + "_why": "slide 138: 24 code lines, 90 columns; 11.0 pt overflows the box" + }, + { + "sldId": 395, + "shape_id": 1555, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 140: chip 'java · Spring for GraphQL' wraps; one line at 10.0 pt" + }, + { + "sldId": 405, + "shape_id": 1662, + "resize": { + "cx": 3129250, + "keep": "right" + }, + "_why": "slide 150: chip 'java · ports & adapters in Spring Boot' wraps; one line at 10.0 pt" + }, + { + "sldId": 405, + "shape_id": 1664, + "resize": { + "cy": 436820 + }, + "_why": "slide 150: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 424, + "shape_id": 1865, + "resize": { + "cy": 436820 + }, + "_why": "slide 169: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "sldId": 435, + "shape_id": 1983, + "pt": 11.0, + "_why": "slide 180: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "sldId": 436, + "shape_id": 1995, + "resize": { + "cx": 2291050, + "keep": "right" + }, + "_why": "slide 181: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "sldId": 441, + "shape_id": 2051, + "pt": 10.0, + "_why": "slide 186: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "sldId": 442, + "shape_id": 2063, + "resize": { + "cx": 2138650, + "keep": "right" + }, + "_why": "slide 187: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "sldId": 447, + "shape_id": 2120, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 192: chip 'java · Spring Boot 4 + Apache Camel' wraps; one line at 10.0 pt" + }, + { + "sldId": 448, + "shape_id": 2134, + "pt": 9.0, + "_why": "slide 193: 24 code lines, 70 columns; 11.0 pt overflows the box" + }, + { + "sldId": 453, + "shape_id": 2188, + "resize": { + "cx": 1910050, + "keep": "right" + }, + "_why": "slide 198: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "sldId": 465, + "shape_id": 2318, + "resize": { + "cx": 3738850, + "keep": "right" + }, + "_why": "slide 210: chip 'java · Spring Boot 4 + Spring Web + RestClient' wraps; one line at 10.0 pt" + }, + { + "sldId": 467, + "shape_id": 2342, + "resize": { + "cx": 5415250, + "keep": "right" + }, + "_why": "slide 212: chip 'java · Spring Boot 4 + RestClient + Spring Data Redis + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "sldId": 468, + "shape_id": 2354, + "resize": { + "cy": 4568130 + }, + "_why": "slide 213: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "sldId": 472, + "shape_id": 2396, + "resize": { + "cx": 4653250, + "keep": "right" + }, + "_why": "slide 217: chip 'java · Spring Boot 4 + Spring Data JPA + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "sldId": 474, + "shape_id": 2420, + "resize": { + "cx": 4424650, + "keep": "right" + }, + "_why": "slide 219: chip 'java · Spring Boot 4 + Spring Cache + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "sldId": 474, + "shape_id": 2421, + "pt": 9.0, + "_why": "slide 219: 25 code lines, 87 columns; 9.5 pt overflows the box", + "line_spacing": 1.0 + }, + { + "sldId": 476, + "shape_id": 2444, + "resize": { + "cx": 5491450, + "keep": "right" + }, + "_why": "slide 221: chip 'java · Spring Cache + Spring Data Redis + @TransactionalEventListener' wraps; one line at 10.0 pt" + }, + { + "sldId": 478, + "shape_id": 2468, + "resize": { + "cx": 4424650, + "keep": "right" + }, + "_why": "slide 223: chip 'java · Spring Boot 4 + Spring Cache + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "sldId": 480, + "shape_id": 2492, + "resize": { + "cx": 5567650, + "keep": "right" + }, + "_why": "slide 225: chip 'java · Spring Boot 4 + @Scheduled + ShedLock + Spring Data Redis + JPA' wraps; one line at 10.0 pt" + }, + { + "sldId": 482, + "shape_id": 2516, + "resize": { + "cx": 5110450, + "keep": "right" + }, + "_why": "slide 227: chip 'java · Spring Boot 4 + @Scheduled + ShedLock + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "sldId": 496, + "shape_id": 2667, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 241: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "sldId": 498, + "shape_id": 2690, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 243: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "sldId": 500, + "shape_id": 2713, + "resize": { + "cx": 2900650, + "keep": "right" + }, + "_why": "slide 245: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "sldId": 504, + "shape_id": 2756, + "resize": { + "cx": 2595849, + "keep": "right" + }, + "_why": "slide 249: chip 'java · Spring Boot 4 + Actuator' wraps; one line at 10.0 pt" + }, + { + "sldId": 269, + "shape_id": 156, + "cuts": [ + { + "from": 7, + "to": 7, + "first": "" + }, + { + "from": 22, + "to": 22, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 14: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 11.0 pt" + }, + { + "sldId": 284, + "shape_id": 328, + "cuts": [ + { + "from": 1, + "to": 1, + "first": "// Write side — command handler" + }, + { + "from": 4, + "to": 5, + "first": " private final OrderRepository writes;", + "last": " private final KafkaTemplate kafka;", + "ellipsis": " // ... final fields: OrderRepository writes, KafkaTemplate kafka" + }, + { + "from": 14, + "to": 14, + "first": "// Read side — query handler reads from a DIFFERENT, denormalised store" + }, + { + "from": 24, + "to": 24, + "first": "// Projection — consumes events, upserts the read model" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 29: 31 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "sldId": 340, + "shape_id": 946, + "cuts": [ + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 6, + "to": 9, + "first": " public OrderService(PaymentClient payments, BulkheadRegistry registry) {", + "last": " }", + "ellipsis": " // ... constructor injects PaymentClient and the BulkheadRegistry" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 22, + "to": 22, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 85: 31 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt, was 8.0 pt" + }, + { + "sldId": 377, + "shape_id": 1346, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 11, + "to": 11, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 122: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 11.0 pt" + }, + { + "sldId": 383, + "shape_id": 1415, + "cuts": [ + { + "from": 4, + "to": 7, + "first": "@ActivityInterface", + "last": "public interface ShippingActivity { String book(SagaInput in, String reservationId); }", + "ellipsis": "// ... InventoryActivity.reserve and ShippingActivity.book are declared the same way" + }, + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 12, + "to": 12, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 128: 31 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "sldId": 389, + "shape_id": 1484, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 134: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 11.0 pt" + }, + { + "sldId": 395, + "shape_id": 1556, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// Spring for GraphQL: implement DataFetcherExceptionResolver to map exceptions", + "last": "// the rest of the data still returns." + }, + { + "from": 15, + "to": 18, + "first": " .extensions(Map.of(", + "last": " \"traceId\", Span.current().getSpanContext().getTraceId()))", + "ellipsis": " // ... .extensions(Map.of(code, retryable, traceId))" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 29, + "to": 32, + "first": " try { return inventory.remaining(order.sku()); }", + "last": " }", + "ellipsis": " // ... calls inventory; on Unavailable throws InventoryUnavailable (nulls the field)" + } + ], + "pt": 9.0, + "_why": "slide 140: 34 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 405, + "shape_id": 1663, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 9, + "to": 11, + "first": " public PlaceOrder(OrderRepository r, EventPublisher e) {", + "last": " }", + "ellipsis": " // ... constructor injection: no framework leak" + }, + { + "from": 19, + "to": 19, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 150: 29 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "sldId": 423, + "shape_id": 1851, + "cuts": [ + { + "from": 13, + "to": 13, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 168: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "sldId": 453, + "shape_id": 2189, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 198: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "sldId": 465, + "shape_id": 2319, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 11, + "to": 15, + "first": " public OrderRouter(RestClient.Builder builder) {", + "last": " }", + "ellipsis": " // ... the constructor builds the monolith, orders and orders-eu RestClients" + }, + { + "from": 16, + "to": 16, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "_why": "slide 210: 32 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "sldId": 467, + "shape_id": 2343, + "cuts": [ + { + "from": 1, + "to": 5, + "first": "// Spring REST that wraps a legacy /orders API and adds:", + "last": "" + }, + { + "from": 9, + "to": 21, + "first": " private final RestClient legacy;", + "last": "", + "ellipsis": " // ... fields and constructor: RestClient legacy, RedisTemplate cache, KafkaTemplate kafka, TTL 60 s" + } + ], + "pt": 9.0, + "_why": "slide 212: 41 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "sldId": 472, + "shape_id": 2397, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "" + }, + { + "from": 6, + "to": 13, + "first": " private static final Duration TTL = Duration.ofSeconds(60);", + "last": " }", + "ellipsis": " // ... TTL 60 s; ProductRepository repo and RedisTemplate cache injected" + }, + { + "from": 14, + "to": 14, + "first": "" + }, + { + "from": 25, + "to": 25, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 217: 34 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "sldId": 476, + "shape_id": 2445, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + }, + { + "from": 21, + "to": 33, + "first": " private final ProductRepository repo;", + "last": "", + "ellipsis": " // ... fields repo, cache, events; @Transactional update() saves, then publishes ProductUpdatedEvent" + } + ], + "pt": 9.0, + "_why": "slide 221: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "sldId": 480, + "shape_id": 2493, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// @EnableScheduling on a @Configuration, then @Scheduled methods run on a", + "last": "" + }, + { + "from": 7, + "to": 15, + "first": " private static final int BATCH = 100;", + "last": "", + "ellipsis": " // ... BATCH = 100; RedisTemplate cache and MetricRepository repo injected" + }, + { + "from": 23, + "to": 23, + "first": "" + }, + { + "from": 30, + "to": 33, + "first": " List rows = ids.stream()", + "last": " .toList();", + "ellipsis": " // ... rows = the ids mapped through loadFromCache, nulls dropped" + } + ], + "pt": 9.0, + "_why": "slide 225: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.5 pt" + }, + { + "sldId": 482, + "shape_id": 2517, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// A @Scheduled task refreshes a hot-keys sorted-set before TTL.", + "last": "" + }, + { + "from": 6, + "to": 17, + "first": " private static final Duration TTL = Duration.ofSeconds(60);", + "last": "", + "ellipsis": " // ... TTL 60 s, REFRESH_BEFORE_S 10; repo, cache and hot (RedisTemplate) injected" + }, + { + "from": 20, + "to": 24, + "first": " Product cached = cache.opsForValue().get(\"product:\" + id);", + "last": " return p;", + "ellipsis": " // ... then cache-aside on product:{id} with TTL" + }, + { + "from": 26, + "to": 26, + "first": "" + } + ], + "pt": 9.0, + "_why": "slide 227: 43 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "_why": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "_why": "references: the reference lines run past the frame into the page number; the frame cannot grow", + "space_before": 6 + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "_why": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "op": "fit_font", + "sldId": 483, + "shape_id": 2528, + "pt": 16, + "_why": "caching pitfalls: six long bullets run 15 pt past the frame; the frame cannot grow (page number below)" + }, + { + "op": "replace", + "scope": "slide", + "find": "Temporal: your saga is plain Java, the Server makes it crash-proof", + "repl": "Temporal: a plain-Java saga that the Server makes durable", + "expect": 1, + "sldIds": [ + 382 + ], + "_why": "title wraps to two lines" + }, + { + "op": "replace", + "scope": "slide", + "find": "The hand-rolled orchestrator: one row, one log, no extra cluster", + "repl": "A hand-rolled orchestrator: one row, one log, no cluster", + "expect": 1, + "sldIds": [ + 384 + ], + "_why": "title wraps to two lines" + } + ] +} diff --git a/_plans/s12-manifest/fixA/Cpp.capsfix.json b/_plans/s12-manifest/fixA/Cpp.capsfix.json new file mode 100644 index 00000000..97d7f8c8 --- /dev/null +++ b/_plans/s12-manifest/fixA/Cpp.capsfix.json @@ -0,0 +1,269 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 429, + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 2, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 440 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code" + } + ] +} \ No newline at end of file diff --git a/_plans/s12-manifest/fixA/DotNet.capsfix.json b/_plans/s12-manifest/fixA/DotNet.capsfix.json new file mode 100644 index 00000000..3d240c7b --- /dev/null +++ b/_plans/s12-manifest/fixA/DotNet.capsfix.json @@ -0,0 +1,928 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 525 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 295 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 319 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 319 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 319 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 320 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 320 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 326 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 326 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 326 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 330 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 418 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 425 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 449 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 449 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 459 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 472 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 478 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 496 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 512 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 515 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 517 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 287 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 288 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 319 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 322 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 322 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 339 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 343 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 390 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 391 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 400 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 424 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 444 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 455 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 455 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 468 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 468 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 479 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 481 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 503 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 505 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 513 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 513 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 516 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 516 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 435 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 435 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 507 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 448 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code" + } + ] +} \ No newline at end of file diff --git a/_plans/s12-manifest/fixA/Go.capsfix.json b/_plans/s12-manifest/fixA/Go.capsfix.json new file mode 100644 index 00000000..533d2267 --- /dev/null +++ b/_plans/s12-manifest/fixA/Go.capsfix.json @@ -0,0 +1,983 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 511 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 410 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 384 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 416 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 436 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 462 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 465 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 467 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 485 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 488 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 491 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 433 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 449 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 378 + ], + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 440 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code" + } + ] +} \ No newline at end of file diff --git a/_plans/s12-manifest/fixA/Python.capsfix.json b/_plans/s12-manifest/fixA/Python.capsfix.json new file mode 100644 index 00000000..ecdbb2b4 --- /dev/null +++ b/_plans/s12-manifest/fixA/Python.capsfix.json @@ -0,0 +1,983 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 511 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 410 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "find": "handlers MUST be idempotent", + "repl": "handlers must be idempotent", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 384 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 416 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 436 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 462 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 465 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 467 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 485 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 488 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 491 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 433 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 449 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 440 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code" + } + ] +} \ No newline at end of file diff --git a/_plans/s12-manifest/fixA/Spring.capsfix.json b/_plans/s12-manifest/fixA/Spring.capsfix.json new file mode 100644 index 00000000..a5927373 --- /dev/null +++ b/_plans/s12-manifest/fixA/Spring.capsfix.json @@ -0,0 +1,972 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 515 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 414 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 421 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 445 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 445 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 455 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 462 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 468 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 486 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 491 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 502 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 505 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 507 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "find": "handlers MUST be idempotent", + "repl": "handlers must be idempotent", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "WITHOUT registering it", + "repl": "without registering it", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 386 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 387 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 388 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 396 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 420 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 440 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 445 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 451 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 451 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 458 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 466 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 469 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 471 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 492 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 495 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 503 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 503 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 506 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 506 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 437 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 453 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 378 + ], + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 444 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code" + } + ] +} \ No newline at end of file diff --git a/_plans/s12-manifest/fixA/quarkus-q.fix1.json b/_plans/s12-manifest/fixA/quarkus-q.fix1.json new file mode 100644 index 00000000..b50fd49a --- /dev/null +++ b/_plans/s12-manifest/fixA/quarkus-q.fix1.json @@ -0,0 +1,1115 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 284 + ], + "find": "an @Incoming(\"order-created\") method (topic order.created)", + "repl": "an @Incoming(\"order-placed\") method (topic order.placed)", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 321 + ], + "find": "@Incoming(\"order.placed\")", + "repl": "@Incoming(\"order-placed\") // topic order.placed", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "new Semaphore(20)", + "repl": "new Semaphore(5)", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "fills its 20 slots", + "repl": "fills its 5 slots", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "Metric.persistAll(loadFromCache(ids)); // batch insert", + "repl": "Metric.persistAll(loadFromCache(ids)); Metric.flush(); // batch insert", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "// MicroProfile applies the breaker first, then retry within the closed state.", + "repl": "// Retry wraps the breaker; an open breaker fails each retry fast.", + "expect": 1, + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 511 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 410 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "// ALWAYS pair with a breaker", + "repl": "// always pair with a breaker", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "# BUILD time", + "repl": "# build time", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 381 + ], + "find": "each task's OUTPUT becomes the next task's INPUT", + "repl": "each task's output becomes the next task's input", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "find": "handlers MUST be idempotent", + "repl": "handlers must be idempotent", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "WITHOUT registering it", + "repl": "without registering it", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 384 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 416 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 419 + ], + "find": "a BUILD-time flag", + "repl": "a build-time flag", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 436 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 455 + ], + "find": "If you DO need", + "repl": "If you do need", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 462 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 465 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 467 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 485 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 488 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 491 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 381 + ], + "find": "line from two slides back", + "repl": "line in the persisted-state code", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 433 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 449 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional" + } + ] +} \ No newline at end of file diff --git a/_plans/s12-manifest/fixA/quarkus-q.fix2.json b/_plans/s12-manifest/fixA/quarkus-q.fix2.json new file mode 100644 index 00000000..9a4ad1f7 --- /dev/null +++ b/_plans/s12-manifest/fixA/quarkus-q.fix2.json @@ -0,0 +1,7 @@ +{"ops":[ + {"op":"replace","scope":"slide","sldIds":[333],"find":"Valet key — scoped","repl":"Valet key — scoped","expect":1,"cat":"voice"}, + {"op":"replace","scope":"notes","sldIds":[378],"find":"persisted FAILED state","repl":"persisted COMPENSATED state","expect":1,"cat":"code"}, + {"op":"replace","scope":"slide","sldIds":[440],"find":"inject correlation and identity","repl":"inject the caller address and identity","expect":1,"cat":"code"}, + {"op":"fit_font","sldId":472,"shape_id":2400,"pt":11.5,"cat":"fit"}, + {"op":"set_geometry","sldId":531,"shape":"Image 1","x":2.16,"y":1.95,"w":9.01,"h":4.30,"cat":"fit"} +]} diff --git a/_plans/s12-manifest/images.md b/_plans/s12-manifest/images.md new file mode 100644 index 00000000..016b840a --- /dev/null +++ b/_plans/s12-manifest/images.md @@ -0,0 +1,326 @@ +# S12 image audit (language decks) + +Every content image of the six Designing-Cloud-Native-APIs decks, deduplicated by content hash (243 unique images; logos and section panels excluded). The K8s-Environment-Access-Policies and Network-Policy-Process-Advisory images belong to the policy batch; `apxo-ci-gate` and `apxo-black-box` are replaced in the Appendix O ops (27-ci-gate, 27-black-box). + +Checks: old container engine, versions against the pins, MassTransit, Poetry, Faust, modern-cpp-kafka, libpqxx, kafka-ui, kubeval, graphql-go, five vs six implementations, positional words, ban-tier phrases, topic names, and a library that is not the deck language's locked one. Watch-tier wording inside figures (ALL-CAPS emphasis such as 'NOT mutually exclusive', 'actually', 'simply') is left: the scanner does not read images, and redrawing every figure for watch-tier wording is out of proportion; listed as a follow-up. + +Ops: `W/ops-15-images/.json` (replace_picture by sldId and shape id; two caption set_text ops). Replacements in `lgtm-presentation/edits/png/`: site figures rendered from `assets/diagrams/` (cairosvg 2.9.1, 1400 px, the arrow glyph written as `->` because cairosvg has no fallback font for it), site figures with per-language label substitutions and authored figures, both with their SVG source in `lgtm-presentation/edits/png/src/` (generators: `gen_images.py` and `gen_ops.py` in the session scratchpad `s12/manifest/fork-images/`; the planner copies them to `_plans/s12-manifest/tools/`). + +## Replacements + +| Image | Verdict | Replacement per deck:slide | +|---|---|---| +| `01b-windows-to-k8s-bridge.png` (c4bbf68a) | stale: 'MassTransit sagas'; Serilog | DotNet:8->01b-windows-to-k8s-bridge-dn | +| `03-comm-protocol-matrix.png` (0aa9f38c) | wrong language: Python labels in Go; Quarkus labels in .NET and Spring; ok in Python and Quarkus | DotNet:17->02-pick-protocol-dotnet Spring:16->02-pick-protocol-spring | +| `03-comm-protocol-matrix.png` (ae3a36ec) | wrong language: Python labels in Go; Quarkus labels in .NET and Spring; ok in Python and Quarkus | Go:16->02-pick-protocol-go | +| `05-comm-service-mesh.png` (34b40288) | wrong language (as protocol matrix) | DotNet:21->02-mesh-handles-wire-dotnet Spring:20->02-mesh-handles-wire-spring | +| `05-comm-service-mesh.png` (a7e0001b) | wrong language (as protocol matrix) | Go:20->02-mesh-handles-wire-go | +| `06-composition-federation.png` (0ba5c3cd) | wrong language (as protocol matrix) | Go:23->03-composition | +| `06-composition-federation.png` (bc342668) | wrong language (as protocol matrix) | DotNet:24->03-composition Spring:23->03-composition | +| `08b-data-cqrs.png` (819ab318) | stale: 'the next slide' (positional), 'genuinely' (ban) | DotNet:30->04-cqrs Go:28->04-cqrs Python:28->04-cqrs Quarkus:28->04-cqrs Spring:28->04-cqrs | +| `08b-data-cqrs.png` (87ee7f25) | stale: 'the next slide' (positional), 'genuinely' (ban) | Cpp:28->04-cqrs | +| `08c-data-log-tailing.png` (6dcd3755) | stale: '(next slide)' (positional) | Cpp:30->04-log-tailing | +| `08c-data-log-tailing.png` (a1d47b26) | stale: '(next slide)' (positional) | DotNet:32->04-log-tailing Go:30->04-log-tailing Python:30->04-log-tailing Quarkus:30->04-log-tailing Spring:30->04-log-tailing | +| `110-failure-taxonomy.png` (168bd1d1) | stale: 'the rest of this appendix' (ban) | DotNet:242->25-failure-taxonomy Go:228->25-failure-taxonomy Python:228->25-failure-taxonomy Quarkus:228->25-failure-taxonomy Spring:232->25-failure-taxonomy | +| `110-failure-taxonomy.png` (d03d5c63) | stale: 'the rest of this appendix' (ban) | Cpp:228->25-failure-taxonomy | +| `117-failure-circuit-breaker.png` (c0d197f9) | Go deck: library list without sony/gobreaker; ok elsewhere | Go:240->25-circuit-breaker | +| `14-stream-topology.png` (36b98911) | Python/Go copy: 'Faust/Kafka Streams style' (faust-streaming; goka in Go). .NET/Quarkus/Spring copy: ok | Go:42->06-derive-streams Python:42->06-derive-streams | +| `15-stream-keda-scaling.png` (d589d624) | wrong language: FastAPI pods in Go, Quarkus pods in .NET and Spring; ok in Python and Quarkus | DotNet:46->06-scale-on-lag Spring:44->06-scale-on-lag | +| `15-stream-keda-scaling.png` (da1ae4af) | wrong language: FastAPI pods in Go, Quarkus pods in .NET and Spring; ok in Python and Quarkus | Go:44->06-scale-on-lag | +| `23-anti-patterns-grid.png` (db3eaabc) | defect: box text broken mid-word | DotNet:93->13-antipatterns-grid Go:91->13-antipatterns-grid Python:91->13-antipatterns-grid Quarkus:91->13-antipatterns-grid Spring:91->13-antipatterns-grid | +| `23-anti-patterns-grid.png` (e35ef8be) | defect: box text broken mid-word | Cpp:91->13-antipatterns-grid | +| `29-container-layers.png` (6cd65c1b) | stale: old container engine named; separate restore step | DotNet:293->29-container-layers | +| `29-dotnet-compilation.png` (f0d220cd) | defect: missing-glyph boxes | DotNet:289->29-dotnet-compilation | +| `29-kestrel-pipeline.png` (f5cec157) | refresh from site (consistency) | DotNet:291->29-kestrel-pipeline | +| `33-appD-state-options.png` (c8676b1f) | Python copy shown in the Go deck ('Where a Python saga keeps its state'); ok in Python | Go:121->17-saga-state | +| `33-appD-state-options-sb.png` (001c8b8b) | Spring Statemachine named as a workflow engine (decision 6: archived upstream, not offered) | Spring:121->17-saga-state | +| `63-appD-statemachine.png` (c362e9a3) | Spring Statemachine figure (decision 6) | Spring:129->17-sagas-architecture (from `examples/17-sagas/architecture.svg`) | +| `42-twelve-factor-map-py.png` (77e98986) | stale: Poetry (pip + requirements.txt); Python tooling in the Go deck | Go:13->01-factors-mapped-go Python:13->01-factors-mapped-py | +| `54-appI-stack-cpp.png` (cde1d928) | stale: modern-cpp-kafka | Cpp:172->22-l7-layers | +| `54-appI-stack-py.png` (ed5dc6a5) | wrong language in the Go deck (FastAPI + durable-rules); ok in Python | Go:172->22-l7-layers | +| `54-appI-stack-sb.png` (957a85d5) | wrong language in the .NET deck (Spring Boot + Camel + Drools); ok in Spring | DotNet:180->22-l7-layers | +| `55-appI-journey.png` (aa7e6af7) | stale: 'each layer earns its place' (ban) | DotNet:181->22-l7-journey Go:173->22-l7-journey Python:173->22-l7-journey Quarkus:173->22-l7-journey Spring:177->22-l7-journey | +| `55-appI-journey.png` (d6bd2e08) | stale: 'each layer earns its place' (ban) | Cpp:173->22-l7-journey | +| `58-appI-masstransit-routingslip-dn.png` (089c9839) | stale: MassTransit | DotNet:195->22-business-rule-routing-dotnet | +| `59-appI-latency-cpp.png` (1dff3a8e) | stale: 'every hop must earn its place' (ban); rule-engine row per language | Cpp:190->22-hop-cost-cpp | +| `59-appI-latency-py.png` (30a50bbf) | stale: 'every hop must earn its place' (ban); rule-engine row per language | Go:190->22-hop-cost-go Python:190->22-hop-cost | +| `59-appI-latency-sb.png` (6f0c88a1) | stale: 'every hop must earn its place' (ban); rule-engine row per language | DotNet:198->22-hop-cost-nrules Quarkus:190->22-hop-cost-drools Spring:194->22-hop-cost-drools | +| `60-appI-east-west.png` (6dc23201) | stale: 'for free' (ban) | Cpp:192->22-east-west | +| `60-appI-east-west.png` (8badba43) | stale: 'for free' (ban) | DotNet:200->22-east-west Go:192->22-east-west Python:192->22-east-west Quarkus:192->22-east-west Spring:196->22-east-west | +| `61-appI-cpp-router.png` (59c9c14e) | stale: modern-cpp-kafka | Cpp:187->22-business-rule-routing-cpp | +| `61-appI-python-router.png` (fc30e611) | wrong language in the Go deck; ok in Python | Go:187->22-business-rule-routing-go | +| `63-appD-stateless-dn.png` (66042487) | stale: 'MassTransit Sagas'; '@Transactional' | DotNet:133->63-appD-stateless-dn | +| `70-appJ-pinned-libraries-dn.png` (854b9f97) | stale: MassTransit row; 'Every entry above' | DotNet:207->70-appJ-pinned-libraries-dn | +| `71-appJ-pinned-platform-dn.png` (89cd4fd4) | stale: Redis license (Redis 8 is RSALv2/SSPLv1/AGPLv3) | DotNet:208->71-appJ-pinned-platform-dn | +| `72-appJ-migration-reference-dn.png` (5d457c4d) | stale: MassTransit in three rows | DotNet:209->72-appJ-migration-reference-dn | +| `86-appK-strangler-shared-database.png` (81c3d748) | stale: '(decomposed DB above)' (positional) | Cpp:205->24-shared-database | +| `86-appK-strangler-shared-database.png` (cd33b74a) | stale: '(decomposed DB above)' (positional) | DotNet:219->24-shared-database Go:205->24-shared-database Python:205->24-shared-database Quarkus:205->24-shared-database Spring:209->24-shared-database | + +## Captions changed with the figure + +- C++ slide 187 (sldId 442): 'modern-cpp-kafka does the dispatch' becomes 'rdkafka++ (librdkafka) does the dispatch'. +- .NET slide 195 (sldId 450): caption rewritten for NRules + Confluent.Kafka. The slide title still names MassTransit RoutingSlip; the .NET batch must retitle it (for example '.NET + NRules: rule-based routing'). + +## Full coverage + +| Group | Image (hash) | Used on deck:slide | Verdict | +|---|---|---|---| +| Main sections | `00-patterns-literacy-stack.png` (378aef87) | DotNet 5 Go 5 Python 5 Quarkus 5 Spring 5 | ok | +| Main sections | `00-patterns-literacy-stack.png` (e4f0f2d4) | Cpp 5 | ok | +| Main sections | `01-cloud-native-pillars.png` (28b54bf4) | DotNet 9 Go 8 Python 8 Quarkus 8 Spring 8 | ok | +| Main sections | `01b-windows-to-k8s-bridge.png` (c4bbf68a) | DotNet 8 | stale: 'MassTransit sagas'; Serilog | +| Main sections | `02-canonical-system.png` (6abb3745) | DotNet 6 Go 6 Python 6 Quarkus 6 Spring 6 | ok | +| Main sections | `02-canonical-system.png` (726a15e7) | Cpp 6 | ok | +| Main sections | `03-comm-protocol-matrix-cpp.png` (fa396f9f) | Cpp 16 | ok | +| Main sections | `03-comm-protocol-matrix.png` (0aa9f38c) | DotNet 17 Quarkus 16 Spring 16 | wrong language: Python labels in Go; Quarkus labels in .NET and Spring; ok in Python and Quarkus | +| Main sections | `03-comm-protocol-matrix.png` (ae3a36ec) | Go 16 Python 16 | wrong language: Python labels in Go; Quarkus labels in .NET and Spring; ok in Python and Quarkus | +| Main sections | `04-comm-sync-async.png` (1b292f56) | Cpp 19 | ok | +| Main sections | `04-comm-sync-async.png` (de5f7860) | DotNet 20 Go 19 Python 19 Quarkus 19 Spring 19 | ok | +| Main sections | `05-comm-service-mesh-cpp.png` (96fa621b) | Cpp 20 | ok | +| Main sections | `05-comm-service-mesh.png` (34b40288) | DotNet 21 Quarkus 20 Spring 20 | wrong language (as protocol matrix) | +| Main sections | `05-comm-service-mesh.png` (a7e0001b) | Go 20 Python 20 | wrong language (as protocol matrix) | +| Main sections | `06-composition-federation-cpp.png` (a41edc2b) | Cpp 23 | ok | +| Main sections | `06-composition-federation.png` (0ba5c3cd) | Go 23 Python 23 | wrong language (as protocol matrix) | +| Main sections | `06-composition-federation.png` (bc342668) | DotNet 24 Quarkus 23 Spring 23 | wrong language (as protocol matrix) | +| Main sections | `07-composition-patterns.png` (152860f0) | DotNet 26 Go 25 Python 25 Quarkus 25 Spring 25 | ok | +| Main sections | `07-composition-patterns.png` (354ccced) | Cpp 25 | ok | +| Main sections | `08-data-per-service-db.png` (6dd7f518) | DotNet 29 Go 27 Python 27 Quarkus 27 Spring 27 | ok | +| Main sections | `08-data-per-service-db.png` (c3431cf9) | Cpp 27 | ok | +| Main sections | `08b-data-cqrs.png` (819ab318) | DotNet 30 Go 28 Python 28 Quarkus 28 Spring 28 | stale: 'the next slide' (positional), 'genuinely' (ban) | +| Main sections | `08b-data-cqrs.png` (87ee7f25) | Cpp 28 | stale: 'the next slide' (positional), 'genuinely' (ban) | +| Main sections | `08c-data-log-tailing.png` (6dcd3755) | Cpp 30 | stale: '(next slide)' (positional) | +| Main sections | `08c-data-log-tailing.png` (a1d47b26) | DotNet 32 Go 30 Python 30 Quarkus 30 Spring 30 | stale: '(next slide)' (positional) | +| Main sections | `09-data-outbox-cdc.png` (0d9014a9) | Cpp 31 | ok | +| Main sections | `09-data-outbox-cdc.png` (581ddd6e) | DotNet 33 Go 31 Python 31 Quarkus 31 Spring 31 | ok | +| Main sections | `10-data-saga.png` (0f8dde9b) | DotNet 35 Go 33 Python 33 Quarkus 33 Spring 33 | ok | +| Main sections | `10-data-saga.png` (1b6d1d7a) | Cpp 33 | ok | +| Main sections | `11-event-topology.png` (350a1df4) | DotNet 37 Go 35 Python 35 Quarkus 35 Spring 35 | ok | +| Main sections | `11-event-topology.png` (db4a1e82) | Cpp 35 | ok | +| Main sections | `11b-event-sourcing-vs-streaming.png` (26c99263) | Cpp 38 | ok | +| Main sections | `11b-event-sourcing-vs-streaming.png` (55032ecc) | DotNet 40 Go 38 Python 38 Quarkus 38 Spring 38 | ok | +| Main sections | `11c-event-tooling-landscape.png` (4bac32ca) | DotNet 41 Go 39 Python 39 Quarkus 39 Spring 39 | ok | +| Main sections | `11c-event-tooling-landscape.png` (f866fc13) | Cpp 39 | ok | +| Main sections | `12-event-schema-registry.png` (8c1e4628) | DotNet 39 Go 37 Python 37 Quarkus 37 Spring 37 | ok | +| Main sections | `12-event-schema-registry.png` (ba712841) | Cpp 37 | ok | +| Main sections | `13-event-log-replay.png` (a3193415) | Cpp 40 | ok | +| Main sections | `13-event-log-replay.png` (e86ff185) | DotNet 42 Go 40 Python 40 Quarkus 40 Spring 40 | ok | +| Main sections | `14-stream-topology-cpp.png` (07925fb2) | Cpp 42 | ok | +| Main sections | `14-stream-topology.png` (36b98911) | Go 42 Python 42 | Python/Go copy: 'Faust/Kafka Streams style' (faust-streaming; goka in Go). .NET/Quarkus/Spring copy: ok | +| Main sections | `14-stream-topology.png` (8fb5e970) | DotNet 44 Quarkus 42 Spring 42 | Python/Go copy: 'Faust/Kafka Streams style' (faust-streaming; goka in Go). .NET/Quarkus/Spring copy: ok | +| Main sections | `15-stream-keda-scaling-cpp.png` (24a1aadd) | Cpp 44 | ok | +| Main sections | `15-stream-keda-scaling.png` (d589d624) | DotNet 46 Quarkus 44 Spring 44 | wrong language: FastAPI pods in Go, Quarkus pods in .NET and Spring; ok in Python and Quarkus | +| Main sections | `15-stream-keda-scaling.png` (da1ae4af) | Go 44 Python 44 | wrong language: FastAPI pods in Go, Quarkus pods in .NET and Spring; ok in Python and Quarkus | +| Main sections | `16-workflows-orchestration.png` (0492e3d2) | DotNet 49 Go 47 Python 47 Quarkus 47 Spring 47 | ok | +| Main sections | `16-workflows-orchestration.png` (72c6e641) | Cpp 47 | ok | +| Main sections | `17-workflows-jobs.png` (12ab57e6) | Cpp 49 | ok | +| Main sections | `17-workflows-jobs.png` (ad0c7d76) | DotNet 51 Go 49 Python 49 Quarkus 49 Spring 49 | ok | +| Main sections | `18-api-management-stack.png` (48f50c69) | Cpp 51 | ok | +| Main sections | `18-api-management-stack.png` (fb65a7ba) | DotNet 53 Go 51 Python 51 Quarkus 51 Spring 51 | ok | +| Main sections | `19-api-registry-apicurio.png` (3430e32e) | Cpp 54 | ok | +| Main sections | `19-api-registry-apicurio.png` (bebfa151) | DotNet 56 Go 54 Python 54 Quarkus 54 Spring 54 | ok | +| Main sections | `20-api-metadata-openmetadata.png` (4e333d5e) | Cpp 57 | ok | +| Main sections | `20-api-metadata-openmetadata.png` (b110fe44) | DotNet 59 Go 57 Python 57 Quarkus 57 Spring 57 | ok | +| Main sections | `21-observability-pillars.png` (74ce993f) | Cpp 60 | ok | +| Main sections | `21-observability-pillars.png` (d87306f9) | DotNet 62 Go 60 Python 60 Quarkus 60 Spring 60 | ok | +| Main sections | `21b-observability-signals.png` (4ba0767f) | Cpp 61 | ok | +| Main sections | `21b-observability-signals.png` (56efca31) | DotNet 63 Go 61 Python 61 Quarkus 61 Spring 61 | ok | +| Main sections | `21c-observability-correlation.png` (6b2db016) | DotNet 67 Go 65 Python 65 Quarkus 65 Spring 65 | ok | +| Main sections | `21c-observability-correlation.png` (b5658885) | Cpp 65 | ok | +| Main sections | `21d-observability-sampling.png` (010bc155) | Cpp 68 | ok | +| Main sections | `21d-observability-sampling.png` (139e850c) | DotNet 70 Go 68 Python 68 Quarkus 68 Spring 68 | ok | +| Main sections | `22-observability-trace.png` (16e5b505) | Cpp 67 | ok | +| Main sections | `22-observability-trace.png` (9325b031) | DotNet 69 Go 67 Python 67 Quarkus 67 Spring 67 | ok | +| Main sections | `23-anti-patterns-grid.png` (db3eaabc) | DotNet 93 Go 91 Python 91 Quarkus 91 Spring 91 | defect: box text broken mid-word | +| Main sections | `23-anti-patterns-grid.png` (e35ef8be) | Cpp 91 | defect: box text broken mid-word | +| Security (section 12) | `100-security-4cs.png` (55805502) | DotNet 74 Go 72 Python 72 Quarkus 72 Spring 72 | ok | +| Security (section 12) | `100-security-4cs.png` (b4801f82) | Cpp 72 | ok | +| Security (section 12) | `101-security-sidecar.png` (d7ce206e) | DotNet 77 Go 75 Python 75 Quarkus 75 Spring 75 | ok | +| Security (section 12) | `101-security-sidecar.png` (e8e5ddb3) | Cpp 75 | ok | +| Security (section 12) | `102-security-valet-key.png` (3ee84b4f) | DotNet 79 Go 77 Python 77 Quarkus 77 Spring 77 | ok | +| Security (section 12) | `102-security-valet-key.png` (be76388f) | Cpp 77 | ok | +| Security (section 12) | `103-security-zero-trust.png` (3550f9a2) | Cpp 80 | ok | +| Security (section 12) | `103-security-zero-trust.png` (e68ee70c) | DotNet 82 Go 80 Python 80 Quarkus 80 Spring 80 | ok | +| Security (section 12) | `104-security-policy-as-code.png` (b338dd3c) | DotNet 84 Go 82 Python 82 Quarkus 82 Spring 82 | ok | +| Security (section 12) | `104-security-policy-as-code.png` (e600c180) | Cpp 82 | ok | +| Security (section 12) | `105-security-bulkhead.png` (53a34471) | Cpp 84 | ok | +| Security (section 12) | `105-security-bulkhead.png` (91df0abf) | DotNet 86 Go 84 Python 84 Quarkus 84 Spring 84 | ok | +| Security (section 12) | `106-security-claim-check.png` (5719389f) | DotNet 88 Go 86 Python 86 Quarkus 86 Spring 86 | ok | +| Security (section 12) | `106-security-claim-check.png` (9556d595) | Cpp 86 | ok | +| Appendices A-I | `24-appA-paradigms.png` (28283e3b) | Cpp 99 | ok | +| Appendices A-I | `24-appA-paradigms.png` (b1b5f6bc) | DotNet 101 Go 99 Python 99 Quarkus 99 Spring 99 | ok | +| Appendices A-I | `25-appA-fetching.png` (8c7daf02) | DotNet 102 Go 100 Python 100 Quarkus 100 Spring 100 | ok | +| Appendices A-I | `25-appA-fetching.png` (ab61389d) | Cpp 100 | ok | +| Appendices A-I | `26-appA-transport.png` (1a9b3321) | DotNet 103 Go 101 Python 101 Quarkus 101 Spring 101 | ok | +| Appendices A-I | `26-appA-transport.png` (572614c5) | Cpp 101 | ok | +| Appendices A-I | `27-appB-rest-versioning.png` (21db182c) | DotNet 107 Go 105 Python 105 Quarkus 105 Spring 105 | ok | +| Appendices A-I | `27-appB-rest-versioning.png` (c56f5f4f) | Cpp 105 | ok | +| Appendices A-I | `28-appB-proto-evolution.png` (57f38e31) | Cpp 107 | ok | +| Appendices A-I | `28-appB-proto-evolution.png` (dc575449) | DotNet 109 Go 107 Python 107 Quarkus 107 Spring 107 | ok | +| Appendices A-I | `29-appB-graphql-evolution.png` (3684db60) | Cpp 109 | ok | +| Appendices A-I | `29-appB-graphql-evolution.png` (cb0641a1) | DotNet 111 Go 109 Python 109 Quarkus 109 Spring 109 | ok | +| Appendices A-I | `29-container-layers.png` (6cd65c1b) | DotNet 293 | stale: old container engine named; separate restore step | +| Appendices A-I | `29-dotnet-compilation.png` (f0d220cd) | DotNet 289 | defect: missing-glyph boxes | +| Appendices A-I | `29-kestrel-pipeline.png` (f5cec157) | DotNet 291 | refresh from site (consistency) | +| Appendices A-I | `30-appC-pinning.png` (5235efd4) | DotNet 116 Go 114 Python 114 Quarkus 114 Spring 114 | ok | +| Appendices A-I | `30-appC-pinning.png` (81a66219) | Cpp 114 | ok | +| Appendices A-I | `30-blazor-circuit.png` (aae8acc9) | DotNet 298 | ok | +| Appendices A-I | `30-signalr-backplane.png` (3b8986c8) | DotNet 300 | ok | +| Appendices A-I | `31-appC-backplane.png` (cc7153be) | Cpp 116 | ok | +| Appendices A-I | `31-appC-backplane.png` (fdfa545c) | DotNet 118 Go 116 Python 116 Quarkus 116 Spring 116 | ok | +| Appendices A-I | `32-appC-reconnect.png` (7ee3e7b0) | DotNet 120 Go 118 Python 118 Quarkus 118 Spring 118 | ok | +| Appendices A-I | `32-appC-reconnect.png` (a53c653c) | Cpp 118 | ok | +| Appendices A-I | `32b-appC-blazor-architectures.png` (86ed6728) | DotNet 122 | ok | +| Appendices A-I | `33-appD-state-options-cpp.png` (a4b0829c) | Cpp 121 | ok | +| Appendices A-I | `33-appD-state-options-dn.png` (b9f6e6e9) | DotNet 125 | ok | +| Appendices A-I | `33-appD-state-options-sb.png` (001c8b8b) | Spring 121 | replaced (decision 6): names Spring Statemachine | +| Appendices A-I | `33-appD-state-options.png` (2ba5a435) | Quarkus 121 | Python copy shown in the Go deck ('Where a Python saga keeps its state'); ok in Python | +| Appendices A-I | `33-appD-state-options.png` (c8676b1f) | Go 121 Python 121 | Python copy shown in the Go deck ('Where a Python saga keeps its state'); ok in Python | +| Appendices A-I | `34-appD-compensation.png` (110f25ac) | Cpp 123 | ok | +| Appendices A-I | `34-appD-compensation.png` (bfccddec) | DotNet 127 Go 123 Python 123 Quarkus 123 Spring 123 | ok | +| Appendices A-I | `35-appD-data-dependency.png` (5d511fb1) | Cpp 125 | ok | +| Appendices A-I | `35-appD-data-dependency.png` (a91b9510) | DotNet 129 Go 125 Python 125 Quarkus 125 Spring 125 | ok | +| Appendices A-I | `36-appE-error-shape.png` (3cfd9ad9) | DotNet 136 Go 128 Python 128 Quarkus 128 Spring 132 | ok | +| Appendices A-I | `36-appE-error-shape.png` (4bf7497a) | Cpp 128 | ok | +| Appendices A-I | `37-appE-rest-errors.png` (120560cd) | Cpp 129 | ok | +| Appendices A-I | `37-appE-rest-errors.png` (a30b87c1) | DotNet 137 Go 129 Python 129 Quarkus 129 Spring 133 | ok | +| Appendices A-I | `38-appE-grpc-errors.png` (d871dbc8) | Cpp 131 | ok | +| Appendices A-I | `38-appE-grpc-errors.png` (f8237add) | DotNet 139 Go 131 Python 131 Quarkus 131 Spring 135 | ok | +| Appendices A-I | `39-appE-kafka-errors.png` (3156a5c7) | Cpp 133 | ok | +| Appendices A-I | `39-appE-kafka-errors.png` (685beb18) | DotNet 141 Go 133 Python 133 Quarkus 133 Spring 137 | ok | +| Appendices A-I | `40-appE-graphql-errors.png` (0745909e) | Cpp 135 | ok | +| Appendices A-I | `40-appE-graphql-errors.png` (107b29df) | DotNet 143 Go 135 Python 135 Quarkus 135 Spring 139 | ok | +| Appendices A-I | `41-appE-retry-storm.png` (0093c188) | DotNet 145 Go 137 Python 137 Quarkus 137 Spring 141 | ok | +| Appendices A-I | `41-appE-retry-storm.png` (7b988b0f) | Cpp 137 | ok | +| Appendices A-I | `42-twelve-factor-map-cpp.png` (3a41b0f1) | Cpp 13 | ok | +| Appendices A-I | `42-twelve-factor-map-py.png` (77e98986) | Go 13 Python 13 | stale: Poetry (pip + requirements.txt); Python tooling in the Go deck | +| Appendices A-I | `42-twelve-factor-map-sb.png` (9b59087e) | Spring 13 | ok | +| Appendices A-I | `42-twelve-factor-map-sb.png` (cc145bf0) | DotNet 14 | ok | +| Appendices A-I | `43-twelve-factor-map-qk.png` (e789dd13) | Quarkus 13 | ok | +| Appendices A-I | `44-twelve-factor-list.png` (9df816db) | Cpp 12 | ok | +| Appendices A-I | `44-twelve-factor-list.png` (f76e7d67) | DotNet 13 Go 12 Python 12 Quarkus 12 Spring 12 | ok | +| Appendices A-I | `45-appF-ddd-strategic.png` (1589830f) | DotNet 149 Go 141 Python 141 Quarkus 141 Spring 145 | ok | +| Appendices A-I | `45-appF-ddd-strategic.png` (5901f132) | Cpp 141 | ok | +| Appendices A-I | `46-appF-context-map.png` (4e484825) | DotNet 151 Go 143 Python 143 Quarkus 143 Spring 147 | ok | +| Appendices A-I | `46-appF-context-map.png` (f6dbd6a7) | Cpp 143 | ok | +| Appendices A-I | `47-appF-hexagonal.png` (144edc2b) | Cpp 145 | ok | +| Appendices A-I | `47-appF-hexagonal.png` (321aefda) | DotNet 153 Go 145 Python 145 Quarkus 145 Spring 149 | ok | +| Appendices A-I | `48-appG-dimensions.png` (0edef599) | Cpp 151 | ok | +| Appendices A-I | `48-appG-dimensions.png` (badc8514) | DotNet 159 Go 151 Python 151 Quarkus 151 Spring 155 | ok | +| Appendices A-I | `49-appG-strength.png` (b5c7885c) | Cpp 153 | ok | +| Appendices A-I | `49-appG-strength.png` (bb132c93) | DotNet 161 Go 153 Python 153 Quarkus 153 Spring 157 | ok | +| Appendices A-I | `50-appG-balance.png` (56f5aa2c) | DotNet 163 Go 155 Python 155 Quarkus 155 Spring 159 | ok | +| Appendices A-I | `50-appG-balance.png` (5ccc809a) | Cpp 155 | ok | +| Appendices A-I | `51-appH-termination.png` (07f0dab6) | Cpp 161 | ok | +| Appendices A-I | `51-appH-termination.png` (0fc5cd54) | DotNet 169 Go 161 Python 161 Quarkus 161 Spring 165 | ok | +| Appendices A-I | `52-appH-workloads.png` (18f9e907) | DotNet 171 Go 163 Python 163 Quarkus 163 Spring 167 | ok | +| Appendices A-I | `52-appH-workloads.png` (615c462b) | Cpp 163 | ok | +| Appendices A-I | `53-appI-l4-vs-l7.png` (1d89d2be) | DotNet 178 Go 170 Python 170 Quarkus 170 Spring 174 | ok | +| Appendices A-I | `53-appI-l4-vs-l7.png` (6aa00289) | Cpp 170 | ok | +| Appendices A-I | `54-appI-stack-cpp.png` (cde1d928) | Cpp 172 | stale: modern-cpp-kafka | +| Appendices A-I | `54-appI-stack-py.png` (ed5dc6a5) | Go 172 Python 172 | wrong language in the Go deck (FastAPI + durable-rules); ok in Python | +| Appendices A-I | `54-appI-stack-qk.png` (d3e5a6ab) | Quarkus 172 | ok | +| Appendices A-I | `54-appI-stack-sb.png` (957a85d5) | DotNet 180 Spring 176 | wrong language in the .NET deck (Spring Boot + Camel + Drools); ok in Spring | +| Appendices A-I | `55-appI-journey.png` (aa7e6af7) | DotNet 181 Go 173 Python 173 Quarkus 173 Spring 177 | stale: 'each layer earns its place' (ban) | +| Appendices A-I | `55-appI-journey.png` (d6bd2e08) | Cpp 173 | stale: 'each layer earns its place' (ban) | +| Appendices A-I | `56-appI-sticky.png` (b0fed89a) | Cpp 179 | ok | +| Appendices A-I | `56-appI-sticky.png` (e370b70c) | DotNet 187 Go 179 Python 179 Quarkus 179 Spring 183 | ok | +| Appendices A-I | `57-appI-steering.png` (3b315a29) | DotNet 189 Go 181 Python 181 Quarkus 181 Spring 185 | ok | +| Appendices A-I | `57-appI-steering.png` (ce980f5a) | Cpp 181 | ok | +| Appendices A-I | `58-appI-camel-drools-sb.png` (1aadf761) | Spring 191 | ok | +| Appendices A-I | `58-appI-camel-drools.png` (196681c3) | Quarkus 187 | ok | +| Appendices A-I | `58-appI-masstransit-routingslip-dn.png` (089c9839) | DotNet 195 | stale: MassTransit | +| Appendices A-I | `59-appI-latency-cpp.png` (1dff3a8e) | Cpp 190 | stale: 'every hop must earn its place' (ban); rule-engine row per language | +| Appendices A-I | `59-appI-latency-py.png` (30a50bbf) | Go 190 Python 190 | stale: 'every hop must earn its place' (ban); rule-engine row per language | +| Appendices A-I | `59-appI-latency-sb.png` (6f0c88a1) | DotNet 198 Quarkus 190 Spring 194 | stale: 'every hop must earn its place' (ban); rule-engine row per language | +| Appendices A-I | `60-appI-east-west.png` (6dc23201) | Cpp 192 | stale: 'for free' (ban) | +| Appendices A-I | `60-appI-east-west.png` (8badba43) | DotNet 200 Go 192 Python 192 Quarkus 192 Spring 196 | stale: 'for free' (ban) | +| Appendices A-I | `61-appI-cpp-router.png` (59c9c14e) | Cpp 187 | stale: modern-cpp-kafka | +| Appendices A-I | `61-appI-python-router.png` (fc30e611) | Go 187 Python 187 | wrong language in the Go deck; ok in Python | +| Appendix D/I extras and J | `62-appD-temporal-dn.png` (37003d3c) | DotNet 131 | ok | +| Appendix D/I extras and J | `62-appD-temporal.png` (a8fd60c4) | Spring 127 | ok | +| Appendix D/I extras and J | `63-appD-stateless-dn.png` (66042487) | DotNet 133 | stale: 'MassTransit Sagas'; '@Transactional' | +| Appendix D/I extras and J | `63-appD-statemachine.png` (c362e9a3) | Spring 129 | replaced (decision 6): Spring Statemachine figure | +| Appendix D/I extras and J | `70-appJ-pinned-libraries-dn.png` (854b9f97) | DotNet 207 | stale: MassTransit row; 'Every entry above' | +| Appendix D/I extras and J | `71-appJ-pinned-platform-dn.png` (89cd4fd4) | DotNet 208 | stale: Redis license (Redis 8 is RSALv2/SSPLv1/AGPLv3) | +| Appendix D/I extras and J | `72-appJ-migration-reference-dn.png` (5d457c4d) | DotNet 209 | stale: MassTransit in three rows | +| Appendices K-L | `80-appK-monolith-deployment.png` (aef5a8ec) | Cpp 199 | ok | +| Appendices K-L | `80-appK-monolith-deployment.png` (cdee389b) | DotNet 213 Go 199 Python 199 Quarkus 199 Spring 203 | ok | +| Appendices K-L | `81-appK-modular-monolith.png` (7078c290) | DotNet 214 Go 200 Python 200 Quarkus 200 Spring 204 | ok | +| Appendices K-L | `81-appK-modular-monolith.png` (e970b151) | Cpp 200 | ok | +| Appendices K-L | `82-appK-modular-monolith-decomposed-db.png` (5244881e) | DotNet 215 Go 201 Python 201 Quarkus 201 Spring 205 | ok | +| Appendices K-L | `82-appK-modular-monolith-decomposed-db.png` (a1559049) | Cpp 201 | ok | +| Appendices K-L | `83-appK-strangler-identify-move-redirect.png` (80285771) | DotNet 216 Go 202 Python 202 Quarkus 202 Spring 206 | ok | +| Appendices K-L | `83-appK-strangler-identify-move-redirect.png` (f19a6072) | Cpp 202 | ok | +| Appendices K-L | `84-appK-strangler-proxy.png` (e5e2e76e) | Cpp 203 | ok | +| Appendices K-L | `84-appK-strangler-proxy.png` (f6e2aed6) | DotNet 217 Go 203 Python 203 Quarkus 203 Spring 207 | ok | +| Appendices K-L | `85-appK-strangler-proxy-redirection.png` (9081c110) | DotNet 218 Go 204 Python 204 Quarkus 204 Spring 208 | ok | +| Appendices K-L | `85-appK-strangler-proxy-redirection.png` (ab19e842) | Cpp 204 | ok | +| Appendices K-L | `86-appK-strangler-shared-database.png` (81c3d748) | Cpp 205 | stale: '(decomposed DB above)' (positional) | +| Appendices K-L | `86-appK-strangler-shared-database.png` (cd33b74a) | DotNet 219 Go 205 Python 205 Quarkus 205 Spring 209 | stale: '(decomposed DB above)' (positional) | +| Appendices K-L | `88-appK-decorating-collaborator.png` (7dc24571) | DotNet 221 Go 207 Python 207 Quarkus 207 Spring 211 | ok | +| Appendices K-L | `88-appK-decorating-collaborator.png` (c426476e) | Cpp 207 | ok | +| Appendices K-L | `90-appL-cache-aside.png` (28a938d5) | DotNet 226 Go 212 Python 212 Quarkus 212 Spring 216 | ok | +| Appendices K-L | `90-appL-cache-aside.png` (d28b7d7e) | Cpp 212 | ok | +| Appendices K-L | `91-appL-read-through.png` (1d537085) | DotNet 228 Go 214 Python 214 Quarkus 214 Spring 218 | ok | +| Appendices K-L | `91-appL-read-through.png` (b24babb5) | Cpp 214 | ok | +| Appendices K-L | `92-appL-write-through.png` (a9a9912c) | DotNet 230 Go 216 Python 216 Quarkus 216 Spring 220 | ok | +| Appendices K-L | `92-appL-write-through.png` (cc1a6713) | Cpp 216 | ok | +| Appendices K-L | `93-appL-write-around.png` (4794da2c) | DotNet 232 Go 218 Python 218 Quarkus 218 Spring 222 | ok | +| Appendices K-L | `93-appL-write-around.png` (7b8aa529) | Cpp 218 | ok | +| Appendices K-L | `94-appL-write-back.png` (08ff93f3) | Cpp 220 | ok | +| Appendices K-L | `94-appL-write-back.png` (7e2c5bfb) | DotNet 234 Go 220 Python 220 Quarkus 220 Spring 224 | ok | +| Appendices K-L | `95-appL-refresh-ahead.png` (e617fe98) | DotNet 236 Go 222 Python 222 Quarkus 222 Spring 226 | ok | +| Appendices K-L | `95-appL-refresh-ahead.png` (f088ddaa) | Cpp 222 | ok | +| Appendix M | `110-failure-taxonomy.png` (168bd1d1) | DotNet 242 Go 228 Python 228 Quarkus 228 Spring 232 | stale: 'the rest of this appendix' (ban) | +| Appendix M | `110-failure-taxonomy.png` (d03d5c63) | Cpp 228 | stale: 'the rest of this appendix' (ban) | +| Appendix M | `111-failure-network-partition.png` (c75254c9) | Cpp 229 | ok | +| Appendix M | `111-failure-network-partition.png` (f394f054) | DotNet 243 Go 229 Python 229 Quarkus 229 Spring 233 | ok | +| Appendix M | `112-failure-split-brain.png` (37f4880b) | Cpp 230 | ok | +| Appendix M | `112-failure-split-brain.png` (bb67f91e) | DotNet 244 Go 230 Python 230 Quarkus 230 Spring 234 | ok | +| Appendix M | `113-failure-partial.png` (89ba62a8) | DotNet 245 Go 231 Python 231 Quarkus 231 Spring 235 | ok | +| Appendix M | `113-failure-partial.png` (ccb596f1) | Cpp 231 | ok | +| Appendix M | `114-failure-gray.png` (385fd669) | Cpp 232 | ok | +| Appendix M | `114-failure-gray.png` (5de01d21) | DotNet 246 Go 232 Python 232 Quarkus 232 Spring 236 | ok | +| Appendix M | `115-failure-amplification.png` (2627a710) | DotNet 247 Go 233 Python 233 Quarkus 233 Spring 237 | ok | +| Appendix M | `115-failure-amplification.png` (54165994) | Cpp 233 | ok | +| Appendix M | `116-failure-cascading.png` (6327e354) | DotNet 248 Go 234 Python 234 Quarkus 234 Spring 238 | ok | +| Appendix M | `116-failure-cascading.png` (98afa202) | Cpp 234 | ok | +| Appendix M | `117-failure-circuit-breaker-cpp.png` (9bae1e7e) | Cpp 240 | ok | +| Appendix M | `117-failure-circuit-breaker.png` (c0d197f9) | DotNet 254 Go 240 Python 240 Quarkus 240 Spring 244 | Go deck: library list without sony/gobreaker; ok elsewhere | +| Appendix M | `118-failure-retry-backoff.png` (4d374ef8) | DotNet 252 Go 238 Python 238 Quarkus 238 Spring 242 | ok | +| Appendix M | `118-failure-retry-backoff.png` (6ae7a4fe) | Cpp 238 | ok | +| Appendix M | `119-failure-timeout-deadline.png` (eba21c5d) | DotNet 250 Go 236 Python 236 Quarkus 236 Spring 240 | ok | +| Appendix M | `119-failure-timeout-deadline.png` (fd475841) | Cpp 236 | ok | +| Appendix M | `120-failure-istio-resilience.png` (167305b9) | DotNet 258 Go 244 Python 244 Quarkus 244 Spring 248 | ok | +| Appendix M | `120-failure-istio-resilience.png` (1d341987) | Cpp 244 | ok | +| Appendix M | `121-failure-matrix.png` (2e8c357a) | DotNet 261 Go 247 Python 247 Quarkus 247 Spring 251 | ok | +| Appendix M | `121-failure-matrix.png` (36296bd8) | Cpp 247 | ok | +| Appendices N and O | `apxn-control-plane.png` (c89ef80f) | Cpp 257 DotNet 271 Go 257 Python 257 Quarkus 257 Spring 261 | ok | +| Appendices N and O | `apxn-deploy-release.png` (2aea96a8) | Cpp 250 DotNet 264 Go 250 Python 250 Quarkus 250 Spring 254 | ok | +| Appendices N and O | `apxn-eval-arch.png` (534117b1) | Cpp 252 DotNet 266 Go 252 Python 252 Quarkus 252 Spring 256 | ok | +| Appendices N and O | `apxn-flag-types.png` (fb1016ab) | Cpp 251 DotNet 265 Go 251 Python 251 Quarkus 251 Spring 255 | ok | +| Appendices N and O | `apxn-lifecycle.png` (4a00d8b2) | Cpp 258 DotNet 272 Go 258 Python 258 Quarkus 258 Spring 262 | ok | +| Appendices N and O | `apxn-rollout.png` (2e7ea1b9) | Cpp 255 DotNet 269 Go 255 Python 255 Quarkus 255 Spring 259 | ok | +| Appendices N and O | `apxo-collection-anatomy.png` (0641912f) | Cpp 263 DotNet 277 Go 263 Python 263 Quarkus 263 Spring 267 | ok | +| Appendices N and O | `apxo-data-driven.png` (8a98e8a5) | Cpp 268 DotNet 282 Go 268 Python 268 Quarkus 268 Spring 272 | ok | +| Appendices N and O | `apxo-newman-run.png` (0a5b3d1a) | Cpp 262 DotNet 276 Go 262 Python 262 Quarkus 262 Spring 266 | ok | +| Appendices N and O | `apxo-test-pyramid.png` (fefc34cd) | Cpp 271 DotNet 285 Go 271 Python 271 Quarkus 271 Spring 275 | ok | +| .NET Appendices P and Q (logo, panel) | `image.png` (8b8865f5) | DotNet 287 DotNet 297 | logo / section panel (not content) | +| .NET Appendices P and Q (logo, panel) | `image.png` (aedbd5df) | DotNet 288 DotNet 289 DotNet 290 DotNet 291 DotNet 292 DotNet 293 DotNet 294 DotNet 295 DotNet 296 DotNet 298 DotNet 299 DotNet 300 DotNet 301 DotNet 302 DotNet 303 | logo / section panel (not content) | +| .NET Appendices P and Q (logo, panel) | `image.png` (fef521f3) | DotNet 287 DotNet 297 | logo / section panel (not content) | + +## Decision 7 (vendor names in figures, 2026-10-12) + +All 257 distinct content images of the six language decks (post-edit copies) were read again for vendor names +(a Sonnet pass over about 100, the planner over the remaining 152 in contact sheets). Hits and fixes: + +| Image (hash) | Used on | Text | Fix | +|---|---|---|---| +| valet-key concept (d2d27ea3eb, 82d4ae02a5) | Python, Quarkus, Spring, Go 77; .NET 79; C++ 77 | "Resource (S3, blob, file)", "Examples: S3 pre-signed URLs, Azure SAS tokens, GCS signed URLs, AWS STS AssumeRole" | `12-valet-key.png` (site figure, labels made neutral; site fix listed) | +| zero-trust (50677a2964, 2f9071c625) | Python, Quarkus, Spring, Go 80; .NET 82; C++ 80 | "Examples: Google IAP, Cloudflare Access, Pomerium, AWS Verified Access, …" | `12-zero-trust.png` (site figure, already neutral) | +| Appendix I layered stack (53094a2bb1, f948b26550, f2533f5626) | Python 172, Quarkus 172, Spring 176 | API Gateway row "Kong · Apigee · Istio gateway · Spring Cloud GW" | `22-l7-layers.png` (site figure, as Go, C++ and .NET already use) | +| .NET pinned toolkit table (authored) | .NET 207 | "not Azure SignalR Service", "no Azure-only dependencies" | `70-appJ-pinned-libraries-dn.png` re-rendered ("no managed SignalR service", "no cloud-only dependencies") | +| .NET migration reference (authored) | .NET 209 | "no Azure required" | `72-appJ-migration-reference-dn.png` re-rendered ("no managed cloud service required") | +| Blazor architectures (a51a3664a6) | .NET 122 | "… no Azure SignalR Service, no Azure App Service." | kept: Azure only in a negation, no source to redraw (listed as open in the report) | + +No image names OpenShift, ACS, AWS SDKs or boto3 outside the removed Appendix S figures. diff --git a/_plans/s12-manifest/shared-code.md b/_plans/s12-manifest/shared-code.md new file mode 100644 index 00000000..ee6bf4c7 --- /dev/null +++ b/_plans/s12-manifest/shared-code.md @@ -0,0 +1,35 @@ +# Language-agnostic code slides + +The YAML, proto, JSON and proxy-config slides that every language deck carries. Ops are in each deck's +edit file (group 5 of the op order, `cat: code` / `notes`). Status: `matches` (agrees with the site and +`examples/`, no change), `replaced` (code changed), `illustrative-ok` (no site or example counterpart; +checked against the current API and left, or corrected only where the API was wrong). + +sldIds: Py = Python, Qk = Quarkus, Go, Sp = Spring, DN = .NET, C = C++. + +| Slide title | sldIds | Source | Status | Change | +|---|---|---|---|---| +| Resilience as configuration, not per-call code (Istio retries) | Py/Qk/Go/Sp 276, DN 277, C 276 | `_docs/02-communications.md` VirtualService | replaced (comment only); C matches | The first comment promised outlier detection the manifest does not have: now "Istio: bounded retries and timeout for inventory-service" (the C++ variant already said so). | +| Scale on lag (events) and on RPS (HTTP) (KEDA) | Py/Qk/Go/Sp 300, DN 302, C 300 | `_docs/06-stream-processing.md` (stale, see site fixes); KEDA v2.21.0 and the http-add-on API | replaced | `ScaledObject` gains `metadata.name` and the kafka trigger `bootstrapServers: "cndp-kafka-bootstrap:9092"` (both required); `HTTPScaledObject` gains `apiVersion: http.keda.sh/v1alpha1`, `metadata.name`, `hosts` and `scaleTargetRef { name, service, port }`. 20 lines (the box holds 20 at 12 pt). The HTTP comment names each deck's service (FastAPI, Quarkus, Spring Boot, ASP.NET Core, Go, Drogon): the Go deck said "FastAPI". | +| Gate breaking changes in CI (.NET, CI + Apicurio v3 REST) | DN 312 | `examples/09-api-registry/verify.sh`, `.github/workflows` (checkout@v7) | replaced | `actions/checkout@v4` → `@v7`; the v3 create-version call now posts a JSON body (`content.content`, `contentType`) built with `jq --rawfile`, artifact `order-placed` as in Example 09; "409" → "400 with RuleViolationException" (Apicurio 3.3). 19 lines. | +| OPA Gatekeeper ConstraintTemplate (Rego) | Py/Qk/Go/Sp 338, DN 340, C 338 | `_docs/12-security.md` | matches | Gatekeeper parses the `rego:` field as Rego v0, so the v0 rule syntax is correct; the slide adds the Constraint and the `allowedRegistries` parameter to the site's shorter version. | +| Evolving a .proto safely | Py/Qk/Go/Sp 363, DN 365, C 363 | `_docs/15-appendix-b-versioning.md` | matches | none | +| The Kubernetes side: probes, preStop, grace period | Py/Qk/Go 420, Sp 424, DN 428 | `_docs/21-appendix-h-shutdown.md`; `examples/21-graceful-shutdown/*` | replaced (paths and one comment) + notes | Liveness `/livez` → `/healthz` (.NET: `/healthz/ready` → `/readyz`, `/healthz/live` → `/healthz`): every example exposes `/readyz` and `/healthz`. Comment "must NOT fail" → "keeps answering during the drain". Notes rewritten (they named `/livez`). | +| Content-based routing with HAProxy | Py/Qk/Go 430, Sp 434, DN 438, C 430 | none (illustrative) | illustrative-ok, one fix | The TLS `bind` gains `alpn h2,http/1.1`: without HTTP/2 on the listener, gRPC clients cannot connect, and the `is_grpc` ACL never matches. | +| Content-based routing with Nginx | Py/Qk/Go 431, Sp 435, DN 439, C 431 | none (illustrative) | illustrative-ok, one fix | `listen 443 ssl http2;` → `listen 443 ssl; http2 on;` (the `http2` listen parameter is deprecated since nginx 1.25.1). | +| Content-based routing in the mesh: Istio VirtualService | Py/Qk/Go 432, Sp 436, DN 440, C 432 | `_docs/22-appendix-i-l7-routing.md` | matches | none | +| Canary release: weighted Istio routing | Py/Qk/Go 437, Sp 441, DN 445, C 437 | `_docs/22-appendix-i-l7-routing.md` | matches | none | +| Header-based steering: dark launches | Py/Qk/Go 438, Sp 442, DN 446, C 438 | none (illustrative) | illustrative-ok | none (networking.istio.io/v1, header match syntax current for Istio 1.31.1) | +| Header injection and URL rewrites | Py/Qk/Go 440, Sp 444, DN 448, C 440 | none (illustrative) | illustrative-ok, two fixes + notes | `x-request-id: "%REQ(x-request-id)%"` set the header to itself (Envoy generates it anyway) → `x-client-ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%"`; `x-tenant-id: "%DOWNSTREAM_PEER_CERT_V_END%"` used the certificate expiry as an identity → `x-peer-identity: "%DOWNSTREAM_PEER_URI_SAN%"` (the SPIFFE ID in the mesh). Notes rewritten in all six decks (the C++ notes called the expiry operator the SPIFFE ID). | +| Istio for east/west: DestinationRule + VirtualService | Py/Qk/Go 449, Sp 453, DN 457, C 449 | `_docs/22-appendix-i-l7-routing.md` | matches (C++ is a compact variant of the same manifest) | none | +| Targeting and rollout live in config (flagd) | Py/Qk/Go 509, Sp 513, DN 523, C 509 | `examples/27-feature-flags/flags/flags.json` | replaced | Adds the `"$schema": "https://flagd.dev/schema/v0/flags.json"` line the example carries; the `new-checkout` flag is otherwise identical. 17 → 18 lines. | + +Small changes use `replace` on the code text (expect 1, `sldIds` given) so the inline comment runs keep +their colour; whole boxes use `set_code` (KEDA, flagd, .NET CI). Ops per deck: Python, Quarkus, Go and +Spring 13 each, .NET 15, C++ 9 (76 in all). Status count over the 78 deck slides: matches 31, replaced +23 (5 of them comment-only), illustrative-ok 24 (18 with a correctness fix). Dry run: `deck_edit.py +--dry-run` with each deck's group-5 ops alone, from the repository root, succeeded for all six decks +with no cross-run merges; `check_rewrites.py` 0 errors. + +Not owned here: the Appendix O code slides (Postman JSON, Newman bash, GitHub Actions), the Maven/Drools +slides of the JVM decks and the `protobuf + ` slides, which the deck reviews cover. diff --git a/_plans/s12-manifest/site-fixes.md b/_plans/s12-manifest/site-fixes.md new file mode 100644 index 00000000..fce354c6 --- /dev/null +++ b/_plans/s12-manifest/site-fixes.md @@ -0,0 +1,536 @@ +# Site fixes found during the S12 deck review + +User decision 3 (2026-10-12): where a site codetab is stale against `examples/`, the site is fixed in this PR +as a separate `docs(site)` commit; `examples/` is the authority. Each row names the doc, the codetab (or block) +and the exact fix. Where a deck slide already carries the corrected code, the row names it, so the site block +can reuse the slide's lines. Apply one doc at a time, then run `python3 scripts/validate_site.py` and +`scripts/voice-scan.sh`; C++ and Go blocks with `{{` need `{% raw %}` guards. + + +### `_docs/28-appendix-o-newman.md` (planner, Appendix O) + +| Section | Block | Fix | +|---|---|---| +| Newman in CI | `yaml` (`.github/workflows/api-tests.yml`) | `actions/checkout@v4` → `actions/checkout@v7` and `actions/upload-artifact@v4` → `actions/upload-artifact@v7` (the repo's workflows use `checkout@v7`; `upload-artifact` v7.0.2 is the newest release, 2026-10-07). | +| Running Newman | `bash` | `--bail \ # stop on first failure (fail fast in CI)` is not a line continuation (the backslash is followed by spaces and a comment), so `--timeout-request 5000` runs as a separate command. Replace the last two option lines with ` --timeout-request 5000 \` and ` --bail`, and put the explanation on its own comment line: `# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure`. | + +## Site fixes found by the shared-code review (examples/ wins) + +| Doc | Block | Fix | +|---|---|---| +| `_docs/21-appendix-h-shutdown.md` | "The Kubernetes side" YAML (line ~282) and the paragraph after it (line ~293) | `livenessProbe` path `/livez` → `/healthz`; prose "a *separate* `/livez`" → "`/healthz`". Every `examples/21-graceful-shutdown/*` service exposes `/healthz` and `/readyz`; none exposes `/livez`. | +| `_docs/21-appendix-h-shutdown.md` | .NET codetab comment (line ~166) `// /healthz/ready → 503` | `// /readyz → 503` (`examples/21-graceful-shutdown/dotnet/OrderService/Program.cs` maps `/readyz`). Owned by the .NET deck review if it touches the same codetab. | +| `_docs/09-api-registry.md` | bash block "register a new version" (lines ~48-52) | `--data-binary @order-placed.avsc` sends the raw schema; the v3 create-version endpoint takes a JSON body. Use the shape from `examples/09-api-registry/verify.sh`: `-d "{\"content\": {\"content\": $(jq -Rs '.' < order-placed.avsc), \"contentType\": \"application/json\"}}"`. | +| `_docs/06-stream-processing.md` | KEDA YAML (lines ~294-307) | Same as the deck slide: `metadata: { name: payment-consumer }`, `bootstrapServers` in the kafka trigger metadata, and for the HTTPScaledObject `apiVersion: http.keda.sh/v1alpha1`, `metadata: { name: order-service }`, `hosts: [...]`, `scaleTargetRef: { name: order-service, service: order-service, port: 8080 }`. No example covers KEDA; this is an API-correctness fix (KEDA v2.21.0, http-add-on). | +| `_docs/27-appendix-n-feature-flags.md` | flagd JSON (line ~225) | Optional: add `"$schema": "https://flagd.dev/schema/v0/flags.json"` as in `examples/27-feature-flags/flags/flags.json`. | + +Checked and left as they are: `_docs/02-communications.md` Istio retries (deck now uses its comment), `_docs/22-appendix-i-l7-routing.md` VirtualService, canary and east/west blocks (identical to the slides), `_docs/15-appendix-b-versioning.md` proto, `_docs/12-security.md` Gatekeeper template (the `rego:` field of a ConstraintTemplate is parsed as Rego v0 by Gatekeeper, so `violation[{"msg": msg}] { ... }` is correct there; the Rego v1 syntax in `examples/12-security/policy/signed_images.rego` is for conftest 0.71.1, a different tool). + +## Site fixes found by the Python deck review (examples/ wins) + +1. `_docs/01-cloud-native-principles.md`, codetab "A twelve-factor service, ready for the platform", Python block + (first line `# pyproject.toml (Poetry) pins everything; config comes from the environment`). The Python + examples use pip with a full `==` freeze in `requirements.txt`, not Poetry, and the readiness probe checks the + database pool. Replace the block with (from `examples/01-cloud-native-principles/python/main.py`): + + ```python + # main.py (excerpt); requirements.txt pins every package with == + from fastapi import FastAPI + from pydantic_settings import BaseSettings + + class Settings(BaseSettings): # config from env — factor III + database_url: str + kafka_bootstrap: str = "" + service_version: str = "0.0.0" + + settings = Settings() + app = FastAPI(title="order-service", version=settings.service_version, lifespan=lifespan) + + @app.get("/healthz") # liveness — the process is up + def healthz(): return {"status": "ok"} + + @app.get("/readyz") # readiness — dependencies are reachable + async def readyz(): + try: + async with pool.acquire() as conn: + await conn.fetchval("SELECT 1") + except Exception: + return {"status": "down", "checks": {"database": "unreachable"}} + return {"status": "ready", "checks": {"database": "ok"}} + ``` + +2. `_docs/01-cloud-native-principles.md` figure `01-factors-mapped` (alt text, line 88) and + `assets/diagrams/01-factors-mapped.svg` + `.excalidraw` (`text` and `originalText`): label + `one repo · Poetry · one image per release` → `one repo · pinned requirements · one image per release`; + alt text `(one repo, Poetry, one image per release)` → `(one repo, pinned requirements, one image per release)`. + (The deck image `42-twelve-factor-map-py.png`, slide 13, likely carries the same label: image-audit fork.) + +Not stale (recorded for the record): the Python tabs of `_docs/26` (tenacity, purgatory) and `_docs/22` +(durable-rules) teach libraries the examples replace with hand-written code (`examples/26-failure-modes/python`, +`examples/22-l7-routing/python`); the concept is the same and the site states the library choice, so no fix. +`_docs/06` prose says "Faust" (lines 43, 193, 240, 272) and `_docs/05` line 835 "Python's Faust": optional wording +fix to "faust-streaming" for consistency with the locked stack; the code tabs already use faust-streaming. + +## Site codetab fixes found by the Quarkus deck pass (examples/ win) + +| Doc | Codetab | Fix | +|---|---|---| +| `_docs/01-cloud-native-principles.md` (line 107) | Quarkus | `@Inject PgPool db;` → `@Inject Pool db;` (PgPool is deprecated in the reactive SQL client), and the readiness body `return db.query("SELECT 1") != null ? up("ready") : down("ready");` (a Query object is never null) → `try { db.query("SELECT 1").executeAndAwait(); return up("ready"); } catch (Exception e) { return down("ready"); }` | +| `_docs/05-event-driven.md` (line 69, also 485, 685, 1081) | Quarkus | Every Quarkus example uses the channel `order-placed` bound to the topic `order.placed` (`mp.messaging.*.order-placed.topic=order.placed`). Change `@Channel("order.placed")` / `@Incoming("order.placed")` to `"order-placed"` and add `// channel order-placed → topic order.placed` to the first block | +| `_docs/09-api-registry.md` (line 231, "Validate before you publish") | Quarkus (and the same call in the other five tabs) | `X-Registry-DryRun: true` is the Apicurio v2 header. The v3 API (`/apis/registry/v3`, Apicurio 3.3.3) takes `dryRun=true` as a query parameter on `POST /groups/{g}/artifacts/{a}/versions` (`VersionsRequestBuilder.PostQueryParameters.dryRun` in apicurio-registry-java-sdk 3.x). Quarkus: replace `@ClientHeaderParam(name = "X-Registry-DryRun", value = "true")` with a `@QueryParam("dryRun") boolean dryRun` parameter called with `true`. Spring, .NET, Python, C++, Go: append `?dryRun=true` to the URL and drop the header. Verify against a running 3.3.3 registry before committing | +| `_docs/11-observability.md` (line 69) | Quarkus | `public ReserveReply reserve(String sku, int qty)` returns the result of the Mutiny client `Inventory.reserveStock`, which is a `Uni` → `public Uni reserve(String sku, int qty)` | +| `_docs/27-appendix-n-feature-flags.md` (line 76) | Quarkus | The example (`examples/27-feature-flags/quarkus`) uses the OpenFeature SDK 1.23.0 and the flagd provider 0.14.2 directly: `OpenFeatureSetup` registers `new FlagdProvider(FlagdOptions.builder().host(flagdHost).port(flagdPort).build())` on `StartupEvent`, config keys `flagd.host` / `flagd.port`, and the resource calls `OpenFeatureAPI.getInstance().getClient()`. Replace the `quarkus-openfeature` / `quarkus.openfeature.flagd.*` comment lines and `@Inject Client flags` with that shape (the deck slide 253 text is the reference) | + +## Site codetabs stale against examples/ (Spring Boot tabs) + +| Doc | Codetab | Fix | +|---|---|---| +| `_docs/02-communications.md` | "gRPC for internal calls", Spring Boot (line ~250) | Replace the `@GrpcService` / grpc-spring-boot-starter server with the example's plain grpc-java server (`examples/02-communications/spring-boot/inventory-service/.../InventoryServiceApplication.java`): `@SpringBootApplication public class InventoryServiceApplication extends InventoryGrpc.InventoryImplBase implements CommandLineRunner`, `run()` starts `ServerBuilder.forPort(grpcPort).addService(this).build().start()` and awaits termination; `reserveStock` unchanged in shape. Use the deck lines of slide 18 (sldId 273) from the second comment line down. | +| `_docs/14-appendix-a-protocols.md` | "How an RPC travels", Spring Boot (line ~80) | `// grpc-spring-boot-starter injects a generated blocking stub` + `@GrpcClient("inventory") private InventoryGrpc.InventoryBlockingStub inventory;` → the example's bean: `@Bean InventoryGrpc.InventoryBlockingStub inventoryStub()` returning `InventoryGrpc.newBlockingStub(ManagedChannelBuilder.forAddress(host, port).usePlaintext().build())` (`examples/02-communications/spring-boot/order-service/.../OrderServiceApplication.java`), injected by constructor. | +| `_docs/14-appendix-a-protocols.md` | "Streaming and bidirectional communication", Spring Boot (line ~180) | Drop `@GrpcService` (grpc-spring-boot-starter); the class extends `OrderEventsGrpc.OrderEventsImplBase` and is registered with `ServerBuilder.forPort(...).addService(...)` as in the 02 example. | +| `_docs/04-data.md` | "The code, in your language", Spring Boot (line ~136) | Spring Data JPA (`OrderRepository`/`OutboxRepository`) → `JdbcClient` and Jackson 3 `JsonMapper` as in `examples/04-data/spring-boot/order-service/.../OrderController.java`; use the deck lines of slide 32 (sldId 287). | +| `_docs/06-stream-processing.md` | "A windowed aggregation", Spring Boot (line ~46) | `KStream`, `Duration.ofMinutes(5)`, `Double` totals → the example topology (`examples/06-stream-processing/spring-boot/stream-processor/.../RevenueTopology.java`): String serdes, `map(parse)` re-key by merchant_id, `groupByKey(Grouped.with(...))`, `TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(windowSeconds))`, `Acc(count, BigDecimal)` with `JacksonJsonSerde` (Spring Kafka 4, Jackson 3), sink `revenue.by-merchant`. Use the deck lines of slide 43 (sldId 298). | +| `_docs/27-appendix-n-feature-flags.md` | "Evaluating a flag", Spring Boot (line ~77) | `// build.gradle: dev.openfeature:sdk, ...` → `// pom.xml: dev.openfeature:sdk, dev.openfeature.contrib.providers:flagd` (the example builds with Maven). | + +## Site codetab fixes found by the .NET deck pass (examples/ wins) + +| Doc | Codetab (.NET block, section) | Stale against | Fix | +|---|---|---|---| +| `_docs/02-communications.md` | "REST at the edge" | ASP.NET Core 10 / C# rules | Drop `.WithOpenApi()` (obsolete in ASP.NET Core 10, ASPDEPR002; Microsoft.AspNetCore.OpenApi documents endpoints without it). Move `public record OrderIn(...)` after the top-level statements (a type declared between top-level statements is error CS8803). | +| `_docs/05-event-driven.md` | "Producing and consuming" | `examples/05-event-driven/dotnet/OrderService/Program.cs` | The comment on `await producer.ProduceAsync(...)` says "fire-and-forget ack"; `ProduceAsync` awaits the delivery report. Change to `// awaits the broker ack`. | +| `_docs/06-stream-processing.md` | "A windowed aggregation" | `examples/06-stream-processing/dotnet/StreamProcessor/Program.cs` | The example uses `ApplicationId = "stream-processor"` (verify.sh checks that consumer group), `Stream` + `Map(Rekey)` + `GroupByKey()`, a `WINDOW_SECONDS` window (10 s), a decimal `Acc` aggregate in `RocksDbWindows.As("revenue-store")` with `JsonSerDes`, and JSON output with half-up revenue to `revenue.by-merchant`. The codetab has `revenue-aggregator`, a typed `Order` stream grouped by `MerchantId`, a 5-minute window and a double sum. Align the ApplicationId, window and aggregate type with the example (the deck slide is now an excerpt of the example). | +| `_docs/11-observability.md` | "Auto-instrument your service" | `examples/11-observability/dotnet/OrderService/Program.cs`, `compose.yaml` | The example calls `.AddOtlpExporter()` with no endpoint (compose sets `OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4318`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`), registers `AddSource("order-service")`, and names the span `reserve-stock`. The codetab exports to `http://otel-collector:4317` and adds HttpClient, gRPC client and EF Core instrumentation packages the example does not reference. Use the example's exporter configuration and source name; keep the extra instrumentations as a comment if wanted. | +| `_docs/16-appendix-c-websockets.md` | "A WebSocket endpoint — and the problem hiding in it" | `examples/16-websockets/dotnet/WsServer/Program.cs` | The example maps `/ws/{clientId}`; the codetab uses `{userId}`. `clients.Remove(userId, out _)` on a `ConcurrentDictionary` should be `TryRemove`. | +| `_docs/18-appendix-e-errors.md` | "REST — status is the category, the body is the specifics" | ASP.NET Core behaviour | `WriteAsJsonAsync(problem, ct)` sets `Content-Type: application/json` and overwrites the `application/problem+json` set on the line before. Pass the media type: `await ctx.Response.WriteAsJsonAsync(problem, (JsonSerializerOptions?)null, "application/problem+json", ct);` (or write through `IProblemDetailsService`). | +| `_docs/22-appendix-i-l7-routing.md` | "Routing inside the app by business rules" (both .NET blocks) | `examples/22-l7-routing/dotnet/RouterService/Program.cs` | Note only: the example keeps its rules in a runtime-editable dictionary (`GET/PUT /rules`, VIP threshold, priority and default topics) and uses no NRules. The NRules codetabs are a valid illustration; add one sentence saying the runnable example uses a rule table, or leave as is. | + +## Site codetab fixes found by the Go deck pass (examples/ wins) + +| Doc | Codetab (Go block) | Stale against | Fix | +|---|---|---|---| +| `_docs/05-event-driven.md` | "Producing and consuming" | `examples/05-event-driven/go/notification-consumer/cmd/consumer/main.go` | `kgo.ConsumerGroup("notification")` → `kgo.ConsumerGroup("notification-group")` | +| `_docs/11-observability.md` | "Auto-instrument your service" | `examples/11-observability/go/order-service/cmd/server/main.go` (`tracer.Start(ctx, "reserve-stock")`) | span name `"reserve_stock"` → `"reserve-stock"` | +| `_docs/19-appendix-f-ddd-hexagonal.md` | "Hexagonal architecture — protocols are adapters" | `examples/19-ddd-hexagonal/go/order-service/domain/{ports.go,service.go}` | ports: `Save(ctx context.Context, order *Order) error` (plus `FindByID`, `FindAll`), `EventPublisher` is `Publish(event OrderPlaced)` (no ctx, no error); use case is `type PlaceOrder struct { repo OrderRepository; pub EventPublisher }` with `func (s *PlaceOrder) Execute(ctx, cmd PlaceOrderCmd) (*Order, error)` that validates, saves, then `s.pub.Publish(OrderPlaced{OrderID: ..., SKU: ..., Quantity: ...})`; the REST adapter calls `Execute`, not `Do` | +| `_docs/26-appendix-m-failure-modes.md` | "Circuit breaker" | `examples/26-failure-modes/go/edge-service/cmd/server/main.go` (gobreaker v2.4.0) | v1 API does not compile against v2: import `github.com/sony/gobreaker/v2`; `gobreaker.NewCircuitBreaker[Result](gobreaker.Settings{...})`; `paymentBreaker.Execute(func() (Result, error) {...})` returns `(Result, error)` (drop `v.(Result)`); treat `gobreaker.ErrOpenState` and `gobreaker.ErrTooManyRequests` as the fallback triggers | +| `_docs/27-appendix-n-feature-flags.md` | "Evaluating a flag: OpenFeature and flagd" | `examples/27-feature-flags/go/flag-service/cmd/server/main.go` (flagd provider 0.7.1) | `openfeature.SetProviderAndWait(flagd.NewProvider(...))` does not compile: `flagd.NewProvider` returns `(provider, error)`. Write `provider, err := flagd.NewProvider(flagd.WithHost("flagd"), flagd.WithPort(8013))`, check `err`, then `openfeature.SetProviderAndWait(provider)` (the example uses `SetProvider`) | +| `_docs/01-cloud-native-principles.md` | "A twelve-factor service, ready for the platform" | `examples/01-cloud-native-principles/go/cmd/server/main.go` (`os.Getenv("SERVICE_VERSION")`) | the hard-coded `"1.4.0"` in the `slog.Info` line → `os.Getenv("SERVICE_VERSION")` (config from the environment, factor III) | + +Valet key (user decision 6, 2026-10-12): `_docs/12-security.md`, codetab "The valet key hands out direct access", +Go block (line ~274, first line `// valet_key.go — mint a scoped, time-bound upload ticket`). The block uses +`aws-sdk-go-v2` (`s3.PresignClient`), a managed-cloud SDK that no Go example uses; `examples/12-security/go/cmd/server/main.go` +mints the valet key as an HS256 JWT with golang-jwt v5.3.1. Replace the whole Go block with (Go deck slide 78, +sldId 333, carries a condensed version of the same code; tabs as in the example; no `{{`, so no raw guard): + +```go +// main.go (excerpt), examples/12-security/go — the valet key is an HS256 JWT (golang-jwt v5) +type valetClaims struct { + Resource string `json:"res"` + Operation string `json:"op"` + jwt.RegisteredClaims +} + +func mintValet(resource, operation string) map[string]any { + expires := time.Now().Add(5 * time.Minute) // time-bound + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, valetClaims{ + Resource: resource, // scoped: this one object + Operation: operation, // operation-restricted: GET or PUT, not both + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expires), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "order-service", + }, + }).SignedString([]byte(valetSecret)) + if err != nil { + slog.Error("sign valet key", "err", err) + } + return map[string]any{"resource": resource, "operation": operation, + "expires": expires.Unix(), "token": token} +} +// the verifier accepts HS256 only, requires exp, checks the issuer, then matches res, op and exp +``` + +The claim-check Go block and the sentence after the valet-key codetabs are superseded by the decision-7 +section (end of this file). + +## Site fixes found by the C++ deck pass + +C++ codetabs (`langs="Spring Boot|Quarkus|.NET|Python|C++|Go"`, fifth block) that disagree with +`examples/*/cpp/`. The deck slide named in each row already carries the corrected code (see +`lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.json`); the site fix can reuse it. + +| Doc (line of the C++ block) | Codetab | Stale | Fix (examples win) | +|---|---|---|---| +| `_docs/04-data.md` (~221) | The code, in your language | `PgTxn` coroutine wrapper, `insert_outbox_sql` | `examples/04-data/cpp/order-service/src/main.cpp` uses libpq directly: `PQexec(conn, "BEGIN")`, `PQexecParams` into `orders (id, sku, quantity, status)` and `outbox (aggregate_id, event_type, payload)` with `$3::jsonb`, `COMMIT`, `ROLLBACK` + 500 on any error. Use the deck slide 32 (sldId 287) excerpt. | +| `_docs/06-stream-processing.md` (~143) | A windowed aggregation | `consumer.poll(100ms)`, `msg.value()` (modern-cpp-kafka API), `emit_window` to `revenue.by-merchant`, 5-minute window | `examples/06-stream-processing/cpp/stream-processor/src/main.cpp`: rdkafka++ `consume(200)`, `cndp::MerchantWindows::apply()`, `send("revenue.by-merchant", ..., cndp::revenue_json(...))`, `commit_batch()` flushes the producer before `commitSync()`; window length is `WINDOW_SECONDS` (default 10 s). Use the deck slide 43 (sldId 298) excerpt. | +| `_docs/11-observability.md` (~149) | Auto-instrument your service | `OtlpGrpcExporterFactory`, `otlp_grpc_exporter_factory.h` | The examples export OTLP/HTTP (`examples/11-observability/cpp/*/src/telemetry.hpp`: `OtlpHttpExporterFactory`, `otlp_http_exporter_factory.h`, endpoint `http://lgtm:4318`). Replace both tokens. | +| `_docs/12-security.md` (~569) | Claim-check: send a reference, not the payload | `producer.produce("order.documents", claim.dump())`, `consumer.poll(100ms)`, `msg.value()` | Locked stack is librdkafka's C++ API: `producer->produce(topic, RdKafka::Topic::PARTITION_UA, RdKafka::Producer::RK_MSG_COPY, claim.data(), claim.size(), key.data(), key.size(), 0, nullptr)` and `std::unique_ptr msg(consumer->consume(100))` with `payload_of(*msg)`, `commitSync(msg.get())` after the fetch (deck slide 87, sldId 342). No claim-check example exists. Superseded by the decision-7 claim-check block (rdkafka++ plus the jwt-cpp ticket). | +| `_docs/12-security.md` (~255) | The valet key hands out direct access | AWS SDK presigned URL | **Required** (decision 7): replace the block with the jwt-cpp excerpt of `examples/12-security/cpp/order-service/src/valet.hpp` given in the decision-7 section. | +| `_docs/17-appendix-d-sagas.md` (~232) | Compensation unwinds a failed saga | `"comp_stock"_s + event` table, `reversed(s.completed_steps())`, `inverse_of(step)` | `examples/17-sagas/cpp/saga-orchestrator/src/{saga_machine.hpp,main.cpp}`: a `Lifecycle` machine (RUNNING → COMPENSATING → COMPENSATED on `StepFailed` / `Compensated`) and `for (int i : cndp::saga::compensation_order(progress.step_index))` calling `step.compensate(context)` with `logStep(..., step.compensationName, "compensate", ...)`. Use the deck slide 124 (sldId 379) excerpt. | +| `_docs/19-appendix-f-ddd-hexagonal.md` (~230) | Hexagonal architecture — protocols are adapters | `virtual Task<> save(...)`, `Task<> publish(const DomainEvent&)`, `class PlaceOrder` with `co_await` | `examples/19-ddd-hexagonal/cpp/order-service/src/domain/{ports.hpp,service.hpp}`: synchronous ports (`void save(const Order&)`, `std::optional find_by_id`, `void publish(const OrderPlaced&)`), use case `PlaceOrderUseCase::execute(const PlaceOrderCmd&)`. Use the deck slide 146 (sldId 401) code. | +| `_docs/21-appendix-h-shutdown.md` (~196) | The application side | `std::stop_source` / `std::jthread kafka_worker` / `request_stop()` inside the signal handler, "(C++20)" | `examples/21-graceful-shutdown/cpp/src/main.cpp`: `std::atomic g_shutting_down`, `InFlightGuard` + `g_in_flight`, `registerBeginningAdvice` re-installs the SIGTERM handler after Drogon's, `/readyz` → 503, drain loop after `run()`. Use the deck slide 164 (sldId 419) code. Also: `request_stop()` is not specified as async-signal-safe; the site prose claiming it is should be dropped. | +| `_docs/24-appendix-k-monolith-to-microservices.md` (~276) | Content-based routing during migration | `cpp_httplib::Client` (the library's namespace is `httplib`; the example does not use it) | `examples/24-monolith-to-microservices/cpp/router/src/main.cpp`: tenant route table (`g_tenant_routes["acme"] = "new-service"`), `forward_request()` with `drogon::HttpClient::newHttpClient(upstream)`, 502 on failure. Use the deck slide 206 (sldId 461) code. | +| `_docs/24-appendix-k-monolith-to-microservices.md` (~513) | The decorating collaborator | `producer_.send("order.placed", body)`; `co_await cache_.get/setex/del` | `examples/24-.../cpp/decorator/src/main.cpp` produces with an `RdKafka::Producer` and calls sw/redis++ synchronously. Use the deck slide 208 (sldId 463) code (`produce_to(*producer_, "order.placed", id, body)`, no `co_await` on `cache_`). | +| `_docs/27-appendix-n-feature-flags.md` (~168, ~313) | Evaluating a flag; The kill switch | OpenFeature C++ SDK (`getApi().setProvider(FlagdProvider{...})`, `flags->getBooleanValue`) | `examples/27-feature-flags/cpp/flag-service/src/main.cpp` calls flagd's gRPC `ResolveBoolean` with a hand-written grpc++ stub (`FlagdClient::evaluateBoolean(flagKey, default, targetingKey, plan)`, 800 ms deadline, default on failure); kill switch `evaluateBoolean("recommendations-enabled", true, user)`. Use the deck slides 253 and 256 (sldIds 508, 511). | +| `_docs/22-appendix-i-l7-routing.md` (~310, ~475) | Routing inside the app by business rules (Lua + dispatcher) | `route(order, ctx)` returning a list of `kafka:`/`http:` destinations | `examples/22-l7-routing/cpp/router-service/routing.lua` defines `route(order, rules)` returning `(topic, vip)`, evaluated in a sandboxed per-call `lua_State` with an instruction and time budget (`routing_engine.hpp`). The deck keeps the site's fan-out version (slides 188, 189); align both if the site changes. | + +## Figures (from the image and policy reviews) + +| File | Fix | +|---|---| +| `assets/diagrams/01-factors-mapped.svg` + `.excalidraw` (`text` and `originalText`) and its alt text in `_docs/01-cloud-native-principles.md` | Label "one repo · Poetry · one image per release" → "one repo · pinned requirements · one image per release" (the Python examples use pip with a full `==` freeze). | +| `assets/diagrams/policy-ci-pipeline.svg`, `policy-gitops-flow.svg` + their `.excalidraw` | kubeval → kubeconform (v0.8.0). Only the K8s policy deck uses these figures; the deck gets fixed PNGs from `lgtm-presentation/edits/png/src/`. | +| `_docs/23-appendix-j-dotnet-toolkit.md` | The platform table lists Redis (or Valkey) as "BSD-3 / BSD"; Redis 8 is RSALv2 / SSPLv1 / AGPLv3 (the AGPLv3 option is the one used). | +| `assets/diagrams/02-pick-protocol.svg`, `02-mesh-handles-wire.svg`, `22-business-rule-routing.svg`, `22-hop-cost.svg` | Optional: they carry Python labels (FastAPI, durable-rules) while the site is six-language; the decks now use per-language copies (`lgtm-presentation/edits/png/src/`). | +| `assets/diagrams/29-container-layers.svg` | Optional: the "COPY --from=build" label overlaps. | + +## Decision 7: vendor-neutral site (no AWS, Azure, Google Cloud, ACS or OpenShift reliance) + +User decision 7 (plan, 2026-10-12). Apply as part of the `docs(site)` commit. The code blocks are the same lines +as the deck slides (`tools/amend_vendor.py` holds them; Go: `tools/amend_decisions.py`), so the site and the decks +agree. Run `python3 scripts/validate_site.py` (raw guards, codetab counts) and the voice scan after applying. + +### `_docs/12-security.md`, "The valet key hands out direct access" + +1. Figure alt text (line ~174): `directly against the resource (S3, blob, file) using the token` → + `directly against the resource (object store, file) using the token`. +2. Replace the six codetab blocks (Spring Boot, Quarkus, .NET, Python, C++, Go, in that order) with the blocks + below. The Go block is the `main.go (excerpt)` block already given in the Go section of this file (golang-jwt; + deck slide 78). None of the blocks has `{{`, so no raw guards. + +```java +// ValetKeyService.java (excerpt), examples/12-security/spring-boot: HMAC-SHA256 valet key +@Service +public class ValetKeyService { + private static final int TTL_SECONDS = 300; // time-bound + private final byte[] secret; // VALET_SECRET, injected through @Value + public Map mint(String resource, String operation) { + long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS; + String payload = resource + ":" + operation + ":" + expires; // one object, one op + Map result = new LinkedHashMap<>(); + result.put("resource", resource); + result.put("operation", operation); + result.put("expires", expires); + result.put("token", hmac(payload)); // HmacSHA256, hex-encoded + return result; + } + public boolean verify(String resource, String operation, long expires, String token) { + if (System.currentTimeMillis() / 1000 > expires) return false; // expired + String expected = hmac(resource + ":" + operation + ":" + expires); + return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time + } +} +// OrderController maps POST /valet-key to mint() and GET /verify-valet to verify() +``` + +```java +// ValetKeyService.java (excerpt), examples/12-security/quarkus: HMAC-SHA256 valet key +@ApplicationScoped +public class ValetKeyService { + private static final int TTL_SECONDS = 300; // time-bound + private final byte[] secret; // VALET_SECRET, injected through @ConfigProperty + public Map mint(String resource, String operation) { + long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS; + String payload = resource + ":" + operation + ":" + expires; // one object, one op + Map result = new LinkedHashMap<>(); + result.put("resource", resource); + result.put("operation", operation); + result.put("expires", expires); + result.put("token", hmac(payload)); // HmacSHA256, hex-encoded + return result; + } + public boolean verify(String resource, String operation, long expires, String token) { + if (System.currentTimeMillis() / 1000 > expires) return false; // expired + String expected = hmac(resource + ":" + operation + ":" + expires); + return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time + } +} +// OrderResource maps POST /valet-key to mint() and GET /verify-valet to verify() +``` + +```csharp +// Program.cs (excerpt), examples/12-security/dotnet: an HMAC-SHA256 valet key +var valetSecret = Environment.GetEnvironmentVariable("VALET_SECRET") + ?? "demo-secret-do-not-use-in-prod"; +app.MapPost("/valet-key", (HttpContext ctx) => +{ + var resource = ctx.Request.Query["resource"].ToString(); + var operation = ctx.Request.Query["operation"].FirstOrDefault() ?? "GET"; + var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 300; // time-bound + var payload = $"{resource}:{operation}:{expires}"; // one object, one op + var sig = ComputeHmac(valetSecret, payload); + return Results.Ok(new { resource, operation, expires, token = sig }); +}); +// GET /verify-valet: 403 once expired, then a constant-time compare + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires) + return Results.Json(new { detail = "expired valet key" }, statusCode: 403); + var expected = ComputeHmac(valetSecret, $"{resource}:{operation}:{expires}"); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(expected))) + return Results.Json(new { detail = "invalid or expired valet key" }, statusCode: 403); +// ComputeHmac: HMACSHA256 keyed with the secret, Convert.ToHexStringLower of the hash +``` + +```python +# main.py (excerpt), examples/12-security/python: an HMAC-SHA256 valet key +VALET_SECRET = os.environ.get("VALET_SECRET", "demo-secret-do-not-use-in-prod") + +def _mint_valet(resource: str, operation: str, ttl: int = 300) -> dict: + expires = int(time.time()) + ttl # time-bound + payload = f"{resource}:{operation}:{expires}" # one object, one operation + sig = hmac.new(VALET_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + return {"resource": resource, "operation": operation, "expires": expires, "token": sig} + +def _verify_valet(resource: str, operation: str, expires: int, token: str) -> bool: + if time.time() > expires: + return False # expired: worthless + payload = f"{resource}:{operation}:{expires}" + expected = hmac.new( + VALET_SECRET.encode(), payload.encode(), hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(token, expected) # constant-time compare + +@app.post("/valet-key") +async def mint_key(resource: str, operation: str = "GET"): + return _mint_valet(resource, operation) +# GET /verify-valet answers 403 for an expired or tampered key +``` + +```cpp +// valet.hpp (excerpt), examples/12-security/cpp: the valet key is an HS256 JWT (jwt-cpp) +inline Minted mint(const std::string& secret, const std::string& resource, + const std::string& operation, + std::chrono::seconds ttl = std::chrono::minutes(5)) { // time-bound + const auto now = std::chrono::system_clock::now(); + const auto exp = std::chrono::time_point_cast(now + ttl); + auto token = jwt::create() + .set_type("JWT") + .set_issuer(kIssuer) // "order-service" + .set_issued_at(now) + .set_expires_at(exp) + .set_payload_claim("res", jwt::claim(resource)) // one object + .set_payload_claim("op", jwt::claim(operation)) // one operation + .sign(jwt::algorithm::hs256{secret}); + return {std::move(token), exp.time_since_epoch().count()}; +} +// verify(): HS256 pinned ("none" and RS256 never reach the verifier), then iss, res, op +const auto decoded = jwt::decode(token); +if (decoded.get_algorithm() != "HS256") return false; +jwt::verify().allow_algorithm(jwt::algorithm::hs256{secret}).with_issuer(kIssuer) + .with_claim("res", jwt::claim(resource)).with_claim("op", jwt::claim(operation)) + .verify(decoded); // signature + exp; throws on failure +``` + +3. Line ~290: `The same shape covers Azure SAS tokens, GCS signed URLs, and AWS STS scoped roles.` → + `Example 12 mints and verifies the ticket in the service itself. Most object stores, open-source ones included,` + `accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL.` + +### `_docs/12-security.md`, "Claim-check: send a reference, not the payload" + +Replace the six codetab blocks with the blocks below (the producer stores the body in any encrypted object store and +publishes a claim whose ticket is Example 12's valet key; no `s3://` URI). The C++ block contains `{{` and keeps a +`{% raw %}` guard. Prose and figure are vendor-neutral already. + +```java +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +@Service +public class ClaimPublisher { + private final ObjectStore store; // any encrypted object store + private final KafkaTemplate> kafka; + private final ValetKeyService valet; // examples/12-security + // constructor injection omitted + public void publish(String orderId, byte[] body) { + String key = "payloads/" + orderId; + store.put(key, body, true); // encrypted at rest, ACL'd + Map claim = new LinkedHashMap<>(valet.mint(key, "GET")); + claim.put("order_id", orderId); + claim.put("uri", "https://objects.internal/" + key); + kafka.send("order.documents", orderId, claim); // only the claim on the topic + } +} +// consumer: read the claim, fetch the body out of band +@KafkaListener(topics = "order.documents", groupId = "documents") +public void onClaim(Map claim) { + byte[] body = store.get((String) claim.get("uri"), (String) claim.get("token")); + handle(body); // PII never touched the topic +} +``` + +```java +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +@ApplicationScoped +public class ClaimPublisher { + @Inject ObjectStore store; // any encrypted object store + @Inject ValetKeyService valet; // examples/12-security + @Inject @Channel("documents-out") Emitter claims; // topic order.documents + public void publish(String orderId, byte[] body) { + String key = "payloads/" + orderId; + store.put(key, body, true); // encrypted at rest, ACL'd + JsonObject claim = new JsonObject(valet.mint(key, "GET")) + .put("order_id", orderId) + .put("uri", "https://objects.internal/" + key); + claims.send(claim); // only the claim on the topic + } +} +@ApplicationScoped +public class ClaimConsumer { + @Inject ObjectStore store; + @Incoming("documents-in") // topic order.documents + public void onClaim(JsonObject claim) { + byte[] body = store.get(claim.getString("uri"), claim.getString("token")); + handle(body); // PII never touched the topic + } +} +``` + +```csharp +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +var key = $"payloads/{orderId}"; +await store.PutAsync(key, largePayload, encrypt: true); // encrypted at rest, ACL'd +var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 900; +var token = ComputeHmac(valetSecret, $"{key}:GET:{expires}"); // GET only, 15 minutes +await producer.ProduceAsync("order.documents", + new Message { Value = JsonSerializer.Serialize(new { + order_id = orderId, + uri = $"https://objects.internal/{key}", + resource = key, operation = "GET", expires, token, + }) }); // claim only: small, routable +// consumer: read the claim, fetch the body out of band +var claim = JsonSerializer.Deserialize(cr.Message.Value)!; +var body = await store.GetAsync(claim.Uri, claim.Token); // PII never touched the topic +consumer.Commit(cr); +``` + +```python +# claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +async def publish_with_claim(producer: AIOKafkaProducer, order_id: str, body: bytes): + key = f"payloads/{order_id}" + await store.put(key, body, encrypt=True) # encrypted at rest, ACL'd + claim = { + "order_id": order_id, + "uri": f"https://objects.internal/{key}", + **_mint_valet(key, "GET", ttl=900), # resource, op, expires, token + } + await producer.send_and_wait( # only the claim goes on the topic + "order.documents", json.dumps(claim).encode(), key=order_id.encode()) + +# consumer: read the claim, fetch the body out of band +async for msg in consumer: + claim = json.loads(msg.value) + body = await store.get(claim["uri"], token=claim["token"]) # the store checks the ticket + await handle(body) # PII never touched the topic + await consumer.commit() # after the side effect +``` + +```cpp +{% raw %} +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +void publish_with_claim(const std::string& order_id, const std::string& body) { + const auto key = "payloads/" + order_id; + store_.put(key, body, /*encrypt=*/true); // 1. store: encrypted, ACL'd + auto ticket = cndp::valet::mint(VALET_SECRET, key, "GET", std::chrono::minutes(15)); + auto claim = nlohmann::json{{"order_id", order_id}, + {"uri", "https://objects.internal/" + key}, + {"token", ticket.token}, {"expires", ticket.expires}}.dump(); + producer_->produce("order.documents", RdKafka::Topic::PARTITION_UA, + RdKafka::Producer::RK_MSG_COPY, claim.data(), claim.size(), + order_id.data(), order_id.size(), 0, nullptr); // 2. claim only +} +// Consumer: consume the claim, fetch the payload, then handle +std::unique_ptr msg(consumer_->consume(100)); +if (msg->err() == RdKafka::ERR_NO_ERROR) { + auto claim = nlohmann::json::parse(payload_of(*msg)); + auto body = store_.get(claim["uri"].get(), + claim["token"].get()); // the store checks the ticket + handle(body); // PII never touched the topic + consumer_->commitSync(msg.get()); // after handle +} +{% endraw %} +``` + +```go +// claim-check sketch: store is any encrypted object store; the token is the valet-key JWT +func publishWithClaim(ctx context.Context, cl *kgo.Client, orderID string, body []byte) error { + key := "payloads/" + orderID + if err := store.Put(ctx, key, body, true); err != nil { return err } // encrypted, ACL'd + claim, _ := json.Marshal(map[string]any{ + "order_id": orderID, + "uri": "https://objects.internal/" + key, + "token": mintValet(key, "GET")["token"], // GET only, five minutes + }) + return cl.ProduceSync(ctx, // only the claim goes on the topic + &kgo.Record{Topic: "order.documents", Key: []byte(orderID), Value: claim}).FirstErr() +} +func onClaim(ctx context.Context, r *kgo.Record) error { + var c Claim + if err := json.Unmarshal(r.Value, &c); err != nil { return err } + body, err := store.Get(ctx, c.URI, c.Token) // the store verifies the ticket + if err != nil { return err } + return handle(ctx, body) // PII never touched the topic +} +``` + +### Other vendor mentions on the site + +| Doc (line) | Find | Replace with | +|---|---|---| +| `_docs/29-glossary.md` (~90) | the row `\| S3 \| Simple Storage Service \| Object storage, e.g. the target of a valet-key upload \|` | delete the row (no chapter uses the acronym after the valet-key fix) | +| `_docs/26-appendix-m-failure-modes.md` (~730) | `the AWS Builders' Library (timeouts, retries, backoff with` / `jitter)` | `the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry` / `throttling)` (github.com/grpc/proposal/blob/master/A6-client-retries.md: "Jitter of plus or minus 0.2 is applied to the backoff delay"; `retryThrottling`) | +| `_docs/22-appendix-i-l7-routing.md` (~47) | `The **API gateway** (Kong, Apigee, an Istio gateway,` | `The **API gateway** (Kong, an Istio gateway,` | +| `_docs/30-appendix-q-blazor-signalr.md` (~99-102) | the paragraph `For teams that want fully managed infrastructure, **Azure SignalR Service** offloads ... scaling equation.` | delete the paragraph (a vendor-hosted service is not a recommended option; the Redis backplane before it is the answer) | +| `_docs/16-appendix-c-websockets.md` (~519-521) | `scale-out, or **Azure SignalR Service** offloads the persistent connections entirely` / ` to a managed endpoint.` | `scale-out.` | +| `_docs/23-appendix-j-dotnet-toolkit.md` (~27) | `commercial-only dependencies, and nothing that requires Azure or any other specific` / `cloud.` | `commercial-only dependencies, and nothing that requires a specific cloud.` | +| `_docs/23-appendix-j-dotnet-toolkit.md` (~50) | `Open-source Redis backplane (not Azure SignalR Service)` | `Open-source Redis backplane (no managed SignalR service)` | +| `_docs/23-appendix-j-dotnet-toolkit.md` (~137) | `no Azure required` | `no managed cloud service required` | +| `_docs/29-appendix-p-dotnet-on-linux.md` (~207) | `when running on OpenShift or RHEL, UBI images get full` | optional: `when running on RHEL, UBI images get full` (nothing depends on it) | + +Checked and left as they are: `_docs/31-appendix-r-minikube.md` (~22) and `_docs/05-event-driven.md` (~1294) only +point to Appendix S, which stays on the site; no recommendation outside Appendix S depends on an OpenShift-only feature +(Routes, SCCs, OLM, EgressFirewall, ACS). `_docs/32-appendix-s-openshift-local.md` is unchanged. + +### Figures + +Change the SVG ``, the `.excalidraw` `text` and `originalText` of the same element, and any alt text that repeats +the label. The deck copies are rendered by `tools/vendor_figures.py` (corrected SVG text in `lgtm-presentation/edits/png/src/`). + +| File | Old label | New label | +|---|---|---| +| `assets/diagrams/12-valet-key.svg` + `.excalidraw` | Resource (S3, blob, file) | Resource (object store, file) | +| `assets/diagrams/12-valet-key.svg` + `.excalidraw` | the app never proxies the bytes — no long-lived credentials at the client (S3 pre-signed URLs, Azure SAS tokens, GCS signed URLs, STS AssumeRole) | the app never proxies the bytes — no long-lived credentials at the client (Example 12: a ticket the service signs and verifies itself) | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | Tier 1 — AdminNetworkPolicy | Tier 1 — CNP Admin tier | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | Tier 3 — BANP | Tier 3 — CNP Baseline tier | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | ANP evaluates | Admin tier evaluates | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | cluster-scoped, priority 0-99 | cluster-scoped, priority 0-1000 | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | Allow | Accept | (2 elements) +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | BANP evaluates | Baseline tier evaluates | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | cluster singleton, safety net | cluster-scoped, safety net | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | OVN-Kubernetes three-tier policy evaluation | ClusterNetworkPolicy three-tier evaluation | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | BANP catches unmatched traffic. | The Baseline tier catches unmatched traffic. | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | ANP Allow/Deny are final — NP and BANP are skipped. | Admin Accept/Deny skip NP and Baseline. | +| `assets/diagrams/advisory-tier-evaluation.svg` + `.excalidraw` | BANP is overridable by NetworkPolicy. | Baseline is overridable by NetworkPolicy. | +| `assets/diagrams/advisory-ci-pipeline.svg` + `.excalidraw` | oc apply --dry-run | kubeconform | +| `assets/diagrams/advisory-ci-pipeline.svg` + `.excalidraw` | ACS Build-time | Baseline Diff | +| `assets/diagrams/advisory-ci-pipeline.svg` + `.excalidraw` | baseline validation | vs observed flows | +| `assets/diagrams/advisory-ci-pipeline.svg` + `.excalidraw` | ACS + OVN-K verify | flow logs + Falco | +| `assets/diagrams/advisory-team-ownership.svg` + `.excalidraw` | EgressFirewall | Egress rules | +| `assets/diagrams/advisory-team-ownership.svg` + `.excalidraw` | ANP / BANP review | Admin / Baseline review | +| `assets/diagrams/advisory-team-ownership.svg` + `.excalidraw` | Perimeter (Palo Alto) | Perimeter firewall | +| `assets/diagrams/advisory-team-ownership.svg` + `.excalidraw` | AdminNetworkPolicy | CNP Admin tier | +| `assets/diagrams/advisory-team-ownership.svg` + `.excalidraw` | cluster guardrails, Allow/Deny/Pass | cluster guardrails, Accept/Deny/Pass | +| `assets/diagrams/advisory-team-ownership.svg` + `.excalidraw` | BANP | CNP Baseline tier | +| `assets/diagrams/advisory-team-ownership.svg` + `.excalidraw` | ACS baselines | Observed baselines | + +The three `advisory-*` figures are used by the network advisory companion (`lgtm-presentation/network-policy-process-advisory.md`) +and deck only; `12-valet-key` is Figure 12.3 of `_docs/12-security.md`. + +## Site fixes found by S12 fix round A (examples/ wins) + +Found while fixing the decks after the Quarkus validation (colours, caps, accuracy, cross-deck). Apply in the +same `docs(site)` commit as the rows above. + +| Doc | Block | Fix | +|---|---|---| +| `_docs/12-security.md` | Bulkhead codetabs, all six languages (lines ~385-472) | The per-tenant capacity is 5 in every `examples/12-security/` service (`CAPACITY = 5` in Spring Boot, Quarkus and C++, `bulkheadLimit = 5` in .NET, `BULKHEAD_LIMIT = 5` in Python, `bulkheadCapacity = 5` in Go); the site shows 20. Spring Boot: `.maxConcurrentCalls(20) // 20 concurrent per tenant` → `.maxConcurrentCalls(5) // 5 concurrent per tenant`; Quarkus: `new Semaphore(20)); // 20 / tenant` → `new Semaphore(5)); // 5 / tenant`; .NET: `new SemaphoreSlim(20)); // 20 / tenant` → `new SemaphoreSlim(5)); // 5 / tenant`; Python: `asyncio.Semaphore(20)) # 20 concurrent / tenant` → `asyncio.Semaphore(5)) # 5 concurrent / tenant`; C++: `try_emplace(tenant, 20); // 20 concurrent / tenant` → `try_emplace(tenant, 5); // 5 concurrent / tenant`; Go: `make(chan struct{}, 20)) // 20 / tenant` → `make(chan struct{}, 5)) // 5 / tenant`. Prose that repeats "20" follows. | +| `_docs/25-appendix-l-caching.md` | Write-back, Quarkus codetab (line ~875) | `@Scheduled(every = "1s") @Transactional` with `Metric.persistAll(...)` inside `try`: the insert is flushed at commit, after the method returns, so a database error never reaches the `catch` and the ids are not re-marked dirty. Flush inside the `try`: `Metric.persistAll(loadFromCache(ids)); Metric.flush(); // batch insert` (the deck's fix, Quarkus slide "Write-back with @Scheduled in Quarkus"). The other five write-back codetabs already run the write (and the commit) inside the `try` (Spring `repo.saveAll` commits in its own transaction; .NET `SaveChangesAsync`, Python `execute_many`, Go `CopyFrom`/`Exec`, C++ `txn.commit()`), as `examples/25-caching/` does. | +| `_docs/26-appendix-m-failure-modes.md` | Retry + breaker, Quarkus codetab (line ~362) | `// MicroProfile applies the breaker first, then retry within the closed state.` is wrong: SmallRye Fault Tolerance applies `@Retry` outside `@CircuitBreaker`, so every attempt goes through the breaker and an open breaker fails each retry fast. → `// Retry wraps the breaker; an open breaker fails each retry fast.` Same file: `// ALWAYS pair with a breaker` → `// always pair with a breaker`, and the .NET `// Polly v8: exponential backoff WITH jitter is built in` → `with` (ALL-CAPS emphasis, voice rule). | +| `_docs/06-stream-processing.md` | Kafka Streams, Spring Boot and Quarkus codetabs (lines ~63, ~83) | `.to("revenue.by-merchant")` writes a `Windowed` key and a `Double` value with the default serdes, which fails at runtime (the default key serde is a plain String serde). Declare `var windowSerde = WindowedSerdes.timeWindowedSerdeFrom(String.class, Duration.ofMinutes(5).toMillis());` and write `.to("revenue.by-merchant", Produced.with(windowSerde, Serdes.Double()))`, as the Quarkus deck slide now does (`examples/06-stream-processing/` maps the window to a JSON string first and uses `Produced.with(Serdes.String(), Serdes.String())`; either shape is correct). The .NET Streamiz block unwraps the window key before `.To` and is correct. | +| `_docs/12-security.md` | Zero-trust prose | Checked: the site describes the generic zero-trust model (an IAP evaluates user, device, MFA, posture) and names neither Pomerium nor OAuth2 Proxy, so the decks' "check device posture" claim about those proxies (fixed in the decks) is not on the site. No change. | diff --git a/_plans/s12-manifest/tools/align_build.py b/_plans/s12-manifest/tools/align_build.py new file mode 100644 index 00000000..206ba6bb --- /dev/null +++ b/_plans/s12-manifest/tools/align_build.py @@ -0,0 +1,1183 @@ +#!/usr/bin/env python3 +"""S12 example alignment: deck edit files for the readiness, shutdown and write-back fixes. + + align_build.py [--check] + +Writes lgtm-presentation/edits/.fixN.json (the next free fix number of each language deck at +the time of the round) so that the deck slides, captions and notes follow +examples/01-cloud-native-principles (readiness answers 503 when the database is down; the Spring, +Quarkus and .NET probes are hand-written handlers), examples/21-graceful-shutdown (one SIGTERM: +readiness 503, DRAIN_DELAY_SECONDS, stop accepting, in-flight drain bounded at 20 s, cleanup, exit 0), +examples/25-caching (write-back answers 202 on /write-back/metrics/{id}; upsert; bounded retry with +a metric:attempts hash, a metric:failed set and backoff) and examples/04-data/quarkus (plain JDBC +outbox insert). Every code box is checked against 95 columns and the line capacity of its box +(probed on the committed decks). --check only runs the checks. The same line lists are the source +of the site codetabs (align_site.py). +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +EDITS = REPO / "lgtm-presentation" / "edits" +MAX_COLS = 95 + +OUT = { + "Python": "Designing-Cloud-Native-APIs-Python.fix4.json", + "Go": "Designing-Cloud-Native-APIs-Go.fix3.json", + "Cpp": "Designing-Cloud-Native-APIs-Cpp.fix3.json", + "Quarkus": "Designing-Cloud-Native-APIs-Quarkus.fix4.json", + "Spring": "Designing-Cloud-Native-APIs-Spring.fix5.json", + "DotNet": "Designing-Cloud-Native-APIs-DotNet.fix5.json", +} + +# line capacity of each code box (deck_edit.box_capacity at the box's current font, probed on the +# committed decks); set_code also allows max(22, current line count) +CAP = { + ("Python", 419): 21, ("Python", 476): 25, ("Python", 500): 24, + ("Go", 269): 21, ("Go", 419): 19, ("Go", 476): 20, ("Go", 500): 21, + ("Cpp", 269): 23, ("Cpp", 419): 23, ("Cpp", 476): 24, ("Cpp", 500): 23, + ("Quarkus", 269): 18, ("Quarkus", 287): 19, ("Quarkus", 419): 20, ("Quarkus", 476): 25, + ("Quarkus", 500): 24, + ("Spring", 269): 25, ("Spring", 423): 25, ("Spring", 480): 24, ("Spring", 504): 25, + ("DotNet", 270): 21, ("DotNet", 427): 24, ("DotNet", 490): 25, ("DotNet", 514): 25, +} + +WB_CAPTION_OLD = ("The dirty set is the key idea — it tells the flusher what to drain. " + "Failed batches go back on the set for retry.") +WB_CAPTION_NEW = ("A write answers 202 Accepted. A failed flush re-queues its ids with backoff; " + "after five attempts an id is parked on metric:failed.") +PROBE_READS = ("The probe reads the status code, not the body: Kubernetes treats any answer from 200 " + "to 399 as ready.") + +# ------------------------------------------------------------------ code (shared with the site) + +CODE = {} + +# --- Example 01: readiness ------------------------------------------------------------------- + +CODE["Go", 269] = [ + "// main.go (excerpt), examples/01-cloud-native-principles/go: config from env, two probes", + "func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {", + " w.Header().Set(\"Content-Type\", \"application/json\")", + " json.NewEncoder(w).Encode(HealthResponse{Status: \"ok\"}) // liveness: the process is up", + "}", + "func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {", + " ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)", + " defer cancel()", + " w.Header().Set(\"Content-Type\", \"application/json\")", + " if err := s.pool.Ping(ctx); err != nil { // readiness: the database answers", + " w.WriteHeader(http.StatusServiceUnavailable) // 503: out of rotation, not restarted", + " json.NewEncoder(w).Encode(ReadinessResponse{Status: \"down\",", + " Checks: map[string]string{\"database\": \"unreachable\"}})", + " return", + " }", + " json.NewEncoder(w).Encode(ReadinessResponse{Status: \"ready\",", + " Checks: map[string]string{\"database\": \"ok\"}})", + "}", + "// main(): SERVICE_VERSION and DATABASE_URL come from the environment (factor III); the mux", + "// serves /, /healthz, /readyz and /orders on :8080 (factor VII, port binding)", +] + +CODE["Cpp", 269] = [ + "// main.cpp (excerpt), examples/01-cloud-native-principles/cpp; conan.lock pins every library", + "using Callback = std::function;", + "const char* env(const char* name, const char* fallback) { // config from env, factor III", + " const char* v = std::getenv(name); return v ? v : fallback; // unset: the default", + "}", + "int main() {", + " g_version = env(\"SERVICE_VERSION\", \"1.0.0\");", + " g_pool = std::make_unique(", + " env(\"PG_CONNINFO\", \"postgresql://appuser:apppass@postgres:5432/appdb\"),", + " std::atoi(env(\"PG_POOL_SIZE\", \"4\")));", + " app().registerHandler(\"/healthz\", [](const HttpRequestPtr&, Callback&& cb) {", + " Json::Value j; j[\"status\"] = \"ok\"; cb(HttpResponse::newHttpJsonResponse(j)); // liveness", + " }, {Get});", + " app().registerHandler(\"/readyz\", [](const HttpRequestPtr&, Callback&& cb) {", + " const bool db_ok = db_ready(); // SELECT 1 via the libpq pool (retry elided)", + " Json::Value j; j[\"status\"] = db_ok ? \"ready\" : \"down\";", + " auto resp = HttpResponse::newHttpJsonResponse(j);", + " if (!db_ok) resp->setStatusCode(k503ServiceUnavailable); // down: out of rotation", + " cb(resp);", + " }, {Get});", + " app().addListener(\"0.0.0.0\", 8080).run(); // factor VII: port binding", + "}", +] + +CODE["Quarkus", 269] = [ + "// HealthResource.java (excerpt), examples/01-cloud-native-principles/quarkus", + "@Path(\"/\") @Produces(MediaType.APPLICATION_JSON)", + "public class HealthResource {", + " @ConfigProperty(name = \"quarkus.datasource.jdbc.url\") String jdbcUrl; // env, factor III", + " @GET @Path(\"healthz\") // liveness: the process is up", + " public Map healthz() { return Map.of(\"status\", \"ok\"); }", + " @GET @Path(\"readyz\") // readiness: SELECT 1 (one retry elided)", + " public Response readyz() {", + " try (var conn = DriverManager.getConnection(jdbcUrl, dbUser, dbPass);", + " var rs = conn.createStatement().executeQuery(\"SELECT 1\")) {", + " return Response.ok(Map.of(\"status\", \"ready\",", + " \"checks\", Map.of(\"database\", \"ok\"))).build();", + " } catch (Exception e) { // down: 503, out of rotation", + " return Response.status(Response.Status.SERVICE_UNAVAILABLE)", + " .entity(Map.of(\"status\", \"down\", \"checks\", Map.of(\"database\", \"unreachable\")))", + " .build();", + " }", + " }", +] +# 18 lines; the closing brace of the class is cut (excerpt) + +CODE["Spring", 269] = [ + "// HealthController.java (excerpt), examples/01-cloud-native-principles/spring-boot", + "// config from env (factor III): SPRING_DATASOURCE_URL, SERVICE_VERSION -> service.version", + "@RestController", + "public class HealthController {", + " private final DataSource dataSource;", + " @Value(\"${service.version}\")", + " private String serviceVersion;", + " public HealthController(DataSource dataSource) { this.dataSource = dataSource; }", + " @GetMapping(\"/healthz\") // liveness: the process is up", + " public Map healthz() { return Map.of(\"status\", \"ok\"); }", + " @GetMapping(\"/readyz\") // readiness: SELECT 1 on the pool", + " public ResponseEntity> readyz() {", + " try (var conn = dataSource.getConnection()) {", + " conn.createStatement().executeQuery(\"SELECT 1\").close();", + " } catch (Exception e) { // down: 503, the pod leaves the Service", + " return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)", + " .body(Map.of(\"status\", \"down\", \"checks\", Map.of(\"database\", \"unreachable\")));", + " }", + " return ResponseEntity.ok(Map.of(\"status\", \"ready\",", + " \"checks\", Map.of(\"database\", \"ok\")));", + " }", + "}", +] + +CODE["DotNet", 270] = [ + "// Program.cs (excerpt), examples/01-cloud-native-principles/dotnet", + "var builder = WebApplication.CreateBuilder(args);", + "// ... ConnectionStrings__Default from the environment (factor III) -> NpgsqlDataSource", + "var app = builder.Build();", + "app.MapGet(\"/healthz\", () => new { status = \"ok\" }); // liveness: the process is up", + "app.MapGet(\"/readyz\", async (NpgsqlDataSource db) => // readiness: SELECT 1", + "{", + " try", + " {", + " await using var conn = await db.OpenConnectionAsync();", + " await using var cmd = conn.CreateCommand();", + " cmd.CommandText = \"SELECT 1\";", + " await cmd.ExecuteScalarAsync();", + " return Results.Ok(new { status = \"ready\", checks = new { database = \"ok\" } });", + " }", + " catch // down: 503, out of rotation", + " {", + " return Results.Json(new { status = \"down\", checks = new { database = \"unreachable\" } },", + " statusCode: StatusCodes.Status503ServiceUnavailable);", + " }", + "});", +] + +# --- Example 21: one SIGTERM ------------------------------------------------------------------ + +CODE["Python", 419] = [ + "# main.py (excerpt), examples/21-graceful-shutdown/python", + "import asyncio, signal # (contextlib and fastapi imports elided)", + "DRAIN_DELAY_SECONDS = 5 # lets endpoint removal propagate (the preStop sleep)", + "shutting_down, uvicorn_sigterm, loop = False, None, None", + "def handle_sigterm(signum, frame):", + " global shutting_down", + " if shutting_down: return # one SIGTERM drives the whole drain", + " shutting_down = True # fail readiness now, keep serving for the delay", + " loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS,", + " uvicorn_sigterm, signum, frame) # then uvicorn drains HTTP", + "@asynccontextmanager", + "async def lifespan(app):", + " global uvicorn_sigterm, loop # uvicorn's handler is already installed: chain to it", + " loop = asyncio.get_running_loop()", + " uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm)", + " yield # --- runs after uvicorn has drained HTTP ---", + " await db_pool.close() # close pools, flush, release resources", + "app = FastAPI(lifespan=lifespan)", + "@app.get(\"/readyz\")", + "async def readyz(): return JSONResponse({}, 503 if shutting_down else 200)", + "# run: uvicorn main:app --timeout-graceful-shutdown 20 # bounds the HTTP drain", +] + +CODE["Go", 419] = [ + "// main.go (excerpt), examples/21-graceful-shutdown/go: one SIGTERM drives the drain", + "const shutdownTimeout = 20 * time.Second // drain delay + this stays under the 30s grace", + "func main() {", + " // ... pgx pool, s := &server{pool: pool}; GET /readyz answers 503 once s.shuttingDown", + " httpServer := &http.Server{Addr: \":8080\", Handler: mux}", + " ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)", + " defer stop()", + " go httpServer.ListenAndServe()", + " <-ctx.Done() // 1. the single SIGTERM", + " stop() // a later signal terminates at once", + " s.shuttingDown.Store(true) // readiness fails first, the server keeps serving", + " time.Sleep(drainDelay()) // 2. DRAIN_DELAY_SECONDS (5s): endpoints update", + " shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)", + " defer cancel() // 3. stop accepting, finish in-flight requests", + " if err := httpServer.Shutdown(shutdownCtx); err != nil {", + " slog.Error(\"shutdown incomplete\", \"err\", err, \"in_flight\", s.inFlight.Load())", + " }", + "} // 4. deferred pool.Close(), exit 0", +] + +CODE["Cpp", 419] = [ + "// order-service (excerpt), examples/21-graceful-shutdown/cpp: one SIGTERM drives the drain", + "std::atomic g_shutting_down{false}; // /readyz answers 503 once it is set", + "std::atomic g_in_flight{0}; // RAII InFlightGuard: one per request", + "extern \"C\" void sigterm_handler(int) { // async-signal-safe: one atomic store", + " g_shutting_down.store(true, std::memory_order_release);", + "}", + "void shutdown_watcher() { // outside signal context", + " while (!g_shutting_down.load(std::memory_order_acquire))", + " std::this_thread::sleep_for(50ms); // (also returns on g_watcher_stop)", + " const int delay = std::atoi(env(\"DRAIN_DELAY_SECONDS\", \"5\"));", + " std::this_thread::sleep_for(std::chrono::seconds(delay)); // endpoints update", + " const auto deadline = std::chrono::steady_clock::now() + 20s; // bounded in-flight wait", + " while (g_in_flight.load() > 0 && std::chrono::steady_clock::now() < deadline)", + " std::this_thread::sleep_for(100ms);", + " app().quit(); // stop the listeners: run() returns", + "}", + "int main() {", + " app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); }); // after init", + " std::thread watcher(shutdown_watcher); // ... handlers, addListener(\"0.0.0.0\", 8080)", + " app().run(); g_watcher_stop.store(true); watcher.join();", + " g_pool.reset(); // close the database connections; exit 0", + "}", +] + +CODE["Quarkus", 419] = [ + "// SignalHandler.java (excerpt), examples/21-graceful-shutdown/quarkus", + "// application.properties: quarkus.shutdown.timeout=20s (bounds the in-flight wait)", + "@ApplicationScoped", + "public class SignalHandler {", + " @Inject ShutdownState shutdownState; // GET /readyz answers 503 once it is set", + " void onStart(@Observes StartupEvent ev) { // replaces the JVM's default SIGTERM handler", + " Signal.handle(new Signal(\"TERM\"), signal -> {", + " if (!shutdownState.markShuttingDown()) return; // one SIGTERM drives the drain", + " long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5", + " new Thread(() -> {", + " try { Thread.sleep(delay * 1000); } // keep serving: endpoints update", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " Quarkus.asyncExit(0); // stop accepting, finish in-flight, close the pool", + " }, \"drain\").start();", + " });", + " }", + "}", +] + +CODE["Spring", 423] = [ + "// OrderServiceApplication.java (excerpt), examples/21-graceful-shutdown/spring-boot", + "// application.properties: server.shutdown=graceful", + "// spring.lifecycle.timeout-per-shutdown-phase=20s", + "@SpringBootApplication", + "public class OrderServiceApplication {", + " public static void main(String[] args) {", + " // registered before Spring starts: replaces the JVM's default SIGTERM handler", + " Signal.handle(new Signal(\"TERM\"), signal -> {", + " ShutdownState state = ShutdownState.getInstance(); // GET /readyz reads it: 503", + " if (!state.markShuttingDown()) return; // one SIGTERM drives the drain", + " long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5", + " new Thread(() -> {", + " try { Thread.sleep(delay * 1000); } // keep serving: endpoints update", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " System.exit(0); // shutdown hook: stop accepting, finish in-flight, close pool", + " }, \"drain\").start();", + " });", + " SpringApplication.run(OrderServiceApplication.class, args);", + " }", + "}", + "// Kafka consumers, the scheduler and WebSockets drain in a ContextClosedEvent listener.", +] + +CODE["DotNet", 427] = [ + "// Program.cs (excerpt), examples/21-graceful-shutdown/dotnet: one SIGTERM drives the drain", + "builder.Services.AddSingleton(); // not ConsoleLifetime", + "builder.Services.Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));", + "var app = builder.Build();", + "var shuttingDown = false; // GET /readyz answers 503 once it is set", + "var drainDelay = int.TryParse(Environment.GetEnvironmentVariable(\"DRAIN_DELAY_SECONDS\"),", + " out var d) && d >= 0 ? d : 5;", + "PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>", + "{", + " ctx.Cancel = true; // the host does not stop at once", + " if (shuttingDown) return; // already draining", + " shuttingDown = true; // 1. readiness fails first", + " _ = Task.Run(async () =>", + " {", + " await Task.Delay(TimeSpan.FromSeconds(drainDelay)); // 2. serve on: endpoints update", + " app.Lifetime.StopApplication(); // 3. Kestrel stops accepting, finishes in-flight", + " });", + "});", + "app.Lifetime.ApplicationStopped.Register(() => dataSource.Dispose()); // 4. close the pool", + "// ... MapGet /healthz (always 200), /readyz, /orders; app.Run();", + "// ManualLifetime : IHostLifetime: WaitForStartAsync and StopAsync return Task.CompletedTask", +] + +# --- Appendix M recaps of Example 21 ------------------------------------------------------------ + +CODE["Python", 500] = [ + "# main.py (excerpt), examples/21-graceful-shutdown/python: one SIGTERM, four steps", + "def handle_sigterm(signum, frame): # installed in the lifespan, chained to uvicorn's", + " global shutting_down", + " if shutting_down: return # already draining", + " shutting_down = True # 1. /readyz answers 503: the LB stops routing", + " loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS, # 2. keep serving 5 s", + " stop_server, signum, frame)", + "def stop_server(signum, frame): # 3. uvicorn stops accepting, finishes in-flight", + " uvicorn_sigterm(signum, frame) # (--timeout-graceful-shutdown 20)", + "@asynccontextmanager", + "async def lifespan(app: FastAPI):", + " global uvicorn_sigterm, loop", + " loop = asyncio.get_running_loop()", + " uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm)", + " yield # ---------- app serves ----------", + " await db_pool.close() # 4. close resources, exit 0", + "@app.get(\"/readyz\")", + "async def readyz():", + " if shutting_down: # liveness (/healthz) stays 200", + " return JSONResponse({\"ready\": False, \"reason\": \"shutting down\"}, 503)", + " return {\"ready\": True} # (the example also pings Postgres here)", + "# K8s: terminationGracePeriodSeconds: 30 >= 5 s delay + 20 s drain + buffer (App. H)", +] + +CODE["Go", 500] = [ + "// main.go (excerpt), examples/21-graceful-shutdown/go: shutdown is a failure mode", + "func (s *server) readyz(w http.ResponseWriter, r *http.Request) {", + " if s.shuttingDown.Load() { // liveness (/healthz) stays 200", + " w.WriteHeader(http.StatusServiceUnavailable)", + " json.NewEncoder(w).Encode(map[string]any{\"ready\": false, \"reason\": \"shutting down\"})", + " return", + " }", + " // ... ping the pool: 503 \"db unreachable\", else {\"ready\": true}", + "}", + "func main() {", + " // ... pool, mux, httpServer; ctx, stop := signal.NotifyContext(..., SIGTERM, SIGINT)", + " <-ctx.Done() // SIGTERM: terminationGracePeriod has started", + " stop()", + " s.shuttingDown.Store(true) // 1. readiness starts failing", + " time.Sleep(drainDelay()) // 2. let the LB stop routing to us (5s)", + " shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)", + " defer cancel()", + " httpServer.Shutdown(shutdownCtx) // 3. finish in-flight work (20s budget)", + "} // 4. deferred pool.Close(), exit 0", +] + +CODE["Cpp", 500] = [ + "// Recap of Appendix H in the failure-mode context: shutdown is a failure mode.", + "// A pod that dies mid-request causes 5xx; a pod that drains cleanly does not.", + "std::atomic g_shutting_down{false}; // /readyz answers 503 once it is set", + "std::atomic g_in_flight{0}; // InFlightGuard per request", + "extern \"C\" void sigterm_handler(int) { // async-signal-safe: one atomic store", + " g_shutting_down.store(true, std::memory_order_release);", + "}", + "void shutdown_watcher() { // one SIGTERM drives the rest", + " // ... wait for the flag, sleep DRAIN_DELAY_SECONDS (5), wait for g_in_flight == 0 (20 s)", + " app().quit(); // stop the listeners: run() returns", + "}", + "int main() {", + " app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); });", + " std::thread watcher(shutdown_watcher);", + " app().addListener(\"0.0.0.0\", 8080).run();", + " watcher.join();", + " g_pool.reset(); // close the pool, then exit 0", + "}", + "// K8s pod spec: terminationGracePeriodSeconds: 30 (>= 5 s delay + 20 s drain + buffer)", +] + +CODE["Quarkus", 500] = [ + "// OrderResource + SignalHandler (excerpt), examples/21-graceful-shutdown/quarkus", + "@GET @Path(\"readyz\") // OrderResource", + "public Response readyz() {", + " if (shutdownState.isShuttingDown()) // 1. readiness flips: 503", + " return Response.status(503)", + " .entity(Map.of(\"ready\", false, \"reason\", \"shutting down\")).build();", + " // ... SELECT 1 on the datasource: 200 {\"ready\": true}, or 503 \"db unreachable\"", + "}", + "@GET @Path(\"healthz\") // liveness stays 200 while draining", + "public Map healthz() { return Map.of(\"status\", \"ok\"); }", + "void onStart(@Observes StartupEvent ev) { // SignalHandler", + " Signal.handle(new Signal(\"TERM\"), signal -> { // one SIGTERM drives the drain", + " if (!shutdownState.markShuttingDown()) return;", + " new Thread(() -> {", + " try { Thread.sleep(drainDelaySeconds() * 1000); } // 2. the LB stops routing", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " Quarkus.asyncExit(0); // 3. drain in-flight HTTP (quarkus.shutdown.timeout=20s)", + " }, \"drain\").start(); // 4. Quarkus closes pools, exit 0", + " });", + "}", +] + +CODE["Spring", 504] = [ + "// excerpt, examples/21-graceful-shutdown/spring-boot: one SIGTERM drives the drain", + "# application.properties: server.shutdown=graceful, timeout-per-shutdown-phase=20s", + "Signal.handle(new Signal(\"TERM\"), signal -> { // registered before SpringApplication.run", + " ShutdownState state = ShutdownState.getInstance();", + " if (!state.markShuttingDown()) return; // 1. readiness flips: /readyz answers 503", + " new Thread(() -> {", + " try { Thread.sleep(drainDelaySeconds() * 1000); } // 2. the LB stops routing (5 s)", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " System.exit(0); // 3. graceful shutdown drains in-flight HTTP (20 s)", + " }, \"drain\").start(); // 4. the context closes the pool, exit 0", + "});", + "@GetMapping(\"/readyz\") // OrderController", + "public ResponseEntity> readyz() {", + " if (shutdownState.isShuttingDown())", + " return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)", + " .body(Map.of(\"ready\", false, \"reason\", \"shutting down\"));", + " // ... SELECT 1: 200 {\"ready\": true}, or 503 \"db unreachable\"; /healthz stays 200", + "}", +] + +CODE["DotNet", 514] = [ + "// OrderService/Program.cs (excerpt, example 21): one SIGTERM drives the drain", + "var shuttingDown = false;", + "PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>", + "{", + " ctx.Cancel = true; // the app drains; the host does not stop at once", + " if (shuttingDown) return; // already draining", + " shuttingDown = true; // 1. readiness → 503: the Service stops routing", + " _ = Task.Run(async () =>", + " {", + " await Task.Delay(TimeSpan.FromSeconds(drainDelay)); // 2. endpoints update (5 s)", + " app.Lifetime.StopApplication(); // 3. Kestrel drains in-flight (ShutdownTimeout 20 s)", + " });", + "});", + "app.MapGet(\"/healthz\", () => new { status = \"ok\" }); // liveness stays 200", + "app.MapGet(\"/readyz\", () => shuttingDown // (the example also pings", + " ? Results.Json(new { ready = false, reason = \"shutting down\" }, statusCode: 503)", + " : Results.Ok(new { ready = true })); // Postgres here)", + "app.MapPost(\"/orders\", async (HttpContext ctx, NpgsqlDataSource db) =>", + "{", + " if (shuttingDown) return Results.Json(new { error = \"shutting down\" }, statusCode: 503);", + " // ... in-flight requests finish before the host stops; 4. the pool is disposed, exit 0", + "});", +] + +# --- Example 25: write-back ----------------------------------------------------------------------- + +CODE["Python", 476] = [ + "MAX_FLUSH_ATTEMPTS, MAX_FLUSH_BACKOFF = 5, 8 # excerpt, examples/25-caching/python", + "async def flusher(): # 2. drain the dirty set to the DB", + " failures = 0", + " while True: # back off after a failed flush", + " await asyncio.sleep(min(FLUSH_PERIOD * 2**failures, MAX_FLUSH_BACKOFF))", + " ids = await cache.spop(\"metric:dirty\", FLUSH_BATCH) # cache: decode_responses=True", + " if not ids: continue", + " rows = [(m, json.dumps(d)) for m in ids if (d := await cache.hgetall(f\"metric:{m}\"))]", + " try: # upsert: an id flushed twice is one row", + " async with pool.acquire() as conn:", + " await conn.executemany(\"INSERT INTO metrics (id, payload) VALUES ($1, $2) \"", + " \"ON CONFLICT (id) DO UPDATE SET payload = $2, ts = NOW()\", rows)", + " failures = 0", + " except Exception: # 3. re-queue, bounded by attempts", + " log.exception(\"flusher error: flush failed\"); failures += 1", + " for mid in ids:", + " n = await cache.hincrby(\"metric:attempts\", mid, 1)", + " key = \"metric:failed\" if n >= MAX_FLUSH_ATTEMPTS else \"metric:dirty\"", + " await cache.sadd(key, mid) # parked after 5 tries, never dropped", + "@app.put(\"/write-back/metrics/{mid}\")", + "async def write_back_write(mid: str, body: MetricIn):", + " mapping = {\"value\": str(body.value), \"tags\": json.dumps(body.tags)}", + " await cache.hset(f\"metric:{mid}\", mapping=mapping) # 1. cache only", + " await cache.sadd(\"metric:dirty\", mid) # mark for flush", + " return JSONResponse(status_code=202, content={\"ok\": True, \"action\": \"cached-for-flush\"})", +] + +CODE["Go", 476] = [ + "// main.go (excerpt), examples/25-caching/go: write-back", + "func handleWriteBackPut(w http.ResponseWriter, r *http.Request) {", + " key := \"metric:\" + r.PathValue(\"id\") // body decoded into parsed, id added: data", + " safeSet(r.Context(), key, string(data), 0) // 1. cache only, no TTL", + " rdb.SAdd(r.Context(), \"metric:dirty\", key) // mark for flush", + " writeJSON(w, 202, parsed) // 202: queued, not yet durable", + "}", + "func flusher(ctx context.Context) { // 2. drain the dirty set to the DB", + " failures := 0", + " for {", + " wait := flushPeriod // 1s, doubling to 8s after failures", + " if failures > 0 { wait = min(flushPeriod<setStatusCode(k202Accepted);", + " callback(resp); // 3. 202: queued, not yet durable", + " }, {Put});", + "void writeback_flusher() { // std::thread, joined at shutdown", + " std::map attempts; int failures = 0; // per-key failure count", + " while (!g_shutdown) { // ... each cycle first sleeps std::min(2 << failures, 8) s", + " for (const auto& key : redis_smembers(\"writeback:dirty\")) { // key -> id, cached payload", + " auto res = g_flush_db->exec(\"INSERT INTO metrics (id, payload) VALUES ($1, $2) \"", + " \"ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, ts = NOW()\",", + " {id, payload});", + " if (res && PQresultStatus(res.get()) == PGRES_COMMAND_OK) {", + " redis_srem(\"writeback:dirty\", key); attempts.erase(key); continue; // DB has it", + " } // failed: stays dirty, retried next cycle", + " if (++attempts[key] >= kMaxFlushAttempts) // bounded: park after 5 attempts", + " { redis_sadd(\"writeback:failed\", key); redis_srem(\"writeback:dirty\", key); }", + " } // ... failures + 1 (at most 2) after a cycle with a failure, 0 after a clean one", + " }", +] +# 24 lines; the closing brace of the function is cut (excerpt) + +CODE["Quarkus", 476] = [ + "// CacheResource + BackgroundTasks (excerpt), examples/25-caching/quarkus: write-back", + "@PUT @Path(\"write-back/metrics/{mid}\") // CacheResource", + "public Response writeBackWrite(@PathParam(\"mid\") String mid, Map body) {", + " // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down)", + " return Response.accepted(Map.of(\"ok\", true, \"pattern\", \"write-back\",", + " \"action\", \"cached-for-flush\")).build(); // 202: queued, not yet durable", + "}", + "@Scheduled(every = \"${cache-service.flush-period:1s}\") // concurrent runs are skipped", + "void flusher() { // BackgroundTasks", + " if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure", + " Set ids = cache.spop(\"metric:dirty\", FLUSH_BATCH);", + " if (ids == null || ids.isEmpty()) return;", + " List failed = new ArrayList<>();", + " for (String mid : ids) {", + " try (var conn = dataSource.getConnection(); var ps = conn.prepareStatement(", + " \"INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) \"", + " + \"ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()\")) {", + " // ... bind mid and the cached hash as JSON; executeUpdate (one row per id)", + " cache.hdel(\"metric:attempts\", mid);", + " } catch (Exception e) { failed.add(mid); } // Log.errorf: flush failed for ", + " }", + " if (failed.isEmpty()) { failures = 0; return; }", + " nextFlushAt = System.currentTimeMillis() + Math.min(FLUSH_PERIOD_MS << ++failures, 8000);", + " requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5", + "}", +] + +CODE["Spring", 480] = [ + "@PutMapping(\"/write-back/metrics/{mid}\") // CacheController (excerpt)", + "public ResponseEntity> writeBackWrite(", + " @PathVariable String mid, @RequestBody Map body) {", + " // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down)", + " return ResponseEntity.accepted().body(Map.of(\"ok\", true, \"pattern\", \"write-back\",", + " \"action\", \"cached-for-flush\")); // 202: queued, not yet durable", + "} // examples/25-caching/spring-boot", + "@Scheduled(fixedDelayString = \"${cache-service.flush-period-ms:1000}\")", + "public void flusher() { // BackgroundTasks", + " if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure", + " Set ids = cache.spop(\"metric:dirty\", FLUSH_BATCH);", + " if (ids == null || ids.isEmpty()) return;", + " List failed = new ArrayList<>();", + " for (String mid : ids) {", + " try { // upsert: an id flushed twice is one row", + " jdbc.sql(\"INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) \"", + " + \"ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()\")", + " .param(mid).param(json).param(json).update(); // json: the cached hash", + " cache.hdel(\"metric:attempts\", mid);", + " } catch (Exception e) { failed.add(mid); } // logged: flush failed for ", + " }", + " if (failed.isEmpty()) { failures = 0; return; }", + " nextFlushAt = System.currentTimeMillis() + Math.min(flushPeriodMs << ++failures, 8000);", + " requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5", +] +# 24 lines; the closing brace of flusher() is cut (excerpt) + +CODE["DotNet", 490] = [ + "// Program.cs (excerpt), examples/25-caching/dotnet: write-back", + "app.MapPut(\"/write-back/metrics/{mid}\", async (string mid, HttpContext ctx) =>", + "{", + " // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down)", + " return Results.Json(new { ok = true, pattern = \"write-back\", action = \"cached-for-flush\" },", + " statusCode: StatusCodes.Status202Accepted); // 202: queued, not yet durable", + "});", + "_ = Task.Run(async () => // the flusher, started at boot", + "{", + " var failures = 0;", + " while (!cts.Token.IsCancellationRequested)", + " {", + " await Task.Delay(Math.Min(FlushPeriodMs << failures, MaxFlushBackoffMs), cts.Token);", + " var ids = await db.SetPopAsync(\"metric:dirty\", 100); // atomic batch dequeue", + " if (ids.Length == 0) continue;", + " var failed = new List(); // Upsert: HGETALL, INSERT ... ON CONFLICT (id)", + " foreach (var mid in ids)", + " try { await Upsert(mid); await db.HashDeleteAsync(\"metric:attempts\", mid); }", + " catch (Exception ex) { logger.LogError(ex, \"flush failed\"); failed.Add(mid); }", + " if (failed.Count == 0) { failures = 0; continue; }", + " failures = Math.Min(failures + 1, 3); // back off: 2 s, 4 s, 8 s", + " await Requeue(db, failed); // HINCRBY metric:attempts: dirty again, failed after 5", + " }", + "});", +] + +# --- Example 04 (Quarkus): plain JDBC outbox --------------------------------------------------- + +CODE["Quarkus", 287] = [ + "// OrderResource.java (excerpt), examples/04-data/quarkus: two rows, one transaction", + "@POST @Transactional // one JTA transaction around both inserts", + "public Response createOrder(CreateOrderRequest request) throws Exception {", + " String orderId = UUID.randomUUID().toString();", + " try (Connection conn = dataSource.getConnection(); // Agroal enlists it in the txn", + " PreparedStatement stmt = conn.prepareStatement(", + " \"INSERT INTO orders (id, sku, quantity, status) VALUES (?, ?, ?, ?) \"", + " + \"RETURNING created_at\")) {", + " // ... bind orderId, sku, quantity, \"confirmed\"; executeQuery (the state change)", + " try (PreparedStatement outboxStmt = conn.prepareStatement(", + " \"INSERT INTO outbox (aggregate_id, event_type, payload) \"", + " + \"VALUES (?, ?, ?::jsonb)\")) {", + " // ... setString: orderId, \"order.placed\", payloadJson {order_id, sku, quantity}", + " outboxStmt.executeUpdate(); // same transaction: the outbox row", + " }", + " return Response.status(Response.Status.CREATED).entity(order).build();", + " }", + "}", + "// No Kafka call on the request path: Debezium tails the WAL and publishes order.placed.", +] + +# ------------------------------------------------------------------ notes + +N = {} + +N["Python", 419] = ( + "Three moving parts, all started by one SIGTERM, the only signal Kubernetes sends before SIGKILL. " + "First, the handler flips a readiness flag so /readyz returns 503 at once; this is the 'stop new " + "traffic' step, and the process keeps serving while the load balancer stops routing to it. Second, " + "after DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) the handler " + "chains to uvicorn's own: uvicorn installs its signal handlers before it imports the app and runs " + "the lifespan, so a handler that replaces them without chaining means uvicorn never starts its drain " + "and the process waits for SIGKILL. uvicorn then stops accepting and finishes in-flight HTTP; " + "--timeout-graceful-shutdown 20 bounds that wait, so delay plus drain stays inside the 30-second " + "grace period. Third, FastAPI's lifespan shutdown block (after the yield) runs once HTTP has " + "drained: that is where you stop consumers (an aiokafka consumer commits offsets and leaves the " + "group) and close pools, and the process exits 0. The order matters: readiness down, drain HTTP, " + "then drain everything else. Pair this with the preStop hook and the termination grace period in " + "the pod spec.") + +N["Python", 500] = ( + "The shutdown sequence that prevents dropped requests during a rolling deploy, from Example 21. One " + "SIGTERM starts it: (1) the handler flips a flag so /readyz returns 503 and Kubernetes stops sending " + "new traffic, while /healthz stays 200; (2) the service keeps serving for DRAIN_DELAY_SECONDS so " + "the load balancer's removal propagates; (3) the handler then hands over to uvicorn, which stops " + "accepting and finishes in-flight requests within --timeout-graceful-shutdown; (4) the lifespan " + "exit closes the pools and the process exits 0. The ordering matters: stop taking new work before " + "draining existing work. This is the application half; Appendix H covers the Kubernetes half " + "(preStop hook, terminationGracePeriodSeconds). Together they give zero-downtime rollouts.") + + +def wb_notes(intro, flusher, failure, tail): + return f"{intro} {flusher} {failure} {tail}" + + +WB_TAIL = ("Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis " + "crash loses unflushed writes. For data that must not be lost, use write-through. For " + "high-throughput telemetry where losing the last second is acceptable, write-back is what " + "lets the service scale.") + +N["Python", 476] = wb_notes( + "Write-back has more moving parts than the other patterns; the slide shows the shape of Example 25. " + "(1) PUT /write-back/metrics/{mid} writes the metric to a Redis hash, adds the id to a 'dirty set', " + "which tells the flusher which ids still have to reach the database, and answers 202 Accepted: the " + "value is queued, not yet durable.", + "(2) The flusher runs as a background asyncio task: it SPOPs up to FLUSH_BATCH dirty ids and writes " + "them in one executemany upsert (ON CONFLICT (id) DO UPDATE), so an id flushed twice stays one row. " + "SPOP is atomic, so two flusher instances each get a distinct subset of ids and the flusher scales " + "out.", + "(3) When the flush fails, each id's count in the metric:attempts hash goes up and the id goes back " + "on the dirty set; after five attempts it moves to metric:failed with its value still in the cache " + "and the failure in the log, so an outage never loses a write without a trace. The sleep between " + "cycles doubles after each failed flush, from one second to at most eight.", + WB_TAIL) + +N["Go", 269] = [ + "Walk the code from Example 01: configuration comes from the environment with os.Getenv (factor " + "III), never from a baked-in file: SERVICE_VERSION names the version the image reports, and " + "DATABASE_URL points the pgx pool at Postgres. The router is the standard library's ServeMux, so " + "there is no router dependency.", + "The two probes are the contract with Kubernetes: /healthz is liveness (is the process up? if not, " + "restart it) and never checks a dependency; /readyz is readiness (can it serve?): it pings the " + "database with a two-second timeout and, when that fails, answers 503 with status down, which takes " + "the pod out of rotation without killing it. " + PROBE_READS + " Getting liveness and readiness " + "wrong is a common cause of cascading restarts, so stress the distinction.", +] + +N["Go", 419] = [ + "Four steps, all started by one SIGTERM, the only signal Kubernetes sends before SIGKILL. (1) " + "signal.NotifyContext turns SIGTERM into a cancelled context; main calls stop() so that a later " + "signal terminates at once, and flips the atomic.Bool behind /readyz, so readiness answers 503 and " + "the Service stops sending new traffic. (2) The service keeps serving for DRAIN_DELAY_SECONDS (5 s " + "in Example 21; it plays the part of a preStop sleep) while the endpoint removal propagates.", + "(3) http.Server.Shutdown stops accepting connections and waits for in-flight requests, bounded by " + "a 20-second context so a stuck request cannot hang the rollout; delay plus drain stays inside the " + "30-second grace period. (4) The deferred pool.Close() runs and the process exits 0; a service " + "with a franz-go client closes it here, which commits offsets and leaves the consumer group. Pair " + "it with the preStop hook and the grace period on the Kubernetes side.", +] + +N["Go", 476] = wb_notes( + "Write-back has more moving parts than the other patterns; the slide shows the shape of Example 25. " + "(1) PUT /write-back/metrics/{id} stores the metric in Redis with no TTL, adds the key to a 'dirty " + "set', which tells the flusher which keys still have to reach the database, and answers 202 " + "Accepted: the value is queued, not yet durable.", + "(2) The flusher runs as a background goroutine: it SPOPs up to flushBatch dirty keys and upserts " + "each one (ON CONFLICT (id) DO UPDATE), so a key flushed twice stays one row. SPOP is atomic, so " + "two flusher instances each get a distinct subset of keys and the flusher scales out.", + "(3) A key whose upsert fails is re-queued: HIncrBy counts its attempts in metric:attempts and it " + "goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in " + "the cache and the failure in the log. The wait between cycles doubles after each failed one, from " + "one second to at most eight.", + WB_TAIL) + +N["Go", 500] = ( + "The shutdown sequence that prevents dropped requests during a rolling deploy, from Example 21. One " + "SIGTERM cancels the signal context: (1) the shuttingDown flag flips so /readyz returns 503 and " + "Kubernetes stops sending new traffic, while /healthz stays 200; (2) the service keeps serving for " + "DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) Server.Shutdown stops accepting " + "and finishes in-flight requests within a 20-second budget; (4) the deferred pool close runs and " + "the process exits 0. The ordering matters: stop taking new work before draining existing work, " + "and close pools last. This is the application half; Appendix H covers the Kubernetes half (preStop " + "hook, terminationGracePeriodSeconds). Together they give zero-downtime rollouts.") + +N["Cpp", 419] = [ + "The application side of graceful shutdown, from the C++ example; one SIGTERM, the only signal " + "Kubernetes sends before SIGKILL, drives all of it. (1) The SIGTERM handler only stores an atomic " + "flag, which is async-signal-safe: no locks, no allocation, no destructors. Drogon installs its own " + "SIGTERM handler that quits at once, so the example replaces it after initialisation with " + "registerBeginningAdvice. /readyz answers 503 once the flag is set, which removes the pod from the " + "Service endpoints while the process keeps serving the requests already routed to it.", + "(2) A watcher thread does the rest outside signal context: it sees the flag, keeps serving for " + "DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) so the endpoint " + "removal propagates, waits up to 20 seconds for the RAII InFlightGuard count to reach zero, and " + "calls app().quit(), which stops the listeners so run() returns. (3) main() joins the watcher, " + "closes the libpq pool and returns 0, inside the 30-second grace period. std::stop_token and " + "std::jthread extend the same pattern to worker threads such as Kafka consumers: the watcher calls " + "request_stop() after it sees the flag, never inside the handler. Deterministic destruction makes " + "the teardown order predictable.", +] + +N["Cpp", 476] = [ + "Write-back from Example 25: PUT /write-back/metrics/{id} stores the payload in Redis under " + "metric: with a one-hour TTL (a queued write has to outlive a database outage), adds the key to " + "the writeback:dirty set and answers 202 Accepted: the write is queued, not yet durable. A " + "background std::thread wakes every two seconds, reads the dirty set, upserts each metric into " + "Postgres with INSERT ... ON CONFLICT DO UPDATE, and removes the key from the set only after the " + "upsert succeeds, so a failed write stays dirty and is retried on the next cycle.", + "Retries are bounded: a per-key counter parks a key on writeback:failed after five failed attempts, " + "with its value still in the cache and an error in the log, and the cycle time backs off to four " + "and then eight seconds while flushes fail. The upsert makes a repeated flush harmless. The flusher " + "has its own libpq connection, separate from the request handlers', and main() joins it after the " + "shutdown flag is set (Appendix H). The risk is the one every write-back carries: a write that was " + "acknowledged but not yet flushed is lost if Redis loses it, so keep write-back for data that " + "tolerates that window, such as metrics.", +] + +N["Cpp", 500] = ( + "Recap of App H in the failure-mode context: pod shutdown is itself a failure mode that graceful " + "drain converts into a non-failure. The C++ disposability discipline of Example 21 (one SIGTERM, an " + "async-signal-safe flag, /readyz answering 503, the drain delay, a bounded in-flight wait, " + "app().quit(), the pool closed and exit 0) is what makes rolling deploys clean.") + +N["Quarkus", 269] = ( + "Walk the code: config comes from the environment via MicroProfile Config (factor III): " + "QUARKUS_DATASOURCE_JDBC_URL, the username and password and SERVICE_VERSION override " + "application.properties, never a baked-in file. The two probes are plain JAX-RS handlers in Example " + "01: /healthz is liveness (am I alive? if not, restart me) and never checks a dependency; /readyz is " + "readiness (can I serve?): it runs SELECT 1 over JDBC, retrying once after a second, and when the " + "database is unreachable it answers 503 with status down, which takes the pod out of rotation " + "without killing it. " + PROBE_READS + " SmallRye Health (@Liveness and @Readiness checks on " + "/q/health/live and /q/health/ready) is the extension that serves the same pair without handler " + "code; the example writes them by hand so every language serves the same paths and bodies. Getting " + "liveness vs readiness wrong is a top cause of cascading restarts, so stress the distinction. Maven " + "with the quarkus-bom (platform 3.40.1) pins every dependency reproducibly.") + +N["Quarkus", 287] = [ + "The whole trick is the @Transactional method: the insert of the order row and the insert of the " + "outbox row run on one Agroal connection inside one JTA transaction, so they commit or roll back " + "together. There is no Kafka call inside the request path: if the broker is down, the order still " + "commits and the event ships later when Debezium tails the write-ahead log and relays the outbox " + "row in commit order. This decouples request latency and availability from Kafka's.", + "Example 04 needs no outbox extension: the outbox is a small table (aggregate id, event type, JSON " + "payload, created_at), the payload is serialized with Jackson, and a standard Debezium Postgres " + "connector handles the CDC. The Debezium Outbox Event Router single-message transform unwraps the " + "row into the event envelope on the connector side. The same commit-together discipline reappears " + "in the saga appendix.", +] + +N["Quarkus", 419] = ( + "Example 21 handles the signal itself, so the whole sequence is visible. A sun.misc.Signal handler, " + "registered on StartupEvent, replaces the JVM's default SIGTERM handler, which would start the " + "shutdown hooks at once. One SIGTERM, the only signal Kubernetes sends before SIGKILL, drives " + "everything: the handler flips the ShutdownState flag, so the hand-written /readyz answers 503 and " + "the Service stops routing, and a drain thread keeps the service serving for DRAIN_DELAY_SECONDS " + "(5 s; it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls " + "Quarkus.asyncExit(0): Quarkus stops accepting connections, waits for in-flight requests up to " + "quarkus.shutdown.timeout (20 s), closes the datasource and exits 0, inside the 30-second grace " + "period. A ShutdownEvent observer is the hook for what Quarkus leaves to you: Kafka consumers, the " + "scheduler, WebSockets. Quarkus also has a built-in pre-shutdown delay (quarkus.shutdown.delay-" + "enabled, a build-time flag) that reports readiness down through SmallRye Health; the example keeps " + "its own /readyz, as Example 01 does. Pair it with the Kubernetes side: probes, preStop and the " + "grace period.") + +N["Quarkus", 476] = [ + "Quarkus write-back, as Example 25 runs it. (1) PUT /write-back/metrics/{mid} writes the metric to " + "a Redis hash, adds the id to a dirty set, which tells the flusher what still has to reach the " + "database, and answers 202 Accepted: the value is queued, not yet durable. (2) A @Scheduled method " + "runs every second (a run is skipped while one is in flight), pops up to FLUSH_BATCH dirty ids with " + "SPOP (atomic, so two flusher instances get distinct subsets and the flusher scales out) and " + "upserts each one over JDBC with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice " + "stays one row. (3) An id whose upsert fails is re-queued: its count in the metric:attempts hash " + "goes up and it goes back on the dirty set, or, after five attempts, onto metric:failed with its " + "value still in the cache and the failure in the log. After a failed run the flusher waits two " + "seconds, then four, then eight before it tries again.", + "This is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses " + "unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry " + "where losing the last second is acceptable, write-back is what lets the service scale.", +] + +N["Quarkus", 500] = ( + "Example 21 handles SIGTERM itself. A sun.misc.Signal handler, registered on StartupEvent, takes " + "the single SIGTERM: (1) it flips the ShutdownState flag, so the hand-written /readyz answers 503 " + "and Kubernetes stops routing new traffic, while /healthz stays 200; (2) a drain thread keeps the " + "service serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) it calls " + "Quarkus.asyncExit(0), and Quarkus stops accepting and drains in-flight HTTP within " + "quarkus.shutdown.timeout (20 s); (4) Quarkus closes the datasource and the process exits 0. " + "Appendix H covers the Kubernetes-side preStop and terminationGracePeriodSeconds.") + +N["Spring", 269] = ( + "Walk the code: config comes from the environment through Spring's property resolution (factor " + "III): SPRING_DATASOURCE_URL and its credentials configure the DataSource and SERVICE_VERSION feeds " + "service.version, never a baked-in file; Spring profiles override per environment. The application " + "name (spring.application.name) shows up in Micrometer metrics and traces. The two probes are a " + "plain @RestController in Example 01: /healthz is liveness (am I alive? if not, restart me) and " + "never checks a dependency; /readyz is readiness (can I serve?): it runs SELECT 1 on a pooled " + "connection and, when that fails, answers 503 with status down, which takes the pod out of " + "rotation without killing it. " + PROBE_READS + " Spring Boot Actuator offers the same pair as " + "/actuator/health/liveness and /actuator/health/readiness with HealthIndicator beans; the example " + "writes them by hand so every language serves the same paths and bodies. Getting liveness vs " + "readiness wrong is a top cause of cascading restarts, so stress the distinction. Maven with " + "spring-boot-starter-parent pins every dependency reproducibly. Constructor injection is the " + "idiomatic Spring style: the bean stays immutable and is unit-testable without a container.") + +N["Spring", 423] = ( + "Example 21 takes the signal itself, so the whole sequence is visible. A sun.misc.Signal handler " + "registered before Spring starts replaces the JVM's default SIGTERM handler, which would run the " + "shutdown hooks at once. One SIGTERM, the only signal Kubernetes sends before SIGKILL, drives " + "everything: the handler flips ShutdownState, so the hand-written /readyz answers 503 and the " + "Service stops routing, and a drain thread keeps the service serving for DRAIN_DELAY_SECONDS (5 s; " + "it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls " + "System.exit(0), which runs Spring's shutdown hook: with server.shutdown=graceful the embedded " + "Tomcat, Jetty or Reactor Netty stops accepting new connections and finishes in-flight HTTP, " + "bounded by spring.lifecycle.timeout-per-shutdown-phase (20 s here; the default is 30 s), then the " + "context closes the pool and the process exits 0, inside the 30-second grace period. Use " + "@EventListener(ContextClosedEvent.class), @PreDestroy or SmartLifecycle to drain what Spring does " + "not drain for you: Kafka consumers (consumer.wakeup() then commit on close), the scheduler (stop " + "accepting new triggers), WebSockets (send a close frame so clients reconnect elsewhere). With " + "Actuator, publishing ReadinessState.REFUSING_TRAFFIC flips /actuator/health/readiness the same " + "way; the example keeps its own /readyz, as Example 01 does. Pair it with the Kubernetes settings: " + "readiness, preStop and the grace period.") + +N["Spring", 480] = ( + "Spring's @Scheduled drives write-back in Example 25. (1) PUT /write-back/metrics/{mid} writes the " + "metric to a Redis hash, adds the id to a 'dirty set', which tells the flusher what still has to " + "reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) " + "@Scheduled(fixedDelayString = ...) runs every second, SPOPs up to FLUSH_BATCH dirty ids and " + "upserts each one through JdbcClient with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed " + "twice stays one row. SPOP is atomic, so two replicas each get a distinct subset of ids and no lock " + "is needed to scale out. (3) An id whose upsert fails is re-queued: its count in the " + "metric:attempts hash goes up and it goes back on the dirty set, or, after five attempts, onto " + "metric:failed with its value still in the cache and the failure in the log. After a failed run " + "the flusher skips runs for two seconds, then four, then eight. " + WB_TAIL) + +N["Spring", 504] = ( + "Example 21 takes SIGTERM itself. A sun.misc.Signal handler registered before Spring starts flips " + "ShutdownState on the single SIGTERM: (1) the hand-written /readyz answers 503, so the Kubernetes " + "readiness probe sees the pod go not-ready and stops routing, while /healthz stays 200; (2) a drain " + "thread keeps the service serving for DRAIN_DELAY_SECONDS so the load balancer's removal " + "propagates; (3) System.exit(0) runs Spring's graceful shutdown, which stops accepting and drains " + "in-flight HTTP within timeout-per-shutdown-phase (20 s); (4) the context closes the pool and the " + "process exits 0. Appendix H covers the Kubernetes preStop + terminationGracePeriodSeconds side.") + +N["DotNet", 270] = ( + "Walk the code: ASP.NET Core 10 uses minimal hosting (Program.cs as a top-level script). Config " + "flows from appsettings.json overridden by environment variables (factor III): " + "ConnectionStrings__Default reaches GetConnectionString(\"Default\"), the cloud-native equivalent of " + "web.config transforms, except that the cluster injects the values through ConfigMaps and Secrets " + "instead of an MSDeploy package. The two probes are mapped by hand in Example 01: /healthz is " + "liveness (am I alive? if not, restart me, and Kubernetes kills the Pod) and never checks a " + "dependency; /readyz is readiness (can I serve? if not, take me out of rotation but don't kill me): " + "it opens a pooled Npgsql connection and runs SELECT 1, and when that fails it answers 503 with " + "status down, which removes the Pod from the Service endpoints. " + PROBE_READS + " Getting these " + "wrong is a top cause of cascading restarts, the same way wrong IIS app-pool ping settings used to " + "crash apps under load. AddHealthChecks with MapHealthChecks and the AspNetCore.HealthChecks.* " + "community packages (MIT) are the framework route to the same pair; the example writes the handlers " + "by hand so every language serves the same paths and bodies. The .NET runtime side: the service " + "ships as a Linux container image built from a UBI Containerfile, not an MSI deployed to IIS.") + +N["DotNet", 427] = ( + "ASP.NET Core's default ConsoleLifetime stops the host as soon as SIGTERM arrives, which leaves no " + "time to fail readiness first. Example 21 replaces it with a ManualLifetime and takes the signal " + "itself through PosixSignalRegistration; one SIGTERM, the only signal Kubernetes sends before " + "SIGKILL, drives everything. The handler cancels the default action and flips a flag, so the " + "hand-written /readyz answers 503 and the Service stops routing, then a task keeps the service " + "serving for DRAIN_DELAY_SECONDS (5 s; it plays the part of a preStop sleep) while the endpoint " + "removal propagates. It then calls StopApplication(): the host signals ApplicationStopping, Kestrel " + "stops accepting and finishes in-flight requests, and every IHostedService gets StopAsync, all " + "bounded by HostOptions.ShutdownTimeout (20 s here; the default is 30 s). ApplicationStopped " + "disposes the data source and the process exits 0, inside the 30-second grace period. The pattern " + "for other work: one BackgroundService per long-running concern (a Kafka consumer, a scheduler, the " + "Temporal worker from Appendix D, the Streamiz processor from Section 6), each draining in its own " + "StopAsync: consumer.Close() commits offsets and leaves the group, a SignalR hub sends close frames, " + "a Temporal worker shuts down. For the WCF refugees: this is the in-box equivalent of " + "ServiceHost.Close, except that SIGTERM triggers it and the budget is HostOptions.ShutdownTimeout " + "rather than a per-binding timeout. The Kubernetes settings (probes, preStop, grace period) are the " + "platform half of the contract.") + +N["DotNet", 490] = ( + "Write-back in .NET, as Example 25 runs it. PUT /write-back/metrics/{mid} writes the metric fields " + "to a Redis hash, adds the id to a 'dirty set' that tells the flusher what to drain, and answers " + "202 Accepted in microseconds: the value is queued, not yet durable. The flusher is a background " + "loop started with the app (a BackgroundService works the same way) that SPOPs up to 100 dirty ids " + "at once. SPOP is the right primitive: it removes and returns elements in one call, so two flusher " + "instances would each get a distinct subset of ids and horizontal scale works. Each id is upserted " + "with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice stays one row. An id whose " + "upsert fails is re-queued: HashIncrementAsync counts its attempts in metric:attempts and it goes " + "back on the dirty set, or, after five attempts, onto metric:failed with its value still in the " + "cache and the failure in the log; the loop then waits two, four and at most eight seconds between " + "runs. " + WB_TAIL) + +N["DotNet", 514] = [ + "The application half of graceful shutdown, from the running example's .NET order-service. " + "PosixSignalRegistration catches the single SIGTERM and cancels the default immediate shutdown; the " + "handler flips a flag so /readyz answers 503 and Kubernetes takes the Pod out of the Service, while " + "/healthz stays 200 so the kubelet does not restart a Pod that is winding down.", + "New requests get 503. After DRAIN_DELAY_SECONDS the handler calls StopApplication(): Kestrel stops " + "accepting and finishes in-flight requests within HostOptions.ShutdownTimeout (20 s), the data " + "source is disposed and the process exits 0, inside terminationGracePeriodSeconds. Appendix H " + "covers the hosted-service drain hooks, the preStop hook and the grace period.", +] + +# ------------------------------------------------------------------ text replacements + +R = {} # deck -> list of (sldId, scope, find, repl, why) + +R["Python"] = [ + (269, "slide", 'return {"status": "down", "checks": {"database": "unreachable"}}', + 'return JSONResponse({"status": "down", "checks": {"database": "unreachable"}}, 503)', + "examples/01 python: readiness answers 503 when the database is down (937f8d7)"), + (269, "notes", "it runs SELECT 1 on the database pool and reports down otherwise, which takes the " + "pod out of rotation without killing it.", + "it runs SELECT 1 on the database pool and, when that fails, answers 503 with status down, which " + "takes the pod out of rotation without killing it. " + PROBE_READS, + "notes follow the 503"), + (419, "slide", "The first SIGTERM flips readiness; the next goes to uvicorn, which drains in-flight " + "HTTP (bounded by --timeout-graceful-shutdown).", + "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS the handler hands over to uvicorn, which " + "drains in-flight HTTP within 20 s.", + "examples/21: one SIGTERM drives the drain (9676f32)"), + (476, "slide", WB_CAPTION_OLD, WB_CAPTION_NEW, "examples/25: 202 and bounded retry (ebca295)"), +] + +R["Go"] = [ + (419, "slide", "signal.NotifyContext catches SIGTERM; readiness fails first so the Service stops " + "routing, then Server.Shutdown drains within a 15-second budget.", + "One SIGTERM: readiness fails first, the service keeps serving for DRAIN_DELAY_SECONDS, then " + "Server.Shutdown drains within 20 seconds.", + "examples/21 go: drain delay and a 20 s shutdown budget (9676f32)"), + (476, "slide", WB_CAPTION_OLD, WB_CAPTION_NEW, "examples/25: 202 and bounded retry (ebca295)"), +] + +R["Cpp"] = [ + (269, "notes", "/readyz runs SELECT 1 through the libpq pool and reports ready or down (readiness), " + "so the probe", + "/readyz runs SELECT 1 through the libpq pool and answers 200 with status ready, or 503 with " + "status down (readiness; the probe reads the status code, not the body), so the probe", + "examples/01 cpp: readiness answers 503 when the database is down (937f8d7)"), + (419, "slide", "SIGTERM → fail readiness → keep serving while endpoints update → drain in-flight → " + "RAII closes the pool.", + "One SIGTERM → fail readiness → serve DRAIN_DELAY_SECONDS → drain in-flight (20 s) → quit → " + "close the pool.", + "examples/21 cpp: watcher thread, drain delay, bounded wait, app().quit() (9676f32)"), + (476, "slide", "Write to Redis and mark dirty; a flusher upserts every 2 s and clears a mark only " + "once the DB has the row.", + "PUT answers 202; the flusher upserts every 2 s, clears a mark once the DB has the row, and parks a " + "key after 5 failures.", + "examples/25 cpp: 202 and bounded retry (ebca295)"), + (500, "slide", "Shutdown is a failure mode: readiness flip, drain in-flight requests, RAII cleanup.", + "Shutdown is a failure mode: readiness flip, drain delay, bounded in-flight drain, then cleanup.", + "examples/21 cpp (9676f32)"), +] + +R["Quarkus"] = [ + (269, "slide", "java · Quarkus + SmallRye Health", "java · Quarkus REST", + "examples/01 quarkus hand-writes /healthz and /readyz"), + (287, "slide", "java · Hibernate Panache + Debezium", "java · Agroal JDBC + Debezium", + "examples/04 quarkus: plain JDBC inserts into the outbox table"), + (419, "slide", "delay flips readiness DOWN first (a build-time setting), then in-flight HTTP gets " + "`timeout` to finish; only HTTP is automatic — drain the rest in a ShutdownEvent observer.", + "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, Quarkus.asyncExit(0) drains in-flight " + "HTTP within quarkus.shutdown.timeout (20 s).", + "examples/21 quarkus: Signal handler, drain delay, asyncExit (9676f32)"), + (476, "slide", "java · Quarkus + @Scheduled + Redis + Panache", "java · Quarkus + @Scheduled + Redis + JDBC", + "examples/25 quarkus flushes with JDBC per-id upserts"), + (476, "slide", WB_CAPTION_OLD, WB_CAPTION_NEW, "examples/25: 202 and bounded retry (ebca295)"), + (500, "slide", "java · Quarkus + SmallRye Health", "java · Quarkus", + "examples/21 quarkus: hand-written /readyz"), +] + +R["Spring"] = [ + (269, "slide", "java · Spring Boot 4 + Actuator", "java · Spring Boot 4", + "examples/01 spring-boot hand-writes /healthz and /readyz"), + (423, "slide", "server.shutdown=graceful drains HTTP; publish a Readiness REFUSING_TRAFFIC event to " + "flip the probe; drain everything else in ContextClosedEvent.", + "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, System.exit(0) runs Spring's graceful " + "shutdown, bounded at 20 s per phase.", + "examples/21 spring-boot: Signal handler, drain delay, System.exit (9676f32)"), + (480, "slide", "java · Spring Boot 4 + @Scheduled + ShedLock + Spring Data Redis + JPA", + "java · Spring Boot 4 + @Scheduled + Spring Data Redis + JdbcClient", + "examples/25 spring-boot: JdbcClient upserts, no ShedLock"), + (480, "slide", "The dirty set is the key idea — it tells the flusher what to drain. ShedLock ensures " + "only one Pod flushes at a time.", WB_CAPTION_NEW, "examples/25: 202 and bounded retry (ebca295)"), + (504, "slide", "java · Spring Boot 4 + Actuator", "java · Spring Boot 4", + "examples/21 spring-boot: hand-written /readyz"), +] + +R["DotNet"] = [ + (270, "slide", "csharp · .NET 10 + ASP.NET Core + AspNetCore.HealthChecks", + "csharp · .NET 10 + ASP.NET Core + Npgsql", + "examples/01 dotnet maps /healthz and /readyz by hand"), + (427, "slide", "HostOptions.ShutdownTimeout bounds the drain; each BackgroundService's StopAsync is " + "your hook. Flip readiness DOWN first so Kubernetes stops routing.", + "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, StopApplication() lets Kestrel drain " + "in-flight requests within ShutdownTimeout (20 s).", + "examples/21 dotnet: PosixSignalRegistration, drain delay, StopApplication (9676f32)"), + (489, "notes", "A BackgroundService drains the cache to the DB", "A background flusher drains the " + "cache to the DB", "examples/25 dotnet: the flusher is a Task.Run loop"), + (490, "slide", "Write-back with BackgroundService in ASP.NET Core", + "Write-back with a background flusher in ASP.NET Core", + "examples/25 dotnet: the flusher is a Task.Run loop, not a BackgroundService"), + (490, "slide", "csharp · ASP.NET Core 10 + BackgroundService + StackExchange.Redis + EF Core", + "csharp · ASP.NET Core 10 + StackExchange.Redis + Npgsql", + "examples/25 dotnet: Npgsql upserts, no EF Core"), + (490, "slide", WB_CAPTION_OLD, WB_CAPTION_NEW, "examples/25: 202 and bounded retry (ebca295)"), +] + +# slide numbers of the current decks, for the _slide comments +SLIDE_NO = { + ("Python", 269): 14, ("Python", 419): 164, ("Python", 476): 221, ("Python", 500): 245, + ("Go", 269): 14, ("Go", 419): 164, ("Go", 476): 221, ("Go", 500): 245, + ("Cpp", 269): 14, ("Cpp", 419): 164, ("Cpp", 476): 221, ("Cpp", 500): 245, + ("Quarkus", 269): 14, ("Quarkus", 287): 32, ("Quarkus", 419): 164, ("Quarkus", 476): 221, + ("Quarkus", 500): 245, + ("Spring", 269): 14, ("Spring", 423): 168, ("Spring", 480): 225, ("Spring", 504): 249, + ("DotNet", 270): 15, ("DotNet", 427): 172, ("DotNet", 489): 234, ("DotNet", 490): 235, + ("DotNet", 514): 259, +} + +WHY_CODE = { + 269: "examples/01: hand-written /healthz and /readyz; readiness answers 503 when the database is down", + 270: "examples/01: hand-written /healthz and /readyz; readiness answers 503 when the database is down", + 287: "examples/04 quarkus: plain JDBC inserts into orders and outbox in one @Transactional method", + 419: "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight " + "drain within 20 s -> cleanup -> exit 0", + 423: "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight " + "drain within 20 s -> cleanup -> exit 0", + 427: "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight " + "drain within 20 s -> cleanup -> exit 0", + 476: "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, " + "metric:failed, backoff)", + 480: "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, " + "metric:failed, backoff)", + 490: "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, " + "metric:failed, backoff)", + 500: "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", + 504: "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", + 514: "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", +} + +ORDER = { # sldIds in deck order, per deck + "Python": [269, 419, 476, 500], "Go": [269, 419, 476, 500], "Cpp": [269, 419, 476, 500], + "Quarkus": [269, 287, 419, 476, 500], "Spring": [269, 423, 480, 504], + "DotNet": [270, 427, 489, 490, 514], +} + + +# line count of each box on the committed decks: set_code's limit is max(22, this) +NOW = { + ("Python", 419): 21, ("Python", 476): 25, ("Python", 500): 21, + ("Go", 269): 19, ("Go", 419): 16, ("Go", 476): 20, ("Go", 500): 20, + ("Cpp", 269): 22, ("Cpp", 419): 22, ("Cpp", 476): 22, ("Cpp", 500): 16, + ("Quarkus", 269): 16, ("Quarkus", 287): 16, ("Quarkus", 419): 17, ("Quarkus", 476): 25, + ("Quarkus", 500): 21, + ("Spring", 269): 25, ("Spring", 423): 25, ("Spring", 480): 24, ("Spring", 504): 19, + ("DotNet", 270): 19, ("DotNet", 427): 24, ("DotNet", 490): 25, ("DotNet", 514): 17, +} +SHAPE = {("Spring", 480): 2493, ("DotNet", 490): 2598} # the slide also has a one-line chip box + + +def check(): + bad = [] + for (deck, sid), lines in CODE.items(): + cap = min(CAP[deck, sid], max(22, NOW[deck, sid])) + if len(lines) > cap: + bad.append(f"{deck} {sid}: {len(lines)} lines > capacity {cap}") + for i, ln in enumerate(lines, 1): + if len(ln) > MAX_COLS: + bad.append(f"{deck} {sid} line {i}: {len(ln)} cols: {ln}") + if "\t" in ln: + bad.append(f"{deck} {sid} line {i}: tab") + return bad + + +def build(deck): + ops = [] + for sid in ORDER[deck]: + no = SLIDE_NO[deck, sid] + for (s, scope, find, repl, why) in R.get(deck, []): + if s == sid: + ops.append({"op": "replace", "scope": scope, "sldIds": [sid], "find": find, + "repl": repl, "expect": 1, "cat": "code" if scope == "slide" else "notes", + "_slide": no, "_why": why, "_src": "align"}) + if (deck, sid) in CODE: + op = {"op": "set_code", "sldId": sid, "lines": CODE[deck, sid], "cat": "code", + "_slide": no, "_why": WHY_CODE[sid], "_src": "align"} + if (deck, sid) in SHAPE: + op["shape_id"] = SHAPE[deck, sid] + ops.append(op) + if (deck, sid) in N: + n = N[deck, sid] + op = {"op": "set_notes", "sldId": sid, "cat": "notes", "_slide": no, + "_why": "notes follow the code", "_src": "align"} + if isinstance(n, list): + op["paragraphs"] = n + else: + op["text"] = n + ops.append(op) + return {"ops": ops} + + +def main(argv): + bad = check() + for b in bad: + print("FAIL", b) + if bad: + return 1 + if "--check" in argv: + print("ok") + return 0 + for deck, name in OUT.items(): + path = EDITS / name + path.write_text(json.dumps(build(deck), indent=1, ensure_ascii=False) + "\n") + print(path.relative_to(REPO), len(build(deck)["ops"]), "ops") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/_plans/s12-manifest/tools/align_site.py b/_plans/s12-manifest/tools/align_site.py new file mode 100644 index 00000000..02320001 --- /dev/null +++ b/_plans/s12-manifest/tools/align_site.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""S12 example alignment: put the deck code of align_build.py into the site codetabs. + + align_site.py + +For each (chapter, heading) the first codetab after the heading has six fenced blocks in the order +Spring Boot, Quarkus, .NET, Python, C++, Go; the listed blocks are replaced by the deck lines (Go +with tab indentation, an excerpt's cut closing brace restored). Blocks not listed stay as they are. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from align_build import CODE, REPO # noqa: E402 + +ORDER = ["Spring", "Quarkus", "DotNet", "Python", "Cpp", "Go"] + +PLAN = [ + ("_docs/01-cloud-native-principles.md", "## A twelve-factor service, ready for the platform", + {"Spring": ("Spring", 269), "Quarkus": ("Quarkus", 269), "DotNet": ("DotNet", 270), + "Cpp": ("Cpp", 269), "Go": ("Go", 269)}), + ("_docs/21-appendix-h-shutdown.md", "## The application side", + {"Spring": ("Spring", 423), "Quarkus": ("Quarkus", 419), "DotNet": ("DotNet", 427), + "Cpp": ("Cpp", 419), "Go": ("Go", 419)}), + ("_docs/25-appendix-l-caching.md", "{% include excalidraw.html\n file=\"24-write-back\"", + {"Spring": ("Spring", 480), "Quarkus": ("Quarkus", 476), "DotNet": ("DotNet", 490), + "Python": ("Python", 476), "Cpp": ("Cpp", 476), "Go": ("Go", 476)}), +] + +# closing lines an excerpt on a slide cut for space +CLOSE = {("Quarkus", 269): ["}"], ("Cpp", 476): ["}"], ("Spring", 480): ["}"]} + + +def site_lines(key): + lines = list(CODE[key]) + CLOSE.get(key, []) + if key[0] == "Go": + out = [] + for ln in lines: + body = ln.lstrip(" ") + n = len(ln) - len(body) + out.append("\t" * (n // 3) + " " * (n % 3) + body if body else "") + lines = out + return lines + + +def main(): + for rel, anchor, repl in PLAN: + path = REPO / rel + text = path.read_text() + start = text.index(anchor) + tabs = text.index("{% include codetabs.html", start) + lines = text[tabs:].split("\n") + blocks, i = [], 1 + while len(blocks) < 6: + while not lines[i].startswith("```"): + i += 1 + j = i + 1 + while lines[j] != "```": + j += 1 + blocks.append((i, j)) + i = j + 1 + for lang, key in sorted(repl.items(), key=lambda kv: -ORDER.index(kv[0])): + a, b = blocks[ORDER.index(lang)] + lines[a + 1:b] = site_lines(key) + path.write_text(text[:tabs] + "\n".join(lines)) + print(rel, "replaced", ", ".join(repl)) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/amend_decisions.py b/_plans/s12-manifest/tools/amend_decisions.py new file mode 100644 index 00000000..eec15b31 --- /dev/null +++ b/_plans/s12-manifest/tools/amend_decisions.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +"""Apply the 2026-10-12 manifest decisions (plan, user decision 6) to three deck edit files. + +Spring: Appendix D presents the hand-rolled saga orchestrator of examples/17-sagas/spring-boot; Spring +Statemachine appears only as archived upstream. Go: valet key and claim-check follow examples/12-security/go +(golang-jwt ticket); minio-go is gone. C++: compendium and third-party library references verified upstream +(table in Designing-Cloud-Native-APIs-Cpp.md). + +Idempotent: ops carrying "_amend" are removed and re-inserted; superseded ops are removed by their find text. +New ops go before the first agenda / Appendix R op (cat "new"), so they see the text after every voice edit. +C++ replace ops find their sldId in the post-edit text copy (M/verify/.md) by the find string. +Run with any python3; then verify_all.py. +""" +import json +import re +import sys +from pathlib import Path + +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +EDITS = REPO / "lgtm-presentation" / "edits" +M = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent +TAG = "S12 decision 6" +CS = "patterncatalyst.github.io/cpp-statelessness/docs/" + + +def load(deck): + return json.loads((EDITS / f"{deck}.json").read_text()) + + +def save(deck, data): + (EDITS / f"{deck}.json").write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n") + + +def insert(deck, new_ops, drop_finds=(), repl_patch=None): + data = load(deck) + ops = [o for o in data["ops"] if not o.get("_amend")] + ops = [o for o in ops if not (o.get("op") == "replace" and o.get("find") in drop_finds)] + for o in ops: + if repl_patch and o.get("op") == "replace" and o.get("find") in repl_patch: + o["repl"] = repl_patch[o["find"]] + o["_amended"] = TAG + at = next(i for i, o in enumerate(ops) if o.get("cat") == "new") + for o in new_ops: + o["_amend"] = TAG + data["ops"] = ops[:at] + new_ops + ops[at:] + save(deck, data) + return len(new_ops) + + +def rep(sid, find, repl, cat, scope="both", expect=1, why=None): + o = {"op": "replace", "scope": scope, "find": find, "repl": repl, "expect": expect, "sldIds": [sid], "cat": cat} + if why: + o["_why"] = why + return o + + +def sid_of(md_lines, find): + sid, hits = None, set() + for line in md_lines: + m = re.match(r"^## \[\d+\].*\(sldId (\d+)\)$", line) + if m: + sid = int(m.group(1)) + elif find in line: + hits.add(sid) + if len(hits) != 1: + raise SystemExit(f"find string not unique to one slide ({hits}): {find[:80]}") + return hits.pop() + + +# ---------------------------------------------------------------- Spring +SPRING = "Designing-Cloud-Native-APIs-Spring" +SM_FACT = ("Spring Statemachine is archived upstream: github.com/spring-projects/spring-statemachine is a " + "read-only archive whose README says the project is no longer maintained (last release 4.0.2, " + "June 2026), and the spring.io announcement of 21 April 2025 made 4.0.x the last open-source line, " + "with later releases for commercial Tanzu Spring customers only.") +spring_code = [ + "// SagaService.java excerpt (examples/17-sagas); helper names stand for its JdbcClient calls", + "private void advance(String sagaId) {", + " String[] statusHolder = new String[1];", + " txTemplate.executeWithoutResult(status -> { // lock, run one step, commit", + " var row = lockRow(sagaId); // SELECT ... FROM sagas WHERE id = :id FOR UPDATE", + " if (row == null || !\"RUNNING\".equals(row[0])) return; // finished: no-op", + " int stepIndex = (int) row[1]; var context = fromJson((String) row[2]);", + " if (stepIndex >= STEPS.size()) { markStatus(sagaId, \"COMPLETED\"); return; }", + " SagaStep step = STEPS.get(stepIndex); // (name, compensation)", + " try {", + " Map result = stepExecutor.execute(step.name(), context);", + " context.put(step.name(), result); // output flows forward", + " logStep(sagaId, step.name(), \"execute\", result);", + " saveProgress(sagaId, stepIndex + 1, context); // step_index + JSONB context", + " statusHolder[0] = \"RUNNING\";", + " } catch (Exception e) {", + " markStatus(sagaId, \"COMPENSATING\"); statusHolder[0] = \"COMPENSATING\";", + " }", + " });", + " if (\"RUNNING\".equals(statusHolder[0])) advance(sagaId); // next step, new transaction", + " else if (\"COMPENSATING\".equals(statusHolder[0])) compensate(sagaId);", + "}", +] +spring_ops = [ + # 46 · section 07 divider notes + rep(301, "For orchestration in this stack we feature Temporal (Java SDK + Spring Boot) for durable execution as " + "the primary approach, with Spring State Machine as a lighter-weight alternative for state-driven flows. " + "Appendix D shows both, side by side, against the saga from the data section.", + "For orchestration, Appendix D sets two approaches against the saga from the data section: the hand-rolled, " + "database-backed orchestrator that Example 17 runs, and Temporal (Java SDK + Spring Boot) for durable " + "execution when the saga outgrows it.", "stack", "notes"), + # 94 · references bullet + rep(349, "Temporal Java SDK docs (docs.temporal.io/dev-guide/java), Spring State Machine, Spring for GraphQL", + "Temporal Java SDK docs (docs.temporal.io/dev-guide/java), Spring for GraphQL", "stack"), + # 121 · figure and notes + {"op": "replace_picture", "sldId": 376, "shape_id": 1333, "png": "lgtm-presentation/edits/png/17-saga-state.png", + "cat": "stack", "_slide": 121, "_why": "figure named 'Temporal / State Machine'; the site figure 17-saga-state"}, + rep(376, "Spring State Machine is a lighter in-process alternative we'll show side by side.", + "Example 17 runs the first option, and this appendix sets its code beside Temporal.", "stack", "notes"), + # 125 · notes + rep(380, "Temporal does the same automatically through its workflow input/output passing; Spring State Machine " + "does it via the extended state context.", + "Temporal does the same through its workflow input/output passing, and Example 17 stores each step's " + "result in the saga's JSONB context under the step name.", "stack", "notes"), + # 126 · two durable paths + {"op": "set_text", "sldId": 381, "shape_id": 1391, "paragraphs": ["Two ways to keep a saga durable"], + "cat": "stack", "_slide": 126}, + {"op": "set_text", "sldId": 381, "shape_id": 1392, "cat": "stack", "_slide": 126, "paragraphs": [ + "Two open-source paths, both shown in this appendix with a diagram and code.", + "The hand-rolled orchestrator (Example 17): one sagas row per instance (status, step_index, JSONB context) " + "and a saga_log, written with JdbcClient inside a TransactionTemplate. No extra dependency, no extra cluster.", + "Temporal: a durable execution engine (Temporal Server MIT, Java SDK Apache 2.0). Workflows and activities " + "are plain Java; the Server persists every event and replays it on recovery. Most power, a separate " + "cluster to run.", + "Decision rule: keep the hand-rolled orchestrator while the saga is short and lives in one service; reach " + "for Temporal when it is long, shared across services, or needs timers, retries and child workflows as " + "first-class features.", + "Spring Statemachine is not on the list: its repository is archived upstream and 4.0.x was its last " + "open-source line.", + ]}, + {"op": "set_notes", "sldId": 381, "cat": "stack", "_slide": 126, "paragraphs": [ + "Frame the choice as owning the transitions against buying durable execution. Both are open source and both " + "survive a pod crash mid-flow.", + "The hand-rolled orchestrator is what Example 17 runs: a sagas table with one row per saga holding status, " + "step_index and a JSONB context, and a saga_log table that records every execute, failed and compensate " + "action. Each step runs in its own transaction that locks the row, runs the step and commits the new " + "context and step index together. On startup the service resumes every saga still marked RUNNING. " + "Nothing new to operate.", + "Temporal: founded by the original Cadence team, MIT-licensed Server and Apache-2.0 Java SDK, actively " + "maintained. It adds durable execution, deterministic replay, automatic retries with backoff, timers, a " + "Saga compensation utility, child workflows, and a CLI and UI to inspect runs. The cost is operating a " + "Temporal cluster, which runs on your own Kubernetes with no SaaS required.", + SM_FACT + " That conflicts with the open-source-only rule, so the deck does not offer it.", + "The decision rule to leave with the audience: the question is how much engine this saga needs.", + ]}, + # 129 · the orchestrator picture + {"op": "set_text", "sldId": 384, "shape_id": 1425, + "paragraphs": ["The hand-rolled orchestrator: one row, one log, no extra cluster"], "cat": "stack", "_slide": 129}, + {"op": "replace_picture", "sldId": 384, "shape_id": 1426, + "png": "lgtm-presentation/edits/png/17-sagas-architecture.png", "cat": "stack", "_slide": 129, + "_why": "Spring Statemachine figure replaced by examples/17-sagas/architecture.svg"}, + {"op": "set_text", "sldId": 384, "shape_id": 1427, "cat": "stack", "_slide": 129, "paragraphs": [ + "A sagas row locked FOR UPDATE, a saga_log of every action, and a resume pass at startup: Example 17."]}, + {"op": "set_notes", "sldId": 384, "cat": "stack", "_slide": 129, "paragraphs": [ + "Walk the picture: the saga-orchestrator service of Example 17. POST /sagas creates a sagas row (status " + "RUNNING, step_index 0, the order in a JSONB context) and runs the steps in order: charge_payment, " + "reserve_stock, book_shipping. Postgres holds two tables: sagas, one row per saga, and saga_log, one row " + "per execute, failed or compensate action.", + "Each step runs in its own transaction. The orchestrator locks the row with SELECT ... FOR UPDATE, returns " + "at once unless the status is RUNNING (a repeated call is a no-op), runs the step, writes its output into " + "the context and the log, and moves step_index forward, all in one commit. When the index passes the last " + "step the status becomes COMPLETED.", + "When a step throws, the same transaction logs the failure and sets COMPENSATING; the compensation pass " + "then walks the completed steps in reverse (release_stock, then refund_payment) and ends in COMPENSATED. " + "The dashed path in the picture is that unwind.", + "Recovery is a StartupRunner: on boot it advances every saga still marked RUNNING. Everything runs " + "in-process in the Spring Boot app, on the Postgres you already operate.", + ]}, + # 130 · the code + {"op": "set_text", "sldId": 385, "shape_id": 1436, + "paragraphs": ["The hand-rolled orchestrator: one step per transaction"], "cat": "code", "_slide": 130}, + {"op": "set_text", "sldId": 385, "shape_id": 1438, "paragraphs": ["java · Spring Boot 4 + JdbcClient"], + "cat": "code", "_slide": 130}, + {"op": "set_code", "sldId": 385, "shape_id": 1439, "lines": spring_code, "cat": "code", "_slide": 130, + "_src": "examples/17-sagas/spring-boot/saga-orchestrator/src/main/java/com/example/saga/service/SagaService.java"}, + {"op": "set_text", "sldId": 385, "shape_id": 1440, "cat": "code", "_slide": 130, "paragraphs": [ + "The row lock, the step's output and the new state commit together; a failed step hands off to " + "compensate()."]}, + {"op": "set_notes", "sldId": 385, "cat": "code", "_slide": 130, "paragraphs": [ + "The excerpt is advance() from SagaService in Example 17, condensed where it talks to Postgres: lockRow, " + "saveProgress, logStep and markStatus stand for its JdbcClient calls, and the failed-step log row is " + "left out.", + "STEPS is a list of SagaStep records pairing each forward step with its compensation (charge_payment with " + "refund_payment, reserve_stock with release_stock, book_shipping with cancel_shipping), so the unwind " + "order is data. advance() opens a " + "TransactionTemplate, locks the saga row with SELECT ... FOR UPDATE and returns at once unless the status " + "is RUNNING, so a redelivered request or a second worker finds a finished saga and does nothing.", + "Inside the transaction the step runs, its result goes into the JSONB context under the step's name (this " + "is how book_shipping can read what reserve_stock produced), the log row is written, and step_index moves " + "forward. The lock, the log and the new state commit together, the same rule as the outbox.", + "After the commit, advance() calls itself for the next step in a new transaction. A crash rolls back at " + "most the step in flight, which runs again on resume, so every step must be idempotent. A thrown " + "exception marks the saga COMPENSATING, and compensate() runs the completed steps' compensations in " + "reverse before setting COMPENSATED.", + "Compared with Temporal: no extra server, but no built-in retries, timers or replay; the retry and resume " + "logic is yours. The step executor in the example simulates the three services.", + ]}, +] +spring_drop = { + "Temporal: founded by the original Cadence team, MIT-licensed Server and Apache-2.0 Java SDK, very actively " + "maintained (recent Java SDK release 1.35 in April 2026).", + "The top row IS the saga as a state machine: CREATED → PAID → RESERVED → SHIPPED → DONE, with each " + "transition triggered by an event (PAY_OK, RESERVE_OK, SHIP_OK, DELIVERED).", + "Below: three pieces of machinery.", + "Spring State Machine — Spring's own framework (Apache 2.0).", + "The whole thing runs in-process inside your Spring Boot app — no extra cluster, Apache-2.0 open source.", +} +spring_patch = { + "If you want the managed alternative, the next few slides show Temporal and Spring State Machine — same " + "problem, different point on the build-vs-buy spectrum.": + "Temporal, in this appendix, solves the same problem at a different point on the build-vs-buy spectrum; " + "Example 17 runs the hand-rolled version with JdbcClient.", +} + +# ---------------------------------------------------------------- Go +GO = "Designing-Cloud-Native-APIs-Go" +go_valet = [ + "// main.go (excerpt), examples/12-security/go: the valet key is an HS256 JWT (golang-jwt v5)", + "type valetClaims struct {", + " Resource string `json:\"res\"`", + " Operation string `json:\"op\"`", + " jwt.RegisteredClaims", + "}", + "func mintValet(resource, operation string) map[string]any {", + " expires := time.Now().Add(5 * time.Minute) // time-bound", + " token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, valetClaims{", + " Resource: resource, Operation: operation, // one object, one operation", + " RegisteredClaims: jwt.RegisteredClaims{Issuer: \"order-service\",", + " ExpiresAt: jwt.NewNumericDate(expires), IssuedAt: jwt.NewNumericDate(time.Now())},", + " }).SignedString([]byte(valetSecret))", + " if err != nil { slog.Error(\"sign valet key\", \"err\", err) }", + " return map[string]any{\"resource\": resource, \"operation\": operation,", + " \"expires\": expires.Unix(), \"token\": token}", + "}", + "// verifyValet: HS256 only, exp required, issuer checked; then res, op and exp must match", + "parsed, err := jwt.ParseWithClaims(token, &claims,", + " func(*jwt.Token) (any, error) { return []byte(valetSecret), nil },", + " jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),", + " jwt.WithExpirationRequired(), jwt.WithIssuer(\"order-service\"))", +] +go_claim = [ + "// claim-check sketch: store is any encrypted object store; the token is the valet-key JWT", + "func publishWithClaim(ctx context.Context, cl *kgo.Client, orderID string, body []byte) error {", + " key := \"payloads/\" + orderID", + " if err := store.Put(ctx, key, body, true); err != nil { return err } // encrypted, ACL'd", + " claim, _ := json.Marshal(map[string]any{", + " \"order_id\": orderID,", + " \"uri\": \"https://objects.internal/\" + key,", + " \"token\": mintValet(key, \"GET\")[\"token\"], // GET only, five minutes", + " })", + " return cl.ProduceSync(ctx, // only the claim goes on the topic", + " &kgo.Record{Topic: \"order.documents\", Key: []byte(orderID), Value: claim}).FirstErr()", + "}", + "func onClaim(ctx context.Context, r *kgo.Record) error {", + " var c Claim", + " if err := json.Unmarshal(r.Value, &c); err != nil { return err }", + " body, err := store.Get(ctx, c.URI, c.Token) // the store verifies the ticket", + " if err != nil { return err }", + " return handle(ctx, body) // PII never touched the topic", + "}", +] +go_ops = [ + {"op": "set_text", "sldId": 333, "shape_id": 866, "paragraphs": ["Valet key — scoped, time-bound, op-restricted"], + "cat": "stack", "_slide": 78}, + {"op": "set_text", "sldId": 333, "shape_id": 868, "paragraphs": ["go · net/http + golang-jwt v5"], + "cat": "stack", "_slide": 78}, + {"op": "set_code", "sldId": 333, "shape_id": 869, "lines": go_valet, "cat": "code", "_slide": 78, + "_src": "examples/12-security/go/cmd/server/main.go (valetClaims, mintValet, verifyValet)"}, + {"op": "set_notes", "sldId": 333, "cat": "stack", "_slide": 78, "paragraphs": [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; " + "after that it is worthless. In Example 12 the ticket is an HS256 JWT signed with golang-jwt v5: the res " + "and op claims carry the scope, exp the expiry, iss the issuer. POST /valet-key returns the resource, the " + "operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) parses with " + "WithValidMethods so only HS256 is accepted (no alg=none, no algorithm swap), requires exp, checks the " + "issuer, then compares res, op and exp with the request. Any mismatch is a 403.", + "The application stays out of the data path: the client presents the ticket to the storage side directly. " + "Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the " + "object key and the expiry signed into the URL. A user-scoped resource path " + "also lets the store's policy restrict each user to their own prefix as defence in depth.", + ]}, + {"op": "set_text", "sldId": 342, "shape_id": 968, "paragraphs": ["go · franz-go + golang-jwt v5"], + "cat": "stack", "_slide": 87}, + {"op": "set_code", "sldId": 342, "shape_id": 969, "lines": go_claim, "cat": "code", "_slide": 87, + "_src": "_docs/12-security.md claim-check Go tab; token from examples/12-security/go mintValet"}, + {"op": "set_notes", "sldId": 342, "cat": "stack", "_slide": 87, "paragraphs": [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, " + "mints a ticket for that key (the golang-jwt valet key of Example 12: GET only, five minutes), and " + "publishes only a small claim {order_id, uri, token} to order.documents with franz-go's ProduceSync. The " + "PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then lets the offset " + "commit (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before " + "it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag: five minutes fits the demo, not a backlog. " + "No runnable example covers claim-check; store stands for any encrypted object store. Set a " + "lifecycle rule that expires claims after the retention period, so a replayed message points at nothing.", + ]}, +] + +# ---------------------------------------------------------------- C++ +CPP = "Designing-Cloud-Native-APIs-Cpp" +cpp_pairs = [ + # (find, repl, cat) + ("scaffold C++ stubs via openapi-generator-cli (cpp-pistache-server template is most mature).", + "scaffold C++ stubs with OpenAPI Generator (stable C++ server generators: cpp-pistache-server, " + "cpp-httplib-server; none targets Drogon).", "stack"), + ("The cpp-stateless compendium's 'budget-aware' rule applies — never retry past the inbound deadline.", + f"Cap the retry budget at the inbound deadline and never retry past it (cpp-statelessness Doc 07, " + f"{CS}07-state-externalization/).", "stack"), + ("Dependencies injected by reference (OrdersClient&, InventoryClient&) — same DI pattern as the cpp-stateless " + "composition root.", + f"Dependencies are injected by reference (OrdersClient&, InventoryClient&) from a composition root in main(), " + f"as in cpp-statelessness Doc 04 ({CS}04-process-scoped-state/).", "stack"), + ("The cpp-stateless compendium's ScopedConnection pattern (RAII checkout, invalidate on broken) applies per " + "service.", + f"Each service checks connections out through an RAII guard that invalidates a broken connection instead of " + f"returning it to the pool (ScopedConn in cpp-statelessness Doc 07, {CS}07-state-externalization/).", "stack"), + ("Note the cpp-stateless idioms: PgTxn is an RAII transaction object (commit on success, rollback on " + "destruction); the consumer is process-scoped;", + "PgTxn stands for a small RAII wrapper over a libpq transaction (BEGIN on construction, ROLLBACK in the " + "destructor unless commit() ran); Example 04 writes the same BEGIN, COMMIT and ROLLBACK with libpq calls " + "directly. The consumer is process-scoped;", "stack"), + ("cpp-httplib (header-only, MIT) is the lean HTTP client we picked back in §2; libcurl is the heavier " + "alternative if you need richer auth or HTTP/2 multiplexing.", + "cpp-httplib (header-only, MIT, github.com/yhirose/cpp-httplib) is the lean HTTP client; libcurl (curl " + "license, curl.se) is the heavier alternative when you need richer auth or HTTP/2 multiplexing.", "stack"), + ("the OpenTelemetry C++ SDK (BSL/Apache 2.0, well-maintained)", + "the OpenTelemetry C++ SDK (opentelemetry-cpp, Apache 2.0, actively released)", "stack"), + ("The cpp-stateless deck has the configuration in detail.", + f"cpp-statelessness Doc 04 shows the BatchSpanProcessor setup ({CS}04-process-scoped-state/).", "stack"), + ("the OpenTelemetry log appender bridges into the OTel pipeline. Never spdlog::basic_logger_mt — that's a file " + "sink which dies on read-only rootfs (the cpp-stateless §11 'logs to stdout, not a file' rule).", + "the spdlog sink in opentelemetry-cpp-contrib bridges into the OTel pipeline. Never spdlog::basic_logger_mt: " + "that's a file sink which dies on a read-only root filesystem (the 'logs to stdout, not a file' rule of " + f"cpp-statelessness Doc 08, {CS}08-ephemeral-filesystem/).", "stack"), + ("For C++: opentelemetry-cpp-contrib has gRPC, libcurl, MySQL client instrumentations; for Drogon and " + "librdkafka the instrumentation is manual middleware, not auto.", + "For C++: opentelemetry-cpp-contrib ships log bridges (spdlog, glog, Boost.Log, log4cxx) and nginx and " + "Apache httpd modules; gRPC, Drogon, librdkafka and libpq spans are written by hand.", "stack"), + ("the C++ ecosystem covers gRPC, libcurl, MySQL well, but Drogon, librdkafka, libpq, and most C++ frameworks " + "require you to write the boundary spans yourself.", + "for C++, opentelemetry-cpp-contrib covers logging bridges and the nginx and httpd modules, while gRPC, " + "Drogon, librdkafka, libpq and most C++ frameworks need the boundary spans written by hand (opentelemetry-cpp's " + "examples/grpc shows the context propagation).", "stack"), + ("Outbound calls (gRPC ClientContext, Kafka producer headers) pick up the trace_id from the active span " + "automatically via the OTel context propagation API — you don't manually propagate, but you do need to ensure " + "your gRPC stubs are wrapped with the OTel interceptor (opentelemetry-cpp-contrib provides this for gRPC).", + "Outbound calls do not pick up the context on their own: inject it with the W3C trace-context propagator into " + "a carrier (the gRPC ClientContext metadata, the Kafka message headers). opentelemetry-cpp's examples/grpc " + "shows the carrier; opentelemetry-cpp-contrib has no gRPC interceptor.", "stack"), + ("The cpp-stateless §6 covers PMR per-request arenas which bound memory per request; bulkheads bound " + "CONCURRENCY per tenant.", + f"PMR per-request arenas bound memory per request (cpp-statelessness Doc 03, {CS}03-pmr/); bulkheads bound " + "concurrency per tenant.", "stack"), + ("The cpp-stateless compendium covers the per-service in-process state patterns (PMR arenas, RAII connection " + "pools);", + "cpp-statelessness covers the per-service in-process state patterns, PMR arenas and RAII connection pools " + f"({CS}04-process-scoped-state/);", "stack"), + ("and the cpp-stateless / cpp-container compendiums that accompany this deck.", + "and two Apache-2.0 companion projects: cpp-statelessness (patterncatalyst.github.io/cpp-statelessness/) on " + "stateless C++ services in containers, and the C++ container optimization tutorial " + "(patterncatalyst.github.io/cpp-container-optimization-tutorial/).", "stack"), + ("The cpp-stateless compendium's port/adapter pattern lets you write the business logic once and adapt to all " + "three boundaries — but in practice most C++ services pick one or two protocols and commit.", + "Ports and adapters (Appendix F, Example 19) let you write the business logic once and adapt it to all three " + "boundaries, though in practice most C++ services pick one or two protocols and commit.", "stack"), + ("The cpp-stateless port/adapter pattern keeps the business logic clean of any protocol concerns;", + "The ports-and-adapters structure of Appendix F keeps the business logic clean of any protocol concerns;", + "stack"), + ("When you upgrade protobuf itself (e.g., 3.21 → 4.0), every consumer rebuilds against the new stubs " + "simultaneously. The cpp-stateless §3 Conan lockfile pinning is exactly the mechanism that keeps this " + "manageable.", + "When you upgrade protobuf itself to a new major version, every consumer rebuilds against the new stubs at " + "the same time. A committed Conan lockfile (conan.lock, as every C++ example here has) is the mechanism that " + f"keeps this manageable; cpp-statelessness Doc 11 shows the profiles around it ({CS}11-build-tooling/).", + "version"), + ("for hot paths you can use protobuf arena allocators (Arena::CreateMessage) to amortise that cost. " + "The cpp-stateless §5 PMR pattern integrates cleanly with protobuf arenas.", + "for hot paths a protobuf arena (google::protobuf::Arena::Create(&arena); CreateMessage is no " + "longer public in protobuf 6.33) amortises that cost. Keep the two allocators apart: protobuf messages on " + "their arena or the default allocator, handler internals on the PMR request arena, with the boundary explicit " + f"in code review (cpp-statelessness Doc 03, {CS}03-pmr/).", "stack"), + ("Reuse the cpp-stateless §6 PMR arenas — per-connection allocations clear on disconnect.", + "Give each connection its own PMR arena (the per-request arena of cpp-statelessness Doc 03, applied to a " + "connection's lifetime) so its allocations are released on disconnect.", "stack"), + ("Per-connection state in PMR arenas (cpp-stateless §6):", + "Per-connection state in a PMR arena (the cpp-statelessness Doc 03 pattern):", "stack"), + ("If you need in-process retries (a sidecar isn't deployed), library options include hedge-cpp or just " + "std::this_thread::sleep_for + backoff math.", + "If you need in-process retries (a sidecar isn't deployed), no widely adopted C++ retry library exists: write " + "the loop with capped exponential backoff and jitter, as Example 26 does (retry_with_backoff in edge-service).", + "stack"), + ("C++ note: cpp-stateless §4 (composition root + adapter pattern) is exactly hexagonal.", + "C++ note: a composition root in main() wiring adapters into ports (Example 19) is hexagonal architecture.", + "stack"), + ("This is the cpp-stateless §4 idiom.", + f"cpp-statelessness Doc 04 describes the same composition-root idiom ({CS}04-process-scoped-state/).", + "stack"), + ("The cpp-stateless §6 PMR-arena pattern is what makes this affordable — you don't pay heap-allocation cost per " + "aggregate, you pay arena cost per request.", + "A PMR request arena makes this affordable: you pay arena cost per request instead of a heap allocation per " + f"aggregate (cpp-statelessness Doc 03, {CS}03-pmr/).", "stack"), + ("Hexagonal ports & adapters → cpp-stateless composition root + adapter pattern.", + "Hexagonal ports & adapters → a composition root in main() plus adapters (Example 19).", "stack"), + ("Conan + lockfile (cpp-stateless §3) pins versions;", + "Conan + a committed conan.lock pins versions;", "stack"), + ("The cpp-stateless §9 covers this in detail. This appendix is the architectural framing; the cpp-stateless " + "deck is the implementation reference. Together they cover the whole discipline.", + "This appendix is the architectural framing; Example 21 is the runnable C++ version, and cpp-statelessness " + f"Doc 09 covers the readiness and signal-handling side ({CS}09-health-checks/).", "stack"), + ("The cpp-stateless §9 covers a complete sequence: stop accepting → drain HTTP → stop Kafka poll → commit " + "offsets → close DB pool → exit.", + "A complete sequence: stop accepting → drain HTTP → stop Kafka poll → commit offsets → close DB pool → exit " + f"(cpp-statelessness Doc 09 walks through the signal-handler side, {CS}09-health-checks/).", "stack"), + ("The cpp-stateless §11 (and the cpp-statelessness compendium more broadly) addresses this directly:", + f"cpp-statelessness addresses this directly ({CS}01-deployment-posture/):", "stack"), + ("The script lives in a ConfigMap or a Git-versioned file; the C++ service watches for changes and re-loads.", + "The script lives in a ConfigMap or a Git-versioned file (ROUTING_RULES_FILE in Example 22); the service " + "compiles it once at startup, so a bad script stops the rollout instead of reaching traffic.", "stack"), + ("Hot-reload story: the cpp-stateless §7 watcher pattern picks up file changes and atomically swaps the loaded " + "Lua state on a per-request boundary. No restart.", + "Example 22 changes the rule inputs (thresholds, topics) live through PUT /rules; a new script version ships " + "as a rollout. A file watcher that swaps in a freshly compiled Lua state between requests is an extension, " + "not part of the example.", "stack"), + ("libpq via PgConn from the cpp-stateless §5 pool.", + "libpq through a PgConn checked out from a process-scoped pool (ScopedConn in cpp-statelessness Doc 07, " + f"{CS}07-state-externalization/).", "stack"), + ("The cpp-stateless §9 graceful-shutdown discipline matters more here than anywhere:", + "Graceful shutdown (Appendix H) matters more here than anywhere:", "stack"), + ("The cpp-stateless idioms (RAII unwinds, std::stop_token cooperative shutdown) are what make recovery clean.", + "RAII unwinding and cooperative cancellation with std::stop_token make recovery clean (cpp-statelessness " + "Docs 02 and 05, patterncatalyst.github.io/cpp-statelessness/).", "stack"), + ("C++ ecosystem: Hystrix-CPP and breaker-cpp exist but neither dominates.", + "C++ has no widely adopted circuit-breaker library; Example 26 hand-rolls one (CircuitBreaker in " + "edge-service).", "stack"), + ("Unleash is a capable open-source alternative backend behind the same OpenFeature SDK.", + "Unleash (AGPL-3.0) is an open-source alternative backend; OpenFeature's C++ contrib ships only a flagd " + "provider, so a C++ service would call Unleash through its own API.", "stack"), +] + + +def main(): + # sldIds come from the previous run's ops when present (the verify text then already holds the rewrite) + known = {o["find"]: o["sldIds"][0] for o in load(CPP)["ops"] if o.get("_amend") and o.get("op") == "replace"} + md = (M / "verify" / f"{CPP}.md").read_text().split("\n") + cpp_ops = [rep(known.get(f) or sid_of(md, f), f, r, c, why="C++ reference verification (Cpp manifest table)") + for f, r, c in cpp_pairs] + n = { + SPRING: insert(SPRING, spring_ops, spring_drop, spring_patch), + GO: insert(GO, go_ops), + CPP: insert(CPP, cpp_ops), + } + print(json.dumps(n)) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/amend_vendor.py b/_plans/s12-manifest/tools/amend_vendor.py new file mode 100644 index 00000000..e311ce84 --- /dev/null +++ b/_plans/s12-manifest/tools/amend_vendor.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +"""Apply user decision 7 (plan, 2026-10-12): vendor-neutral language decks. + +- No Appendix S (OpenShift Local) slides: the clone_slide / set_* ops of S are dropped, the agenda says + "Appendix R" only, and the Appendix R notes no longer point at OpenShift Local. +- No AWS, Azure, Google Cloud or other managed-service framing: valet key and claim check follow + examples/12-security/ (a ticket the service signs and verifies itself; a generic object store), + and every other vendor mention is rewritten vendor-neutrally. +- OpenShift mentions outside Appendix S are rewritten to generic Kubernetes. + +Run order: amend_decisions.py, then this script (it removes its own ops, tagged "_vendor", and re-inserts +them just before the first agenda / Appendix R op, so they see the text after every earlier op). +Replace ops find their slides and counts in the pre-decision-7 text copies, BASE/.md (the +verify_all.py output of the edit files at 39a5b1e), default M/work/vendor-base. + + python3 amend_vendor.py [M] +""" +import json +import re +import sys +from pathlib import Path + +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +EDITS = REPO / "lgtm-presentation" / "edits" +M = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent +BASE = M / "work" / "vendor-base" +TAG = "S12 decision 7" +P = "lgtm-presentation/edits/png/" +DECKS = ["Python", "Quarkus", "Spring", "DotNet", "Go", "Cpp"] + +# ------------------------------------------------------------------ text pairs (all six decks) +# (find, repl, cat); a pair may match in any number of decks, but must match somewhere. +GLOBAL = [ + # overview / title notes: no Appendix S + (" and run Example 05 on minikube and OpenShift Local.", " and run Example 05 on minikube.", "new"), + ("Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, " + "failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, " + "failure modes, feature flags, API testing, minikube) are there to jump to.", "new"), + # event-driven tools landscape + ("'I need to get data from SFTP to Kafka to S3 with these transforms in the middle'", + "'I need to get data from SFTP to Kafka to an object store with these transforms in the middle'", "stack"), + # 4C's + ("KMS for keys, CloudTrail / audit logging, region", "KMS for keys, audit logging, region", "stack"), + ("'someone left an S3 bucket open'", "'someone left a storage bucket open to the internet'", "stack"), + # valet key concept + ("Cons: requires the storage service to validate tokens (S3, Azure Blob, GCS all do); ", + "Cons: requires the storage side to validate tokens; ", "stack"), + ("The canonical implementations: S3 pre-signed URLs (15-minute upload tokens for a specific bucket+key+method), " + "Azure SAS tokens (Shared Access Signatures with per-resource scoping), GCS signed URLs, AWS STS AssumeRole " + "with a tight scoping policy.", + "Every implementation has the same shape: sign the operation, the object key and the expiry with a key the " + "storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an " + "HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same " + "shape.", "stack"), + # strangler fig (auth) + ("validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD)", "validates JWTs from a modern IdP (Keycloak, Dex)", + "stack"), + # zero-trust + ("Edge (IAP): Google IAP, Cloudflare Access, Pomerium, AWS Verified Access — ", + "Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — ", "stack"), + ("Pomerium is open source; Google IAP / Cloudflare Access / AWS Verified Access are managed services.", + "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", "stack"), + ("Pomerium open source; Google IAP / Cloudflare Access / AWS Verified Access are managed.", + "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", "stack"), + ("they authenticate against your IdP (Keycloak, Auth0, Azure AD, Google Workspace)", + "they authenticate against your IdP (Keycloak, or Dex federating an existing directory)", "stack"), + ("an Identity-Aware Proxy (Google IAP, Cloudflare Access, Pomerium, AWS Verified Access)", + "an Identity-Aware Proxy (Pomerium or OAuth2 Proxy)", "stack"), + # claim check concept + ("into a secure object store (S3, Azure Blob, GCS) and publishes", "into a secure object store and publishes", + "stack"), + # choosing the right pattern (notes) + ("Valet key requires storage-side validation — S3, Azure Blob, GCS all support it natively, so the cost is " + "usually just understanding the SDK.", + "Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the " + "signature, the operation and the expiry.", "stack"), + # glossary + ("; SAS (Shared Access Signature, Azure's scoped token)", "", "stack"), + ("SAS is Azure's valet-key token; ", "", "stack"), + # Appendix I gateways + ("(Kong, Apigee, Istio gateway, ", "(Kong, Istio gateway, ", "stack"), + # Appendix M references + ("AWS Builders' Library (timeouts, retries, backoff with jitter)", + "the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling)", "stack"), + ("the AWS Builders' Library has excellent practical articles on timeouts and backoff-with-jitter;", + "the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a " + "protocol;", "stack"), +] + +# deck-only pairs +DECK = { + "Cpp": [ + ("the service hands the client a pre-signed PUT URL with a short expiry and a scoped key", + "the service hands the client a signed ticket with a short expiry, scoped to one key and one operation", + "stack"), + ("the C++ service mints the URL in microseconds, the upload happens directly to S3 or MinIO,", + "the C++ service mints the ticket in microseconds, the upload goes directly to the object store,", "stack"), + ("uri: 's3://claims/abc-123'", "uri: 'https://objects.internal/claims/abc-123'", "stack"), + ("where you can use bucket policies, KMS keys, audit logs", "where you can use per-object access policies, " + "KMS keys, audit logs", "stack"), + ("; AWS SDK for C++ enables valet-key and claim-check directly.", + "; Example 12 mints valet-key tickets with jwt-cpp, and a claim check carries the same ticket.", "stack"), + ("(Drogon, gRPC, librdkafka, OpenTelemetry C++, AWS SDK for C++)", + "(Drogon, gRPC, librdkafka, OpenTelemetry C++, jwt-cpp)", "stack"), + ], + "DotNet": [ + ("Runs on Kubernetes as a regular ASP.NET Core Pod — no Azure App Service, no Azure SignalR Service.", + "Runs on Kubernetes as a regular ASP.NET Core Pod, with no managed hosting and no managed SignalR service.", + "stack"), + ("What we explicitly avoid: Azure SignalR Service (cloud-coupled, paid), Azure App Service hosting " + "(Kubernetes is the deployment target), Azure Static Web Apps (Blazor WASM is hosted as static files inside " + "an nginx Pod or the ASP.NET Core process itself).", + "What it avoids: a managed SignalR service and managed app hosting (Kubernetes is the deployment target); " + "Blazor WASM is hosted as static files inside an nginx Pod or the ASP.NET Core process itself.", "stack"), + ("Azure SignalR Service is not used: it is the cloud-coupled option, and the open-source Redis backplane is " + "what runs on your own Kubernetes.", + "A managed, cloud-hosted SignalR service is not used: the open-source Redis backplane is what runs on your " + "own Kubernetes.", "stack"), + ("and neither needs Azure SignalR Service.", "and neither needs a managed SignalR service.", "stack"), + ("both work without ANY Azure service — no Azure SignalR Service, no Azure App Service, no Azure Static Web " + "Apps. Open Redis for the backplane is all you need.", + "both work without any managed cloud service. Open-source Redis for the backplane is all you need.", + "stack"), + ("The bold final line is the one to leave up: nothing here requires Azure.", + "The bold final line is the one to leave up: nothing here requires a managed cloud service.", "stack"), + ("If your team is moving off Azure App Service to Kubernetes, you don't lose Blazor — you just stop paying " + "for Azure SignalR Service and run Redis yourself, with the same throughput characteristics.", + "A team moving from managed app hosting to Kubernetes keeps Blazor: it runs Redis itself as the backplane " + "instead of a managed SignalR service.", "stack"), + ("with no commercial-only dependency and no Azure-only service.", + "with no commercial-only dependency and no cloud-only service.", "stack"), + ("nothing requires Azure or any other cloud-specific service.", "nothing requires a cloud-specific service.", + "stack"), + ("(open-source, Apache 2.0, not Confluent Cloud)", "(open-source, Apache 2.0)", "stack"), + ("UBI images are freely redistributable, based on RHEL 9, and supported by Red Hat on OpenShift.", + "UBI images are freely redistributable, based on RHEL 9, and run on any Kubernetes cluster.", "stack"), + ("UBI images are freely redistributable, supported by Red Hat on OpenShift, and carry", + "UBI images are freely redistributable, run on any Kubernetes cluster, and carry", "stack"), + ("freely redistributable, commercially supported on OpenShift.", + "freely redistributable, and run on any Kubernetes cluster.", "stack"), + ("freely redistributable and supported on OpenShift;", "freely redistributable and run on any Kubernetes " + "cluster;", "stack"), + ], +} + +# strings inside the agenda and Appendix R ops (cat "new"), patched in place +NEW_PATCH = [ + ("Appendices R–S · Example 05 on minikube and OpenShift Local", "Appendix R · Example 05 on minikube"), + ("Appendices P–S · .NET on Linux, Blazor, minikube, OpenShift Local", + "Appendices P–R · .NET on Linux, Blazor, minikube"), + ("Appendices R and S run Example 05 on minikube and on OpenShift Local with the same verify.sh as compose.", + "Appendix R runs Example 05 on minikube with the same verify.sh as compose."), + (" OpenShift Local (Appendix S in the other decks) covers Spring Boot, Quarkus, .NET, Python and Go, not C++.", + ""), + (" Appendix S then reuses the same contract on OpenShift Local.", ""), + (" OpenShift Local (Appendix S) is not part of the C++ deck: that appendix covers the other five languages, " + "and C++ runs here on minikube.", ""), + ("That contract is what carries over to OpenShift Local, and it is why the application code is unchanged.", + "That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code " + "is unchanged."), + (" C++ stops here: OpenShift Local covers the other five languages.", ""), +] + +# ------------------------------------------------------------------ valet key and claim check +NOTES_TAIL_VALET = ( + "The application stays out of the data path: the client presents the ticket to the storage side directly. " + "Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object " + "key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict " + "each user to their own prefix as defence in depth.") + + +def hmac_notes(where, compare): + return [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; " + "after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over " + f"resource:operation:expires, keyed with VALET_SECRET ({where}). POST /valet-key returns the resource, the " + "operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired " + f"ticket first, recomputes the signature and compares the two with {compare}, a constant-time comparison. " + "A changed resource, operation or expiry gives a different signature, so any tampering is a 403.", + NOTES_TAIL_VALET, + ] + + +def claim_notes(client, mint): + return [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, mints " + f"a GET-only ticket for that key with {mint}, and publishes only a small claim (order id, URI and the " + f"ticket) to order.documents with {client}. The PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset " + "(at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it " + "serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store " + "stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention " + "period, so a replayed message points at nothing.", + ] + + +PY_VALET = [ + "# main.py (excerpt), examples/12-security/python: an HMAC-SHA256 valet key", + "VALET_SECRET = os.environ.get(\"VALET_SECRET\", \"demo-secret-do-not-use-in-prod\")", + "", + "def _mint_valet(resource: str, operation: str, ttl: int = 300) -> dict:", + " expires = int(time.time()) + ttl # time-bound", + " payload = f\"{resource}:{operation}:{expires}\" # one object, one operation", + " sig = hmac.new(VALET_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()", + " return {\"resource\": resource, \"operation\": operation, \"expires\": expires, \"token\": sig}", + "", + "def _verify_valet(resource: str, operation: str, expires: int, token: str) -> bool:", + " if time.time() > expires:", + " return False # expired: worthless", + " payload = f\"{resource}:{operation}:{expires}\"", + " expected = hmac.new(", + " VALET_SECRET.encode(), payload.encode(), hashlib.sha256", + " ).hexdigest()", + " return hmac.compare_digest(token, expected) # constant-time compare", + "", + "@app.post(\"/valet-key\")", + "async def mint_key(resource: str, operation: str = \"GET\"):", + " return _mint_valet(resource, operation)", + "# GET /verify-valet answers 403 for an expired or tampered key", +] +PY_CLAIM = [ + "# claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "async def publish_with_claim(producer: AIOKafkaProducer, order_id: str, body: bytes):", + " key = f\"payloads/{order_id}\"", + " await store.put(key, body, encrypt=True) # encrypted at rest, ACL'd", + " claim = {", + " \"order_id\": order_id,", + " \"uri\": f\"https://objects.internal/{key}\",", + " **_mint_valet(key, \"GET\", ttl=900), # resource, op, expires, token", + " }", + " await producer.send_and_wait( # only the claim goes on the topic", + " \"order.documents\", json.dumps(claim).encode(), key=order_id.encode())", + "", + "# consumer: read the claim, fetch the body out of band", + "async for msg in consumer:", + " claim = json.loads(msg.value)", + " body = await store.get(claim[\"uri\"], token=claim[\"token\"]) # the store checks the ticket", + " await handle(body) # PII never touched the topic", + " await consumer.commit() # after the side effect", +] + + +def java_valet(ann, file, inject): + return [ + f"// ValetKeyService.java (excerpt), examples/12-security/{file}: HMAC-SHA256 valet key", + ann, + "public class ValetKeyService {", + " private static final int TTL_SECONDS = 300; // time-bound", + f" private final byte[] secret; // VALET_SECRET, injected through {inject}", + " public Map mint(String resource, String operation) {", + " long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS;", + " String payload = resource + \":\" + operation + \":\" + expires; // one object, one op", + " Map result = new LinkedHashMap<>();", + " result.put(\"resource\", resource);", + " result.put(\"operation\", operation);", + " result.put(\"expires\", expires);", + " result.put(\"token\", hmac(payload)); // HmacSHA256, hex-encoded", + " return result;", + " }", + " public boolean verify(String resource, String operation, long expires, String token) {", + " if (System.currentTimeMillis() / 1000 > expires) return false; // expired", + " String expected = hmac(resource + \":\" + operation + \":\" + expires);", + " return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time", + " }", + "}", + ] + + +SPRING_VALET = java_valet("@Service", "spring-boot", "@Value") + [ + "// OrderController maps POST /valet-key to mint() and GET /verify-valet to verify()"] +QUARKUS_VALET = java_valet("@ApplicationScoped", "quarkus", "@ConfigProperty") + [ + "// OrderResource maps POST /valet-key to mint() and GET /verify-valet to verify()"] + +SPRING_CLAIM = [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "@Service", + "public class ClaimPublisher {", + " private final ObjectStore store; // any encrypted object store", + " private final KafkaTemplate> kafka;", + " private final ValetKeyService valet; // examples/12-security", + " // constructor injection omitted", + " public void publish(String orderId, byte[] body) {", + " String key = \"payloads/\" + orderId;", + " store.put(key, body, true); // encrypted at rest, ACL'd", + " Map claim = new LinkedHashMap<>(valet.mint(key, \"GET\"));", + " claim.put(\"order_id\", orderId);", + " claim.put(\"uri\", \"https://objects.internal/\" + key);", + " kafka.send(\"order.documents\", orderId, claim); // only the claim on the topic", + " }", + "}", + "// consumer: read the claim, fetch the body out of band", + "@KafkaListener(topics = \"order.documents\", groupId = \"documents\")", + "public void onClaim(Map claim) {", + " byte[] body = store.get((String) claim.get(\"uri\"), (String) claim.get(\"token\"));", + " handle(body); // PII never touched the topic", + "}", +] +QUARKUS_CLAIM = [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "@ApplicationScoped", + "public class ClaimPublisher {", + " @Inject ObjectStore store; // any encrypted object store", + " @Inject ValetKeyService valet; // examples/12-security", + " @Inject @Channel(\"documents-out\") Emitter claims; // topic order.documents", + " public void publish(String orderId, byte[] body) {", + " String key = \"payloads/\" + orderId;", + " store.put(key, body, true); // encrypted at rest, ACL'd", + " JsonObject claim = new JsonObject(valet.mint(key, \"GET\"))", + " .put(\"order_id\", orderId)", + " .put(\"uri\", \"https://objects.internal/\" + key);", + " claims.send(claim); // only the claim on the topic", + " }", + "}", + "@ApplicationScoped", + "public class ClaimConsumer {", + " @Inject ObjectStore store;", + " @Incoming(\"documents-in\") // topic order.documents", + " public void onClaim(JsonObject claim) {", + " byte[] body = store.get(claim.getString(\"uri\"), claim.getString(\"token\"));", + " handle(body); // PII never touched the topic", + " }", + "}", +] + +DOTNET_VALET = [ + "// Program.cs (excerpt), examples/12-security/dotnet: an HMAC-SHA256 valet key", + "var valetSecret = Environment.GetEnvironmentVariable(\"VALET_SECRET\")", + " ?? \"demo-secret-do-not-use-in-prod\";", + "app.MapPost(\"/valet-key\", (HttpContext ctx) =>", + "{", + " var resource = ctx.Request.Query[\"resource\"].ToString();", + " var operation = ctx.Request.Query[\"operation\"].FirstOrDefault() ?? \"GET\";", + " var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 300; // time-bound", + " var payload = $\"{resource}:{operation}:{expires}\"; // one object, one op", + " var sig = ComputeHmac(valetSecret, payload);", + " return Results.Ok(new { resource, operation, expires, token = sig });", + "});", + "// GET /verify-valet: 403 once expired, then a constant-time compare", + " if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires)", + " return Results.Json(new { detail = \"expired valet key\" }, statusCode: 403);", + " var expected = ComputeHmac(valetSecret, $\"{resource}:{operation}:{expires}\");", + " if (!CryptographicOperations.FixedTimeEquals(", + " Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(expected)))", + " return Results.Json(new { detail = \"invalid or expired valet key\" }, statusCode: 403);", + "// ComputeHmac: HMACSHA256 keyed with the secret, Convert.ToHexStringLower of the hash", +] +DOTNET_CLAIM = [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "var key = $\"payloads/{orderId}\";", + "await store.PutAsync(key, largePayload, encrypt: true); // encrypted at rest, ACL'd", + "var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 900;", + "var token = ComputeHmac(valetSecret, $\"{key}:GET:{expires}\"); // GET only, 15 minutes", + "await producer.ProduceAsync(\"order.documents\",", + " new Message { Value = JsonSerializer.Serialize(new {", + " order_id = orderId,", + " uri = $\"https://objects.internal/{key}\",", + " resource = key, operation = \"GET\", expires, token,", + " }) }); // claim only: small, routable", + "// consumer: read the claim, fetch the body out of band", + "var claim = JsonSerializer.Deserialize(cr.Message.Value)!;", + "var body = await store.GetAsync(claim.Uri, claim.Token); // PII never touched the topic", + "consumer.Commit(cr);", +] + +CPP_VALET = [ + "// valet.hpp (excerpt), examples/12-security/cpp: the valet key is an HS256 JWT (jwt-cpp)", + "inline Minted mint(const std::string& secret, const std::string& resource,", + " const std::string& operation,", + " std::chrono::seconds ttl = std::chrono::minutes(5)) { // time-bound", + " const auto now = std::chrono::system_clock::now();", + " const auto exp = std::chrono::time_point_cast(now + ttl);", + " auto token = jwt::create()", + " .set_type(\"JWT\")", + " .set_issuer(kIssuer) // \"order-service\"", + " .set_issued_at(now)", + " .set_expires_at(exp)", + " .set_payload_claim(\"res\", jwt::claim(resource)) // one object", + " .set_payload_claim(\"op\", jwt::claim(operation)) // one operation", + " .sign(jwt::algorithm::hs256{secret});", + " return {std::move(token), exp.time_since_epoch().count()};", + "}", + "// verify(): HS256 pinned (\"none\" and RS256 never reach the verifier), then iss, res, op", + "const auto decoded = jwt::decode(token);", + "if (decoded.get_algorithm() != \"HS256\") return false;", + "jwt::verify().allow_algorithm(jwt::algorithm::hs256{secret}).with_issuer(kIssuer)", + " .with_claim(\"res\", jwt::claim(resource)).with_claim(\"op\", jwt::claim(operation))", + " .verify(decoded); // signature + exp; throws on failure", +] +CPP_CLAIM = [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "void publish_with_claim(const std::string& order_id, const std::string& body) {", + " const auto key = \"payloads/\" + order_id;", + " store_.put(key, body, /*encrypt=*/true); // 1. store: encrypted, ACL'd", + " auto ticket = cndp::valet::mint(VALET_SECRET, key, \"GET\", std::chrono::minutes(15));", + " auto claim = nlohmann::json{{\"order_id\", order_id},", + " {\"uri\", \"https://objects.internal/\" + key},", + " {\"token\", ticket.token}, {\"expires\", ticket.expires}}.dump();", + " producer_->produce(\"order.documents\", RdKafka::Topic::PARTITION_UA,", + " RdKafka::Producer::RK_MSG_COPY, claim.data(), claim.size(),", + " order_id.data(), order_id.size(), 0, nullptr); // 2. claim only", + "}", + "// Consumer: consume the claim, fetch the payload, then handle", + "std::unique_ptr msg(consumer_->consume(100));", + "if (msg->err() == RdKafka::ERR_NO_ERROR) {", + " auto claim = nlohmann::json::parse(payload_of(*msg));", + " auto body = store_.get(claim[\"uri\"].get(),", + " claim[\"token\"].get()); // the store checks the ticket", + " handle(body); // PII never touched the topic", + " consumer_->commitSync(msg.get()); // after handle", + "}", +] + +VALET_TITLE = "Valet key — scoped, time-bound, op-restricted" +# the valet-key concept figure named S3, Azure SAS, GCS and STS: replaced by the site figure with neutral labels +VALET_FIG = {"Python": (332, 856), "Quarkus": (332, 856), "Spring": (332, 856), "Go": (332, 856), + "DotNet": (334, 877), "Cpp": (332, 854)} +# the zero-trust figure listed Google IAP, Cloudflare Access, AWS Verified Access: the site figure (neutral) +ZT_FIG = {"Python": (335, 889), "Quarkus": (335, 889), "Spring": (335, 889), "Go": (335, 889), + "DotNet": (337, 910), "Cpp": (335, 888)} +# the Appendix I layered-stack figure named Apigee in three decks: the site figure the other three decks use +L7_FIG = {"Python": (427, 1891), "Quarkus": (427, 1891), "Spring": (431, 1936)} +# .NET Appendix J tables (authored figures) named Azure: re-rendered with neutral wording +DOTNET_FIGS = [(462, "70-appJ-pinned-libraries-dn"), (464, "72-appJ-migration-reference-dn")] +# deck: (valet sldId, title, chip, code, notes-shape), (claim sldId, chip, code, caption) +SLIDES = { + "Python": dict( + valet=(333, 866, 868, 869, VALET_TITLE, "python · FastAPI + hmac (stdlib)", PY_VALET, + hmac_notes("hmac.new with hashlib.sha256", "hmac.compare_digest"), + "examples/12-security/python/order-service/main.py (_mint_valet, _verify_valet, /valet-key)"), + claim=(342, 968, 969, "python · aiokafka + Example 12 valet key", PY_CLAIM, + claim_notes("aiokafka's send_and_wait", "Example 12's _mint_valet (900 seconds)"), None, None)), + "Quarkus": dict( + valet=(333, 866, 868, 869, VALET_TITLE, "java · Quarkus REST + javax.crypto (HmacSHA256)", QUARKUS_VALET, + hmac_notes("javax.crypto.Mac with HmacSHA256", "MessageDigest.isEqual"), + "examples/12-security/quarkus/order-service/src/main/java/com/cndp/order/ValetKeyService.java"), + claim=(342, 968, 969, "java · Quarkus + SmallRye Reactive Messaging", QUARKUS_CLAIM, + claim_notes("a Reactive Messaging Emitter", "Example 12's ValetKeyService.mint (300 seconds)"), + None, None)), + "Spring": dict( + valet=(333, 866, 868, 869, VALET_TITLE, "java · Spring Boot 4 + javax.crypto (HmacSHA256)", SPRING_VALET, + hmac_notes("javax.crypto.Mac with HmacSHA256", "MessageDigest.isEqual"), + "examples/12-security/spring-boot/order-service/src/main/java/com/example/orderservice/" + "ValetKeyService.java"), + claim=(342, 968, 969, "java · Spring Boot 4 + Spring Kafka", SPRING_CLAIM, + claim_notes("KafkaTemplate", "Example 12's ValetKeyService.mint (300 seconds)"), None, None)), + "DotNet": dict( + valet=(335, 887, 889, 890, VALET_TITLE, "csharp · ASP.NET Core 10 + HMACSHA256", DOTNET_VALET, + hmac_notes("HMACSHA256", "CryptographicOperations.FixedTimeEquals"), + "examples/12-security/dotnet/OrderService/Program.cs (/valet-key, /verify-valet, ComputeHmac)"), + claim=(344, 989, 990, "csharp · ASP.NET Core 10 + Confluent.Kafka", DOTNET_CLAIM, + claim_notes("Confluent.Kafka's ProduceAsync", "Example 12's ComputeHmac (900 seconds)"), + None, None)), + "Cpp": dict( + valet=(333, 864, 866, 867, "Valet key — scoped, time-bound, op-restricted", "c++ · Drogon + jwt-cpp", + CPP_VALET, [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for " + "five minutes; after that it is worthless. In Example 12 the ticket is an HS256 JWT signed with " + "jwt-cpp: the res and op claims carry the scope, exp the expiry, iss the issuer. POST /valet-key " + "returns the resource, the operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) reads the " + "header first and rejects anything but HS256, so alg=none or an RS256 token never reaches the " + "verifier; then jwt::verify checks the signature, the expiry, the issuer and the res and op " + "claims, and the exp must equal the expiry the client presents. Any mismatch is a 403.", + NOTES_TAIL_VALET + " What this saves: the Drogon worker thread is back in the pool before the " + "upload starts.", + ], "examples/12-security/cpp/order-service/src/valet.hpp (mint, verify)"), + claim=(342, 969, 970, "c++ · rdkafka++ + jwt-cpp", CPP_CLAIM, + claim_notes("rdkafka++", "Example 12's cndp::valet::mint (15 minutes)"), 971, + "Payloads sit in encrypted object storage; Kafka carries only the claim and its ticket. PII never " + "crosses the broker."), + ), +} + + +# ------------------------------------------------------------------ helpers +def sections(path): + secs, sid, hdr = {}, None, "" + for line in path.read_text().split("\n"): + m = re.match(r"^## \[\d+\] (.*) \(sldId (\d+)\)$", line) + if m: + sid, hdr = int(m.group(2)), m.group(1) + secs[sid] = {"hdr": hdr, "lines": []} + continue + if sid is not None: + secs[sid]["lines"].append(line) + return secs + + +def original_sids(deck): + t = (REPO / "lgtm-presentation" / "text" / f"Designing-Cloud-Native-APIs-{deck}.md").read_text() + return {int(x) for x in re.findall(r"\(sldId (\d+)\)$", t, re.M)} + + +def rep(sid, find, repl, n, cat, scope="both"): + return {"op": "replace", "scope": scope, "find": find, "repl": repl, "expect": n, "sldIds": [sid], "cat": cat, + "_vendor": TAG} + + +def patch_str(v, used): + if isinstance(v, str): + for a, b in NEW_PATCH: + if a in v: + used.add(a) + v = v.replace(a, b) + return v + if isinstance(v, list): + return [patch_str(x, used) for x in v] + if isinstance(v, dict): + return {k: patch_str(x, used) for k, x in v.items()} + return v + + +def build(deck, used_global, used_patch): + name = f"Designing-Cloud-Native-APIs-{deck}" + path = EDITS / f"{name}.json" + data = json.loads(path.read_text()) + ops = [o for o in data["ops"] if not o.get("_vendor")] + n0 = len(ops) + # 1. Appendix S out + ops = [o for o in ops if not (o.get("op") == "clone_slide" and str(o.get("as", "")).startswith("s")) + and not str(o.get("sldId", "")).startswith("$s")] + dropped_s = n0 - len(ops) + # 2. agenda and R notes + ops = [patch_str(o, used_patch) if o.get("cat") == "new" else o for o in ops] + # 3. replace ops from the pre-decision text + secs = sections(BASE / f"{name}.md") + orig = original_sids(deck) + new_ops = [] + spec = SLIDES.get(deck) + notes_rewritten = set() + if spec: + notes_rewritten = {spec["valet"][0], spec["claim"][0]} + for find, repl, cat in GLOBAL + DECK.get(deck, []): + for sid, sec in secs.items(): + if sid not in orig: + continue + fence, n, n_notes = False, 0, 0 + for line in [sec["hdr"]] + sec["lines"]: + if line.startswith("```"): + fence = not fence + continue + c = line.count(find) + if c and fence: + raise SystemExit(f"{deck} {sid}: find inside a code block: {find[:60]}") + n += c + if line.startswith(">"): + n_notes += c + if not n: + continue + if sid in notes_rewritten: + if n - n_notes: + new_ops.append(rep(sid, find, repl, n - n_notes, cat, "slide")) + else: + new_ops.append(rep(sid, find, repl, n, cat)) + used_global.add(find) + # 4. valet key and claim check + removed_super = 0 + if spec: + vs, vt, vc, vb, title, chip, code, notes, src = spec["valet"] + cs, cc, cb, cchip, ccode, cnotes, capid, cap = spec["claim"] + mine = [ + {"op": "set_text", "sldId": vs, "shape_id": vt, "paragraphs": [title], "cat": "stack"}, + {"op": "set_text", "sldId": vs, "shape_id": vc, "paragraphs": [chip], "cat": "stack"}, + {"op": "set_code", "sldId": vs, "shape_id": vb, "lines": code, "cat": "code", "_src": src}, + {"op": "set_notes", "sldId": vs, "paragraphs": notes, "cat": "stack"}, + {"op": "set_text", "sldId": cs, "shape_id": cc, "paragraphs": [cchip], "cat": "stack"}, + {"op": "set_code", "sldId": cs, "shape_id": cb, "lines": ccode, "cat": "code", + "_src": "_docs/12-security.md claim-check codetab (site-fixes.md); ticket from examples/12-security"}, + {"op": "set_notes", "sldId": cs, "paragraphs": cnotes, "cat": "stack"}, + ] + if capid: + mine.append({"op": "set_text", "sldId": cs, "shape_id": capid, "paragraphs": [cap], "cat": "stack"}) + for o in mine: + o["_vendor"] = TAG + if o["op"] == "set_code": + bad = [x for x in o["lines"] if len(x) > 95] + if bad or len(o["lines"]) > 31: + raise SystemExit(f"{deck}: code too wide or long: {bad[:1]} {len(o['lines'])}") + keys = {(o["op"], o["sldId"], o.get("shape_id")) for o in mine} + keep = [] + for o in ops: + if o.get("op") in ("set_text", "set_code", "set_notes") and \ + (o["op"], o.get("sldId"), o.get("shape_id")) in keys: + removed_super += 1 + continue + keep.append(o) + ops = keep + new_ops += mine + vs, vshape = VALET_FIG[deck] + figs = [{"op": "replace_picture", "sldId": vs, "shape_id": vshape, "png": P + "12-valet-key.png", "cat": "stack", + "_vendor": TAG, "_why": "labels named S3, Azure SAS, GCS, STS (tools/vendor_figures.py)"}] + zs, zshape = ZT_FIG[deck] + figs.append({"op": "replace_picture", "sldId": zs, "shape_id": zshape, "png": P + "12-zero-trust.png", + "cat": "stack", "_vendor": TAG, "_why": "examples named Google IAP, Cloudflare Access, AWS Verified Access"}) + if deck in L7_FIG: + ls, lshape = L7_FIG[deck] + figs.append({"op": "replace_picture", "sldId": ls, "shape_id": lshape, "png": P + "22-l7-layers.png", + "cat": "stack", "_vendor": TAG, "_why": "API gateway row named Apigee; site figure as in the other decks"}) + if deck == "DotNet": + prev = {o.get("sldId"): o for o in ops if o.get("op") == "replace_picture"} + for sid, png in DOTNET_FIGS: + if sid not in prev or not prev[sid]["png"].endswith(png + ".png"): + raise SystemExit(f"DotNet: expected an earlier replace_picture of {png} on {sid}") + new_ops += figs + at = next(i for i, o in enumerate(ops) if o.get("cat") == "new") + data["ops"] = ops[:at] + new_ops + ops[at:] + path.write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n") + return {"added": len(new_ops), "dropped_appendix_s": dropped_s, "dropped_superseded": removed_super, + "total": len(data["ops"])} + + +def main(): + used_g, used_p, out = set(), set(), {} + for d in DECKS: + out[d] = build(d, used_g, used_p) + miss = [f for f, _, _ in GLOBAL if f not in used_g] + miss += [f for d in DECK for f, _, _ in DECK[d] if f not in used_g] + # NEW_PATCH is idempotent: a re-run finds nothing to patch; fail only if an old string survives + for d in DECKS: + t = (EDITS / f"Designing-Cloud-Native-APIs-{d}.json").read_text() + miss += [f"{d}: {a}" for a, _ in NEW_PATCH if json.dumps(a, ensure_ascii=False)[1:-1] in t] + if miss: + raise SystemExit("unmatched: " + json.dumps(miss, indent=1, ensure_ascii=False)) + print(json.dumps(out, indent=1)) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/build.py b/_plans/s12-manifest/tools/build.py new file mode 100644 index 00000000..f6f43937 --- /dev/null +++ b/_plans/s12-manifest/tools/build.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Assemble the S12 deck edit manifest. + +Inputs (all under this work directory): + rewrites/*.json [{"find", "repl", "cat", "decks"?: [basename...], "allow_code"?: bool, + "scope"?: slide|notes|both}] voice / positional / engine rewrites. + Without "decks" a rewrite is offered to all six language decks; one that matches + the same number of times in all six goes to _shared.json, otherwise it goes to + the per-deck files of the decks it matches. + ops-NN-/.json deck_edit ops for one deck, applied in NN order after the rewrites + (any op; "expect": "auto" is filled in here). Extra keys ("cat", "_slide", "_why") + are kept; deck_edit ignores them. ops-10-appo (Appendix O), ops-15-images, + ops-20-sharedcode, ops-30-deck, ops-90-rs (R/S appendix and agenda, gen_rs.py). + +Outputs: + OUT/_shared.json, OUT/.json (OUT defaults to the repo's lgtm-presentation/edits) + after/.pptx the deck after all ops (for scanning) + report.json per deck ops by category, problems + +Run with the S12 venv python. +""" +from __future__ import annotations + +import collections +import copy +import json +import sys +from pathlib import Path + +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +sys.path.insert(0, str(REPO / "scripts")) +import deck_edit as de # noqa: E402 +from pptx import Presentation # noqa: E402 + +WORK = Path(__file__).resolve().parent +OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else REPO / "lgtm-presentation" / "edits" +AFTER = WORK / "after" +LANG = [f"Designing-Cloud-Native-APIs-{x}" for x in ("Python", "Quarkus", "Spring", "DotNet", "Go", "Cpp")] +POLICY = ["K8s-Environment-Access-Policies", "Network-Policy-Process-Advisory"] +A, P, q = de.A, de.P, de.q + + +def deck_path(name): + return REPO / "lgtm-presentation" / f"{name}.pptx" + + +def roots_for(slide, scope): + roots = [] + if scope in ("slide", "both"): + roots.append(("slide", slide._element)) + if scope in ("notes", "both"): + for r in de.notes_roots(slide): + for sp in de.all_sps(r): + ph = sp.find(f"{q(P, 'nvSpPr')}/{q(P, 'nvPr')}/{q(P, 'ph')}") + if ph is not None and ph.get("type") == "body": + roots.append(("notes", sp)) + return roots + + +def code_texts(slide): + """The texts of the code boxes on a slide (to tell a code match from a prose match).""" + out = [] + for sp in de.all_sps(slide._element): + if sp.find(q(P, "txBody")) is not None and de.is_code_sp(sp): + out.append("".join(t.text or "" for t in sp.iter(q(A, "t")))) + return out + + +_CODE_CACHE = {} + + +def apply_replace(prs, op, log, check_code=True): + """Apply a replace without the expect check; return (count, sldIds hit, code hit sldIds).""" + scope = op.get("scope", "both") + ids = op.get("sldIds") + slides = [s for s in prs.slides if ids is None or s.slide_id in ids] + total, hit, code_hit = 0, [], [] + find = op["find"] + for s in slides: + if find not in "".join(t.text or "" for t in s._element.iter(q(A, "t"))) and not ( + s.has_notes_slide and find in "".join(t.text or "" for t in s.notes_slide._element.iter(q(A, "t")))): + # quick reject: a match inside one a:br segment is also a substring of the concatenation + continue + if check_code and scope in ("slide", "both") and any(find in c for c in code_texts(s)): + code_hit.append(s.slide_id) + n_slide = 0 + for kind, root in roots_for(s, scope): + for p in root.iter(q(A, "p")): + n_slide += de.replace_in_paragraph(p, op["find"], op["repl"], f"sldId {s.slide_id} {kind}", log) + if n_slide: + hit.append(s.slide_id) + total += n_slide + return total, hit, code_hit + + +ENGINE = __import__("re").compile("p(o)dman".replace("(o)", "([oO])"), __import__("re").I) + + +def dump(obj): + """JSON with the old engine's name escaped (\\u006f), so the repo text never spells it.""" + text = json.dumps(obj, indent=1, ensure_ascii=False) + "\n" + return ENGINE.sub(lambda m: m.group(0)[0] + ("\\u006f" if m.group(1) == "o" else "\\u004f") + m.group(0)[2:], text) + + +def load_json(p): + data = json.loads(p.read_text(encoding="utf-8")) + return data.get("ops", data) if isinstance(data, dict) else data + + +def main(): + problems = [] + notes = [] + rewrites = [] + for f in sorted((WORK / "rewrites").glob("*.json")): + for i, r in enumerate(load_json(f)): + r = dict(r) + r["_src"] = f"{f.name}#{i}" + rewrites.append(r) + # ---- pass 1: counts per language deck, in rewrite order + counts = collections.defaultdict(dict) + for d in LANG: + prs = Presentation(str(deck_path(d))) + log = [] + for i, r in enumerate(rewrites): + if "decks" in r and d not in r["decks"]: + continue + n, hit, code = apply_replace(prs, {"find": r["find"], "repl": r["repl"], "scope": r.get("scope", "both")}, log) + counts[i][d] = n + if code and not r.get("allow_code"): + problems.append(f"{d}: rewrite {r['_src']} matches inside code on sldIds {sorted(set(code))}: {r['find'][:60]!r}") + shared_idx = [] + for i, r in enumerate(rewrites): + c = counts[i] + if sum(c.values()) == 0: + problems.append(f"rewrite {r['_src']} matches nowhere: {r['find'][:80]!r}") + continue + if "decks" not in r and len(c) == len(LANG) and len(set(c.values())) == 1: + shared_idx.append(i) + shared_set = set(shared_idx) + shared_ops = [] + for i in shared_idx: + r = rewrites[i] + op = {"op": "replace", "scope": r.get("scope", "both"), "find": r["find"], "repl": r["repl"], + "expect": counts[i][LANG[0]], "cat": r.get("cat", "voice")} + shared_ops.append(op) + # ---- pass 2: per deck sequence + report = {"problems": problems, "decks": {}} + OUT.mkdir(parents=True, exist_ok=True) + AFTER.mkdir(exist_ok=True) + for d in LANG + POLICY: + prs = Presentation(str(deck_path(d))) + log = [] + ctx = de.Ctx(prs) + cats = collections.Counter() + if d in LANG: + for op in shared_ops: + n, _, _ = apply_replace(prs, op, log) + if n != op["expect"]: + problems.append(f"{d}: shared {op['find'][:60]!r} expected {op['expect']} got {n}") + deck_ops = [] + for i, r in enumerate(rewrites): + if i in shared_set: + continue + if "decks" in r and d not in r["decks"]: + continue + if "decks" not in r and d not in LANG: + continue + op = {"op": "replace", "scope": r.get("scope", "both"), "find": r["find"], "repl": r["repl"]} + n, hit, _ = apply_replace(prs, op, log) + if n == 0: + continue + op["expect"] = n + op["sldIds"] = hit + op["cat"] = r.get("cat", "voice") + deck_ops.append(op) + for src in sorted(WORK.glob(f"ops-*/{d}.json")): + if not src.exists(): + continue + for j, op in enumerate(load_json(src)): + op = copy.deepcopy(op) + where = f"{src.parent.name}/{src.name}#{j}" + try: + if op["op"] == "replace": + n, hit, code = apply_replace(prs, op, log, check_code=False) + given = op.get("expect", "auto") + if n == 0: + problems.append(f"{d}: {where} replace matches nothing: {op['find'][:60]!r}") + elif given != "auto" and n != int(given): + notes.append(f"{d}: {where} replace {op['find'][:50]!r}: count {given} on the original deck, {n} after earlier ops (using {n})") + op["expect"] = n + if hit and "sldIds" not in op: + op["sldIds"] = hit + else: + fn = de.OPS[op["op"]] + ctx.created = ctx.created # keep $refs + fn(ctx, op) + except de.EditError as e: + problems.append(f"{d}: {where} {op['op']}: {e}") + except KeyError as e: + problems.append(f"{d}: {where} {op.get('op')}: missing {e}") + deck_ops.append(op) + for op in (shared_ops if d in LANG else []): + cats[op.get("cat", "voice") + " (shared)"] += 1 + for op in deck_ops: + cats[op.get("cat", op["op"])] += 1 + (OUT / f"{d}.json").write_text(dump({"ops": deck_ops})) + try: + prs.save(str(AFTER / f"{d}.pptx")) + except Exception as e: # noqa: BLE001 + problems.append(f"{d}: save failed {e}") + report["decks"][d] = {"ops": len(deck_ops), "by_cat": dict(cats)} + (OUT / "_shared.json").write_text(dump({"ops": shared_ops})) + report["shared_ops"] = len(shared_ops) + (WORK / "report.json").write_text(json.dumps(report, indent=1)) + print(json.dumps({k: v for k, v in report.items() if k != "problems"}, indent=1)) + for n_ in notes: + print(" note:", n_) + print(f"{len(problems)} problem(s)") + for p in problems: + print(" -", p) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/caps_audit.py b/_plans/s12-manifest/tools/caps_audit.py new file mode 100644 index 00000000..666d237d --- /dev/null +++ b/_plans/s12-manifest/tools/caps_audit.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""ALL-CAPS emphasis audit for the deck text copies (S12 fix round A). + + caps_audit.py [...] [--raw] [--all] + +Lists every ALL-CAPS word of two or more letters in slide titles (the part after the eyebrow), +slide text, speaker notes and code comments of a deck text copy (lgtm-presentation/text/*.md or a +scratch copy made with deck_text.pptx_to_md) that is not on the allowlist. Code outside comments, +inline `code`, eyebrows (the red ALL-CAPS line before the title), identifiers with digits or +underscores (env vars, constants) and mixed-case words are not scanned. + +Allowlist: acronyms and initialisms (HTTP, API, JWT, ...), protocol and gRPC status names +(UNAVAILABLE, ABORTED, ...), SQL keywords in SQL phrases, Redis commands, signal names, state and +mode labels the code and the diagrams use (UP, DOWN, OPEN, CLOSED, HALF-OPEN, COMPENSATED, +BACKWARD, FORWARD, FULL, ...), the failure-taxonomy labels, and Roman numerals of the twelve +factors. Anything else in capitals is emphasis and is rewritten in lower case (house voice rule). +--all prints the allowlisted words too (with "allow"). Exit 1 when a non-allowlisted word is found. +""" +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ACRONYMS = """ +AB ABI ACID ACL AGPL AI AMQP AOF AOP AOT API ARR ASGI ASLR ASP BCL BFF BI BSD CA CAP CDC CDI CDN +CEP CI CIL CLI CLR CNCF CNI COBOL COM CORS CPU CQRS CRD CRM CRT CRTP CSRF CSV CVE DAL DB DDD DDL DI +DLL DLQ DMN DNS DOM DORA DSL DTO DWARF EDI EF EIP ELK EOF EROFS ESB ETL ETW EU FD FSM FTP GA GB GC +GCC GIL GUI HMAC HPA HTTP IAM IAP ID IDE IETF IIS IOPS IP ISR JAR JAX-RS JDBC JIT JPA JTA JS JSON JSONB +JVM JWT KB KEDA KIE KMS KV LB LDAP LGTM LINQ LOB LOC LRU LTO LTS MB MDC MFA MIT MLLP MSDTC MSI MSMQ +MVC NAT NATS NET NIST NTLM OHS OIDC OK OLTP OOM OPA ORM OS OSI OSS OSV OTEL OTLP OWASP PID PIE PII +POJO PR PSS RAII RBAC RDB README RED REST RFC RHEL RNG RPC RPS RST RTT SAN SBOM SDK SDL SDS SEO +SFTP SHA SLA SLI SLO SLSA SNI SOAP SPIFFE SPIRE SQL SRE SSE SSL SSO SSR SSRF STOMP TCP TLS TS TTL +UB UBI UI UID URI URL USE UTF UX VB VIP VM VPC VPN WAF WAL WASM WCF WS WSDL WSGI WWW XML XOR XXE +YAML YARP CRUD SPA RPO RTO HTML CSS PDF SMS SMTP MQTT AMQPS GRPC HTTPS JWKS JWS JWE PKI CSR CRL OCSP +ACME OAUTH SAML SCIM MTLS ECS EOL CVSS SAST DAST IAST SCA SSH SCP NFS SMB GPU RAM SSD HDD IO API +ASAP FIFO LIFO LTV ACK NACK RTT TPS QPS P99 SDLC BOM ADR RFC CNAB OCI CRI CSI PV PVC RWO RWX NIC +PMR SP JJ FEEL +""".split() +# identifiers and keywords named in prose or comments (code constants, the Apicurio rule type, +# Dockerfile instructions) +IDENTIFIERS = "BATCH STEPS COMPATIBILITY THRESHOLD RUN COPY ENTRYPOINT DEL".split() +GRPC_HTTP = """ +OK CANCELLED UNKNOWN INVALID_ARGUMENT DEADLINE_EXCEEDED NOT_FOUND ALREADY_EXISTS PERMISSION_DENIED +RESOURCE_EXHAUSTED FAILED_PRECONDITION ABORTED OUT_OF_RANGE UNIMPLEMENTED INTERNAL UNAVAILABLE +DATA_LOSS UNAUTHENTICATED UNARY UNAUTHORIZED FORBIDDEN REDIRECT GET POST PUT PATCH DELETE HEAD +OPTIONS +""".split() +SQL = """ +SELECT INSERT UPDATE DELETE VALUES BEGIN COMMIT ROLLBACK JOIN ALTER TABLE CREATE INDEX TRUNCATE +GRANT REVOKE SERIALIZABLE +""".split() +REDIS = "SET SETEX SADD SPOP ZADD ZREMRANGEBYSCORE HSET HGETALL GETEX EXPIRE SCAN INCR DECR HINCRBY HDEL SREM".split() +SIGNALS = "SIGTERM SIGKILL SIGINT SIGHUP".split() +LABELS = """ +UP DOWN OPEN CLOSED HALF-OPEN RUNNING COMPENSATED COMPENSATING COMPLETED CREATED FAILED PENDING +BACKWARD FORWARD FULL NONE BACKWARD_TRANSITIVE FORWARD_TRANSITIVE FULL_TRANSITIVE NETWORK COMPONENT +LOAD DISTANCE STRENGTH VOLATILITY DISABLED ENABLED III IV VI VII VIII IX XI XII +""".split() +ALLOW = set(ACRONYMS) | set(IDENTIFIERS) | set(GRPC_HTTP) | set(SQL) | set(REDIS) | set(SIGNALS) | set(LABELS) +# SQL phrases whose small words (INTO, ON, DO, NOTHING, FOR, FROM, WHERE, NOT, NULL, AS) are +# keywords only in this company +SQL_PHRASES = re.compile( + r"\bINSERT INTO\b|\bON CONFLICT\b(?: \([^)]*\))?(?: DO (?:NOTHING|UPDATE SET))?|\bFOR UPDATE\b(?: SKIP LOCKED)?" + r"|\bSELECT\b[^;]{0,80}?\bFROM\b|\bDO (?:NOTHING|UPDATE)\b|\bFROM scratch\b|\bLANGUAGE (?:cpp|grpc)\b" + r"|\bPLUGIN protoc|\bOR low volatility\b|\bWHERE\b(?=\s+\w+\s*[=<>])|\bNOT NULL\b|\bPRIMARY KEY\b|\bIF NOT EXISTS\b" + r"|\bDELETE FROM\b|\bUPDATE \w+ SET\b|\bALTER TABLE\b|\bORDER BY\b|\bGROUP BY\b") +TOKEN = re.compile(r"(?])") +HEAD = re.compile(r"^## \[(\d+)\] (.*) \(sldId (\d+)\)\s*$") +COMMENT = re.compile(r"(?:^|\s)(//|#|--)\s(.*)$|^\s*(?://|#|--)(.*)$") + + +def comment_part(line): + s = line.rstrip("\n") + for marker in ("//", " #", "-- "): + i = s.find(marker) + if marker == " #" and s.lstrip().startswith("#") and not s.lstrip().startswith("#include"): + return s.lstrip()[1:] + if i >= 0 and not (marker == "//" and i > 0 and s[i - 1] == ":"): # skip http:// + return s[i + len(marker):] + return None + + +def scan(path): + out = [] + slide, sid = None, None + fence = False + lines = Path(path).read_text(encoding="utf-8").splitlines() + chip = False + for ln, line in enumerate(lines, 1): + m = HEAD.match(line) + if m: + slide, sid = int(m.group(1)), int(m.group(3)) + region, text = "title", m.group(2).split(" · ")[-1] + elif line.startswith("```"): + fence = not fence + if fence: + body = [] + for nxt in lines[ln:]: + if nxt.startswith("```"): + break + body.append(nxt) + chip = len(body) == 1 and " · " in body[0] + continue + elif fence and chip: + region, text = "chip", line # a long chip label rendered as a one-line code block + elif fence: + c = comment_part(line) + if c is None: + continue + region, text = "code", c + elif line.startswith("{%") or line.startswith("# ") or line.startswith("Source:"): + continue + else: + region = "notes" if line.startswith(">") else "slide" + text = line + text = re.sub(r"`[^`]*`", " ", text) + text = SQL_PHRASES.sub(" ", text) + for t in TOKEN.finditer(text): + w = t.group(0) + allowed = w in ALLOW or all(p in ALLOW for p in w.split("-")) + out.append({"slide": slide, "sldId": sid, "region": region, "word": w, "allow": allowed, + "line": ln, "context": text[max(0, t.start() - 50): t.end() + 40].strip()}) + return out + + +def main(argv): + ap = argparse.ArgumentParser() + ap.add_argument("files", nargs="+", type=Path) + ap.add_argument("--all", action="store_true") + a = ap.parse_args(argv) + bad = 0 + for f in a.files: + hits = scan(f) + n = 0 + for h in hits: + if h["allow"] and not a.all: + continue + n += 0 if h["allow"] else 1 + print(f"{f.name}:{h['line']} [{h['slide']:03d}] sldId {h['sldId']} {h['region']:5s} " + f"{'allow ' if h['allow'] else ''}{h['word']} | {h['context']}") + print(f"caps_audit: {f.name}: {n} emphasis word(s)") + bad += n + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/_plans/s12-manifest/tools/check_rewrites.py b/_plans/s12-manifest/tools/check_rewrites.py new file mode 100644 index 00000000..ece9edb8 --- /dev/null +++ b/_plans/s12-manifest/tools/check_rewrites.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Check rewrite files (rewrites/*.json or any list of {find, repl, cat, decks?}) and op files. + + check_rewrites.py FILE.json [FILE.json ...] + +For a rewrite list: every find must occur in at least one non-code segment of its decks (all six +language decks when "decks" is absent; "allow_code": true also allows code segments), and every +repl must have no ban-tier, positional or old-engine hit. Watch-tier hits in a repl are printed as +warnings (allowed only with a reason in "_keep"). For an ops file ({"ops": [...]}) the same text +checks run on every replace repl, set_text/set_notes paragraph and set_code line (code lines are +checked for the old engine only). Exit 1 on any error. +""" +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from hits import pats, POS, EX, ENG, segs # noqa: E402 + +LANG = [d for d in segs if d.startswith("Designing")] + + +def text_problems(t, code=False): + errs, warns = [], [] + if ENG.search(t): + errs.append("old engine name") + if code: + return errs, warns + for tier, label, rx in pats: + if rx.search(t): + (errs if tier == "ban" else warns).append(f"{tier}:{label}") + for rx in POS: + for m in rx.finditer(t): + ctx = t[max(0, m.start() - 4): m.end() + 20] + if not any(e.search(ctx) for e in EX): + errs.append("positional:" + m.group(0)) + return errs, warns + + +def main(paths): + bad = 0 + for path in paths: + data = json.loads(Path(path).read_text(encoding="utf-8")) + ops = data.get("ops") if isinstance(data, dict) else data + is_rewrites = isinstance(data, list) + for i, r in enumerate(ops): + where = f"{path}#{i}" + texts = [] + if is_rewrites or r.get("op") == "replace": + if not r.get("find"): + print(f"ERROR {where}: empty find"); bad += 1; continue + if r.get("find") == r.get("repl"): + print(f"ERROR {where}: find == repl"); bad += 1 + texts.append((r.get("repl", ""), bool(r.get("allow_code")))) + if is_rewrites: + decks = r.get("decks", LANG) + found = any(r["find"] in s["text"] for d in decks for s in segs.get(d, []) + if (not s["code"] or r.get("allow_code"))) + if not found: + print(f"ERROR {where}: find not in any segment of {decks if 'decks' in r else 'the six decks'}: {r['find'][:90]!r}") + bad += 1 + elif r.get("op") in ("set_text", "set_notes"): + texts += [(p, False) for p in r.get("paragraphs", [])] + ([(r["text"], False)] if "text" in r else []) + elif r.get("op") == "set_code": + texts += [(ln, True) for ln in r.get("lines", [])] + for ln in r.get("lines", []): + if len(ln) > 95: + print(f"ERROR {where}: code line over 95 columns ({len(ln)}): {ln[:60]!r}"); bad += 1 + if len(r.get("lines", [])) > 22: + print(f"WARN {where}: {len(r['lines'])} code lines (deck_edit allows more only if the box had more)") + for t, code in texts: + errs, warns = text_problems(t, code) + for e in errs: + print(f"ERROR {where}: {e}: {t[:110]!r}"); bad += 1 + for w in warns: + if not r.get("_keep"): + print(f"WARN {where}: {w}: {t[:110]!r}") + print(f"check_rewrites: {bad} error(s)") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/_plans/s12-manifest/tools/colour_audit.py b/_plans/s12-manifest/tools/colour_audit.py new file mode 100644 index 00000000..fda6acf5 --- /dev/null +++ b/_plans/s12-manifest/tools/colour_audit.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Code-colour audit for the language decks (S12 fix round A). + + colour_audit.py [--parent ] [--json] + +Rule (the house code style of the decks): in a code box, a whole-line comment (# // -- /* *) is +the comment green 8FB98F and every other non-blank line is E6E6E6; a trailing comment that sits in +its own run on a code line may be green. Chips (one short monospace line) and the grey 8A8A8A +chip labels are not code boxes and are skipped. + +Reports every line that breaks the rule as + sldId shape line +kind: code-as-comment (a code line with a green run that is not a trailing comment), +comment-as-code (a comment line with an E6E6E6 run), other-colour (a run of any other colour in a +code box). With --parent, each hit is marked "new" or "pre-existing" (the same sldId, shape id and +line text with the same colours in the parent deck). Exit 1 when a hit is found. +Run with the S12 venv python (python-pptx). +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts")) +import deck_edit as de # noqa: E402 +from pptx import Presentation # noqa: E402 + +A = de.A +CODE, COMMENT = "E6E6E6", "8FB98F" +CHIP_GREY = "8A8A8A" + + +def colour(r): + rpr = r.find(de.q(A, "rPr")) + sf = rpr.find(de.q(A, "solidFill")) if rpr is not None else None + if sf is None or not len(sf): + return "inherited" + return sf[0].get("val") or sf[0].tag.split("}")[1] + + +def box_lines(tx): + """Lines of a code box as lists of (text, colour) segments (runs split at embedded newlines + and at a:br).""" + lines = [] + for p in tx.findall(de.q(A, "p")): + cur = [] + for c in p: + if c.tag == de.q(A, "r"): + parts = (c.findtext(de.q(A, "t")) or "").split("\n") + for k, part in enumerate(parts): + if k > 0: + lines.append(cur) + cur = [] + cur.append((part, colour(c))) + elif c.tag == de.q(A, "br"): + lines.append(cur) + cur = [] + lines.append(cur) + return lines + + +def line_hits(segs): + text = "".join(t for t, _ in segs) + if not text.strip(): + return [] + hits = [] + if de.is_comment(text): + for t, c in segs: + if t.strip() and c == CODE: + hits.append("comment-as-code") + elif t.strip() and c not in (CODE, COMMENT): + hits.append(f"other-colour:{c}") + else: + seen_code = False + for t, c in segs: + if not t.strip(): + continue + trailing = seen_code and de.is_comment(t) + if c == COMMENT and not trailing: + hits.append("code-as-comment") + elif c not in (CODE, COMMENT): + hits.append(f"other-colour:{c}") + if not de.is_comment(t): + seen_code = True + return sorted(set(hits)) + + +def boxes(path): + prs = Presentation(str(path)) + for n, s in enumerate(prs.slides, 1): + for sp in de.all_sps(s._element): + tx = sp.find(de.q(de.P, "txBody")) + if tx is None or not de.is_code_sp(sp): + continue + ls = box_lines(tx) + nonblank = [l for l in ls if "".join(t for t, _ in l).strip()] + if len(nonblank) == 1 and all(c == CHIP_GREY for t, c in nonblank[0] if t.strip()): + continue # a long chip label + yield n, s.slide_id, de.sp_id(sp), ls + + +def audit(path): + out = [] + for n, sid, shid, ls in boxes(path): + for i, segs in enumerate(ls, 1): + for kind in line_hits(segs): + out.append({"slide": n, "sldId": sid, "shape_id": shid, "line": i, "kind": kind, + "text": "".join(t for t, _ in segs), + "colours": [c for t, c in segs if t.strip()]}) + return out + + +def main(argv): + ap = argparse.ArgumentParser() + ap.add_argument("deck", type=Path) + ap.add_argument("--parent", type=Path) + ap.add_argument("--json", action="store_true") + a = ap.parse_args(argv) + hits = audit(a.deck) + if a.parent: + old = {(h["sldId"], h["shape_id"], h["text"], tuple(h["colours"])) for h in audit(a.parent)} + for h in hits: + h["origin"] = "pre-existing" if (h["sldId"], h["shape_id"], h["text"], tuple(h["colours"])) in old else "new" + if a.json: + print(json.dumps(hits, indent=1)) + else: + for h in hits: + print(f"{h['slide']:3d} sldId {h['sldId']} shape {h['shape_id']} line {h['line']:2d} {h['kind']}" + + (f" [{h['origin']}]" if "origin" in h else "") + f" {h['text'][:90]!r}") + print(f"colour_audit: {a.deck.name}: {len(hits)} hit(s) on " + f"{len({h['sldId'] for h in hits})} slide(s)") + return 1 if hits else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/_plans/s12-manifest/tools/companion_docx.py b/_plans/s12-manifest/tools/companion_docx.py new file mode 100644 index 00000000..1e126e50 --- /dev/null +++ b/_plans/s12-manifest/tools/companion_docx.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Edit a policy companion .docx in place so its text equals a new revision of its .md (decision 7). + + companion_docx.py [--ops ops.json] [--png rIdN=path ...] + +The docx was built from the md with one paragraph per md line (headings, bullets, numbered items, +continuation lines, one paragraph per fenced block, two per image: the picture and its alt text) and a +table per md table. This script parses both md revisions into that paragraph model, checks that the old md +reproduces the docx text exactly, aligns old and new with difflib, and applies the difference as +paragraph-level operations: set the text of a paragraph (runs rebuilt from the md's **bold**, `code` and +*italic* spans, formatting copied from existing runs of the same kind), insert a paragraph (a clone of an +existing paragraph of the same style) or delete one. Tables keep their shape; changed cells get new text. +--png replaces an embedded image by relationship id and rescales its height to the new aspect ratio. +At the end the output docx is re-read and compared with the new md; any difference is an error. +Run with the S12 venv python (python-docx 1.2.0). +""" +import copy +import difflib +import json +import re +import sys + +from docx import Document +from docx.oxml.ns import qn +from docx.table import Table +from docx.text.paragraph import Paragraph + +LINK = re.compile(r"\[([^\]]+)\]\([^)]+\)") + + +def spans(text): + """md inline -> [(text, kind)]; kind in plain, bold, code, italic (as the original converter did).""" + text = LINK.sub(r"\1", text) + out = [] + for i, part in enumerate(re.split(r"\*\*(.+?)\*\*", text)): + if i % 2: + out.append((part, "bold")) + continue + for j, p2 in enumerate(re.split(r"`([^`]+)`", part)): + if j % 2: + out.append((p2, "code")) + continue + for k, p3 in enumerate(re.split(r"(?.fixN.json (committed decks) or appends the ops to +the deck JSON (Go, C++; ops tagged "_fixA"), and writes the scratch result to $FIXA/.pptx. +Run with the S12 venv python from the repository root. +""" +from __future__ import annotations + +import copy +import json +import os +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / "scripts")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import deck_edit as de # noqa: E402 +import colour_audit # noqa: E402 +import fixA_rules as R # noqa: E402 +from pptx import Presentation # noqa: E402 + +FIXA = Path(os.environ.get("FIXA", "/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/fixA")) +EDITS = REPO / "lgtm-presentation" / "edits" +COMMITTED = {"Python": "d51c29f", "Quarkus": "40db0f0", "Spring": "94b7ec5", "DotNet": "99c5bf5"} +OUTFILE = {"Python": "fix1", "Quarkus": "fix1", "Spring": "fix2", "DotNet": "fix2"} + + +def name(d): + return f"Designing-Cloud-Native-APIs-{d}" + + +def base_deck(d): + """Path of the deck the fixA ops apply to.""" + out = FIXA / "base" / f"{d}.pptx" + out.parent.mkdir(parents=True, exist_ok=True) + pptx = REPO / "lgtm-presentation" / f"{name(d)}.pptx" + if d in COMMITTED: + subprocess.run(["cp", str(pptx), str(out)], check=True) + return out + jf = EDITS / f"{name(d)}.json" + ops = [o for o in json.loads(jf.read_text())["ops"] if not o.get("_fixA")] + tmp = FIXA / "base" / f"{d}.nofixA.json" + tmp.write_text(json.dumps({"ops": ops})) + r = subprocess.run([sys.executable, str(REPO / "scripts/deck_edit.py"), str(pptx), str(EDITS / "_shared.json"), + str(tmp), "--out", str(out)], cwd=REPO, capture_output=True, text=True) + if r.returncode: + raise SystemExit(f"{d}: base apply failed: {r.stderr[-800:]}") + return out + + +def count_replace(ctx, op): + """Apply a replace op and return its real count (no expect check).""" + o = dict(op, expect=0) + try: + de.op_replace(ctx, o) + return 0 + except de.EditError as e: + msg = str(e) + return int(msg.rsplit("found ", 1)[1]) + + +def locate_pair(ctx, find, repl, sids=None, scopes=("slide", "notes")): + """Apply find->repl everywhere (or on sids); return ops with sldIds and counts.""" + ops = [] + for scope in scopes: + hits, total = [], 0 + for s in ctx.prs.slides: + if sids is not None and s.slide_id not in sids: + continue + roots = [] + if scope == "slide": + roots.append(s._element) + else: + for r in de.notes_roots(s): + for sp in de.all_sps(r): + ph = sp.find(f"{de.q(de.P, 'nvSpPr')}/{de.q(de.P, 'nvPr')}/{de.q(de.P, 'ph')}") + if ph is not None and ph.get("type") == "body": + roots.append(sp) + n = 0 + for root in roots: + for p in root.iter(de.q(de.A, "p")): + n += de.replace_in_paragraph(p, find, repl, f"sldId {s.slide_id}", ctx.log) + if n: + hits.append(s.slide_id) + total += n + if total: + ops.append({"op": "replace", "scope": scope, "sldIds": hits, "find": find, "repl": repl, + "expect": total}) + return ops + + +FOUND = {} + + +def code_subst(ctx, op): + """Turn {"op": "code_subst", "sldId", "subst": [[old line, [new lines]], ...]} into a set_code + op with the box's current lines, each old line (exact, once) replaced.""" + s = ctx.slide(op["sldId"]) + sp = de.select_sp(s._element, op, kind="code") + lines = [l["text"] for l in de.code_lines(sp.find(de.q(de.P, "txBody")))] + for old, new in op["subst"]: + idx = [i for i, l in enumerate(lines) if l == old] + if len(idx) != 1: + raise SystemExit(f"code_subst sldId {op['sldId']}: {old!r} found {len(idx)} times") + lines[idx[0]:idx[0] + 1] = new + o = {"op": "set_code", "sldId": op["sldId"], "lines": lines} + for k in ("shape", "shape_id", "pt", "line_spacing", "cat", "_why"): + if k in op: + o[k] = op[k] + return o + + +def run_op(ctx, op): + de.OPS[op["op"]](ctx, op) + + +def build(d, report): + base = base_deck(d) + prs = Presentation(str(base)) + ctx = de.Ctx(prs) + out = [] + # 1 + 2: base and content ops (replace counts re-measured) + for src, op in R.base_ops(d) + R.content_ops(d): + if op["op"] == "replace" and op.get("locate"): + meta = {k: v for k, v in op.items() if k in ("cat", "_why")} + scopes = (op["scope"],) if op.get("scope") else ("slide", "notes") + found = locate_pair(ctx, op["find"], op["repl"], None, scopes) + FOUND.setdefault(("content", op["find"]), []).extend([d] if found else []) + for o in found: + out.append(dict(o, **meta, _src=src)) + continue + if op["op"] == "code_subst": + op = code_subst(ctx, op) + if op["op"] == "replace": + n = count_replace(ctx, op) + if n == 0: + report.append(f"{d}: dropped ({src}, no match): {op['find'][:70]!r}") + continue + if n != op["expect"]: + report.append(f"{d}: re-measured ({src}): {op['find'][:60]!r} {op['expect']} -> {n}") + out.append(dict(op, expect=n, _src=src)) + else: + run_op(ctx, op) + out.append(dict(op, _src=src)) + # 3: caps pairs + for find, repl, sids in R.caps_pairs(d): + ops = locate_pair(ctx, find, repl, sids) + FOUND.setdefault(("caps", find), []).extend([d] if ops else []) + for o in ops: + out.append(dict(o, cat="voice", _src="caps")) + # 4: recolour every code box the audit still flags + tmp = FIXA / "base" / f"{d}.pre-recolour.pptx" + prs.save(str(tmp)) + boxes = sorted({(h["sldId"], h["shape_id"]) for h in colour_audit.audit(tmp)}) + for sid, shid in boxes: + # allow_noop: replaying the whole chain with the fixed set_code already colours most of + # these boxes; the op then only has to hold, not to change anything + op = {"op": "recolour_code", "sldId": sid, "shape_id": shid, "allow_noop": True, "cat": "code", + "_src": "colour"} + run_op(ctx, op) + out.append(op) + res = FIXA / f"{d}.pptx" + prs.save(str(res)) + return out, res, base + + +def main(argv): + write = "--write" in argv + decks = [a for a in argv if not a.startswith("--")] or ["Python", "Quarkus", "Spring", "DotNet", "Go", "Cpp"] + report = [] + for d in decks: + ops, res, base = build(d, report) + cats = {} + for o in ops: + cats[o["_src"]] = cats.get(o["_src"], 0) + 1 + print(f"{d}: {len(ops)} ops {cats} -> {res}") + if write: + if d in COMMITTED: + f = EDITS / f"{name(d)}.{OUTFILE[d]}.json" + f.write_text(json.dumps({"ops": ops}, indent=1, ensure_ascii=False) + "\n") + else: + f = EDITS / f"{name(d)}.json" + data = json.loads(f.read_text()) + data["ops"] = [o for o in data["ops"] if not o.get("_fixA")] + [dict(o, _fixA=True) for o in ops] + f.write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n") + print(f" wrote {f.relative_to(REPO)}") + for (kind, find), ds in FOUND.items(): + if not ds: + report.append(f"{kind} rule matched in no deck: {find!r}") + for r in report: + print(" " + r) + hits = {} + for (kind, find), ds in FOUND.items(): + for x in ds: + hits[x] = hits.get(x, 0) + 1 + print(" rules matched per deck:", hits) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/_plans/s12-manifest/tools/fixA_rules.py b/_plans/s12-manifest/tools/fixA_rules.py new file mode 100644 index 00000000..3599265e --- /dev/null +++ b/_plans/s12-manifest/tools/fixA_rules.py @@ -0,0 +1,297 @@ +"""S12 fix round A: the rules fixA_build.py turns into fixup ops (see that file). + +base_ops(d) validator inputs in _plans/s12-manifest/fixA/ (Quarkus Q.fix1 + Q.fix2, capsfix) +content_ops(d) figure, code accuracy, notes drift, advisory and polish ops; a replace without + sldIds and with "locate": true is placed on every slide/notes body where it matches + (0 matches is allowed); "code_subst" is turned into a set_code of the box's lines +caps_pairs(d) (find, repl, sldIds or None) pairs that lower-case ALL-CAPS emphasis or remove a + soft positional reference; located the same way +""" +import json +from pathlib import Path + +FIXA = Path(__file__).resolve().parent.parent / "fixA" +PNG = "lgtm-presentation/edits/png" + + +def _load(fn): + return json.loads((FIXA / fn).read_text())["ops"] + + +def base_ops(d): + if d == "Quarkus": + return ([("Q.fix1", o) for o in _load("quarkus-q.fix1.json")] + + [("Q.fix2", o) for o in _load("quarkus-q.fix2.json")]) + return [("capsfix", o) for o in _load(f"{d}.capsfix.json")] + + +def loc(find, repl, cat, why, scope=None): + o = {"op": "replace", "locate": True, "find": find, "repl": repl, "cat": cat, "_why": why} + if scope: + o["scope"] = scope + return o + + +# replace ops located in every deck where the text exists +COMMON_CONTENT = [ + # advisory: OAuth2 Proxy (and Pomerium's open-source core) do not assess device posture + loc("validate identity (OAuth/OIDC against your IdP), check device posture, then issue a JWT", + "validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT", + "code", "OAuth2 Proxy has no device-posture check; the proxies enforce per-route identity policy"), + loc("validate identity (OIDC against your IdP), check device posture, issue a JWT", + "validate identity (OIDC against your IdP), apply the route's access policy, issue a JWT", + "code", "OAuth2 Proxy has no device-posture check (C++ slide wording)"), + loc("validates identity against your IdP (OIDC), checks device posture, and issues a JWT", + "validates identity against your IdP (OIDC), applies the route's access policy, and issues a JWT", + "code", "OAuth2 Proxy has no device-posture check"), + # advisory: vendor framework citation -> vendor-neutral reference already used in the decks' family + loc("; Microsoft Cloud Adoption Framework: security patterns.", "; OWASP Microservices Security Cheat Sheet.", + "stack", "vendor-neutral reference (decision 7)"), + # notes / caption drift ("persisted FAILED state" and the header-injection caption are in the capsfix files) +] + +DECK_CONTENT = { + "Python": [ + loc("Semaphore(50))", "Semaphore(5))", "code", "examples/12-security/python BULKHEAD_LIMIT = 5"), + loc("# 50 concurrent requests per tenant;", "# 5 concurrent requests per tenant;", "code", "bulkhead 5"), + loc("once 50 are running", "once 5 are running", "code", "bulkhead 5"), + ], + "Spring": [ + loc("tracer.start_span(...), span.set_attribute(...), span.add_event(...)", "tracer.spanBuilder(...).startSpan(), span.setAttribute(...), span.addEvent(...)", "code", "OpenTelemetry Java API names, not Python's", "slide"), + loc("// 50 by default", "// maxConcurrentCalls: 5", "code", + "examples/12-security/spring-boot CAPACITY = 5; Resilience4j's own default is 25, not 50"), + ], + "DotNet": [ + loc("tracer.start_span(...), span.set_attribute(...), span.add_event(...)", "activitySource.StartActivity(...), activity.SetTag(...), activity.AddEvent(...)", "code", "System.Diagnostics Activity API (OpenTelemetry .NET), not Python's", "slide"), + loc("new SemaphoreSlim(20)); // 20 / tenant", "new SemaphoreSlim(5)); // 5 / tenant", "code", + "examples/12-security/dotnet bulkheadLimit = 5"), + loc("its own twenty slots", "its own five slots", "code", "bulkhead 5"), + loc('app.MapHealthChecks("/healthz/live", new()', 'app.MapHealthChecks("/healthz", new()', "code", + "examples/01-cloud-native-principles/dotnet maps /healthz and /readyz"), + loc('app.MapHealthChecks("/healthz/ready", new()', 'app.MapHealthChecks("/readyz", new()', "code", + "examples/01-cloud-native-principles/dotnet maps /healthz and /readyz"), + loc("// /healthz/ready → 503", "// /readyz → 503", "code", + "examples/21-graceful-shutdown/dotnet maps /readyz"), + loc("Map health checks at /healthz/live and /healthz/ready;", "Map health checks at /healthz and /readyz;", "code", + "the examples' probe paths"), + loc('s.Status = "FAILED";', 's.Status = "COMPENSATED";', "code", + "examples/17-sagas/dotnet ends a compensated saga in COMPENSATED (the notes say so after capsfix)"), + ], + "Go": [ + loc("make(chan struct{}, 20)) // 20 per tenant", "make(chan struct{}, 5)) // 5 per tenant", "code", + "examples/12-security/go bulkheadCapacity = 5"), + loc("at most 20 requests per tenant", "at most 5 requests per tenant", "code", "bulkhead 5"), + ], + "Cpp": [ + loc("std::make_unique(50)); // 50 / tenant", "std::make_unique(5)); // 5 / tenant", "code", + "examples/12-security/cpp CAPACITY = 5"), + loc("50 concurrent requests per tenant", "5 concurrent requests per tenant", "code", "bulkhead 5"), + ], + "Quarkus": [ + loc("tracer.start_span(...), span.set_attribute(...), span.add_event(...)", "tracer.spanBuilder(...).startSpan(), span.setAttribute(...), span.addEvent(...)", "code", "OpenTelemetry Java API names, not Python's", "slide"), + {"op": "code_subst", "sldId": 298, "cat": "code", + "_why": "Kafka Streams: the windowed Double result needs explicit serdes on .to(...)", + "subst": [[" StreamsBuilder b = new StreamsBuilder();", + [" StreamsBuilder b = new StreamsBuilder();", + " var windowSerde = WindowedSerdes.timeWindowedSerdeFrom(String.class, 300_000L);"]], + [" .to(\"revenue.by-merchant\");", + [" .to(\"revenue.by-merchant\", Produced.with(windowSerde, Serdes.Double()));"]]]}, + ], +} + +FIGURE = { # the outbox figure whose heading said "Dual-write is a lie" + "Python": (286, "Google Shape;349;p33", "04-dual-write-outbox.png"), + "Quarkus": (286, "Google Shape;349;p33", "04-dual-write-outbox.png"), + "Spring": (286, "Google Shape;349;p33", "04-dual-write-outbox.png"), + "DotNet": (288, "Google Shape;370;p35", "04-dual-write-outbox.png"), + "Go": (286, "Google Shape;349;p33", "04-dual-write-outbox.png"), + "Cpp": (286, "Google Shape;349;p33", "04-dual-write-outbox-cpp.png"), +} + +TOPOLOGY = {"Python": 531, "Spring": 535, "DotNet": 562, "Go": 531, "Cpp": 531} # Quarkus: Q.fix2 + + +def _polish(d): + ops = [ + loc("Event-driven tools landscape — Kafka", "Event-driven tools landscape — Kafka", "voice", + "title spacing (one space around the dash, as the other titles)", "slide"), + loc("Valet key — scoped", "Valet key — scoped", "voice", "title spacing (Q.fix2 in Quarkus)", "slide"), + loc("Glossary of acronyms (1 of 2) — protocols", "Glossary of acronyms (1 of 2) — protocols", "voice", + "title spacing, as the (2 of 2) title", "slide"), + ] + if d in TOPOLOGY: + ops.append({"op": "set_geometry", "sldId": TOPOLOGY[d], "shape": "Image 1", "x": 2.16, "y": 1.95, + "w": 9.01, "h": 4.30, "cat": "fit", + "_why": "Appendix R topology figure enlarged to the free area, as Q.fix2 does in Quarkus"}) + return ops + + +def content_ops(d): + sid, shape, png = FIGURE[d] + ops = [{"op": "replace_picture", "sldId": sid, "shape": shape, "png": f"{PNG}/{png}", "cat": "voice", + "_why": "figure heading 'Dual-write is a lie' -> 'Dual-write is unsafe' (src/04-dual-write-outbox.svg)"}] + ops += COMMON_CONTENT + DECK_CONTENT.get(d, []) + _polish(d) + return [("content", o) for o in ops] + + +def P(find, repl): + return (find, repl, None) + + +COMMON_CAPS = [ + P("a field with a NEW number", "a field with a new number"), + P("SAFE: brand-new field", "safe: brand-new field"), + P("SAFE: new field; old clients", "safe: new field; old clients"), + P("BREAKING (don't)", "breaking (don't)"), + P("per-POD state", "per-pod state"), + P("per-POD registry", "per-pod registry"), + P("IDENTIFY: pick", "Identify: pick"), + P("MOVE: build", "Move: build"), + P("caches THAT — the canonical value", "caches that row — the canonical value"), + P("because you DO want", "because you do want"), + P("stops sending NEW traffic", "stops sending new traffic"), + P("an EXPLICIT timeout", "an explicit timeout"), + P("a DIFFERENT, denormalised", "a different, denormalised"), + P("nulls THIS field only", "nulls this field only"), + P("fail readiness FIRST", "fail readiness first"), + P("deliberately NOT writing to cache", "no cache write here"), + P("Retry ONLY idempotent calls", "Retry only idempotent calls"), + P("Do NOT decorate a non-idempotent POST", "Do not decorate a non-idempotent POST"), + P("Do NOT retry a non-idempotent POST", "Do not retry a non-idempotent POST"), + P("flip to NOT ready while draining", "flip to not-ready while draining"), + P("// NOT write store", "// read store only"), + P("projections AND the outbox pattern (covered next) work", "projections and the outbox pattern work"), + P("plain Java that LOOKS synchronous", "plain Java that looks synchronous"), + P("workflow-local variable AND in Temporal's", "workflow-local variable and in Temporal's"), + P("always invokes the method AND writes", "always invokes the method and writes"), + P("Always runs the method body AND writes", "Always runs the method body and writes"), + P("the constraint AND eventual durability is acceptable AND you", "the constraint, eventual durability is acceptable and you"), + P("is the constraint AND eventual durability is acceptable.", "is the constraint and eventual durability is acceptable."), + P("only ONE instance flushes", "only one instance flushes"), + P("only ONE flusher Pod", "only one flusher Pod"), + P("only ONE Pod refreshing", "only one Pod refreshing"), + P("ALWAYS pair retry with a breaker", "always pair retry with a breaker"), + P("Pods — OR ", "Pods — or "), + # .NET + P("SCALING THE BLAZOR SERVER POOL", "scaling the Blazor Server pool"), + P("this REPLACES the MSDTC", "this replaces the MSDTC"), + P("this REPLACES the old MSDTC", "this replaces the old MSDTC"), + P("'save the order AND queue the event'", "'save the order and queue the event'"), + P("this is THE most important pattern", "this is the most important pattern"), + P("You DON'T want one", "You don't want one"), + P("write the state change AND an outbox row in ONE local", "write the state change and an outbox row in one local"), + P("server-side WITHOUT persisting it", "server-side without persisting it"), + P("your .NET Pods talk TO", "your .NET Pods talk to"), + P("SET, not REMOVE", "set, not remove"), + P("on all OTHER Pods", "on all other Pods"), + P("read STILL returns", "read still returns"), + P("when EITHER the caller", "when either the caller"), + P("backoff WITH jitter", "backoff with jitter"), + P("IDistributedCache OR + FusionCache", "IDistributedCache or FusionCache"), + P("sorted set OR FusionCache", "sorted set or FusionCache"), + # soft positional references + P(" and give you the mesh CRDs we'll see next.", " and give you the mesh CRDs (VirtualService, DestinationRule)."), + P("Pair with a circuit breaker (next slides) so a down dep stops retries entirely.", + "Pair with a circuit breaker so a down dependency stops retries entirely."), + P("Strength is a ladder we'll see next (intrusive to contract).", "Strength is a ladder (intrusive to contract)."), + P(" (write-through, covered later in this appendix)", " (write-through)"), + P("(and the one we'll see code for)", "(and the one the code models)"), + P("the scaling problem the following slides solve with a Redis backplane", + "the scaling problem a Redis backplane solves"), + P("The next slides cover each with code; the final matrix maps patterns to modes.", + "Each one gets code, and a matrix maps patterns to modes."), + P("The next slides cover three ways to do the redirect:", "There are three ways to do the redirect:"), + P("The next slides give each its own diagram and per-language code", "Each gets its own diagram and per-language code"), + P("The next slides give you a decision rule and then show each protocol in code", + "The section gives a decision rule and then shows each protocol in code"), + P("ties back to the stickiness point covered earlier.", "ties back to the session-affinity trade-off."), + P("payload modification that we'll see more of — the L7 router", "payload modification — the L7 router"), + P("state, which is the next section.", "state, the subject of stream processing."), + P("that the next slides alternate between pattern-and-implementation pairs", + "that the section alternates between pattern-and-implementation pairs"), +] + +CPP_CAPS = [ + P("NO in-process", "no in-process"), + P("parsed ONCE in main()", "parsed once in main()"), + P("configured ONCE per artifact", "configured once per artifact"), + P("Set ONCE at the edge", "Set once at the edge"), + P("Push the aggregation OUT to the edge", "Push the aggregation out to the edge"), + P("things to design OUT of a C++ service", "things to design out of a C++ service"), + P("writes the row AND an outbox row", "writes the row and an outbox row"), + P("write state AND an outbox row", "write state and an outbox row"), + P("Event STREAMING means", "Event streaming means"), + P("are TRANSPORTED", "are transported"), + P("use event STREAMING (with", "use event streaming (with"), + P("There is NO first-class Temporal C++ SDK", "There is no first-class Temporal C++ SDK"), + P("as ACTIVITIES, invoked", "as activities, invoked"), + P("for THIS tenant?", "for this tenant?"), + P("// BATCH, not Simple", "// batch, not simple"), + P("To FILTER traces", "To filter traces"), + P("ONE trace_id binds", "One trace_id binds"), + P("ESSENTIAL: stops", "Essential: stops"), + P("audit logging UNIFORMLY across", "audit logging uniformly across"), + P("uploading a file THROUGH the C++", "uploading a file through the C++"), + P("lives in YOUR logs", "lives in your logs"), + P("record WHO made each request", "record who made each request"), + P("and WHAT they were", "and what they were"), + P("are a SEPARATE bulkhead", "are a separate bulkhead"), + P("use the field NAMES for accessor", "use the field names for accessor"), + P("for any tag number AND name", "for any tag number and name"), + P("stubs are BUILD output", "stubs are build output"), + P("// THIS pod now owns this user", "// this pod now owns the user"), + P("when ANY pod wants", "when any pod wants"), + P("produce IDENTICAL output", "produce identical output"), + P("a STABLE machine code", "a stable machine code"), + P("you've LOST the record", "you've lost the record"), + P("Other fields STILL RETURN", "Other fields still return"), + P("clients ALL retry now", "clients all retry now"), + P("the LANGUAGE to talk about WHERE service", "the language to talk about where service"), + P("business: CORE (your", "business: core (your"), + P("goes), SUPPORTING (necessary", "goes), supporting (necessary"), + P("shipping), GENERIC (commoditised", "shipping), generic (commoditised"), + P("Inside the context, ONE model.", "Inside the context, one model."), + P("boundaries SHOULD match", "boundaries should match"), + P("published language for ALL consumers", "published language for all consumers"), + P("published TO?", "published to?"), + P("injection by REFERENCE, not", "injection by reference, not"), + P("An AGGREGATE is a consistency", "An aggregate is a consistency"), + P("stay within ONE aggregate", "stay within one aggregate"), + P("through DOMAIN EVENTS (asynchronously", "through domain events (asynchronously"), + P("name what BUSINESS-LEVEL happened", "name what happened at the business level"), + P("framing is BALANCED coupling", "framing is balanced coupling"), + P("change TOGETHER need", "change together need"), + P("shared database: AVOID.", "shared database: avoid."), + P("services have INDEPENDENT volatility", "services have independent volatility"), + P("by REAL DDD-bounded contexts and INDEPENDENT volatility", "by real DDD-bounded contexts and independent volatility"), + P("Two things that DO change together", "Two things that do change together"), + P("are MODERATE strength", "are moderate strength"), + P("are LOW strength on read", "are low strength on read"), + P("but MODERATE on write paths", "but moderate on write paths"), + P("are LOWEST strength", "are lowest strength"), + P("stubs are GENERATED C++", "stubs are generated C++"), + P("in one service is FREE", "in one service is free"), + P("between modules is FREE", "between modules is free"), + P("Kubernetes ASSUMES this", "Kubernetes assumes this"), + P("Fail readiness FIRST.", "Fail readiness first."), + P("start the countdown EARLY", "start the countdown early"), + P("continue on OTHER pods", "continue on other pods"), + P("should have ONE shutdown coordinator", "should have one shutdown coordinator"), + P("Failing it RESTARTS the pod", "Failing it restarts the pod"), + P("and TEST it under load", "and test it under load"), + P("happens WITHOUT modifying your binary", "happens without modifying your binary"), + P("a unit of DEPLOYMENT, not", "a unit of deployment, not"), + P("splitting the data FIRST while", "splitting the data first while"), + P("(1) IDENTIFY the seam", "(1) Identify the seam"), + P("(2) MOVE — implement", "(2) Move — implement"), + P("a USEFUL INTERMEDIATE state", "a useful intermediate state"), + P("is often FASTER and more", "is often faster and more"), + P("health to OBSERVED traffic", "health to observed traffic"), + P("shutdown IS a failure mode", "shutdown is a failure mode"), + P("How to SEE the failure modes", "How to see the failure modes"), + P("reads the payload AND business context", "reads the payload and business context"), +] + + +def caps_pairs(d): + return COMMON_CAPS + (CPP_CAPS if d == "Cpp" else []) diff --git a/_plans/s12-manifest/tools/fixA_verify.py b/_plans/s12-manifest/tools/fixA_verify.py new file mode 100644 index 00000000..5d131fb8 --- /dev/null +++ b/_plans/s12-manifest/tools/fixA_verify.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""S12 fix round A: scan edited decks (scratch copies) for every gate of the round. + + fixA_verify.py [...] + +Per deck: colour_audit (code lines coloured as comments and every other colour hit), caps_audit +(ALL-CAPS emphasis outside the allowlist), the voice scanner's ban tier on the text copy (prose, +and --raw with code included), positional terms (the hits.py set plus soft references such as +"covered next", "two slides back", "slide later", "next slides", "we'll see next"), the old +engine name and the stale-stack denylist. Writes .md next to the deck. Exit 1 on any hit. +Run with the S12 venv python. +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(REPO / "scripts")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import deck_text # noqa: E402 +import colour_audit # noqa: E402 +import caps_audit # noqa: E402 + +POS = [r"\babove\b", r"\bbelow\b", r"\bnext\s+slides?\b", r"\bprevious\s+slides?\b", r"\bprevious\s+section\b", + r"\bfollowing\s+(section|slides?)\b", r"\bsee\s+slide\s+\d+", r"\bpreceding\b", r"\bcovered\s+(next|later|earlier)\b", + r"\bslides?\s+(back|later|earlier|ago)\b", r"\b(later|earlier)\s+slides?\b", r"\bwe'll\s+see\s+(next|later|more)\b", + r"\bnext\s+section\b", r"\bthe\s+slide\s+(before|after)\b", r"\bin\s+a\s+moment\b"] +POS = [re.compile(p, re.I) for p in POS] +EX = [re.compile(r"\b(?:above|below)\s+(?:[$~<>]?\d|zero|the\s+(?:threshold|limit|cutoff|floor|ceiling))", re.I), + re.compile(r"\b(?:above|below)-\w+", re.I), re.compile(r"\b(?:at\s+or|or)\s+(?:above|below)\b", re.I)] +DENY = json.loads((REPO / "_plans/s12-manifest/denylist.json").read_text()) + + +def voice_ban(md, raw=False): + cmd = ["bash", str(REPO / "scripts/voice/scan.sh"), "--tier", "ban", "--hits", "all"] + (["--raw"] if raw else []) + [str(md)] + out = subprocess.run(cmd, capture_output=True, text=True).stdout + return [l for l in out.splitlines() if re.match(r"^\S+:\d+:", l)] + + +def check(deck: Path): + md_text, _ = deck_text.pptx_to_md(deck) + md = deck.with_suffix(".md") + md.write_text(md_text) + name = deck.stem.split(".")[0] + res = {} + hits = colour_audit.audit(deck) + res["colour code-as-comment"] = [h for h in hits if h["kind"] == "code-as-comment"] + res["colour other"] = [h for h in hits if h["kind"] != "code-as-comment"] + res["caps"] = [h for h in caps_audit.scan(md) if not h["allow"]] + res["ban"] = voice_ban(md) + res["ban --raw"] = voice_ban(md, raw=True) + pos, eng, deny = [], [], [] + fence = False + for i, line in enumerate(md_text.split("\n"), 1): + if line.startswith("```"): + fence = not fence + continue + if re.search("p" + "odman", line, re.I): + eng.append(i) + for e in DENY: + if e.get("files") and not any(f in deck.name or f.endswith(name) for f in e["files"]): + continue + if re.search(e["regex"], line, re.I if "i" in e.get("flags", "") else 0): + if e.get("exempt") and re.search(e["exempt"], line, re.I): + continue + deny.append(f"{e['id']}:{i}:{line.strip()[:80]}") + if fence: + continue + l2 = re.sub(r"`[^`]*`", " ", line) + for rx in POS: + for m in rx.finditer(l2): + ctx = l2[max(0, m.start() - 4): m.end() + 20] + if not any(x.search(ctx) for x in EX): + pos.append(f"{i}:{m.group(0)}: {l2.strip()[:90]}") + res["positional"], res["engine"], res["denylist"] = pos, eng, deny + return res + + +def main(argv): + bad = 0 + for a in argv: + r = check(Path(a)) + line = ", ".join(f"{k} {len(v)}" for k, v in r.items()) + print(f"{Path(a).name}: {line}") + for k, v in r.items(): + for x in v[:15]: + print(f" {k}: {x if isinstance(x, str) else json.dumps(x)[:160]}") + bad += len(v) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/_plans/s12-manifest/tools/gen_advisory.py b/_plans/s12-manifest/tools/gen_advisory.py new file mode 100644 index 00000000..931fcb15 --- /dev/null +++ b/_plans/s12-manifest/tools/gen_advisory.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +"""Decision 7: the Network-Policy-Process-Advisory deck reworked to upstream Kubernetes and open source. + +Same process and phases; ACS, OpenShift (OVN-Kubernetes, EgressFirewall, oc, OpenShift GitOps) and the +Red Hat attribution of the recommendations are replaced by upstream APIs and open-source tools. Facts and +citations: _plans/s12-manifest/Network-Policy-Process-Advisory.md. Writes the whole edit file (the earlier +voice and link ops are superseded: every slide's text and notes are set here). +""" +import json +from pathlib import Path + +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +OUT = REPO / "lgtm-presentation" / "edits" / "Network-Policy-Process-Advisory.json" +P = "lgtm-presentation/edits/png/" + +# sldId: (eyebrow or None, title or None, body paragraphs or None, caption or None, caption shape, notes) +S = {} +S[256] = dict(title=["Network Policy Process", + "Advisory — Shifting from perimeter to in-cluster segmentation."], notes=[ + "Welcome the audience and set context. This deck presents a recommended approach for operationalizing " + "in-cluster network segmentation on Kubernetes, with upstream APIs and open-source tools. The starting point " + "is common: perimeter and east-west controls run through the firewall team on a traditional appliance, there " + "is no in-cluster network policy, and developers have never written a firewall rule. The goal is not to pick " + "tools or assign owners; it is to describe a process and a maturity path that the organization can adopt at " + "its own pace. We will walk through where organizations typically start, how to handle the developer skill " + "gap, the CI/CD flow for policy changes, repo structure, day-two operations, the firewall team's evolving " + "role, and what maturity looks like. Ask the audience: do you have a documented process for who authors, " + "reviews, and approves network policies in your clusters today? The answer usually reveals the gap this deck " + "fills."]) +S[257] = dict(body=[ + "Perimeter and east-west controls run through the firewall team on a traditional appliance today.", + "The organization wants to shift network segmentation left into the cluster (NetworkPolicy, " + "ClusterNetworkPolicy, egress rules) but has not defined the workflow.", + "Developers are not security specialists. Most have never written a firewall rule and don't inherently know " + "what traffic their applications require.", + "The goal: a recommended process for operationalizing in-cluster network segmentation, not a tool selection " + "exercise or an ownership assignment.", +], caption="A recommended approach, built on upstream Kubernetes and open-source tools.", notes=[ + "Frame the current state without judgment. The perimeter firewall handles everything: north-south and " + "east-west traffic controls run through the firewall team on the perimeter appliance. There is no Kubernetes " + "NetworkPolicy in the cluster. Developers are not security specialists; most have never written a firewall " + "rule and do not inherently know what traffic their applications require. This is not unusual. Most " + "organizations start in exactly this position. The shift to in-cluster segmentation is not about replacing " + "the firewall team or the perimeter; it is about extending the organization's security posture into a layer " + "the perimeter cannot see. The perimeter firewall has no visibility into pod-to-pod traffic inside the " + "cluster. A compromised pod behind the perimeter can reach every other pod unless the cluster itself " + "enforces segmentation. That is the gap this process fills."]) +S[258] = dict(title="The four-phase maturity path", body=[ + "Phase 1 — Observe: understand what traffic flows today from the CNI's flow logs (Hubble or Calico). No " + "enforcement yet.", + "Phase 2 — Baseline: apply default-deny in every namespace, generate policies from the observed flows, clean " + "them up.", + "Phase 3 — Guardrails: introduce cluster-scoped ClusterNetworkPolicy (Admin and Baseline tiers) owned by the " + "platform team.", + "Phase 4 — Developer ownership: app teams author and own their namespace-scoped NetworkPolicies via PR.", + "The timeline for each phase depends on the organization's complexity, team capacity, and risk tolerance. " + "Each phase builds on the previous.", +], notes=[ + "Walk through the four phases. The most important point is that these are sequential and the timeline " + "depends on the organization's complexity and risk tolerance. Phase 1 is observation: reading the CNI's flow " + "logs to discover what traffic flows before writing any policy. Phase 2 is baseline: applying default-deny " + "and cleaning up policies generated from the observed flows. Phase 3 is guardrails: the platform team " + "introduces cluster-scoped ClusterNetworkPolicy rules in the Admin and Baseline tiers that developers cannot " + "override. Phase 4 is developer ownership: app teams author their own namespace-scoped NetworkPolicies via " + "pull request. The most common failure is skipping Phase 1 and jumping straight to enforcement; teams that " + "did this caused outages from denied traffic they did not know existed. The observation phase pays for " + "itself by revealing undocumented dependencies and unauthorized connections. Stress that each phase builds " + "on the previous: you cannot do Phase 3 well without Phase 2's baseline, and Phase 2 is reckless without " + "Phase 1's visibility."]) +S[259] = dict(title="Observe the flows", body=[ + "The CNI records flows between pods, namespaces, and external destinations: Hubble with Cilium, flow logs " + "(Goldmane, Whisker) with Calico.", + "Inspektor Gadget's advise_networkpolicy gadget records the TCP and UDP traffic of chosen namespaces. " + "Together they give the network baseline, the source of truth for what traffic is expected.", + "The firewall team and platform team review the flows together and identify which services talk to each " + "other, which make outbound calls, and which cross namespace boundaries.", + "What field teams typically see: organizations are surprised by the volume of east-west traffic that was " + "invisible behind the perimeter.", +], notes=[ + "Explain what observation means without a commercial console. The CNI already sees every flow. With " + "Cilium, Hubble shows them (hubble observe, or the Hubble UI); with Calico 3.30 and later, Felix sends flow " + "logs to Goldmane and the Whisker console shows them. Inspektor Gadget, a CNCF Sandbox project, adds the " + "advise_networkpolicy gadget: it records the TCP and UDP traffic of the namespaces you choose and later " + "turns it into NetworkPolicy YAML. At this stage, nothing is denied. The goal is pure visibility. The " + "firewall team and platform team review the observed flows together. They identify which services talk to " + "each other and which should not, which services make outbound calls to external endpoints, which " + "connections cross namespace boundaries, and which traffic patterns were previously invisible behind the " + "perimeter. What field teams typically see: organizations are surprised by the volume of east-west traffic " + "they did not know existed. A frontend directly querying a database it should go through an API for. A " + "monitoring agent making outbound calls to a SaaS endpoint. Discovery is the highest-value step."]) +S[260] = dict(title="Baseline: default-deny + generated policies", body=[ + "Apply default-deny NetworkPolicy in every namespace — the single highest-impact security measure.", + "Generate NetworkPolicy YAML from the observed flows: advise_networkpolicy writes one policy per workload, " + "with rules from the observed peers.", + "Generated policies capture what traffic did flow, not what should flow. The platform team reviews, removes " + "unauthorized connections, and applies cleaned-up policies alongside default-deny.", + "The first default-deny rollout reveals undocumented dependencies — monitoring agents, legacy batch jobs, " + "SaaS sidecars. Discovering these in dev/test is the point.", +], caption="Generated policies are a starting point, not the final state.", notes=[ + "Walk through the baseline phase. The first action is applying default-deny NetworkPolicy in every " + "namespace: podSelector empty, policyTypes Ingress and Egress. This is the single highest-impact security " + "measure. Simultaneously, generate NetworkPolicy YAML from the observed flows. Inspektor Gadget's " + "advise_networkpolicy writes one policy per workload, with ingress and egress rules based on the peers it " + "saw; it can also write CiliumNetworkPolicy. On Calico, a StagedNetworkPolicy shows in the flow logs what a " + "policy would deny before it is enforced. These generated policies capture what traffic did flow, not what " + "traffic should flow. The platform team reviews them, removes connections that should not have existed, " + "and applies the cleaned-up policies alongside the default-deny. This audit step finds the connections " + "nobody documented. The first default-deny rollout in a non-prod namespace immediately reveals undocumented " + "dependencies: a monitoring agent scraping every pod, a legacy batch job connecting to an external " + "database, a sidecar phoning home to a SaaS endpoint. Discovering these in dev or test is the point."]) +S[261] = dict(body=[ + "Phase 3: the platform team introduces cluster-scoped guardrails with ClusterNetworkPolicy, in its Admin and " + "Baseline tiers. These are the policies developers cannot override.", + "Phase 4: app teams begin authoring their own namespace-scoped NetworkPolicies via pull request, with CI " + "validation and platform team review.", + "The transition from Phase 3 to Phase 4 is gradual — developers start by reviewing generated baselines, then " + "modify them, then eventually author from scratch.", + "The platform team controls the guardrails. Developers control their application connectivity. Even if a " + "developer writes no policies at all, the Baseline tier ensures default-deny.", +], caption="The Baseline tier addresses the developer skill gap directly: safety by default.", notes=[ + "Explain the transition from platform-owned guardrails to developer ownership. In Phase 3, the platform " + "team introduces ClusterNetworkPolicy rules in the Admin and Baseline tiers: cluster-scoped rules that " + "developers cannot override. They enforce non-negotiable boundaries: deny cross-environment traffic, accept " + "monitoring, restrict control-plane access. In Phase 4, app teams begin authoring their own namespace-scoped " + "NetworkPolicies. The transition is gradual: developers start by reviewing generated baselines, then modify " + "them, then eventually author from scratch as they build familiarity. The key architectural point: even if " + "a developer writes no NetworkPolicies at all, the Baseline-tier default-deny keeps the namespace closed. " + "This directly addresses the developer skill gap: the platform provides safety by default, and developers " + "add precision as they learn. This is a fundamentally different model from asking every developer to be a " + "firewall engineer."]) +S[262] = dict(body=[ + "Tier 1 — ClusterNetworkPolicy, Admin tier: cluster-scoped, priority 0-1000, Accept/Deny/Pass. Cannot be " + "overridden by tenants.", + "Tier 2 — NetworkPolicy (NP): namespace-scoped, owned by app teams. Only evaluated if the Admin tier passed " + "the traffic.", + "Tier 3 — ClusterNetworkPolicy, Baseline tier: cluster-scoped safety net. Overridable by NetworkPolicy.", + "Admin Accept/Deny are final — NP and the Baseline tier are skipped. Admin Pass hands evaluation to the next " + "tier.", +], caption="Platform team controls tiers 1 and 3. App teams control tier 2.", caption_shape=6, notes=[ + "This is the most important conceptual slide. A CNI that implements the upstream ClusterNetworkPolicy API " + "evaluates traffic against three tiers of policy. Tier 1 is the Admin tier: cluster-scoped, priority zero " + "to one thousand, with Accept, Deny, or Pass actions. If the traffic matches an Admin Accept or Deny rule, " + "tiers 2 and 3 are skipped entirely. If it matches a Pass rule, evaluation hands to tier 2. Tier 2 is " + "standard Kubernetes NetworkPolicy: namespace-scoped, owned by app teams. Tier 3 is the Baseline tier, the " + "safety net: it catches traffic that the Admin tier passed and no NetworkPolicy matched. Point to the " + "diagram showing the evaluation flow. The value proposition: the platform team controls tiers 1 and 3, the " + "guardrails. Developers control tier 2, their application's policies. This separation of concerns is what " + "makes the model scalable."]) +S[263] = dict(eyebrow="ADMIN · BASELINE", title="ClusterNetworkPolicy: Admin and Baseline tiers", body=[ + "The Admin tier enforces non-negotiable rules: deny traffic to the control plane, accept monitoring agents " + "scraping all pods, deny cross-environment traffic (dev → prod).", + "A Pass rule explicitly delegates to the NetworkPolicy tier — the platform team says 'app teams, this one is " + "yours to decide.'", + "The Baseline tier is the safety net. Recommended: a default-deny Baseline policy. If no Admin rule and no NP " + "allows the traffic, the Baseline tier denies it.", + "Critical: an Admin-tier policy with an empty namespace selector ({}) matches all namespaces including " + "kube-system and the CNI's own. Use workload-specific label selectors.", +], caption="An overly broad Admin-tier policy can lock the cluster. Use workload-specific labels.", notes=[ + "Dive deeper into the two cluster tiers. The Admin tier enforces non-negotiable rules: deny traffic to the " + "control plane from application namespaces, accept monitoring agents scraping all pods, deny " + "cross-environment traffic like dev to prod. A Pass rule explicitly delegates the decision to the " + "NetworkPolicy tier: this is how the platform team says app teams, this one is yours to decide. The Baseline " + "tier is the safety net, configured as default-deny. If no Admin rule and no NetworkPolicy allows the " + "traffic, the Baseline tier denies it, so a namespace with no NetworkPolicies still has a default-deny " + "posture without the app team creating it. Critical caution: an Admin-tier policy with an empty namespace " + "selector matches all namespaces, including kube-system and the CNI's own namespaces. This can lock the " + "cluster. Label application namespaces and use that label as the namespace selector.", + "Status, as of network-policy-api v0.2.0 (April 2026): ClusterNetworkPolicy is " + "policy.networking.k8s.io/v1alpha2, an alpha API. It merged the earlier AdminNetworkPolicy and " + "BaselineAdminNetworkPolicy (v1alpha1), which upstream now lists as previous APIs, and renamed Allow to " + "Accept. Calico enforces it from 3.32 and no longer enforces the old pair; Cilium 1.20 enforces it behind " + "the k8sClusterNetworkPolicy.enabled Helm value. Check the release notes before each upgrade."]) +S[264] = dict(body=[ + "Generated baselines: developers review generated policies, not author from scratch. Reviewing a concrete " + "policy is dramatically easier than writing one from zero.", + "Platform-authored starter templates: web-facing service, internal API, batch job, database archetypes. " + "Developers pick the template, customize selectors, submit a PR.", + "The Baseline tier as safety net: a developer who deploys without any NetworkPolicy gets default-deny " + "automatically. Missing policies become connectivity issues, not security gaps.", + "Golden-path provisioning: new namespaces come with default-deny (a Kyverno generate rule), egress rules, " + "starter templates, and RBAC bindings pre-installed.", +], notes=[ + "Walk through the four approaches for bridging the developer skill gap. First, generated baselines invert " + "the skill requirement: developers review a policy generated from observed flows, they do not author one " + "from scratch. Reviewing a concrete policy (does this look right, should this connection exist) is " + "dramatically easier than writing one from zero. Second, platform-authored starter templates for common " + "workload archetypes: web-facing, internal API, batch job, database. Developers pick the template, customize " + "the namespace and label selectors, and submit a PR. Third, the Baseline tier as a safety net converts " + "missing policies from a security gap into a connectivity issue that surfaces immediately in testing. " + "Fourth, golden-path namespace provisioning includes default-deny, egress rules, starter templates, and RBAC " + "bindings automatically; Kyverno's add-network-policy sample is a generate rule that creates the " + "default-deny NetworkPolicy in every new namespace. The developer starts with a secured namespace and never " + "sees the everything-is-open starting point that leads to procrastination."]) +S[266] = dict(body=[ + "Schema check: kubeconform and kubectl apply --dry-run=client validate YAML and API version.", + "Policy lint: conftest / kyverno test / gator verify enforce organizational rules (no allow-all, required " + "labels); trivy config scans the manifests.", + "Baseline diff: compare the change with the policy generated from observed flows; flag traffic outside the " + "baseline.", + "Review: CODEOWNERS enforces — app teams author NP (platform reviews), platform authors ClusterNetworkPolicy " + "(firewall reviews).", +], caption_shape=6, notes=[ + "Walk through the policy CI/CD pipeline. Every policy change (NetworkPolicy, ClusterNetworkPolicy, egress " + "rules) flows through the same pipeline. Schema check with kubeconform and kubectl apply --dry-run=client " + "catches invalid YAML and wrong API versions. Policy lint with conftest, kyverno test or Gatekeeper's gator " + "verify catches organizational policy violations like allow-all rules or missing labels, and trivy config " + "scans the manifests (its KSV-0038 check flags network policies without pod or namespace selectors). The " + "baseline diff compares the change with the policy generated from observed flows and flags rules that would " + "allow traffic outside the baseline. Platform or security team reviews the PR; CODEOWNERS enforces who " + "reviews based on the file path. Merge triggers an Argo CD sync to staging, smoke tests validate " + "connectivity, then sync to production. Post-deploy validation reads the Hubble or Calico flow logs, and " + "Falco alerts on unexpected egress at runtime. Point to the diagram. The key principle: the CI pipeline is " + "the only path to production. No exceptions, no emergency kubectl apply, no just this once."]) +S[265] = dict(notes=None) # unchanged +S[267] = dict(body=[ + "platform-baselines/ — ClusterNetworkPolicy (Admin and Baseline tiers) and egress rules. Platform team " + "authors, firewall/security team reviews. Cluster-wide scope.", + "onboarding/ — starter NetworkPolicy templates per workload archetype. Platform team maintains, app teams " + "consume.", + "app-policies/ — per-namespace NetworkPolicy. App teams author, platform team reviews. Kustomize overlays " + "for dev/test/prod.", + "CODEOWNERS enforces review requirements by path. The same PR flow applies regardless of target " + "environment.", +], notes=[ + "Describe the three-layer repository model. The platform-baselines directory contains the " + "ClusterNetworkPolicy resources of the Admin and Baseline tiers and the egress rules: cluster-scoped " + "policies authored by the platform team and reviewed by the firewall and security team. The onboarding " + "directory contains starter NetworkPolicy templates per workload archetype, maintained by the platform team " + "and consumed by app teams as starting points. The app-policies directory contains per-namespace " + "NetworkPolicies authored by app teams, with base and overlay directories for Kustomize-based environment " + "promotion. CODEOWNERS enforces review requirements by path: platform-baselines requires firewall team " + "review, app-policies requires platform team review. This separation provides clear ownership boundaries. " + "The same PR that modifies a policy flows through the same CI pipeline regardless of whether it targets " + "dev, test, or production; only the Kustomize overlay differs."]) +S[268] = dict(body=[ + "Firewall team: authors egress rules, reviews the Admin and Baseline tiers, owns the perimeter firewall " + "(north-south and compliance).", + "Platform team: authors ClusterNetworkPolicy guardrails and starter templates, reviews app NetworkPolicies, " + "owns cluster security posture.", + "App teams: author namespace-scoped NetworkPolicy, consume templates and generated baselines, own " + "application connectivity.", + "The firewall team moves from 'approve every rule' to 'set the boundaries and review the exceptions.'", +], caption_shape=6, notes=[ + "Describe the three-team ownership model and point to the diagram. The firewall team authors the egress " + "rules for external destinations, reviews all cluster-scoped policies (the Admin and Baseline tiers) " + "because they have cluster-wide blast radius, and continues to own the perimeter for north-south traffic, " + "TLS inspection, and compliance logging. The platform team authors the ClusterNetworkPolicy guardrails, " + "maintains starter templates, reviews app team NetworkPolicies, and owns the overall cluster security " + "posture. App teams author namespace-scoped NetworkPolicy for their services, consume starter templates and " + "generated baselines, and own their application's connectivity. The critical framing: the firewall team " + "moves from approve every rule to set the boundaries and review the exceptions. This is faster for " + "developers, safer for the organization, and a better use of the firewall team's expertise."]) +S[269] = dict(body=[ + "Step 1: compare with the observed baseline — was this connection ever in the generated policies in Git?", + "Step 2: read the flow logs (hubble observe --verdict DROPPED, or Calico's flow logs) and match the dropped " + "flow to the policies that select the pod: which tier (Admin, NP, or Baseline) denied it tells you who " + "updates the policy.", + "Step 3: cilium sysdump or calicoctl cluster diags collects policy state, CNI configuration, and logs for " + "offline analysis or escalation.", + "Target: denied-flow triage should take under 30 minutes. If it takes longer, the observability tooling is " + "not configured correctly.", +], caption="The flow logs and the tier that matched say who owns the fix.", notes=[ + "Walk through the denied-flow triage process. When a developer reports my service cannot reach X, the " + "first step is comparing with the observed baseline: was this connection ever in the generated policies " + "kept in Git? If not, it was likely never policy-allowed, and the decision is whether to add a policy or " + "investigate why the new connection exists. The second step is the flow logs: hubble observe with " + "--verdict DROPPED on Cilium, or the Calico flow logs in Whisker, show the dropped flow with source pod, " + "destination and port. Matching it to the policies that select the pod tells you which tier denied the " + "traffic: Admin, NetworkPolicy, or Baseline. If the Admin tier denied it, the platform team needs to act. " + "If a NetworkPolicy denied it, the app team updates their policy. If the Baseline tier denied it, no one " + "wrote a policy for this connection at all. The third step is a diagnostics bundle, cilium sysdump or " + "calicoctl cluster diags, for offline analysis, an upstream issue, or escalation to your support provider. " + "The target is under 30 minutes for triage. If it regularly takes longer, the observability tooling is not " + "configured correctly."]) +S[270] = dict(body=[ + "Break-glass: platform team creates a temporary Admin-tier Pass rule at high priority, time-boxed with an " + "expiration annotation, logged via incident ticket. Proper policy change follows within 24 hours via normal " + "PR.", + "Argo CD drift detection: if someone manually applies a policy via kubectl apply instead of the PR pipeline, " + "Argo CD detects the drift and alerts or reverts it (self-heal).", + "Baseline drift: new flows outside the observed baseline show up in the flow logs and Falco alerts — catches " + "both policy drift and application drift.", + "All break-glass events are reviewed in the quarterly policy review.", +], notes=[ + "Explain the break-glass process and drift detection. When a denied flow is blocking a production incident " + "and the normal PR process is too slow, a platform team member creates a temporary Admin-tier " + "ClusterNetworkPolicy with a Pass rule at high priority. This is time-boxed with an expiration annotation " + "and logged via an incident ticket. If the Baseline tier is default-deny and no NetworkPolicy covers the " + "target, a temporary NetworkPolicy is needed as well. Within 24 hours, the proper policy change goes " + "through the normal PR and CI pipeline, and the temporary rule is removed. All break-glass events are " + "reviewed in the quarterly policy review. For drift detection: Argo CD reports sync status for every policy " + "manifest; if someone manually applies a policy, Argo CD detects the drift and alerts, or reverts it when " + "self-heal is on. Baseline drift catches new traffic flows outside the observed baseline, whether from " + "policy changes or application changes. The critical lesson: define the break-glass process before you " + "enforce anything in production. The first denied-flow incident without a break-glass process causes panic " + "and a rollback of all policies."]) +S[271] = dict(body=[ + "What stays: north-south edge controls (the perimeter firewall for TLS inspection, DDoS, compliance " + "logging), egress rule authorship (approved CIDRs, DNS rules), Admin and Baseline tier review (cluster-wide " + "blast radius).", + "What shifts: east-west traffic segmentation moves to NetworkPolicy authored by app teams. The firewall team " + "does not approve every pod-to-pod rule — that does not scale.", + "The new model: the firewall team's expertise is applied at a new layer. Position this as elevation, not " + "replacement.", + "Teams that presented in-cluster policy as a replacement met resistance. Teams that positioned it as the " + "firewall team's expertise at a new layer got buy-in.", +], notes=[ + "Frame the firewall team's evolving role as elevation, not replacement. What stays with the firewall team: " + "north-south edge controls on the perimeter firewall for TLS inspection, DDoS mitigation, and compliance " + "logging. Egress rule authorship: the firewall team catalogs approved external destinations and authors the " + "egress rules, which is their domain expertise. Admin and Baseline tier review: the firewall team reviews " + "all cluster-scoped policies because they have cluster-wide blast radius. What shifts to the platform and " + "app teams: east-west traffic segmentation is handled by NetworkPolicy authored by app teams. The firewall " + "team does not need to approve every pod-to-pod rule; that does not scale. The key framing: teams that " + "presented in-cluster policy as a replacement for the firewall team met resistance. Teams that positioned " + "it as the firewall team's expertise applied at a new layer got buy-in."]) +S[272] = dict(eyebrow="EGRESS", body=[ + "Admin-tier ClusterNetworkPolicy egress rules with networks peers (CIDR, standard channel) restrict outbound " + "traffic cluster-wide.", + "DNS-based rules: CNP domainNames peers are experimental and Accept-only; Cilium's toFQDNs " + "(CiliumNetworkPolicy) gives DNS-based egress today.", + "The firewall team's domain: they catalog approved external endpoints and author the egress rules per " + "environment.", + "Combine with NetworkPolicy: NetworkPolicy for intra-cluster segmentation, cluster egress rules for external " + "traffic control. They complement each other.", +], notes=[ + "Explain egress control with upstream APIs. A ClusterNetworkPolicy in the Admin tier can carry egress rules " + "whose peers are networks, CIDR ranges, which is part of the standard channel; deny everything outside the " + "approved ranges and accept the approved ones. DNS names are harder: the domainNames peer of " + "ClusterNetworkPolicy is in the experimental channel and only valid in Accept rules, and Calico's " + "open-source edition has no DNS-based policy. On Cilium, a CiliumNetworkPolicy with toFQDNs gives DNS-based " + "egress today, through Cilium's DNS proxy, which matters for external services that change IP addresses " + "without notice. The firewall team's domain: they catalog approved external endpoints and author the egress " + "rules. This extends what they already do at the perimeter, controlling which destinations workloads can " + "reach, to the cluster level, where the perimeter has no visibility. Combine the two: NetworkPolicy for " + "intra-cluster segmentation, cluster egress rules for external traffic control. Caveat: DNS-based rules " + "depend on the DNS answers the cluster sees, so test them against the real resolvers."]) +S[273] = dict(body=[ + "Default-deny in every namespace — no namespace without coverage.", + "Baselines reviewed — new flows outside them raise alerts.", + "Admin-tier guardrails enforced — cross-environment traffic blocked, monitoring allowed.", + "Baseline-tier default-deny — the cluster-wide safety net catches unmatched traffic.", + "App teams authoring policies via PR — not kubectl apply.", + "CI validates before merge — schema, lint, baseline diff, dry-run on every PR.", + "Egress rules per environment — outbound traffic restricted to approved destinations.", + "Denied-flow triage under 30 minutes. Quarterly policy review cadence.", +], notes=[ + "Walk through the maturity checklist. Default-deny in every namespace: no namespace without coverage. " + "Baselines reviewed: every production namespace has a reviewed baseline in Git, and new flows outside it " + "raise alerts. Admin-tier guardrails enforced: cross-environment traffic blocked, monitoring allowed, " + "control-plane access restricted. Baseline-tier default-deny: the cluster-wide safety net catches any " + "traffic not explicitly allowed. App teams authoring policies via PR, not kubectl apply. CI validates " + "before merge: schema checks, policy linting, the baseline diff, and dry-run apply on every PR. Egress rules " + "restrict outbound traffic to approved destinations. Firewall team reviews, does not block: turnaround on " + "Admin and Baseline tier reviews is prompt. Denied-flow triage under 30 minutes. Quarterly policy review " + "cadence covering break-glass events, stale policies, baseline refreshes, and RBAC audits. The maturity " + "timeline is agreed upon by the organization; there is no universal pace."]) +S[274] = dict(title="What teams wish they'd done", body=[ + "1. 'We should have started with observation, not enforcement.' Jumping to default-deny without observing " + "the flows caused outages from denied traffic they didn't know existed.", + "2. 'We over-engineered the templates.' Start with 3-5 archetypes, not twenty. Refine based on actual usage.", + "3. 'We underestimated the egress work.' Cataloging every external endpoint is the most time-consuming part. " + "Start this inventory in Phase 1.", + "4. 'We should have involved the firewall team earlier.' Position as elevation, not replacement.", + "5. 'We needed a break-glass process on day one.' The first denied-flow incident without break-glass caused " + "panic and a rollback of all policies.", +], caption="These lessons come from real engagements.", notes=[ + "Walk through the top five lessons from real engagements. First, teams that jumped to default-deny without " + "observing the flows caused outages from denied traffic they did not know existed. The observation phase " + "pays for itself. Second, the first set of starter templates should be simple: three to five archetypes, " + "not twenty. Start broad and refine based on actual PR volume. Third, cataloging every external endpoint " + "every service needs to reach is the most time-consuming part of the shift. Start this inventory in Phase 1 " + "alongside the flow observation. Fourth, teams that presented in-cluster policy as a replacement for the " + "firewall team met resistance; teams that positioned it as the firewall team's expertise applied at a new " + "layer got buy-in. Fifth, the first denied-flow incident in production without a break-glass process caused " + "panic and a rollback of all policies. Define break-glass before you enforce. These lessons are from real " + "engagements: they are patterns, not hypotheticals."]) +S[275] = dict(body=[ + "Network Policy API (ClusterNetworkPolicy) — network-policy-api.sigs.k8s.io", + "network-policy-api v0.2.0 release — github.com/kubernetes-sigs/network-policy-api/releases/tag/v0.2.0", + "Calico ClusterNetworkPolicy (v3.32 release notes) — github.com/projectcalico/calico, release-notes/" + "v3.32.0-release-notes.md", + "Cilium network policy — docs.cilium.io/en/stable/network/kubernetes/policy/", + "Inspektor Gadget advise_networkpolicy — inspektor-gadget.io/docs/latest/gadgets/advise_networkpolicy", + "Kubernetes Network Policies — kubernetes.io/docs/concepts/services-networking/network-policies/", + "Companion: Kubernetes Environment Access Policies (k8s-environment-policies.md)", +], notes=[ + "Point the audience to authoritative sources. The Network Policy API site covers ClusterNetworkPolicy, its " + "Admin and Baseline tiers and the list of implementations. The v0.2.0 release notes record the move from " + "AdminNetworkPolicy and BaselineAdminNetworkPolicy to ClusterNetworkPolicy and the Allow-to-Accept rename. " + "The Calico 3.32 release notes and the Cilium network policy page say how each CNI enforces it. The " + "Inspektor Gadget page covers generating NetworkPolicy from observed traffic. The Kubernetes Network " + "Policies documentation covers the standard NetworkPolicy API that app teams will author. The companion " + "document, Kubernetes Environment Access Policies, covers the broader governance framework including RBAC, " + "admission control, GitOps, and audit that surrounds the network policy process described in this deck."]) + +FIG = {262: "net-advisory-tier-evaluation", 266: "net-advisory-ci-pipeline", 268: "net-advisory-team-ownership"} + + +def main(): + ops = [] + for sid in sorted(S): + s = S[sid] + if s.get("eyebrow"): + ops.append({"op": "set_text", "sldId": sid, "shape_id": 2, "paragraphs": [s["eyebrow"]], "cat": "stack"}) + if s.get("title"): + t = s["title"] if isinstance(s["title"], list) else [s["title"]] + ops.append({"op": "set_text", "sldId": sid, "shape_id": 3, "paragraphs": t, "cat": "stack"}) + if s.get("body"): + ops.append({"op": "set_text", "sldId": sid, "shape_id": 4, "paragraphs": s["body"], "cat": "stack"}) + if s.get("caption"): + ops.append({"op": "set_text", "sldId": sid, "shape_id": s.get("caption_shape", 5), + "paragraphs": [s["caption"]], "cat": "stack"}) + if s.get("notes"): + ops.append({"op": "set_notes", "sldId": sid, "paragraphs": s["notes"], "cat": "stack"}) + if sid in FIG: + ops.append({"op": "replace_picture", "sldId": sid, "shape_id": 5, "png": P + FIG[sid] + ".png", + "cat": "stack", "_why": "vendor-neutral labels (tools/vendor_figures.py)"}) + for o in ops: + o["_vendor"] = "S12 decision 7" + OUT.write_text(json.dumps({"ops": ops}, indent=1, ensure_ascii=False) + "\n") + print(len(ops)) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/gen_appo.py b/_plans/s12-manifest/tools/gen_appo.py new file mode 100644 index 00000000..a46a1de6 --- /dev/null +++ b/_plans/s12-manifest/tools/gen_appo.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Appendix O: shared sentence rewrites (rewrites/10-appo.json) and per-deck code/figure ops (ops-10-appo/).""" +import json, sys +from pathlib import Path +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +from pptx import Presentation +from pptx.util import Emu +WORK = Path(__file__).resolve().parent +E = "P" + "odman"; e = "p" + "odman" +RW = [ + ("Postman collections as an automated gate — on plain " + E, "Postman collections as an automated gate on Docker Engine", "engine"), + ("The arc: why Newman, the anatomy of a collection, one collection against five backends, running it and shaping the output, data-driven cases, the CI gate on " + E + ", and where Newman sits in the test pyramid.", + "The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid.", "engine"), + ("This is a quietly powerful idea: the collection is the API specification made executable.", "The collection is the API specification made executable.", "voice"), + ("One collection, five backends", "One collection, six backends", "stack"), + ("Only this step is language-specific; the Newman command is identical for all five.", "Only this step is language-specific; the Newman command is identical for all six.", "stack"), + ("That is the whole point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, or C++, and run the exact same collection against it.", + "That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it.", "stack"), + ("In CI you would start the service with " + e + " compose rather than a foreground command, but locally a background process and a newman run is the fastest loop.", + "In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop.", "engine"), + ("Bring the stack up on " + E + ", wait for readiness, run the collection — a failed assertion fails the build.", + "Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build.", "engine"), + ("The services come up on " + E + " exactly as they do on a laptop - no managed cloud - and Newman runs against them.", + "The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them.", "engine"), + ("The gate on " + E, "The gate on Docker Engine", "engine"), + ("The full gate: a pull request builds the image, " + e + " compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge - pass merges, fail blocks.", + "The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks.", "engine"), + ("The important property is parity: the stack in CI is the same stack you run on your laptop, on the same " + E + ", so a green run in CI means the contract holds against the real services, not a mocked approximation.", + "The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock.", "engine"), + ("the API/contract tier — above unit tests, below a thin end-to-end layer", "the API/contract tier: on top of unit tests, under a thin end-to-end layer", "positional"), + ("Newman is not a replacement for unit tests - it is the layer above them.", "Newman does not replace unit tests; it is the layer on top of them.", "positional"), + ("and reserve a thin layer of full end-to-end tests for the few journeys that genuinely need the whole system.", + "and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system.", "voice"), + ("And test the failure paths as deliberately as the happy path - a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice - which ties this appendix back to the error-format, security, idempotency, and rate-limiting material.", + "Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material.", "voice"), + ("One collection is the conformance suite for all five implementations: Newman tests over HTTP, as a black box.", + "One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box.", "stack"), + ("Gate CI on plain " + E + " so the build fails on a broken contract; keep Newman in the API/contract tier.", + "Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier.", "engine"), + ("Because Newman tests over HTTP, one collection is the conformance suite for all five implementations.", + "Because Newman tests over HTTP, one collection is the conformance suite for all six implementations.", "stack"), + ("Wire it into CI on plain " + E + " so the build fails on a broken contract, and keep Newman in its lane - the API/contract tier, above unit tests and below a thin end-to-end layer.", + "Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer.", "engine"), +] +LAUNCH = { # _docs/28-appendix-o-newman.md, "One collection, six backends" codetab + "Spring": ["# Spring Boot — start the service under test, then run the same collection", + "./mvnw spring-boot:run & # http://localhost:8080"], + "Quarkus": ["# Quarkus — dev mode, then the same collection", + "./mvnw quarkus:dev & # http://localhost:8080"], + "DotNet": ["# .NET — run the service, then the same collection", + "dotnet run & # http://localhost:8080"], + "Python": ["# Python / FastAPI — serve, then the same collection", + "uvicorn app.main:app --port 8080 & # http://localhost:8080"], + "Cpp": ["# C++ / Drogon — build and run, then the same collection", + "cmake --build build && ./build/order-service & # http://localhost:8080"], + "Go": ["# Go — run the service, then the same collection", + "go run ./cmd/order-service & # http://localhost:8080"], +} +NEWMAN_RUN = "newman run orders.postman_collection.json -e local.postman_environment.json" +POSTMAN = [ # _docs/28, "Anatomy of a collection" + '{', ' "name": "Create order",', ' "request": {', ' "method": "POST",', + ' "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }],', + ' "url": "{{base_url}}/orders",', + ' "body": { "mode": "raw", "raw": "{\\"sku\\":\\"A-1\\",\\"qty\\":2}" }', ' },', + ' "event": [{', ' "listen": "test",', ' "script": { "exec": [', + ' "pm.test(\'201 Created\', () => pm.response.to.have.status(201));",', + ' "pm.test(\'under 300ms\', () => pm.expect(pm.response.responseTime).to.be.below(300));",', + ' "const body = pm.response.json();",', + ' "pm.test(\'returns an id\', () => pm.expect(body).to.have.property(\'id\'));",', + ' "pm.collectionVariables.set(\'order_id\', body.id);"', ' ] }', ' }]', '}'] +RUNNING = [ # _docs/28, "Running Newman" and "Data-driven tests" (comment moved off the continuation line) + "# install once (Node.js):", + "# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1", + "newman run orders.postman_collection.json \\", + " --environment local.postman_environment.json \\", + " --reporters cli,junit,htmlextra \\", + " --reporter-junit-export results/newman.xml \\", + " --timeout-request 5000 \\", + " --bail", + "# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure", + "", + "# data-driven: one iteration per CSV row; the columns become {{variables}}", + "newman run orders.postman_collection.json -e local.postman_environment.json \\", + " --iteration-data cases.csv", +] +CI = [ # _docs/28, "Newman in CI" (actions at v7, as in .github/workflows) + "# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js)", + "jobs:", " api-tests:", " runs-on: ubuntu-latest", " steps:", + " - uses: actions/checkout@v7", + " - run: docker compose up -d --build --wait # order, payment, inventory, ...", + " - run: ./scripts/wait-for-ready.sh # poll /readyz before testing", + " - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1", + " - run: |", + " newman run orders.postman_collection.json \\", + " -e ci.postman_environment.json \\", + " --reporters cli,junit \\", + " --reporter-junit-export results/newman.xml", + " - uses: actions/upload-artifact@v7 # keep the report even on failure", + " if: always()", + " with: { name: newman-results, path: results/ }", +] +BASE = {"Python": 516, "Quarkus": 516, "Go": 516, "Cpp": 516, "Spring": 520, "DotNet": 530} + + +def ids(prs, sid): + s = next(x for x in prs.slides if x.slide_id == sid) + code = [sh.shape_id for sh in s.shapes if sh.has_text_frame and Emu(sh.height).inches > 3 and + any("Mono" in (r.font.name or "") for p in sh.text_frame.paragraphs for r in p.runs)] + pics = [sh.shape_id for sh in s.shapes if sh.shape_type == 13 and 1.5 < Emu(sh.top).inches < 2.0] + return code, pics + + +def main(): + (WORK / "rewrites").mkdir(exist_ok=True) + (WORK / "rewrites" / "10-appo.json").write_text(json.dumps( + [{"find": f, "repl": r, "cat": c, "_src": "Appendix O"} for f, r, c in RW], indent=1, ensure_ascii=False)) + (WORK / "ops-10-appo").mkdir(exist_ok=True) + for deck, b in BASE.items(): + name = f"Designing-Cloud-Native-APIs-{deck}" + prs = Presentation(str(REPO / "lgtm-presentation" / f"{name}.pptx")) + ops = [] + def code(off, lines, why): + c, _ = ids(prs, b + off) + assert len(c) == 1, (deck, b + off, c) + ops.append({"op": "set_code", "sldId": b + off, "shape_id": c[0], "lines": lines, "cat": "code", "_why": why}) + def pic(off, png, why): + _, p = ids(prs, b + off) + assert len(p) == 1, (deck, b + off, p) + ops.append({"op": "replace_picture", "sldId": b + off, "shape_id": p[0], + "png": f"lgtm-presentation/edits/png/{png}.png", "cat": "stack", "_why": why}) + code(3, POSTMAN, "_docs/28 Anatomy of a collection (adds the response-time assertion)") + pic(4, "27-black-box", "the old figure shows five backends; the site figure shows all six") + code(5, LAUNCH[deck] + [NEWMAN_RUN], "_docs/28 codetab 'One collection, six backends', this deck's language") + code(6, RUNNING, "_docs/28 Running Newman + Data-driven tests; pinned install; no comment after a line continuation") + code(8, CI, "_docs/28 Newman in CI on Docker Engine; actions at v7 as in .github/workflows") + pic(9, "27-ci-gate", "the old figure shows the old container engine; site figure 27-ci-gate") + (WORK / "ops-10-appo" / f"{name}.json").write_text(json.dumps({"ops": ops}, indent=1, ensure_ascii=False)) + print(name, len(ops)) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/gen_fit.py b/_plans/s12-manifest/tools/gen_fit.py new file mode 100644 index 00000000..c2ec5d85 --- /dev/null +++ b/_plans/s12-manifest/tools/gen_fit.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Layout-fit amendment of the language-deck edit files (S12, step "layout fit"). + + gen_fit.py apply [deck ...] plan files -> lgtm-presentation/edits/.json + gen_fit.py suggest + print plan entries for the deck_fit.py flags of an + after-edit copy (chips, font fits, frame growth); code + that does not fit at 9 pt and other manual cases are + listed as "manual" + +Run with the S12 venv python from the repository root. The base of every deck file is its +committed pre-fit version (git show FIT_BASE:, FIT_BASE defaults to c70ab3a), so `apply` is +idempotent. Plan: _plans/s12-manifest/fit/.json, {"entries": [...]}; an entry is one of + + {"sldId": N, "shape_id": M, "pt": 11, "line_spacing": 1.0} + font fit. When the deck file has a set_code op for that box, "pt" (and "line_spacing") + are added to it; otherwise a fit_font op is appended. + {"sldId": N, "shape_id": M, "lines": [...], "pt": 10} + replacement lines for the deck file's set_code op for that box, or an appended set_code op. + {"sldId": N, "shape_id": M, "cuts": [{"from": a, "to": b, "first": "...", "ellipsis": "# ..."}], + "edits": [{"line": i, "old": "...", "new": "..."}], "pt": 9, "line_spacing": 1.0} + excerpt. Applied to the lines of the deck file's set_code op for that box (edits, which + only reword comments, are allowed there), or appended as a cut_code op that keeps the + formatting of the remaining lines (code the edits do not touch). + {"sldId": N, "shape_id": M, "resize": {"cx": 2400000, "keep": "right"}} + frame change; appends a resize_shape op. + {"op": ...} + any deck_edit op, appended as given (title and bullet shortening by replace). + +Every op this tool adds or changes carries "cat": "fit" and "_fit" (the reason); appended ops go +after the Appendix R ops, at the end of the file. +""" +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +PLANS = REPO / "_plans" / "s12-manifest" / "fit" +EDITS = REPO / "lgtm-presentation" / "edits" +BASE = __import__("os").environ.get("FIT_BASE", "c70ab3a") +LANG = [f"Designing-Cloud-Native-APIs-{x}" for x in ("Python", "Quarkus", "Spring", "DotNet", "Go", "Cpp")] + + +def base_ops(deck): + rel = f"lgtm-presentation/edits/{deck}.json" + txt = subprocess.run(["git", "show", f"{BASE}:{rel}"], cwd=REPO, check=True, + capture_output=True, text=True).stdout + return json.loads(txt) + + +def apply(decks): + for deck in decks: + plan_p = PLANS / f"{deck}.json" + if not plan_p.exists(): + print(f"{deck}: no plan") + continue + plan = json.loads(plan_p.read_text())["entries"] + data = base_ops(deck) + ops = data["ops"] + tail = [] + amended = appended = 0 + for e in plan: + why = e.get("_why", "layout fit") + if "op" in e: + o = {k: v for k, v in e.items() if not k.startswith("_")} + o.update({"cat": "fit", "_fit": why}) + tail.append(o) + appended += 1 + continue + sid, shid = e["sldId"], e["shape_id"] + sc = [o for o in ops if o.get("op") == "set_code" and o.get("sldId") == sid + and o.get("shape_id") == shid] + if len(sc) > 1: + raise SystemExit(f"{deck}: {len(sc)} set_code ops for {sid}/{shid}") + if "cuts" in e: + if sc: + o = sc[0] + src = list(o["lines"]) if "lines" in o else o["code"].rstrip("\n").split("\n") + src = [ln.replace("\t", " ").rstrip() for ln in src] + for ed in e.get("edits", []): + i = int(ed["line"]) - 1 + if src[i] != ed["old"].rstrip(): + raise SystemExit(f"{deck} {sid}/{shid}: edit line {i + 1} is {src[i]!r}, not {ed['old']!r}") + src[i] = ed["new"] + new, drop, ins = [], set(), {} + for c in e["cuts"]: + a, b = int(c["from"]), int(c.get("to", c["from"])) + if src[a - 1] != c["first"].rstrip(): + raise SystemExit(f"{deck} {sid}/{shid}: line {a} is {src[a - 1]!r}, not {c['first']!r}") + drop.update(range(a - 1, b)) + if c.get("ellipsis"): + ins[a - 1] = c["ellipsis"] + for i, ln in enumerate(src): + if i in ins: + new.append(ins[i]) + if i not in drop: + new.append(ln) + o["lines"] = new + o.pop("code", None) + for k in ("pt", "line_spacing"): + if e.get(k) is not None: + o[k] = e[k] + o["_fit"] = why + amended += 1 + else: + if e.get("edits"): + raise SystemExit(f"{deck} {sid}/{shid}: edits need a set_code op") + o = {"op": "cut_code", "sldId": sid, "shape_id": shid, "cuts": e["cuts"]} + for k in ("pt", "line_spacing"): + if e.get(k) is not None: + o[k] = e[k] + o.update({"cat": "fit", "_fit": why}) + tail.append(o) + appended += 1 + elif "resize" in e: + o = {"op": "resize_shape", "sldId": sid, "shape_id": shid, **e["resize"], + "cat": "fit", "_fit": why} + tail.append(o) + appended += 1 + elif "lines" in e: + if sc: + o = sc[0] + o["lines"] = list(e["lines"]) + o.pop("code", None) + else: + o = {"op": "set_code", "sldId": sid, "shape_id": shid, "lines": list(e["lines"]), "cat": "fit"} + tail.append(o) + appended += 1 + for k in ("pt", "line_spacing", "max_lines"): + if e.get(k) is not None: + o[k] = e[k] + o["_fit"] = why + amended += bool(sc) + elif "pt" in e: + if sc: + o = sc[0] + o["pt"] = e["pt"] + if e.get("line_spacing") is not None: + o["line_spacing"] = e["line_spacing"] + o["_fit"] = why + amended += 1 + else: + o = {"op": "fit_font", "sldId": sid, "shape_id": shid, "pt": e["pt"]} + if e.get("line_spacing") is not None: + o["line_spacing"] = e["line_spacing"] + o.update({"cat": "fit", "_fit": why}) + tail.append(o) + appended += 1 + else: + raise SystemExit(f"{deck}: entry without pt, lines, resize or op: {e}") + ops.extend(tail) + (EDITS / f"{deck}.json").write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n") + print(f"{deck}: {len(plan)} plan entries; {amended} set_code ops amended, {appended} ops appended") + + +# ----------------------------------------------------------------- suggestions + +def suggest(deck, pptx, flags_p): + sys.path.insert(0, str(REPO / "scripts")) + import deck_edit as de # noqa: E402 + from pptx import Presentation # noqa: E402 + q, A, P = de.q, de.A, de.P + prs = Presentation(pptx) + sw, sh = prs.slide_width, prs.slide_height + slides = {s.slide_id: s for s in prs.slides} + flags = json.loads(Path(flags_p).read_text()) + by = {} + for f in flags: + by.setdefault((f["sldId"], f["shape_id"]), []).append(f) + out, manual = [], [] + + def frame(e): + xf = e.find(f".//{q(A, 'xfrm')}") + if xf is None or xf.find(q(A, "off")) is None: + return None + o, x = xf.find(q(A, "off")), xf.find(q(A, "ext")) + return [int(o.get("x")), int(o.get("y")), int(o.get("x")) + int(x.get("cx")), int(o.get("y")) + int(x.get("cy"))] + + def ins(e): + b = e.find(f"{q(P, 'txBody')}/{q(A, 'bodyPr')}") + g = (lambda k, d: int(b.get(k, d))) if b is not None else (lambda k, d: d) + return g("lIns", 91440), g("tIns", 45720), g("rIns", 91440), g("bIns", 45720) + + for (sid, shid), fs in sorted(by.items(), key=lambda kv: (kv[1][0]["slide"], kv[0][1] or 0)): + num = fs[0]["slide"] + kinds = {f["kind"].split("-with")[0]: f["by_pt"] for f in fs} + if shid is None: + manual.append((num, sid, shid, fs)) + continue + s = slides[sid] + sp = next((e for e in s._element.iter(q(P, "sp")) if de.sp_id(e) == shid), None) + if sp is None: + manual.append((num, sid, shid, fs)) + continue + tx = sp.find(q(P, "txBody")) + paras = [de.ptext(p) for p in tx.findall(q(A, "p"))] + fonts = {de.run_font(r) for r in tx.iter(q(A, "r"))} - {None} + mono = bool(fonts) and all(de.MONO_RE.search(f) for f in fonts) + sizes = [de.run_size(r) for r in tx.iter(q(A, "r")) if de.run_size(r)] + cur = max(sizes or [1800]) / 100 + fr = frame(sp) + li, ti, ri, bi = ins(sp) + if mono and len([ln for ln in de.code_lines(tx) if ln["text"].strip()]) == 1: + # chip: one line wide enough for its text, right edge kept + text = next(ln["text"] for ln in de.code_lines(tx) if ln["text"].strip()) + need = int((len(text) * 0.6 * cur + 4) * 12700) + li + ri + if need > fr[2] - fr[0]: + out.append({"sldId": sid, "shape_id": shid, "resize": {"cx": need, "keep": "right"}, + "_why": f"slide {num}: chip '{text}' wraps; one line at {cur} pt"}) + else: + manual.append((num, sid, shid, fs)) + continue + if mono: + lines = de.code_lines(tx) + n = len(lines) + wide = max(len(l["text"].replace("\t", " ").rstrip()) for l in lines) + spcs = [int(e.get("val")) / 100000 for e in tx.iter(q(A, "spcPct"))] + spc = max(spcs) if spcs else 1.0 + h = (fr[3] - fr[1] - ti) / 12700 + w = (fr[2] - fr[0] - li - ri) / 12700 + + def fits(pt, sp_): + return n <= math.floor((h - 1.32 * pt) / (pt * 1.2 * sp_) + 1.02) and wide <= math.floor(w / (pt * 0.6)) + got = None + pt = cur + while pt >= 9 and got is None: + if fits(pt, spc) and pt < cur: + got = (pt, None) + pt -= 0.5 + pt = max(cur, 9.0) + while pt >= 9 and got is None and spc > 1.0: + if fits(pt, 1.0): + got = (pt, 1.0) + pt -= 0.5 + if got and got[0] >= 9: + e = {"sldId": sid, "shape_id": shid, "pt": got[0], + "_why": f"slide {num}: {n} code lines, {wide} columns; {cur} pt overflows the box"} + if got[1]: + e["line_spacing"] = got[1] + out.append(e) + else: + manual.append((num, sid, shid, fs + [{"kind": f"needs excerpt: {n} lines, {wide} cols, " + f"{math.floor((h - 1.32 * 9) / (9 * 1.2) + 1.02)} lines fit at 9 pt"}])) + continue + # text: grow the frame into free space, else shrink + top = kinds.get("overflow-top", 0) + bot = kinds.get("overflow-bottom", 0) + right = kinds.get("overflow-right", 0) + others = [k for k in kinds if k not in ("overflow-top", "overflow-bottom", "overflow-right")] + if not (top or bot or right) or others and not all(k.startswith("collision") for k in others): + manual.append((num, sid, shid, fs)) + continue + m = int(3 * 12700) + upper, lower = 0, sh + for e in s._element.iter(q(P, "sp"), q(P, "pic"), q(P, "graphicFrame"), q(P, "cxnSp")): + if e is sp: + continue + of = frame(e) + if of is None: + continue + if of[0] <= fr[0] and of[1] <= fr[1] and of[2] >= fr[2] and of[3] >= fr[3]: + continue # a container (backdrop, full-slide picture) + if of[2] - of[0] >= sw * 0.95 and of[3] - of[1] >= sh * 0.95: + continue + if min(of[2], fr[2] + (int(right * 12700) + m if right else 0)) <= max(of[0], fr[0]): + continue # not in the same column + if of[1] >= fr[1] + (fr[3] - fr[1]) // 2: + lower = min(lower, of[1] - 12700) # below (or overlapping from below) + elif of[3] <= fr[1] + (fr[3] - fr[1]) // 2: + upper = max(upper, of[3] + 12700) # above + need = (fr[3] - fr[1]) + (int(top * 12700) + m if top else 0) + (int(bot * 12700) + m if bot else 0) + y0 = fr[1] - (int(top * 12700) + m if top else 0) + y1 = y0 + need + if y1 > lower: + y1, y0 = lower, lower - need + nf = [fr[0], y0, fr[2] + (int(right * 12700) + m if right else 0), y1] + blocked = [] if y0 >= upper else ["above"] + if nf[1] >= 0 and nf[3] <= sh and nf[2] <= sw and not blocked: + r = {} + if nf[1] != fr[1]: + r["y"] = nf[1] + r["cy"] = nf[3] - nf[1] + if nf[2] != fr[2]: + r["cx"] = nf[2] - nf[0] + out.append({"sldId": sid, "shape_id": shid, "resize": r, + "_why": f"slide {num}: text overflows its frame by " + + ", ".join(f"{k} {v} pt" for k, v in kinds.items() if k.startswith("overflow")) + + "; free space around the frame"}) + else: + hh = (fr[3] - fr[1]) / 12700 + guess = math.floor(cur * math.sqrt(hh / (hh + top + bot + 0.0001)) * 2) / 2 + out.append({"sldId": sid, "shape_id": shid, "pt": guess, + "_why": f"slide {num}: text overflows by {top + bot} pt and the frame cannot grow; " + f"{cur} pt -> {guess} pt", "_auto_shrink": True}) + return out, manual + + +if __name__ == "__main__": + if len(sys.argv) >= 2 and sys.argv[1] == "apply": + apply(sys.argv[2:] or LANG) + elif len(sys.argv) == 5 and sys.argv[1] == "suggest": + o, m = suggest(sys.argv[2], sys.argv[3], sys.argv[4]) + print(json.dumps({"entries": o, "manual": [{"slide": a, "sldId": b, "shape_id": c, "flags": d} + for a, b, c, d in m]}, indent=1, ensure_ascii=False)) + else: + print(__doc__) + sys.exit(2) diff --git a/_plans/s12-manifest/tools/gen_rs.py b/_plans/s12-manifest/tools/gen_rs.py new file mode 100644 index 00000000..0b4acb28 --- /dev/null +++ b/_plans/s12-manifest/tools/gen_rs.py @@ -0,0 +1,586 @@ +#!/usr/bin/env python3 +"""Generate the Appendix R / S slides and the agenda update for each language deck. + +Writes rs/.json (deck_edit ops). Template slides are the Appendix O slides of the same +deck (divider, figure, code, bullets); shape ids are read from each deck so the ops address the +right boxes. Run with the S12 venv python. Content source: _docs/31-appendix-r-minikube.md, +_docs/32-appendix-s-openshift-local.md and examples/05-event-driven/{k8s,openshift}/. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +sys.path.insert(0, str(REPO / "scripts")) +from pptx import Presentation # noqa: E402 +from pptx.util import Emu # noqa: E402 + +WORK = Path(__file__).resolve().parent +PNG = "lgtm-presentation/edits/png" # relative to the repo root (run deck_edit from there) + +DECKS = { + # deck: (lang id in scripts, display name, templates {divider, figure, code, bullets}, last page) + "Python": ("python", "Python", dict(divider=516, figure=525, code=521, bullets=527), 243), + "Quarkus": ("quarkus", "Quarkus", dict(divider=516, figure=525, code=521, bullets=527), 243), + "Spring": ("spring-boot", "Spring Boot", dict(divider=520, figure=529, code=525, bullets=531), 247), + "DotNet": ("dotnet", ".NET", dict(divider=530, figure=539, code=535, bullets=541), 271), + "Go": ("go", "Go", dict(divider=516, figure=525, code=521, bullets=527), 243), + "Cpp": ("cpp", "C++", dict(divider=516, figure=525, code=521, bullets=527), 243), +} + +ENV = { # k8s/overlays//kustomization.yaml, the variables each overlay appends + "spring-boot": [ + "- name: SPRING_DATASOURCE_URL", + ' value: "jdbc:postgresql://cndp-postgres-rw:5432/appdb"', + "- name: SPRING_DATASOURCE_USERNAME", + ' value: "$(PG_USER)"', + "- name: SPRING_DATASOURCE_PASSWORD", + ' value: "$(PG_PASSWORD)"', + "- name: SPRING_KAFKA_BOOTSTRAP_SERVERS", + ' value: "cndp-kafka-bootstrap:9092"', + ], + "quarkus": [ + "- name: QUARKUS_DATASOURCE_JDBC_URL", + ' value: "jdbc:postgresql://cndp-postgres-rw:5432/appdb"', + "- name: QUARKUS_DATASOURCE_USERNAME", + ' value: "$(PG_USER)"', + "- name: QUARKUS_DATASOURCE_PASSWORD", + ' value: "$(PG_PASSWORD)"', + "- name: KAFKA_BOOTSTRAP_SERVERS", + ' value: "cndp-kafka-bootstrap:9092"', + ], + "dotnet": [ + "- name: DATABASE_URL", + ' value: "Host=cndp-postgres-rw;Port=5432;Database=appdb;\\', + ' Username=$(PG_USER);Password=$(PG_PASSWORD)"', + "- name: KAFKA_BOOTSTRAP", + ' value: "cndp-kafka-bootstrap:9092"', + "# order-service only", + "- name: SERVICE_VERSION", + ' value: "1.0.0"', + ], + "python": [ + "- name: DATABASE_URL", + ' value: "postgres://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb"', + "- name: KAFKA_BOOTSTRAP", + ' value: "cndp-kafka-bootstrap:9092"', + "# memory: requests 128Mi, limits 256Mi", + ], + "cpp": [ + "- name: PG_CONNINFO", + ' value: "postgresql://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb"', + "- name: KAFKA_BOOTSTRAP", + ' value: "cndp-kafka-bootstrap:9092"', + "# memory: requests 128Mi, limits 256Mi", + ], + "go": [ + "- name: DATABASE_URL", + ' value: "postgres://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb"', + "- name: KAFKA_BOOTSTRAP", + ' value: "cndp-kafka-bootstrap:9092"', + "# memory: requests 128Mi, limits 256Mi", + ], +} + +AGENDA_TAIL = { + # second agenda column from "07 · Workflows & Jobs" down; the E-I line is shortened so the + # extra R-S line fits the fixed box (no autofit). + "default": [ + "07 · Workflows & Jobs", "08 · API Management", "09 · API Registry", "10 · API Metadata", + "11 · Observability", "12 · Security", "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices K–L · Monolith → Microservices, Caching Patterns", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendices R–S · Example 05 on minikube and OpenShift Local", + "Glossary of acronyms", + ], + "DotNet": [ + "07 · Workflows & Jobs", "08 · API Management", "09 · API Registry", "10 · API Metadata", + "11 · Observability", "12 · Security", "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices J–L · .NET 10 Toolkit, Monolith → Microservices, Caching", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendices P–S · .NET on Linux, Blazor, minikube, OpenShift Local", + "Glossary of acronyms", + ], + "Cpp": [ + "07 · Workflows & Jobs", "08 · API Management", "09 · API Registry", "10 · API Metadata", + "11 · Observability", "12 · Security", "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices K–L · Monolith → Microservices, Caching Patterns", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendix R · Example 05 on minikube", + "Glossary of acronyms", + ], +} + +AGENDA_NOTES = { + "default": ( + "The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates " + "why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through " + "data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, " + "registry, metadata, observability), then security, and close on anti-patterns.", + "The appendices are reference material for the questions that come up: protocols, versioning, " + "WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, " + "caching, failure modes, feature flags and API testing{extra}. Appendices R and S run Example 05 on " + "minikube and on OpenShift Local with the same verify.sh as compose. A glossary of acronyms closes the deck.", + "Tell the audience which sections you will cover given the session length. The overview that follows " + "reframes the agenda as the narrative arc: what we build and in what order.", + ), + "Cpp": ( + "The agenda: the same scope as the other decks in the series. A short setup, thirteen sections, the " + "deep-dive appendices A to O, Appendix R and a glossary. Every section is shown in idiomatic C++23; " + "the architecture and the running example are the same as in the other languages.", + "Appendix R runs Example 05 on minikube with the same verify.sh as compose. OpenShift Local " + "(Appendix S in the other decks) covers Spring Boot, Quarkus, .NET, Python and Go, not C++.", + "Tell the audience which sections you will cover given the session length.", + ), +} + + +def shapes(prs, sid): + s = next(x for x in prs.slides if x.slide_id == sid) + out = [] + for sh in s.shapes: + d = dict(id=sh.shape_id, name=sh.name, y=round(Emu(sh.top).inches, 2), h=round(Emu(sh.height).inches, 2), + pic=sh.shape_type == 13, text=sh.text_frame.text if sh.has_text_frame else "") + if sh.has_text_frame: + sz = [r.font.size.pt for p in sh.text_frame.paragraphs for r in p.runs if r.font.size] + fn = [r.font.name or "" for p in sh.text_frame.paragraphs for r in p.runs] + d["size"] = max(sz) if sz else 0 + d["mono"] = any("Mono" in f for f in fn) + out.append(d) + return out + + +def pick(sh, cond, what, sid): + hit = [s for s in sh if cond(s)] + if len(hit) != 1: + raise SystemExit(f"template {sid}: {what} matched {len(hit)}: {[(s['id'], s['text'][:30]) for s in hit]}") + return hit[0]["id"] + + +def roles(prs, t): + r = {} + d = shapes(prs, t["divider"]) + r["div_letter"] = pick(d, lambda s: not s["pic"] and s.get("size") == 22, "divider letter", t["divider"]) + r["div_title"] = pick(d, lambda s: not s["pic"] and s.get("size", 0) >= 40, "divider title", t["divider"]) + r["div_sub"] = pick(d, lambda s: not s["pic"] and 14 <= s.get("size", 0) <= 18, "divider subtitle", t["divider"]) + for kind in ("figure", "code", "bullets"): + sh = shapes(prs, t[kind]) + r[f"{kind}_eyebrow"] = pick(sh, lambda s: not s["pic"] and abs(s["y"] - 0.42) < 0.05, "eyebrow", t[kind]) + r[f"{kind}_title"] = pick(sh, lambda s: not s["pic"] and abs(s["y"] - 0.74) < 0.05, "title", t[kind]) + r[f"{kind}_foot"] = pick(sh, lambda s: not s["pic"] and abs(s["y"] - 6.50) < 0.05, "caption", t[kind]) + if kind == "figure": + r["figure_pic"] = pick(sh, lambda s: s["pic"] and 1.5 < s["y"] < 2.0, "picture", t[kind]) + if kind == "code": + r["code_chip"] = pick(sh, lambda s: s.get("mono") and abs(s["y"] - 1.06) < 0.05, "chip", t[kind]) + r["code_box"] = pick(sh, lambda s: s.get("mono") and s["h"] > 3, "code box", t[kind]) + if kind == "bullets": + r["bullets_body"] = pick(sh, lambda s: not s["pic"] and abs(s["y"] - 1.85) < 0.05 and s["h"] > 3, "body", t[kind]) + return r + + +class Gen: + def __init__(self, deck, prs): + self.deck = deck + self.lang, self.name, self.t, self.page = DECKS[deck] + self.r = roles(prs, self.t) + self.ops = [] + self.n = 0 + + def _clone(self, kind, tag): + self.ops.append({"op": "clone_slide", "template": self.t[kind], "as": tag, "cat": "new"}) + return "$" + tag + + def _page(self, ref): + self.page += 1 + self.ops.append({"op": "set_page_number", "sldId": ref, "text": str(self.page), "cat": "new"}) + + def _notes(self, ref, paras): + self.ops.append({"op": "set_notes", "sldId": ref, "paragraphs": list(paras), "cat": "new"}) + + def _text(self, ref, sid, paras): + self.ops.append({"op": "set_text", "sldId": ref, "shape_id": sid, "paragraphs": list(paras), "cat": "new"}) + + def divider(self, tag, letter, title, sub, notes): + ref = self._clone("divider", tag) + self._text(ref, self.r["div_letter"], [letter]) + self._text(ref, self.r["div_title"], [title]) + self._text(ref, self.r["div_sub"], [sub]) + self._notes(ref, notes) + + def bullets(self, tag, eyebrow, title, items, foot, notes): + ref = self._clone("bullets", tag) + self._page(ref) + self._text(ref, self.r["bullets_eyebrow"], [eyebrow]) + self._text(ref, self.r["bullets_title"], [title]) + self._text(ref, self.r["bullets_body"], items) + self._text(ref, self.r["bullets_foot"], [foot]) + self._notes(ref, notes) + + def figure(self, tag, eyebrow, title, png, caption, notes): + ref = self._clone("figure", tag) + self._page(ref) + self._text(ref, self.r["figure_eyebrow"], [eyebrow]) + self._text(ref, self.r["figure_title"], [title]) + self.ops.append({"op": "replace_picture", "sldId": ref, "shape_id": self.r["figure_pic"], + "png": f"{PNG}/{png}.png", "cat": "new"}) + self._text(ref, self.r["figure_foot"], [caption]) + self._notes(ref, notes) + + def code(self, tag, eyebrow, title, chip, lines, caption, notes): + ref = self._clone("code", tag) + self._page(ref) + self._text(ref, self.r["code_eyebrow"], [eyebrow]) + self._text(ref, self.r["code_title"], [title]) + self._text(ref, self.r["code_chip"], [chip]) + self.ops.append({"op": "set_code", "sldId": ref, "shape_id": self.r["code_box"], "lines": list(lines), "cat": "new"}) + self._text(ref, self.r["code_foot"], [caption]) + self._notes(ref, notes) + + +R_EB = "APPENDIX R · MINIKUBE" +S_EB = "APPENDIX S · OPENSHIFT LOCAL" +R_TIMES = "Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s" +S_TIMES = "Spring Boot 139 s · Quarkus 123 s · .NET 125 s · Python 121 s · Go 120 s" + + +def appendix_r(g: Gen): + lang, name = g.lang, g.name + cpp = g.deck == "Cpp" + g.divider("r0", "R", "Example 05 on minikube", + "The same services and the same verify.sh on a single-node Kubernetes cluster", + ["Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node " + "Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves " + "the code; a cluster adds questions compose never asks: where configuration and credentials live, " + "how a service finds a broker that an operator manages, what restarts a process that exits, and how " + "a request from the host reaches a pod.", + "The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, " + "how images get into the node, how to run it, and the recorded results." + + (" OpenShift Local (Appendix S) is not part of the C++ deck: that appendix covers the other five " + "languages, and C++ runs here on minikube." if cpp else + " Appendix S then reuses the same contract on OpenShift Local.")]) + g.bullets("r1", R_EB, "From compose to a cluster", [ + "Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress.", + "Scope: Example 05 (event-driven fan-out), three services, all six languages, one node.", + "One Kafka broker, one Postgres instance, no service mesh: a local topology, not production.", + "The contract carries over: Service names, the Secret, environment variables, the health endpoint.", + "The same verify.sh runs against compose and the cluster: ten checks per language.", + ], "Site: Appendix R · Code: examples/05-event-driven/k8s/", + ["What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, " + "one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it.", + "What it fixes is the contract between an application and its platform: the Service names, the Secret that " + "holds the database credential, the environment variables each language reads, and the health endpoint. " + "That contract is what carries over to OpenShift Local, and it is why the application code is unchanged.", + "The test is the one compose uses: verify.sh, ten checks, with its container operations switched to " + "kubectl exec."]) + g.bullets("r2", R_EB, "The minikube runtime", [ + "Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5.", + "8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services.", + "NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation.", + "No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop.", + "Every command passes --context cndp-k8s; setup-profile.sh restores the prior context.", + ], "minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0", + ["minikube runs the control plane and the workload in one container on Docker Engine, the same engine " + "compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the " + "pinned kubectl, so client and server are the same version.", + "Host access uses two NodePorts published on the loopback address when the profile is created. " + "Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load " + "and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both " + "ports bind to 127.0.0.1.", + "The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with " + "--keep-context, and setup-profile.sh restores the prior context because minikube's restart path can " + "override that flag."]) + g.figure("r3", R_EB, "One node, two namespaces", "31-minikube-topology", + "The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec", + ["The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and " + "cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, " + "Postgres 18, the LGTM stack and the Postgres Secret.", + "From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on " + "127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube " + "profiles stay stopped while this one runs."]) + g.bullets("r4", R_EB, "Compose to Kubernetes", [ + "kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1).", + "postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw.", + "Credential → Secret cndp-postgres-app, read through secretKeyRef.", + "build: → docker build, then minikube image load; imagePullPolicy: Never.", + "healthcheck → startup and readiness probes on the order-service /healthz.", + "ports: 8080:8080 → NodePort 30080.", + ], "The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses.", + ["Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a " + "Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster " + "only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts.", + "The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP " + "surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET " + "images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the " + "earliest offset, so a restart loses nothing."]) + g.code("r5", R_EB, "One overlay per language", "yaml · kustomize", [ + f"# k8s/overlays/{lang}/kustomization.yaml (excerpt)", + "images:", + " - name: order-service", + f" newName: localhost/cndp-05/{lang}-order-service", + " newTag: v1", + " # shipping-consumer and notification-consumer map the same way", + "# appended to each Deployment's environment by a JSON patch", + *ENV[lang], + ], "base/ is language-neutral; each overlay maps the images and appends the variables its language reads.", + [f"The {name} overlay. The base Deployments are language-neutral: they run with a restricted security context " + "(non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read " + "PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service.", + "Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables " + "that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) " + "only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base."]) + g.figure("r6", R_EB, "Images built on the host", "31-minikube-image-flow", + "docker build on the host, minikube image load into the node; pods never pull", + ["The cluster does not build images. Each service is built on the host with docker build, which keeps the " + "host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd " + "store with minikube image load.", + "The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead " + "of trying a registry that does not have it. Tags are always v1; nothing uses latest."]) + g.code("r7", R_EB, "Run it", "bash · minikube", [ + "# from examples/05-event-driven/, with no other profile or container running", + "k8s/setup-profile.sh # create cndp-k8s; check the published ports", + f"k8s/build-images.sh {lang:<8}# docker build, then minikube image load", + f"k8s/deploy.sh {lang:<14}# Strimzi, Kafka, Postgres, LGTM, then the overlay", + "RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh", + "", + "# all six languages, a fresh namespace for each", + "OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh", + "", + "# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile", + "k8s/teardown.sh --operator", + "minikube stop -p cndp-k8s", + ], "With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged.", + ["The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another " + f"profile or a container is running. build-images.sh builds and loads the three {name} images, and deploy.sh " + "installs Strimzi, then Kafka, Postgres and LGTM, then the overlay.", + "verify.sh is the file compose uses. Its container operations go through helper functions, and " + "RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in " + "cndp-postgres-0.", + "What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the " + "recorded results that follow. teardown.sh never deletes the profile; minikube stop frees the memory and keeps " + "the images."]) + g.bullets("r8", R_EB, "Recorded results", [ + "10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0).", + R_TIMES + ".", + "Tempo received traces from order-service, shipping-consumer and notification-consumer in every language.", + "shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset.", + "Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them.", + ], "Times cover the image build and load, the deploy, the checks and the namespace teardown.", + ["The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. " + "After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all " + "three services reported in every language.", + "The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no " + "shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment " + "appears. That is the event-driven contract from the Event-Driven section, observed on a cluster." + + (" C++ stops here: OpenShift Local covers the other five languages." if cpp else "")]) + + +def appendix_s(g: Gen): + lang, name = g.lang, g.name + g.divider("s0", "S", "Example 05 on OpenShift Local", + "Operators from OLM, restricted-v2 pods, Routes and an internal registry", + ["Appendix S runs the same three services and the same verify.sh on OpenShift Local (CRC). OpenShift is " + "stricter than minikube in four places, and each one changes the deployment: operators come from the " + "Operator Lifecycle Manager, pods run under a security context constraint that assigns an arbitrary " + "user ID, traffic enters through Routes, and images come from a registry inside the cluster.", + "The scope is the same as Appendix R: one example, one node. The language overlays are the minikube " + "overlays, unchanged; the OpenShift directory adds only what the platform needs. Five languages run " + "here: Spring Boot, Quarkus, .NET, Python and Go."]) + g.bullets("s1", S_EB, "What OpenShift changes", [ + "Operators come from OLM: AMQ Streams 3.2 by Subscription, a pinned CSV, Manual approval.", + "Pods run under the restricted-v2 SCC with an arbitrary non-root user ID in group 0.", + "Traffic enters through edge-TLS Routes on apps-crc.testing, not NodePorts.", + "Images go to the internal registry with skopeo copy; pods pull with imagePullPolicy: Always.", + "Kafka 4.2.0 (the AMQ Streams 3.2 bundle); Postgres from Red Hat's rhel10/postgresql-18.", + ], "Site: Appendix S · Code: examples/05-event-driven/openshift/", + ["The four differences from minikube, and what each one changes. AMQ Streams is the supported Red Hat build " + "of Strimzi, installed the OpenShift way through a Subscription. Its 3.2 bundle ships broker images for Kafka " + "4.1 and 4.2 only, so the Kafka resource is patched to 4.2.0.", + "The Postgres image is Red Hat's, built for an assigned user ID; the schema runs from a postgresql-start hook " + "as appuser. There is no node image store to load into, so a registry is the interface, and the pull policy is " + "Always because the tag stays v1 across runs."]) + g.bullets("s2", S_EB, "The OpenShift Local runtime", [ + "crc 2.64.0: OpenShift 4.22.14 and Kubernetes v1.35.6 in a VM on the host (KVM).", + "12 vCPU, 32768 MiB, 80 GB disk: the platform, Kafka, Postgres, LGTM and three services.", + "Every oc call names --context crc-admin; no script runs oc config use-context.", + "The kubeadmin password, tokens and the pull secret are never printed or stored.", + "One cluster at a time: setup-crc.sh refuses while minikube or a container is running.", + ], "CRC is stopped after use, never deleted, so the next start is quick.", + ["OpenShift Local runs a single-node OpenShift cluster in a virtual machine managed by the crc command. crc " + "setup makes api.crc.testing and *.apps-crc.testing resolve locally, which is what lets a Route work without " + "a tunnel.", + "crc start prints the kubeadmin and developer passwords; setup-crc.sh discards that output, so no password " + "reaches a terminal, a log or a file. It records the current kubectl context first, including the case where " + "none is set, and restores it on exit."]) + g.figure("s3", S_EB, "Topology on OpenShift Local", "32-openshift-topology", + "The operator lives in its own namespace and manages Kafka in cndp-05; Routes carry the host traffic", + ["The topology. cndp-amq-streams holds the Subscription and the AMQ Streams operator; cndp-05 holds the three " + "services, Kafka 4.2.0, Postgres 18 and the LGTM stack. The operator's OperatorGroup selects all " + "namespaces, so it manages the Kafka cluster in cndp-05 without a second OperatorGroup there.", + "The host reaches order-service and Grafana through edge-TLS Routes and uses oc exec for the Kafka and " + "Postgres checks."]) + g.code("s4", S_EB, "Pinning an operator with OLM", "yaml · OLM", [ + "# openshift/operators/subscription.yaml (excerpt)", + "apiVersion: operators.coreos.com/v1alpha1", + "kind: Subscription", + "metadata:", + " name: amq-streams", + " namespace: cndp-amq-streams", + "spec:", + " name: amq-streams", + " source: redhat-operators", + " sourceNamespace: openshift-marketplace", + " channel: amq-streams-3.2.x", + " startingCSV: amqstreams.v3.2.1-14", + " installPlanApproval: Manual", + ], "deploy.sh approves only the InstallPlan for the pinned CSV; a later upgrade waits unapproved.", + ["On minikube the Strimzi operator is a Helm release. On OpenShift an operator is a Subscription to a package " + "in a catalog, and OLM does the install. startingCSV names the ClusterServiceVersion to install, and " + "installPlanApproval: Manual makes OLM propose an InstallPlan and wait.", + "deploy.sh approves only the InstallPlan that lists the pinned CSV and waits for the CSV to reach Succeeded. " + "The channel is the minor channel amq-streams-3.2.x, not the default stable channel, so a new minor release " + "does not change what is installed. Before installing, deploy.sh refuses to continue if it finds another " + "project's AMQ Streams or Strimzi installation, because a cluster-wide operator would also manage its clusters."]) + g.bullets("s5", S_EB, "restricted-v2 without exceptions", [ + "The base already sets non-root, no privilege escalation, no capabilities, RuntimeDefault seccomp.", + "No runAsUser, fsGroup or SCC binding in the overlays; nothing runs oc adm policy.", + "Images end in a numeric USER (185 on the JVM images, 1001 on the others) and own their files as group 0.", + "otel-lgtm 0.36.0 runs as an arbitrary UID with emptyDir volumes for /data, /tmp and /var/tempo.", + "Every application pod reported openshift.io/scc: restricted-v2 in all five languages.", + ], "If a future image needs root: a scoped anyuid RoleBinding for one ServiceAccount, never cluster-wide.", + ["restricted-v2 is the default SCC for ordinary workloads: it requires runAsNonRoot, forbids privilege " + "escalation, drops all capabilities, applies the runtime-default seccomp profile and assigns a user ID from " + "the project's range, with group 0. The minikube base already sets that security context and never names a " + "user ID, so the OpenShift overlays add nothing.", + "Each Containerfile ends in a numeric non-root USER and copies files with --chown=:0 --chmod=g=u, so the " + "assigned user ID, which is always in group 0, can read and write what the image owns. The observability " + "image is built to run as root; run with a read-only root it needed three emptyDir mounts and " + "GF_PLUGINS_PREINSTALL_DISABLED=true, and then ran under restricted-v2 with no exception."]) + g.figure("s6", S_EB, "Images through the internal registry", "32-openshift-image-flow", + "skopeo pushes from the Docker image store to the internal registry; the token never touches a file", + ["The Docker builds are the ones from Appendix R, with the same names and the same host caches; only the " + "destination changes. docker push to the registry route would need the ingress CA trusted by the Docker " + "daemon, a permanent root-owned change to the host. skopeo copy reads the image from the Docker daemon and " + "takes the CA from a directory the user owns.", + "The first push creates the ImageStream; pods pull the v1 tag with imagePullPolicy: Always."]) + g.code("s7", S_EB, "A registry push without a token on disk", "bash · skopeo", [ + 'token="$(oc --context crc-admin create token builder -n cndp-05 --duration=10m)"', + 'skopeo copy --dest-cert-dir "$STATE/registry-ca" \\', + ' --dest-authfile <(printf \'{"auths":{"%s":{"auth":"%s"}}}\' "$REGISTRY_ROUTE" \\', + ' "$(printf \'builder:%s\' "$token" | base64 -w0)") \\', + f' "docker-daemon:localhost/cndp-05/{lang}-order-service:v1" \\', + f' "docker://$REGISTRY_ROUTE/cndp-05/{lang}-order-service:v1"', + "unset token", + ], "A ten-minute builder token in a shell variable, passed through a pipe: no file, no argument list.", + ["The credential is the short-lived token of the project's builder service account, held only in a shell " + "variable. skopeo reads credentials from an authfile, and the authfile here is a process substitution, a pipe, " + "so no file contains the token.", + "printf is a Bash builtin, so the token is in no process's argument list either, which is what --dest-creds and " + "--dest-registry-token would expose to ps. skopeo login is not used because it writes an authfile to disk. " + "build-push-images.sh creates one token per image."]) + if g.deck == "DotNet": + g.code("s7n", S_EB, "Trace context through Kafka headers", "csharp · Confluent.Kafka", [ + "// OrderService/Program.cs (excerpt): producer span, context into the headers", + 'publish = orderSource.StartActivity("order.placed publish", ActivityKind.Producer);', + "var message = new Message { Value = json, Headers = new Headers() };", + "Propagators.DefaultTextMapPropagator.Inject(", + " new PropagationContext(", + " publish?.Context ?? Activity.Current?.Context ?? default, Baggage.Current),", + " message.Headers,", + " (headers, key, value) => headers.Add(key, Encoding.UTF8.GetBytes(value)));", + 'await producer.ProduceAsync("order.placed", message);', + "", + "// ShippingConsumer/Program.cs (excerpt): continue the trace from the headers", + "var parent = Propagators.DefaultTextMapPropagator.Extract(", + " default,", + " cr.Message.Headers,", + " (headers, key) => headers is null", + " ? Array.Empty()", + " : headers.Where(h => h.Key == key)", + " .Select(h => h.GetValueBytes())", + " .Where(b => b is not null)", + " .Select(b => Encoding.UTF8.GetString(b!)));", + 'using var span = source.StartActivity("process_shipment", ActivityKind.Consumer,', + " parent.ActivityContext);", + ], "Confluent.Kafka has no built-in tracing: the W3C trace context travels in the record headers.", + ["The check that matters for an event-driven example is that one trace crosses the topic. Spring Boot, " + "Quarkus, Python and Go propagate the W3C trace context in the Kafka record headers through their " + "instrumentation. Confluent.Kafka has no built-in tracing, so the .NET order-service span and the consumer " + "spans were three unrelated traces until the code carried the context itself.", + "The order-service starts a producer span and injects the context into the record headers; each consumer " + "extracts it and starts its span as a child. On compose the .NET example passes verify.sh 10 of 10, and " + "Tempo holds one trace with all three services."]) + g.code("s8", S_EB, "Run it", "bash · OpenShift Local", [ + "# from examples/05-event-driven/, with minikube and compose stopped", + "openshift/setup-crc.sh # crc start, ingress CA, registry route", + f"openshift/build-push-images.sh {lang:<8}# docker build, then skopeo copy", + f"openshift/deploy.sh {lang:<19}# operator, infrastructure, overlay", + "RUNTIME=kubernetes KUBECTL=oc KUBE_CONTEXT=crc-admin KUBE_NAMESPACE=cndp-05 \\", + " BASE=https://order-service-cndp-05.apps-crc.testing \\", + ' CURL_OPTS="--cacert $HOME/.local/state/cndp-05-crc/ingress-ca.crt" ./verify.sh', + "", + "# five languages, a fresh project for each", + "OUT=/tmp/cndp-ocp-matrix openshift/verify-matrix.sh", + "", + "# remove the operator, its CRDs, the projects and the route change; then crc stop", + "openshift/teardown.sh", + ], "The Route's certificate is verified against the cluster's ingress CA; nothing uses curl -k.", + ["The run. setup-crc.sh starts CRC, reads the ingress CA from the default-ingress-cert ConfigMap and enables " + f"the registry's default route only if it is off. build-push-images.sh builds the three {name} images and " + "copies them into the registry; deploy.sh installs the operator, the infrastructure and the overlay.", + "verify.sh is unchanged: KUBECTL=oc and KUBE_CONTEXT=crc-admin select the cluster, and the Route's certificate " + "is checked against the ingress CA. Plain HTTP on the Route answers with a redirect to HTTPS.", + "What to show: the ten check marks over HTTPS, then the openshift.io/scc annotation on a pod. Fallback: the " + "recorded results that follow. teardown.sh deletes the Kafka resources while the operator still runs, then " + "the project, the operator and the CRDs it owns, and finally runs crc stop."]) + g.bullets("s9", S_EB, "Recorded results", [ + "10 passed, 0 failed in all five languages, every pod under restricted-v2 (2026-10-11).", + S_TIMES + ".", + "One trace per order crosses Kafka: order-service, shipping-consumer, notification-consumer.", + "Plain HTTP on the Route answers 302 to HTTPS; Grafana 13.2.3 is healthy through its Route.", + "A second teardown finds nothing to do and exits 0; CRC is stopped, not deleted.", + ], "OpenShift Local 4.22.14 · crc 2.64.0 · AMQ Streams amqstreams.v3.2.1-14 · Kafka 4.2.0", + ["The recorded run, 2026-10-11: each language passed verify.sh, produced a trace that Tempo returned with all " + "three services, and had every application pod under restricted-v2. Between languages only the cndp-05 project " + "is removed; the operator stays, so later languages skip the install.", + "Teardown removed the operator, its CRDs, the namespaces and the registry route change, then stopped CRC. The " + "SCC rejection of a fixed root user ID was not exercised on this run."]) + + +def agenda(g: Gen, prs): + key = g.deck if g.deck in AGENDA_TAIL else "default" + s = next(x for x in prs.slides if x.slide_id == 257) + col = [sh for sh in s.shapes if sh.has_text_frame and "Glossary of acronyms" in sh.text_frame.text] + assert len(col) == 1 + g.ops.append({"op": "set_text", "sldId": 257, "shape_id": col[0].shape_id, "paragraphs": AGENDA_TAIL[key], + "cat": "new", "_why": "agenda gains Appendices R-S (C++: R)"}) + nkey = "Cpp" if g.deck == "Cpp" else "default" + paras = list(AGENDA_NOTES[nkey]) + extra = ", plus the .NET toolkit, .NET on Linux and Blazor Server" if g.deck == "DotNet" else "" + paras = [p.replace("{extra}", extra) for p in paras] + g.ops.append({"op": "set_notes", "sldId": 257, "paragraphs": paras, "cat": "new"}) + + +def main(): + (WORK / "ops-90-rs").mkdir(exist_ok=True) + for deck in DECKS: + name = f"Designing-Cloud-Native-APIs-{deck}" + prs = Presentation(str(REPO / "lgtm-presentation" / f"{name}.pptx")) + g = Gen(deck, prs) + agenda(g, prs) + appendix_r(g) + if deck != "Cpp": + appendix_s(g) + (WORK / "ops-90-rs" / f"{name}.json").write_text(json.dumps({"ops": g.ops}, indent=1, ensure_ascii=False) + "\n") + print(name, len(g.ops), "ops, last page", g.page, g.r) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/hits.py b/_plans/s12-manifest/tools/hits.py new file mode 100644 index 00000000..8c64d5a9 --- /dev/null +++ b/_plans/s12-manifest/tools/hits.py @@ -0,0 +1,40 @@ +import json,re,sys,collections +from pathlib import Path +segs=json.loads((Path(__file__).resolve().parent/"segs.json").read_text()) +pats=[] +for line in Path("/home/rsedor/Dev/cloud-native-design-patterns/scripts/voice/patterns.tsv").read_text().splitlines(): + if not line or line.startswith("#"): continue + f=line.split("\t") + if len(f)<4: continue + tier,case,label,rx=f[:4] + rx=rx.replace("[[:space:]]",r"\s") + try: pats.append((tier,label,re.compile(rx, 0 if case=="c" else re.I))) + except re.error as e: print("bad",label,e,file=sys.stderr) +POS=[re.compile(r"\babove\b",re.I),re.compile(r"\bbelow\b",re.I),re.compile(r"\bnext\s+slide\b",re.I),re.compile(r"\bprevious\s+slide\b",re.I),re.compile(r"\bprevious\s+section\b",re.I),re.compile(r"\bfollowing\s+section\b",re.I),re.compile(r"\bsee\s+slide\s+\d+",re.I),re.compile(r"\bpreceding\b",re.I)] +EX=[re.compile(r"\b(?:above|below)\s+(?:[$~<>]?\d|zero|the\s+(?:threshold|limit|cutoff|floor|ceiling))",re.I),re.compile(r"\b(?:above|below)-\w+",re.I),re.compile(r"\b(?:at\s+or|or)\s+(?:above|below)\b",re.I)] +ENG=re.compile("p"+"odman",re.I) +def classify(t): + cats=[] + for tier,label,rx in pats: + if rx.search(t): cats.append(f"{tier}:{label}") + for rx in POS: + for m in rx.finditer(t): + ctx=t[max(0,m.start()-4):m.end()+20] + if not any(e.search(ctx) for e in EX): cats.append("pos:"+m.group(0).lower()) + if ENG.search(t): cats.append("engine") + return cats +if __name__=="__main__": + tier=sys.argv[1] if len(sys.argv)>1 else "ban" + decks=[d for d in segs if d.startswith("Designing")] + uniq=collections.OrderedDict() + for d in decks: + for s in segs[d]: + if s["code"]: continue + c=classify(s["text"]) + c=[x for x in c if x.startswith(tier) or tier=="all"] + if not c: continue + k=s["text"] + uniq.setdefault(k,{"cats":c,"where":[]})["where"].append(f"{d.split('-')[-1]}:{s['n']}/{s['sid']}{'N' if s['kind']=='notes' else ''}") + print(len(uniq),"unique segments") + for k,v in uniq.items(): + print("----",v["cats"],v["where"]); print(k) diff --git a/_plans/s12-manifest/tools/images_gen_images.py b/_plans/s12-manifest/tools/images_gen_images.py new file mode 100644 index 00000000..452ec2a9 --- /dev/null +++ b/_plans/s12-manifest/tools/images_gen_images.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Build the replacement figures for the S12 image audit. + +site : assets/diagrams/.svg rendered as is (arrow glyphs -> ASCII for cairosvg) +variant : a site SVG with label substitutions per language; source kept in edits/png/src/ +authored : new SVGs in the house style (Appendix J tables, .NET bridge and Stateless figures) +All PNGs go to lgtm-presentation/edits/png/ at 1400 px wide (cairosvg 2.9.1). +""" +import html +import re +from pathlib import Path + +import cairosvg + +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +DIAG = REPO / "assets" / "diagrams" +OUT = REPO / "lgtm-presentation" / "edits" / "png" +SRC = OUT / "src" +SRC.mkdir(parents=True, exist_ok=True) +FONT = "'Red Hat Text', 'Liberation Sans', system-ui, sans-serif" + + +def ascii_arrows(s): + return s.replace("→", "->").replace("←", "<-") + + +def render(svg_text, png_name): + cairosvg.svg2png(bytestring=ascii_arrows(svg_text).encode(), write_to=str(OUT / f"{png_name}.png"), + output_width=1400, background_color="white") + + +SITE = ["04-cqrs", "04-log-tailing", "22-l7-journey", "22-east-west", "24-shared-database", + "25-failure-taxonomy", "13-antipatterns-grid", "03-composition", "06-scale-on-lag", + "06-derive-streams", "17-saga-state", "22-l7-layers", "25-circuit-breaker", "22-hop-cost", + "29-dotnet-compilation", "29-kestrel-pipeline", "29-container-layers"] + +E = html.escape +VARIANTS = { + # name: (site svg, [(old, new), ...]); old/new are SVG-escaped text as it appears in the file + "22-hop-cost-cpp": ("22-hop-cost", [("In-app rule eval (durable-rules)", "In-app rule eval (sol2 / Lua)")]), + "22-hop-cost-go": ("22-hop-cost", [("In-app rule eval (durable-rules)", "In-app rule eval (gopher-lua)")]), + "22-hop-cost-drools": ("22-hop-cost", [("In-app rule eval (durable-rules)", "In-app rule eval (Drools)")]), + "22-hop-cost-nrules": ("22-hop-cost", [("In-app rule eval (durable-rules)", "In-app rule eval (NRules)")]), + "02-pick-protocol-go": ("02-pick-protocol", [(">FastAPI<", ">net/http ServeMux<"), + (">Strawberry / Graphene<", ">gqlgen<"), + (">grpc + protobuf<", ">grpc-go + protobuf<")]), + "02-pick-protocol-dotnet": ("02-pick-protocol", [(">FastAPI<", ">ASP.NET Core minimal APIs<"), + (">Strawberry / Graphene<", ">HotChocolate<"), + (">grpc + protobuf<", ">Grpc.AspNetCore + protobuf<")]), + "02-pick-protocol-spring": ("02-pick-protocol", [(">FastAPI<", ">Spring Web MVC<"), + (">Strawberry / Graphene<", ">Spring for GraphQL<"), + (">grpc + protobuf<", ">grpc-java + protobuf<")]), + "02-mesh-handles-wire-go": ("02-mesh-handles-wire", [(">FastAPI / grpc server<", ">net/http / grpc-go server<")]), + "02-mesh-handles-wire-dotnet": ("02-mesh-handles-wire", [(">FastAPI / grpc server<", ">ASP.NET Core / gRPC server<")]), + "02-mesh-handles-wire-spring": ("02-mesh-handles-wire", [(">FastAPI / grpc server<", ">Spring Boot / gRPC server<")]), + "01-factors-mapped-py": ("01-factors-mapped", [("one repo · Poetry · one image per release", + "one repo · pip + requirements.txt · one image per release")]), + "01-factors-mapped-go": ("01-factors-mapped", [("one repo · Poetry · one image per release", + "one repo · go.mod + go.sum · one image per release"), + ("pydantic-settings · per-profile · secrets injected", + "os.Getenv · per-profile · secrets injected")]), +} + +ROUTER = { + "22-business-rule-routing-cpp": dict( + front="Drogon HttpController", + code=["Task<> Orders::route(HttpRequestPtr req, auto cb) {", + " auto order = parse<OrderIn>(req); // front", + " auto ctx = co_await enrich(order); // context", + " auto dests = router.evaluate(order, ctx); // rules", + " for (const auto& dest : dests) // dispatch", + " produce_to(*producer_, topic, id, body); // rdkafka++"], + engine=["sol2 + Lua", "embedded Lua VM", "rules.lua, sandboxed", "(loaded at startup)"]), + "22-business-rule-routing-go": dict( + front="net/http handler", + code=["func (s *Server) route(w http.ResponseWriter, r *http.Request) {", + "rc := enrich(ctx, order) // context", + "dests := rt.evaluate(ctx, order, rc) // rules", + "for _, dest := range dests { // dispatch", + " cl.Produce(ctx, record(dest), done) // franz-go", + "}"], + engine=["gopher-lua", "Lua VM in pure Go", "rules.lua, sandboxed", "(pooled LState)"]), + "22-business-rule-routing-dotnet": dict( + front="OrderRouter (ASP.NET Core)", + code=["public async Task Route(OrderInbound msg) {", + "var fact = new OrderFact(id, cents, customer); // context", + "var session = rules.CreateSession();", + "session.Insert(fact); session.Fire(); // rules", + "foreach (var dest in fact.WhereTo.Split(',')) // dispatch", + " await producer.ProduceAsync(topic, message); // Confluent.Kafka"], + engine=["NRules", "Rete engine for .NET", "rules in C# (NRules.Fluent)", "(forward chaining)"]), +} + + +def router_variant(name, spec): + svg = (DIAG / "22-business-rule-routing.svg").read_text() + texts = re.findall(r"(]*>)([^<]*)()", svg) + old = [t[1] for t in texts] + # locate the front title, the code lines and the engine lines by their site text + fi = old.index("FastAPI / Starlette router") + ci = old.index("@app.post("/orders/route") # front") + ei = old.index("durable-rules") + repl = {fi: spec["front"]} + for k, line in enumerate(spec["code"]): + repl[ci + k] = line + for k, line in enumerate(spec["engine"]): + repl[ei + k] = line + out, i = [], 0 + + def sub(m): + nonlocal i + new = repl.get(i, m.group(2)) + i += 1 + return m.group(1) + new.replace(" ", " ") * 0 + new + m.group(3) if False else m.group(1) + new + m.group(3) + svg2 = re.sub(r"(]*>)([^<]*)()", sub, svg) + return svg2 + + +# ------------------------------------------------------------------ authored figures + +def tbl(title, subtitle, header, rows, colw, foot, accent_col=None, italic_col=0, width=1600): + rowh, top = 30, 120 + h = top + 34 + rowh * len(rows) + 90 + h = int(h * 1.08) # blank band at the bottom: the slide caption sits over the frame bottom + x0 = 40 + parts = [f'', + f'', + f'{E(title)}', + f'{E(subtitle)}'] + x = x0 + for k, hd in enumerate(header): + parts.append(f'{E(hd)}') + x += colw[k] + parts.append(f'') + for r, row in enumerate(rows): + y = top + 34 + r * rowh + if r % 2 == 0: + parts.append(f'') + x = x0 + for k, cell in enumerate(row): + style = "" + fill = "#242424" + if k == italic_col: + style = ' font-style="italic"' + fill = "#5a5a5a" + elif k == 1: + style = ' font-weight="700"' + fill = "#151515" + if accent_col is not None and k == accent_col: + fill = "#0066CC" + style = ' font-weight="700"' + parts.append(f'{E(cell)}') + x += colw[k] + yf = top + 34 + rowh * len(rows) + 40 + for k, line in enumerate(foot): + parts.append(f'{E(line)}') + parts.append("") + return "\n".join(parts) + + +def md_table(path, start_hdr): + lines = path.read_text().split("\n") + i = next(k for k, l in enumerate(lines) if l.startswith(start_hdr)) + rows = [] + for l in lines[i + 2:]: + if not l.startswith("|"): + break + rows.append([c.strip() for c in l.strip().strip("|").split("|")]) + return rows + + +def bridge_svg(): + rows = [("IIS app pool", "Kubernetes Pod (ReplicaSet)"), + ("App pool recycle", "Graceful shutdown + rolling deploy"), + ("WCF service + DataContract", "gRPC + .proto (or ASP.NET Core + OpenAPI)"), + ("WCF bindings (BasicHttp / NetTcp / MSMQ)", "REST + gRPC + Kafka, picked per call"), + ("COM+ / MSDTC distributed transactions", "Saga + outbox (no two-phase commit)"), + ("MSMQ / SQL Server Service Broker", "Kafka topic + consumer group (Confluent.Kafka)"), + ("Windows Service", "IHostedService / BackgroundService in a Pod"), + ("App.config / web.config", "appsettings.json + env vars + ConfigMap / Secret"), + ("Windows performance counters", "OpenTelemetry metrics → Prometheus / Mimir"), + ("Windows Event Log", "Structured logs (Microsoft.Extensions.Logging) → Loki"), + ("Active Directory + Windows Auth", "mTLS via Istio + SPIFFE identity"), + (".NET Remoting", "gRPC over HTTP/2"), + ("BizTalk orchestration", "Temporal workflows / Stateless state machines")] + W, H = 2000, 1120 + p = [f'', + '', + f'', + f'From the Windows world to Kubernetes', + f'what you knew, and what it becomes', + f'Windows / .NET 4.x era', + f'Kubernetes / .NET 10 era'] + for k, (l, r) in enumerate(rows): + y = 190 + k * 61 + p.append(f'') + p.append(f'') + p.append(f'') + p.append(f'{E(l)}') + p.append(f'{E(r)}') + p.append(f'The architectural shift: stop owning the host (IIS, MSDTC, MSMQ). Kubernetes owns the platform; you own the code.') + p.append(f'Most of these are patterns you already used; the platform now runs them instead of IIS.') + p.append("") + return "\n".join(p) + + +def stateless_svg(): + W, H = 2000, 1020 + p = [f'', + '' + '', + f'', + f'Stateless for in-process sagas in .NET', + f'lighter-weight: states + triggers + transitions, persisted in your own DB'] + states = [("Created", "#fcf2e1", "#c9a14a", "#6b4a12"), ("Paid", "#e7eef8", "#2f5ea8", "#1d3a6b"), + ("Reserved", "#f7e6f0", "#a0457a", "#6b2a50"), ("Shipped", "#f5ebd6", "#c4904a", "#6b4a12"), + ("Done", "#e8f2e0", "#4f8a2f", "#2d5a16")] + trig = ["PayOk", "ReserveOk", "ShipOk", "Delivered"] + for k, (n, f, s, t) in enumerate(states): + x = 200 + k * 340 + p.append(f'') + p.append(f'{n}') + if k < 4: + p.append(f'') + p.append(f'{trig[k]}') + p.append('') + p.append('on StepFail') + for k, n in enumerate(["Compensating", "Failed"]): + x = 880 + k * 340 + p.append(f'') + p.append(f'{n}') + p.append('') + boxes = [ + (102, "#f5ebd6", "#c4904a", "#5a3a08", "StateMachine<TState, TTrigger>", + ["states · triggers · transitions", "guards (predicate on edge)", "entry / exit actions", + "OnTransitioned listeners", "", "Stateless package (Apache 2.0)", "tiny dependency, no extra runtime"]), + (746, "#eeeeee", "#888888", "#3a3a3a", "Persist state yourself", + ["serialize CurrentState → saga row", "", "EF Core · Dapper · Marten", "", + "one DbContext transaction per step", "scheduled sweeper for in-flight sagas"]), + (1390, "#e7eef8", "#2f5ea8", "#1d3a6b", "Driving the machine", + ["await sm.FireAsync(", " Trigger.PayOk, payload);", "", "// guards + actions run on transition", + "// persister saves new CurrentState", "", "need durable timers and retries:", "Temporal (separate server)"]), + ] + for x, f, s, t, title, lines in boxes: + w = 576 if x != 1390 else 542 + p.append(f'') + p.append(f'{title}') + for k, line in enumerate(lines): + mono = x == 1390 and (line.startswith("await") or line.startswith(" ") or line.startswith("//")) + fam = ' font-family="\'Red Hat Mono\', \'Liberation Mono\', monospace"' if mono else "" + p.append(f'{E(line)}') + p.append(f'in-process · no extra cluster to run · open source (Apache 2.0)') + p.append(f'When the saga is small, in-process, and you do not want a separate Temporal cluster.') + p.append("") + return "\n".join(p) + + +def main(): + for name in SITE: + render((DIAG / f"{name}.svg").read_text(), name) + for name, (base, subs) in VARIANTS.items(): + svg = (DIAG / f"{base}.svg").read_text() + for o, n in subs: + assert o in svg, (name, o) + svg = svg.replace(o, n) + (SRC / f"{name}.svg").write_text(svg) + render(svg, name) + for name, spec in ROUTER.items(): + svg = router_variant(name, spec) + (SRC / f"{name}.svg").write_text(svg) + render(svg, name) + j = REPO / "_docs" / "23-appendix-j-dotnet-toolkit.md" + lib = md_table(j, "| Concern | Library |") + svg = tbl("The pinned .NET 10 toolkit", "every library in your csproj: purpose, license, NuGet package", + ["Concern", "Library", "Purpose", "License", "NuGet package"], + [r[:5] for r in lib], [160, 300, 500, 130, 560], + ["Every entry is MIT, Apache 2.0 or BSD-3 · every entry runs on plain Kubernetes · no cloud-only dependencies", + "Kafka through Confluent.Kafka directly; stream processing on Streamiz.Kafka.Net."], accent_col=3, width=1740) + (SRC / "70-appJ-pinned-libraries-dn.svg").write_text(svg) + render(svg, "70-appJ-pinned-libraries-dn") + plat = md_table(j, "| Concern | Component |") + plat = [[c if not (r[1].startswith("Redis") and k == 3) else "AGPL-3.0 (Redis 8) / BSD (Valkey)" + for k, c in enumerate(r)] for r in plat] + svg = tbl("The pinned cloud-native platform stack", "the cluster-side open-source components your .NET Pods rely on", + ["Concern", "Component", "Purpose", "License"], plat, [260, 420, 520, 340], + ["These components serve a Spring Boot or Python stack the same way.", + "Standard CNCF-friendly stack; .NET 10 is the runtime in the application Pods."], accent_col=3) + (SRC / "71-appJ-pinned-platform-dn.svg").write_text(svg) + render(svg, "71-appJ-pinned-platform-dn") + mig = md_table(j, "| Concern | Coming from") + svg = tbl("Migration reference", "concrete patterns from Windows / .NET Framework 4.x to Kubernetes / .NET 10", + ["Concern", "Coming from (Windows / .NET 4.x)", "Going to (.NET 10 / Kubernetes)", "What changes most"], + mig, [200, 380, 560, 640], + ["Almost every row keeps the shape of the pattern; what changes is who owns the host and the substrate.", + "The last column is usually 'the platform owns more, your code owns less'."], width=1860) + (SRC / "72-appJ-migration-reference-dn.svg").write_text(svg) + render(svg, "72-appJ-migration-reference-dn") + svg = bridge_svg() + (SRC / "01b-windows-to-k8s-bridge-dn.svg").write_text(svg) + render(svg, "01b-windows-to-k8s-bridge-dn") + svg = stateless_svg() + (SRC / "63-appD-stateless-dn.svg").write_text(svg) + render(svg, "63-appD-stateless-dn") + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/images_gen_ops.py b/_plans/s12-manifest/tools/images_gen_ops.py new file mode 100644 index 00000000..3d783bd8 --- /dev/null +++ b/_plans/s12-manifest/tools/images_gen_ops.py @@ -0,0 +1,62 @@ +import json,collections,sys +from pptx import Presentation +F="/tmp/claude-1000/-home-rsedor-Dev/0dea024d-0bf0-4ae5-bddd-7dec0cf60b22/scratchpad/s12/manifest/fork-images" +W=F+"/../work" +idx=json.load(open(F+"/index.json")) +ALL=["Cpp","DotNet","Go","Python","Quarkus","Spring"] +def lang(d,m): return m.get(d) +RULES=[ # (descr prefix, {deck: png}, cat, why) + ("08b-data-cqrs",{d:"04-cqrs" for d in ALL},"voice","image text had a positional reference ('the next slide') and 'genuinely'"), + ("08c-data-log-tailing",{d:"04-log-tailing" for d in ALL},"positional","image text had '(next slide)'"), + ("55-appI-journey",{d:"22-l7-journey" for d in ALL},"voice","subtitle 'each layer earns its place' (ban tier)"), + ("60-appI-east-west",{d:"22-east-west" for d in ALL},"voice","caption 'for free' (ban tier)"), + ("86-appK-strangler-shared-database",{d:"24-shared-database" for d in ALL},"positional","'(decomposed DB above)'"), + ("110-failure-taxonomy",{d:"25-failure-taxonomy" for d in ALL},"voice","'the rest of this appendix' (ban tier)"), + ("23-anti-patterns-grid",{d:"13-antipatterns-grid" for d in ALL},"voice","box text broken mid-word (layout defect)"), + ("59-appI-latency",{"Cpp":"22-hop-cost-cpp","Go":"22-hop-cost-go","Python":"22-hop-cost","Quarkus":"22-hop-cost-drools","Spring":"22-hop-cost-drools","DotNet":"22-hop-cost-nrules"},"voice","'every hop must earn its place' (ban tier); rule-engine row per language"), + ("54-appI-stack",{"Cpp":"22-l7-layers","DotNet":"22-l7-layers","Go":"22-l7-layers"},"stack","in-app router named modern-cpp-kafka (C++), Spring Boot + Camel (in the .NET deck), FastAPI + durable-rules (in the Go deck)"), + ("61-appI-cpp-router",{"Cpp":"22-business-rule-routing-cpp"},"stack","modern-cpp-kafka did the dispatch; now rdkafka++"), + ("61-appI-python-router",{"Go":"22-business-rule-routing-go"},"stack","Go deck showed FastAPI + durable-rules; now net/http + gopher-lua + franz-go"), + ("58-appI-masstransit-routingslip-dn",{"DotNet":"22-business-rule-routing-dotnet"},"stack","MassTransit RoutingSlip removed; NRules + Confluent.Kafka (site Appendix I .NET codetab)"), + ("03-comm-protocol-matrix",{"Go":"02-pick-protocol-go","DotNet":"02-pick-protocol-dotnet","Spring":"02-pick-protocol-spring"},"stack","framework labels of another language (Python in Go, Quarkus in .NET and Spring)"), + ("05-comm-service-mesh",{"Go":"02-mesh-handles-wire-go","DotNet":"02-mesh-handles-wire-dotnet","Spring":"02-mesh-handles-wire-spring"},"stack","app box named another language's framework"), + ("06-composition-federation",{"Go":"03-composition","DotNet":"03-composition","Spring":"03-composition"},"stack","gateway named Strawberry (Go) or SmallRye GraphQL (.NET, Spring)"), + ("14-stream-topology",{"Go":"06-derive-streams","Python":"06-derive-streams"},"stack","caption 'Faust/Kafka Streams style' (faust-streaming; goka for Go)"), + ("15-stream-keda-scaling",{"Go":"06-scale-on-lag","DotNet":"06-scale-on-lag","Spring":"06-scale-on-lag"},"stack","HTTP pods named FastAPI (Go) or Quarkus (.NET, Spring)"), + ("33-appD-state-options",{"Go":"17-saga-state"},"stack","title 'Where a Python saga keeps its state' in the Go deck"), + ("42-twelve-factor-map-py",{"Python":"01-factors-mapped-py","Go":"01-factors-mapped-go"},"stack","Poetry (examples use pip + requirements.txt); Go deck showed Python tooling"), + ("117-failure-circuit-breaker.png",{"Go":"25-circuit-breaker"},"stack","library list without the locked Go library (sony/gobreaker)"), + ("01b-windows-to-k8s-bridge",{"DotNet":"01b-windows-to-k8s-bridge-dn"},"stack","'Temporal workflows / MassTransit sagas'; Serilog row"), + ("63-appD-stateless-dn",{"DotNet":"63-appD-stateless-dn"},"stack","'or: MassTransit Sagas'; '@Transactional' (Java)"), + ("70-appJ-pinned-libraries-dn",{"DotNet":"70-appJ-pinned-libraries-dn"},"stack","MassTransit row; 'Every entry above' (positional); rows now from site Appendix J"), + ("71-appJ-pinned-platform-dn",{"DotNet":"71-appJ-pinned-platform-dn"},"stack","Redis license BSD (Redis 8 is AGPLv3 option); rows from site Appendix J"), + ("72-appJ-migration-reference-dn",{"DotNet":"72-appJ-migration-reference-dn"},"stack","MassTransit in three rows; rows from site Appendix J"), + ("29-container-layers",{"DotNet":"29-container-layers"},"engine","old container engine named in a box; separate restore step (S9 changed to one RUN)"), + ("29-dotnet-compilation",{"DotNet":"29-dotnet-compilation"},"stack","missing-glyph boxes in the old render; site figure"), + ("29-kestrel-pipeline",{"DotNet":"29-kestrel-pipeline"},"stack","refreshed from the site figure (same content, consistent render)"), +] +CAPTIONS={("Cpp",442):"Drogon is the HTTP front; embedded Lua is the rules engine; rdkafka++ (librdkafka) does the dispatch.", + ("DotNet",450):"NRules owns the decision; Confluent.Kafka producers and HttpClient dispatch, in-process behind the Istio mesh."} +ops=collections.defaultdict(list); used=collections.Counter(); table=[] +for x in idx: + for pre,m,cat,why in RULES: + if x["descr"].startswith(pre) or (pre.endswith(".png") and x["descr"]==pre): + if pre=="117-failure-circuit-breaker.png" and x["descr"]!=pre: continue + png=m.get(x["deck"]) + if not png: continue + ops[x["deck"]].append({"op":"replace_picture","sldId":x["sid"],"shape_id":x["shape_id"],"png":f"lgtm-presentation/edits/png/{png}.png","cat":cat,"_slide":x["n"],"_why":why}) + used[pre]+=1; table.append((x["deck"],x["n"],x["sid"],x["descr"],png)) + break +# captions: find the caption shape (y ~ 6.50in) on that slide +for (deck,sid),text in CAPTIONS.items(): + prs=Presentation(f"/home/rsedor/Dev/cloud-native-design-patterns/lgtm-presentation/Designing-Cloud-Native-APIs-{deck}.pptx") + s=[t for t in prs.slides if t.slide_id==sid][0] + cap=[sh for sh in s.shapes if sh.has_text_frame and abs(sh.top/914400-6.50)<0.06] + assert len(cap)==1,(deck,sid,[sh.name for sh in cap]) + ops[deck].append({"op":"set_text","sldId":sid,"shape_id":cap[0].shape_id,"paragraphs":[text],"cat":"stack","_why":"caption repeated the replaced figure's stale library"}) +import os +os.makedirs(W+"/ops-15-images",exist_ok=True) +for d in ALL: + json.dump({"ops":ops[d]},open(f"{W}/ops-15-images/Designing-Cloud-Native-APIs-{d}.json","w"),indent=1,ensure_ascii=False) + print(d,len(ops[d])) +json.dump(table,open(F+"/table.json","w")) diff --git a/_plans/s12-manifest/tools/test_set_code_colours.py b/_plans/s12-manifest/tools/test_set_code_colours.py new file mode 100644 index 00000000..ac7c0e5e --- /dev/null +++ b/_plans/s12-manifest/tools/test_set_code_colours.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Regression test for deck_edit.py set_code / recolour_code colours (S12 fix round A). + +The bug: code_lines() took the run that ended the previous line ("# comment\\n") as the template +of the next line, so set_code wrote code lines in the comment green 8FB98F. This test builds +small decks with code boxes in the three layouts the decks use (newlines embedded in one +paragraph, one paragraph per line, a:br breaks), each starting with a comment line, runs +set_code and recolour_code on them, and checks every run colour by line type. + + /bin/python _plans/s12-manifest/tools/test_set_code_colours.py # exit 0 = pass +""" +from __future__ import annotations + +import copy +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import deck_edit as de # noqa: E402 +import colour_audit # noqa: E402 +from lxml import etree # noqa: E402 +from pptx import Presentation # noqa: E402 +from pptx.util import Inches # noqa: E402 + +A, P = de.A, de.P +GREEN, WHITE = de.COMMENT_RGB, de.CODE_RGB + + +def run(text, rgb): + r = etree.Element(de.q(A, "r")) + rpr = etree.SubElement(r, de.q(A, "rPr"), lang="en-US", sz="1200") + sf = etree.SubElement(rpr, de.q(A, "solidFill")) + etree.SubElement(sf, de.q(A, "srgbClr"), val=rgb) + etree.SubElement(rpr, de.q(A, "latin"), typeface="Red Hat Mono") + etree.SubElement(r, de.q(A, "t")).text = text + return r + + +def br(rgb): + b = etree.Element(de.q(A, "br")) + b.append(copy.deepcopy(run("", rgb).find(de.q(A, "rPr")))) + return b + + +def add_box(slide, name, paragraphs): + """paragraphs: list of lists of elements (runs / breaks).""" + tb = slide.shapes.add_textbox(Inches(0.5), Inches(1), Inches(12), Inches(5.5)) + tb.name = name + tx = tb._element.find(de.q(P, "txBody")) + for p in tx.findall(de.q(A, "p")): + tx.remove(p) + for elems in paragraphs: + p = etree.SubElement(tx, de.q(A, "p")) + for e in elems: + p.append(e) + return tb + + +def build(path): + prs = Presentation() + prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5) + layout = prs.slide_layouts[6] + # 1: embedded newlines, one paragraph; the comment run ends with "\n" (the original bug) + s1 = prs.slides.add_slide(layout) + add_box(s1, "embedded", [[run("# a comment first\n", GREEN), run("x = 1\ny = 2", WHITE)]]) + # 2: one paragraph per line, first paragraph a comment + s2 = prs.slides.add_slide(layout) + add_box(s2, "paragraphs", [[run("// a comment first", GREEN)], [run("int x = 1;", WHITE)], + [run("int y = 2;", WHITE)]]) + # 3: a:br breaks, first line a comment + s3 = prs.slides.add_slide(layout) + add_box(s3, "breaks", [[run("// a comment first", GREEN), br(GREEN), run("int x = 1;", WHITE), + br(WHITE), run("int y = 2;", WHITE)]]) + # 4: a box with no comment line at all (comments must still come out green) + s4 = prs.slides.add_slide(layout) + add_box(s4, "nocomment", [[run("a = 1\nb = 2\nc = 3", WHITE)]]) + add_box(s4, "nocomment-paras", [[run("a = 1", WHITE)], [run("b = 2", WHITE)], [run("c = 3", WHITE)]]) + # 5: a box already miscoloured (code lines green): recolour_code repairs it + s5 = prs.slides.add_slide(layout) + add_box(s5, "miscoloured", [[run("# head\n", GREEN), run("x = 1\n", GREEN), run("y = 2 ", GREEN), + run("# trailing", GREEN)]]) + prs.save(path) + return [s.slide_id for s in prs.slides] + + +NEW = { + "embedded": ["# rewritten comment", "first = call()", "second = other() # inline", "", + "# another comment", "third = 3"], + "paragraphs": ["// rewritten comment", "int a = 1;", "", "int b = 2; // inline"], + "breaks": ["// rewritten comment", "int a = 1;", "// middle comment", "int b = 2;"], + "nocomment": ["# a comment in a box that had none", "a = 10", "b = 20"], +} + + +def main(): + fails = [] + with tempfile.TemporaryDirectory() as td: + src, out = Path(td) / "t.pptx", Path(td) / "out.pptx" + ids = build(src) + prs = Presentation(str(src)) + ctx = de.Ctx(prs) + for sid, name in zip(ids, ["embedded", "paragraphs", "breaks", "nocomment"]): + de.op_set_code(ctx, {"op": "set_code", "sldId": sid, "shape": name, "lines": NEW[name]}) + de.op_recolour_code(ctx, {"op": "recolour_code", "sldId": ids[4], "shape": "miscoloured"}) + try: + de.op_recolour_code(ctx, {"op": "recolour_code", "sldId": ids[4], "shape": "miscoloured"}) + fails.append("recolour_code on an already-correct box did not fail without allow_noop") + except de.EditError: + pass + prs.save(str(out)) + hits = colour_audit.audit(out) + for h in hits: + fails.append(f"slide {h['slide']} line {h['line']} {h['kind']}: {h['text']!r} {h['colours']}") + # explicit check of the original bug: the first code line after a leading comment is E6E6E6 + prs2 = Presentation(str(out)) + for n, (s, name) in enumerate(zip(prs2.slides, ["embedded", "paragraphs", "breaks", "nocomment", + "miscoloured"]), 1): + sp = next(x for x in de.all_sps(s._element) if de.sp_name(x) == name) + ls = colour_audit.box_lines(sp.find(de.q(P, "txBody"))) + texts = ["".join(t for t, _ in l) for l in ls] + want = NEW.get(name, ["# head", "x = 1", "y = 2 # trailing"]) + if texts != want: + fails.append(f"{name}: text {texts!r} != {want!r}") + code1 = next(l for l in ls if "".join(t for t, _ in l).strip() + and not de.is_comment("".join(t for t, _ in l))) + if [c for t, c in code1 if t.strip()][0] != WHITE: + fails.append(f"{name}: first code line {code1!r} is not {WHITE}") + com = next(l for l in ls if de.is_comment("".join(t for t, _ in l))) + if any(c != GREEN for t, c in com if t.strip()): + fails.append(f"{name}: comment line {com!r} is not {GREEN}") + # the template fix on its own: code_lines picks the code run, not the "...\n" comment run + prs3 = Presentation(str(src)) + sp = next(x for x in de.all_sps(prs3.slides[0]._element) if de.sp_name(x) == "embedded") + ls = de.code_lines(sp.find(de.q(P, "txBody"))) + if de.run_rgb(ls[1]["run"]) != WHITE: + fails.append(f"code_lines: line 2 template run is {de.run_rgb(ls[1]['run'])}, want {WHITE}") + # cut_code: an ellipsis in a box without a comment line is comment-coloured + prs5 = Presentation(str(src)) + ctx5 = de.Ctx(prs5) + de.op_cut_code(ctx5, {"op": "cut_code", "sldId": ids[3], "shape": "nocomment-paras", + "cuts": [{"from": 2, "to": 2, "first": "b = 2", "ellipsis": "# ... b elided"}]}) + sp = next(x for x in de.all_sps(prs5.slides[3]._element) if de.sp_name(x) == "nocomment-paras") + ell = [l for l in colour_audit.box_lines(sp.find(de.q(P, "txBody"))) + if "".join(t for t, _ in l).startswith("# ...")] + if not ell or any(c != GREEN for t, c in ell[0] if t.strip()): + fails.append(f"cut_code: ellipsis line {ell!r} is not {GREEN}") + # colour: false keeps the template colours (no recolouring) + prs4 = Presentation(str(src)) + ctx4 = de.Ctx(prs4) + de.op_set_code(ctx4, {"op": "set_code", "sldId": ids[3], "shape": "nocomment", "colour": False, + "lines": NEW["nocomment"]}) + if "recoloured" in ctx4.log[-1]: + fails.append("colour: false still recoloured") + for f in fails: + print("FAIL", f) + print("test_set_code_colours:", "FAILED" if fails else "ok", f"({len(fails)} failure(s))") + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_plans/s12-manifest/tools/units.py b/_plans/s12-manifest/tools/units.py new file mode 100644 index 00000000..0061bc07 --- /dev/null +++ b/_plans/s12-manifest/tools/units.py @@ -0,0 +1,38 @@ +"""Build sentence-level voice units from segs.json for the six language decks.""" +import json, re, collections, sys +from pathlib import Path +from hits import pats, POS, EX, ENG, segs +SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9'\"‘“(`\[])") +KEEP_WATCH = set() # all watch labels are offered; policy is applied by the authors +def sentence_hits(t): + cats=[] + for tier,label,rx in pats: + if label.startswith("em-dash pileup"): continue + if rx.search(t): cats.append(f"{tier}:{label}") + if t.count("—")>=2: cats.append("watch:em-dash x2 in one sentence") + for rx in POS: + for m in rx.finditer(t): + ctx=t[max(0,m.start()-4):m.end()+20] + if not any(e.search(ctx) for e in EX): cats.append("pos:"+m.group(0).lower()) + if ENG.search(t): cats.append("engine") + return cats +decks=[d for d in segs if d.startswith("Designing")] +units=collections.OrderedDict() +for d in decks: + short=d.split('-')[-1] + for s in segs[d]: + if s["code"]: continue + for sent in SPLIT.split(s["text"]): + c=sentence_hits(sent) + if not c: continue + u=units.setdefault(sent,{"cats":c,"where":[]}) + u["where"].append(f"{short}:{s['n']}/{s['sid']}{'N' if s['kind']=='notes' else ''}") +out=[] +for i,(k,v) in enumerate(units.items()): + out.append({"u":i,"text":k,"cats":v["cats"],"where":v["where"]}) +Path("units.json").write_text(json.dumps(out,indent=0,ensure_ascii=False)) +c=collections.Counter() +for u in out: + tiers={x.split(':')[0] for x in u['cats']} + c["ban" if "ban" in tiers else "pos" if "pos" in tiers else "engine" if "engine" in tiers else "watch"]+=1 +print(len(out), dict(c)) diff --git a/_plans/s12-manifest/tools/vendor_figures.py b/_plans/s12-manifest/tools/vendor_figures.py new file mode 100644 index 00000000..cceec89a --- /dev/null +++ b/_plans/s12-manifest/tools/vendor_figures.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Decision 7 figures: vendor-neutral label copies of four site SVGs, rendered for the decks. + +Writes lgtm-presentation/edits/png/src/.svg (the corrected SVG text) and +lgtm-presentation/edits/png/.png (cairosvg 2.9.1, 1400 px, arrow glyphs written as ASCII because +cairosvg has no fallback font for them). The same label changes are the site fixes listed in +site-fixes.md (SVG text, the .excalidraw text and originalText, and alt text). Run with the S12 venv python. +""" +from pathlib import Path + +import cairosvg + +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +DIAG = REPO / "assets" / "diagrams" +OUT = REPO / "lgtm-presentation" / "edits" / "png" +SRC = OUT / "src" + +FIGS = { + # png name: (site svg, [(old element text, new element text, expected count)]) + "12-valet-key": ("12-valet-key", [ + ("Resource (S3, blob, file)", "Resource (object store, file)", 1), + ("the app never proxies the bytes — no long-lived credentials at the client (S3 pre-signed URLs, Azure " + "SAS tokens, GCS signed URLs, STS AssumeRole)", + "the app never proxies the bytes — no long-lived credentials at the client (Example 12: a ticket the " + "service signs and verifies itself)", 1), + ]), + # the site zero-trust figure is vendor-neutral already; the decks' copy listed Google IAP, Cloudflare Access + # and AWS Verified Access as examples + "12-zero-trust": ("12-zero-trust", []), + "net-advisory-tier-evaluation": ("advisory-tier-evaluation", [ + ("Tier 1 — AdminNetworkPolicy", "Tier 1 — CNP Admin tier", 1), + ("Tier 3 — BANP", "Tier 3 — CNP Baseline tier", 1), + ("ANP evaluates", "Admin tier evaluates", 1), + ("cluster-scoped, priority 0-99", "cluster-scoped, priority 0-1000", 1), + ("Allow", "Accept", 2), + ("BANP evaluates", "Baseline tier evaluates", 1), + ("cluster singleton, safety net", "cluster-scoped, safety net", 1), + ("OVN-Kubernetes three-tier policy evaluation", "ClusterNetworkPolicy three-tier evaluation", 1), + ("BANP catches unmatched traffic.", "The Baseline tier catches unmatched traffic.", 1), + ("ANP Allow/Deny are final — NP and BANP are skipped.", "Admin Accept/Deny skip NP and Baseline.", 1), + ("BANP is overridable by NetworkPolicy.", "Baseline is overridable by NetworkPolicy.", 1), + ]), + "net-advisory-ci-pipeline": ("advisory-ci-pipeline", [ + ("oc apply --dry-run", "kubeconform", 1), + ("ACS Build-time", "Baseline Diff", 1), + ("baseline validation", "vs observed flows", 1), + ("ACS + OVN-K verify", "flow logs + Falco", 1), + ]), + "net-advisory-team-ownership": ("advisory-team-ownership", [ + ("EgressFirewall", "Egress rules", 1), + ("ANP / BANP review", "Admin / Baseline review", 1), + ("Perimeter (Palo Alto)", "Perimeter firewall", 1), + ("AdminNetworkPolicy", "CNP Admin tier", 1), + ("cluster guardrails, Allow/Deny/Pass", "cluster guardrails, Accept/Deny/Pass", 1), + ("BANP", "CNP Baseline tier", 1), + ("ACS baselines", "Observed baselines", 1), + ]), +} + + +# authored .NET Appendix J tables (tools/images_gen_images.py builds them from _docs/23 tables, which the site +# fix changes the same way): corrected in place in edits/png/src/ and re-rendered +SRC_FIXES = { + "70-appJ-pinned-libraries-dn": [ + ("Open-source Redis backplane (not Azure SignalR Service)", "Open-source Redis backplane (no managed SignalR service)", 1), + ("Every entry is MIT, Apache 2.0 or BSD-3 · every entry runs on plain Kubernetes · no Azure-only dependencies", + "Every entry is MIT, Apache 2.0 or BSD-3 · every entry runs on plain Kubernetes · no cloud-only dependencies", 1), + ], + "72-appJ-migration-reference-dn": [ + ("Same C# both sides of the wire; no Azure required", "Same C# both sides of the wire; no managed cloud service required", 1), + ], +} + + +def main(): + jobs = [(png, DIAG / f"{site}.svg", subs) for png, (site, subs) in FIGS.items()] + jobs += [(png, SRC / f"{png}.svg", subs) for png, subs in SRC_FIXES.items()] + for png, path, subs in jobs: + site = path.stem + svg = path.read_text() + for old, new, n in subs: + a, b = f">{old}", f">{new}" + if svg.count(a) != n and svg.count(b) == n: + continue # already corrected (re-run on a fixed src copy) + if svg.count(a) != n: + raise SystemExit(f"{site}: {old!r} found {svg.count(a)}x, expected {n}") + svg = svg.replace(a, b) + (SRC / f"{png}.svg").write_text(svg) + cairosvg.svg2png(bytestring=svg.replace("→", "->").replace("←", "<-").encode(), + write_to=str(OUT / f"{png}.png"), output_width=1400, background_color="white") + print(png) + + +if __name__ == "__main__": + main() diff --git a/_plans/s12-manifest/tools/verify_all.py b/_plans/s12-manifest/tools/verify_all.py new file mode 100644 index 00000000..68790ae0 --- /dev/null +++ b/_plans/s12-manifest/tools/verify_all.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Dry-run every edits file against a copy of its deck, then apply to a scratch copy and scan the result. + +Run from anywhere with the S12 venv python. Writes M/verify/.{dryrun.txt,after.pptx,md} and prints a table. +""" +import json, re, shutil, subprocess, sys +from pathlib import Path +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +M = Path(__file__).resolve().parent.parent +V = str(M.parent / "venv" / "bin" / "python") +sys.path.insert(0, str(REPO / "scripts")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import deck_text # noqa: E402 +from hits import POS, EX # noqa: E402 +EDITS = Path(sys.argv[1]) if len(sys.argv) > 1 else REPO / "lgtm-presentation" / "edits" +OUT = M / "verify" +OUT.mkdir(exist_ok=True) +LANG = [f"Designing-Cloud-Native-APIs-{x}" for x in ("Python", "Quarkus", "Spring", "DotNet", "Go", "Cpp")] +POLICY = ["K8s-Environment-Access-Policies", "Network-Policy-Process-Advisory"] +DENY = json.loads((REPO / "_plans/s12-manifest/denylist.json").read_text()) if (REPO / "_plans/s12-manifest/denylist.json").exists() else [] +rows = [] +for d in LANG + POLICY: + src = REPO / "lgtm-presentation" / f"{d}.pptx" + copy = OUT / f"{d}.pptx" + shutil.copy2(src, copy) + files = ([str(EDITS / "_shared.json")] if d in LANG else []) + [str(EDITS / f"{d}.json")] + dr = subprocess.run([V, str(REPO / "scripts/deck_edit.py"), str(copy), *files, "--dry-run"], + cwd=REPO, capture_output=True, text=True) + (OUT / f"{d}.dryrun.txt").write_text(dr.stdout + dr.stderr) + ok_dry = dr.returncode == 0 and '"ok": true' in dr.stdout + after = OUT / f"{d}.after.pptx" + ap = subprocess.run([V, str(REPO / "scripts/deck_edit.py"), str(copy), *files, "--out", str(after)], + cwd=REPO, capture_output=True, text=True) + md, _ = deck_text.pptx_to_md(after) if ap.returncode == 0 else ("", None) + mdp = OUT / f"{d}.md" + mdp.write_text(md) + scan = lambda tier: subprocess.run(["bash", str(REPO / "scripts/voice/scan.sh"), "--tier", tier, "--hits", "all", str(mdp)], + capture_output=True, text=True).stdout + ban = scan("ban"); watch = scan("watch") + nb = len([l for l in ban.splitlines() if re.match(r"^\S+:\d+:", l)]) + nw = len([l for l in watch.splitlines() if re.match(r"^\S+:\d+:", l)]) + # positional + engine + denylist over prose lines (code fences skipped for positional) + pos = eng = 0; deny = [] + fence = False + for i, line in enumerate(md.split("\n"), 1): + if line.startswith("```"): + fence = not fence + if re.search("p" + "odman", line, re.I): + eng += 1 + for e in DENY: + if e.get("files") and not any(f in d for f in e["files"]): + continue + if re.search(e["regex"], line, re.I if "i" in e.get("flags", "") else 0): + if e.get("exempt") and re.search(e["exempt"], line, re.I): + continue + deny.append(f"{e['id']}:{i}:{line.strip()[:90]}") + if fence: + continue + l2 = re.sub(r"`[^`]*`", " ", line) + for rx in POS: + for m in rx.finditer(l2): + ctx = l2[max(0, m.start() - 4): m.end() + 20] + if not any(x.search(ctx) for x in EX): + pos += 1 + (OUT / f"{d}.ban.txt").write_text(ban); (OUT / f"{d}.watch.txt").write_text(watch) + (OUT / f"{d}.deny.txt").write_text("\n".join(deny)) + nslides = md.count("\n## [") + rows.append((d, ok_dry, ap.returncode == 0, nslides, nb, nw, pos, eng, len(deny))) +print(f"{'deck':42} dry apply slides ban watch pos engine deny") +for r in rows: + print(f"{r[0]:42} {str(r[1]):5} {str(r[2]):5} {r[3]:6} {r[4]:3} {r[5]:5} {r[6]:3} {r[7]:6} {r[8]:4}") diff --git a/_plans/s12-manifest/tools/watch_ledger.py b/_plans/s12-manifest/tools/watch_ledger.py new file mode 100644 index 00000000..02be1213 --- /dev/null +++ b/_plans/s12-manifest/tools/watch_ledger.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Classify every watch-tier hit left in the post-edit text copies (M/verify/.md). + +Prints unclassified hits (to fix) and writes M/verify/watch-ledger.md (per deck, kept hits with reasons). +""" +import re, subprocess, sys, collections, json +from pathlib import Path +M = Path(__file__).resolve().parent.parent +VER = M / "verify" +REPO = Path("/home/rsedor/Dev/cloud-native-design-patterns") +SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9'\"‘“(`\[])") +decks = sys.argv[1:] or [p.stem for p in sorted(VER.glob("*.md")) if not p.name.startswith("watch")] +ledger = collections.defaultdict(list) +unclassified = [] +for d in decks: + md = VER / f"{d}.md" + out = subprocess.run(["bash", str(REPO / "scripts/voice/scan.sh"), "--tier", "watch", "--hits", "all", str(md)], + capture_output=True, text=True).stdout + lines = md.read_text().split("\n") + pat = None + for l in out.splitlines(): + m = re.match(r"^## \[watch\] (.*)$", l) + if m: + pat = m.group(1); continue + m = re.match(r"^\S*?:(\d+):(.*)$", l) + if not m or pat is None: + continue + ln = int(m.group(1)); text = lines[ln - 1] + # find the slide heading for context + k = ln - 1 + while k > 0 and not lines[k].startswith("## ["): + k -= 1 + slide = lines[k][:60] if k > 0 else "" + reason = None + if pat.startswith("em-dash"): + sents = SPLIT.split(text) + if all(s.count("—") <= 1 for s in sents): + reason = "notes-paragraph" if text.startswith(">") else "one-per-sentence" + elif pat.startswith("environment-specific"): + if re.search(r"minikube|OpenShift|k3d|kind\b", text, re.I) and not re.search(r"laptop|my machine", text, re.I): + reason = "literal-subject" + elif pat.startswith("parallel-number"): + if re.search(r"\d+ passed, \d+ failed|\d+ vCPU|MiB|GB|\bms\b|\bs\b", text): + reason = "table-data" + elif pat.startswith("lie"): + if re.search(r"failures lie|failure that lies|hang, or lie|mtime can lie|clock[s]? (can )?lie|health check[s]? (can )?lie|metrics lie", text, re.I): + reason = "machine-subject" + elif pat.startswith("quietly"): + if re.search(r"(silently|quietly) (rais|fail|drop|corrupt|break|increas|retr|los|swallow|skip|ignor|discard|diverg|degrad|stale|accept|truncat|wrap|reorder|overwrit|duplicat|block|time)|break(s)? silently|fail(s|ing)? silently|(dropp|lost|lose|ignored|swallowed|discarded)\w* silently", text, re.I): + reason = "machine-subject" + elif pat.startswith("soft hype") and re.search(r"landscape", text, re.I): + reason = "survey-term" + elif pat.startswith("deliberate (adjective)") and re.search(r"deliberate (intermediate|transition|stage|state)", text, re.I): + reason = "planned-stage" + elif pat.startswith("exclamation") and re.search(r"Release It!", text): + reason = "quoted" + if reason: + ledger[d].append((slide, pat, reason, text[:140])) + else: + unclassified.append((d, ln, slide, pat, text[:200])) +for u in unclassified: + print("UNCLASSIFIED", *u, sep=" | ") +print(len(unclassified), "unclassified;", {d: len(v) for d, v in ledger.items()}) +with open(VER / "watch-ledger.json", "w") as f: + json.dump({"ledger": ledger, "unclassified": unclassified}, f, indent=1, ensure_ascii=False) diff --git a/_plans/s12-manifest/tools/watch_ledger_md.py b/_plans/s12-manifest/tools/watch_ledger_md.py new file mode 100644 index 00000000..c2ba8765 --- /dev/null +++ b/_plans/s12-manifest/tools/watch_ledger_md.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Write watch-ledger.md from M/verify/watch-ledger.json (output of watch_ledger.py). + +Lines watch_ledger.py leaves unclassified are carried over from the committed ledger when the same deck already +lists the same pattern and text with a reason (manual classification); anything else is an error. + python3 watch_ledger_md.py [M] +""" +import collections +import json +import re +import sys +from pathlib import Path + +M = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent +OUT = Path(__file__).resolve().parent.parent / "watch-ledger.md" +DECKS = [f"Designing-Cloud-Native-APIs-{d}" for d in ("Python", "Quarkus", "Spring", "DotNet", "Go", "Cpp")] + \ + ["K8s-Environment-Access-Policies", "Network-Policy-Process-Advisory"] + +data = json.loads((M / "verify" / "watch-ledger.json").read_text()) +ledger = {d: [tuple(r) for r in data["ledger"].get(d, [])] for d in DECKS} +old = collections.defaultdict(dict) +deck = None +for line in OUT.read_text().split("\n"): + m = re.match(r"^## (\S+)$", line) + if m: + deck = m.group(1) + continue + cells = [c.strip() for c in line.strip("|").split(" | ")] if line.startswith("| [") else None + if deck and cells and len(cells) == 4: + old[deck][(cells[1], cells[3][:100])] = cells[2] +for d, ln, slide, pat, text in data["unclassified"]: + reason = old[d].get((pat, text[:140].replace("|", "\\|")[:100])) + if not reason: + raise SystemExit(f"unclassified and not in the committed ledger: {d} {ln} {pat} {text[:80]}") + ledger[d].append((slide, pat, reason, text[:140])) + +out = ["# Watch-tier ledger (decks after the S12 edits)", "", + "Every watch-tier line the scanner still reports in the post-edit text copies (all edit files applied to fresh " + "copies of the committed decks, including the decision-7 amendment of 2026-10-12), with its reason code from the " + "README policy. Ban tier is 0 in all eight decks. Counts are scanner lines (one per pattern per line).", "", + "| Deck | Kept watch lines | By reason |", "|---|---|---|"] +byp = collections.Counter() +for d in DECKS: + c = collections.Counter(r[2] for r in ledger[d]) + for r in ledger[d]: + byp[(r[1], r[2])] += 1 + out.append(f"| {d} | {len(ledger[d])} | " + ", ".join(f"{k} {v}" for k, v in sorted(c.items())) + " |") +out += ["", "By pattern and reason (all decks):", "", "| Pattern | Reason | Lines |", "|---|---|---|"] +out += [f"| {p} | {r} | {n} |" for (p, r), n in sorted(byp.items())] +out += ["", "Notes:", "", + "- `literal-subject`: the Appendix R slides and eyebrows (`APPENDIX R · MINIKUBE`), agenda and notes where " + "minikube is the subject (no Appendix S slides after decision 7).", + "- `notes-paragraph`: notes paragraphs with three or more em dashes, each sentence holding at most one.", + "- `table-data`: recorded results and unit lists (\"10 passed, 0 failed\").", + "- `quoted`: the book title *Release It!*.", + "- The Network-Policy-Process-Advisory rework (decision 7) left no watch-tier line."] +for d in DECKS: + if not ledger[d]: + continue + out += ["", f"## {d}", "", "| Slide | Pattern | Reason | Text |", "|---|---|---|---|"] + out += [f"| {s} | {p} | {r} | {t.replace('|', chr(92) + '|')} |" for s, p, r, t in ledger[d]] +OUT.write_text("\n".join(out) + "\n") +print({d: len(ledger[d]) for d in DECKS}) diff --git a/_plans/s12-manifest/watch-ledger.md b/_plans/s12-manifest/watch-ledger.md new file mode 100644 index 00000000..e1424876 --- /dev/null +++ b/_plans/s12-manifest/watch-ledger.md @@ -0,0 +1,400 @@ +# Watch-tier ledger (decks after the S12 edits) + +Every watch-tier line the scanner still reports in the post-edit text copies (all edit files applied to fresh copies of the committed decks, including the decision-7 amendment of 2026-10-12), with its reason code from the README policy. Ban tier is 0 in all eight decks. Counts are scanner lines (one per pattern per line). + +| Deck | Kept watch lines | By reason | +|---|---|---| +| Designing-Cloud-Native-APIs-Python | 54 | literal-subject 26, machine-subject 7, notes-paragraph 15, planned-stage 2, quoted 2, survey-term 1, table-data 1 | +| Designing-Cloud-Native-APIs-Quarkus | 51 | literal-subject 26, machine-subject 7, notes-paragraph 12, planned-stage 2, quoted 2, survey-term 1, table-data 1 | +| Designing-Cloud-Native-APIs-Spring | 54 | literal-subject 26, machine-subject 7, notes-paragraph 15, planned-stage 2, quoted 2, survey-term 1, table-data 1 | +| Designing-Cloud-Native-APIs-DotNet | 63 | literal-subject 25, machine-subject 7, notes-paragraph 25, planned-stage 2, quoted 2, survey-term 1, table-data 1 | +| Designing-Cloud-Native-APIs-Go | 55 | literal-subject 26, machine-subject 7, notes-paragraph 16, planned-stage 2, quoted 2, survey-term 1, table-data 1 | +| Designing-Cloud-Native-APIs-Cpp | 45 | literal-subject 24, machine-subject 6, notes-paragraph 11, planned-stage 1, quoted 1, survey-term 1, table-data 1 | +| K8s-Environment-Access-Policies | 8 | notes-paragraph 8 | +| Network-Policy-Process-Advisory | 0 | | + +By pattern and reason (all decks): + +| Pattern | Reason | Lines | +|---|---|---| +| deliberate (adjective) | planned-stage | 11 | +| em-dash pileup (3+ per line) | notes-paragraph | 102 | +| environment-specific noun | literal-subject | 153 | +| exclamation in prose | quoted | 11 | +| lie / lies / lying (person vs machine) | machine-subject | 13 | +| parallel-number title | table-data | 6 | +| quietly / silently | machine-subject | 28 | +| soft hype | survey-term | 6 | + +Notes: + +- `literal-subject`: the Appendix R slides and eyebrows (`APPENDIX R · MINIKUBE`), agenda and notes where minikube is the subject (no Appendix S slides after decision 7). +- `notes-paragraph`: notes paragraphs with three or more em dashes, each sentence holding at most one. +- `table-data`: recorded results and unit lists ("10 passed, 0 failed"). +- `quoted`: the book title *Release It!*. +- The Network-Policy-Process-Advisory rework (decision 7) left no watch-tier line. + +## Designing-Cloud-Native-APIs-Python + +| Slide | Pattern | Reason | Text | +|---|---|---|---| +| ## [227] APPENDIX M · Distributed systems fail differently ( | lie / lies / lying (person vs machine) | machine-subject | - Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | lie / lies / lying (person vs machine) | machine-subject | - Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often inf | +| ## [088] SECURITY · Choosing the right pattern (sldId 343) | deliberate (adjective) | planned-stage | > Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a | +| ## [205] APPENDIX K · STRANGLER FIG · With a shared database | deliberate (adjective) | planned-stage | ## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | soft hype | survey-term | ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) | +| ## [108] APPENDIX B · VERSIONING · Evolving a .proto safely | quietly / silently | machine-subject | > Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse t | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | - Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | > Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break | +| ## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | - The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration streng | +| ## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | > Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing | +| ## [028] DATA · CQRS — Command Query Responsibility Segregat | em-dash pileup (3+ per line) | notes-paragraph | > CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises fo | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | em-dash pileup (3+ per line) | notes-paragraph | > Each of the four tools does a different job. KAFKA is the SUBSTRATE — a partitioned, ordered, durable log. Consumers track their own offse | +| ## [061] OBSERVABILITY · The signals in depth — traces, metr | em-dash pileup (3+ per line) | notes-paragraph | > Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer WHERE the time went and which hop failed | +| ## [063] OBSERVABILITY · Auto-injection vs. instrumentation | em-dash pileup (3+ per line) | notes-paragraph | > This is the slide the user specifically asked for: distinguish auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation — thre | +| ## [065] OBSERVABILITY · How correlation works — context pro | em-dash pileup (3+ per line) | notes-paragraph | > Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling WITH the request, in-band, | +| ## [069] OBSERVABILITY · Choosing a sampling strategy (sldId | em-dash pileup (3+ per line) | notes-paragraph | > The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-lim | +| ## [092] ANTI-PATTERNS · The heuristics that keep you out of | em-dash pileup (3+ per line) | notes-paragraph | > These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or | +| ## [121] APPENDIX D · SAGAS · Where a Python saga keeps its | em-dash pileup (3+ per line) | notes-paragraph | > First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-f | +| ## [125] APPENDIX D · SAGAS · When step B needs data only st | em-dash pileup (3+ per line) | notes-paragraph | > This is the specific case you asked about, and a common source of bad designs. The orchestrator calls service A, then service B — but B ne | +| ## [150] APPENDIX G · COUPLING · Coupling is necessary — bal | em-dash pileup (3+ per line) | notes-paragraph | > Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real sk | +| ## [153] APPENDIX G · COUPLING · Integration strength: a lad | em-dash pileup (3+ per line) | notes-paragraph | > Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the clas | +| ## [155] APPENDIX G · COUPLING · Balancing coupling: strengt | em-dash pileup (3+ per line) | notes-paragraph | > This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module | +| ## [203] APPENDIX K · STRANGLER FIG · With a proxy — step ze | em-dash pileup (3+ per line) | notes-paragraph | > The pre-condition for incremental migration: clients hit the proxy, the proxy forwards to the monolith. Day zero, behaviour is unchanged. | +| ## [219] APPENDIX L · WRITE-AROUND · Write-around in Python | em-dash pileup (3+ per line) | notes-paragraph | > Two write variants and the standard cache-aside read for context. The pure write-around just inserts to the DB — the cache isn't touched a | +| ## [221] APPENDIX L · WRITE-BACK · Write-back with a backgro | em-dash pileup (3+ per line) | notes-paragraph | > Write-back has more moving parts than the other patterns; the slide shows the canonical shape. (1) Writes go to cache and add the key to a | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | exclamation in prose | quoted | - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bul | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | exclamation in prose | quoted | > Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | parallel-number title | table-data | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | +| ## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native | environment-specific noun | literal-subject | > Welcome and framing. This is a working architect's tour of designing cloud-native APIs — not a survey of buzzwords. Set expectations: we b | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | - Appendix R · Example 05 on minikube | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | > The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shu | +| ## [003] OVERVIEW · What we'll build, and the order we'll bu | environment-specific noun | literal-subject | > Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchr | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | ## [273] Example 05 on minikube (sldId 528) | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | > Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six lan | +| ## [274] APPENDIX R · MINIKUBE · From compose to a cluster ( | environment-specific noun | literal-subject | ## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | - No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the c | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikub | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh r | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | > From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec r | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | - build: → docker build, then minikube image load; imagePullPolicy: Never. | +| ## [278] APPENDIX R · MINIKUBE · One overlay per language (s | environment-specific noun | literal-subject | ## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | ## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | docker build on the host, minikube image load into the node; pods never pull | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | > The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | [chip] bash · minikube | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | > What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teard | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | + +## Designing-Cloud-Native-APIs-Quarkus + +| Slide | Pattern | Reason | Text | +|---|---|---|---| +| ## [227] APPENDIX M · Distributed systems fail differently ( | lie / lies / lying (person vs machine) | machine-subject | - Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | lie / lies / lying (person vs machine) | machine-subject | - Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often inf | +| ## [088] SECURITY · Choosing the right pattern (sldId 343) | deliberate (adjective) | planned-stage | > Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a | +| ## [205] APPENDIX K · STRANGLER FIG · With a shared database | deliberate (adjective) | planned-stage | ## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | soft hype | survey-term | ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) | +| ## [108] APPENDIX B · VERSIONING · Evolving a .proto safely | quietly / silently | machine-subject | > Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse t | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | - Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | > Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break | +| ## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | - The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration streng | +| ## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | > Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing | +| ## [028] DATA · CQRS — Command Query Responsibility Segregat | em-dash pileup (3+ per line) | notes-paragraph | > CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises fo | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | em-dash pileup (3+ per line) | notes-paragraph | > Each of the four tools does a different job. KAFKA is the SUBSTRATE — a partitioned, ordered, durable log. Consumers track their own offse | +| ## [061] OBSERVABILITY · The signals in depth — traces, metr | em-dash pileup (3+ per line) | notes-paragraph | > Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer WHERE the time went and which hop failed | +| ## [063] OBSERVABILITY · Auto-injection vs. instrumentation | em-dash pileup (3+ per line) | notes-paragraph | > This is the slide the user specifically asked for: distinguish auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation — thre | +| ## [065] OBSERVABILITY · How correlation works — context pro | em-dash pileup (3+ per line) | notes-paragraph | > Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling WITH the request, in-band, | +| ## [069] OBSERVABILITY · Choosing a sampling strategy (sldId | em-dash pileup (3+ per line) | notes-paragraph | > The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-lim | +| ## [092] ANTI-PATTERNS · The heuristics that keep you out of | em-dash pileup (3+ per line) | notes-paragraph | > These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or | +| ## [121] APPENDIX D · SAGAS · Where a Quarkus saga keeps its | em-dash pileup (3+ per line) | notes-paragraph | > First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-f | +| ## [125] APPENDIX D · SAGAS · When step B needs data only st | em-dash pileup (3+ per line) | notes-paragraph | > This is the specific case you asked about, and a common source of bad designs. The orchestrator calls service A, then service B — but B ne | +| ## [150] APPENDIX G · COUPLING · Coupling is necessary — bal | em-dash pileup (3+ per line) | notes-paragraph | > Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real sk | +| ## [153] APPENDIX G · COUPLING · Integration strength: a lad | em-dash pileup (3+ per line) | notes-paragraph | > Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the clas | +| ## [155] APPENDIX G · COUPLING · Balancing coupling: strengt | em-dash pileup (3+ per line) | notes-paragraph | > This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | exclamation in prose | quoted | - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bul | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | exclamation in prose | quoted | > Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | parallel-number title | table-data | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | +| ## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native | environment-specific noun | literal-subject | > Welcome and framing. This is a working architect's tour of designing cloud-native APIs on Quarkus 3.40.1 (LTS, Java 25) — not a survey of | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | - Appendix R · Example 05 on minikube | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | > The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shu | +| ## [003] OVERVIEW · What we'll build, and the order we'll bu | environment-specific noun | literal-subject | > Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchr | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | ## [273] Example 05 on minikube (sldId 528) | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | > Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six lan | +| ## [274] APPENDIX R · MINIKUBE · From compose to a cluster ( | environment-specific noun | literal-subject | ## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | - No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the c | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikub | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh r | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | > From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec r | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | - build: → docker build, then minikube image load; imagePullPolicy: Never. | +| ## [278] APPENDIX R · MINIKUBE · One overlay per language (s | environment-specific noun | literal-subject | ## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | ## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | docker build on the host, minikube image load into the node; pods never pull | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | > The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | [chip] bash · minikube | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | > What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teard | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | + +## Designing-Cloud-Native-APIs-Spring + +| Slide | Pattern | Reason | Text | +|---|---|---|---| +| ## [231] APPENDIX M · Distributed systems fail differently ( | lie / lies / lying (person vs machine) | machine-subject | - Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and | +| ## [252] APPENDIX M · Take-aways and references (sldId 507) | lie / lies / lying (person vs machine) | machine-subject | - Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often inf | +| ## [088] SECURITY · Choosing the right pattern (sldId 343) | deliberate (adjective) | planned-stage | > Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a | +| ## [209] APPENDIX K · STRANGLER FIG · With a shared database | deliberate (adjective) | planned-stage | ## [209] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 464) | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | soft hype | survey-term | ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) | +| ## [108] APPENDIX B · VERSIONING · Evolving a .proto safely | quietly / silently | machine-subject | > Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse t | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | - Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | > Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break | +| ## [161] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | - The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration streng | +| ## [161] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | > Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing | +| ## [028] DATA · CQRS — Command Query Responsibility Segregat | em-dash pileup (3+ per line) | notes-paragraph | > CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises fo | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | em-dash pileup (3+ per line) | notes-paragraph | > Each of the four tools does a different job. KAFKA is the SUBSTRATE — a partitioned, ordered, durable log. Consumers track their own offse | +| ## [061] OBSERVABILITY · The signals in depth — traces, metr | em-dash pileup (3+ per line) | notes-paragraph | > Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer WHERE the time went and which hop failed | +| ## [063] OBSERVABILITY · Auto-injection vs. instrumentation | em-dash pileup (3+ per line) | notes-paragraph | > This is the slide the user specifically asked for: distinguish auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation — thre | +| ## [065] OBSERVABILITY · How correlation works — context pro | em-dash pileup (3+ per line) | notes-paragraph | > Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling WITH the request, in-band, | +| ## [069] OBSERVABILITY · Choosing a sampling strategy (sldId | em-dash pileup (3+ per line) | notes-paragraph | > The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-lim | +| ## [092] ANTI-PATTERNS · The heuristics that keep you out of | em-dash pileup (3+ per line) | notes-paragraph | > These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or | +| ## [121] APPENDIX D · SAGAS · Where a saga keeps its state ( | em-dash pileup (3+ per line) | notes-paragraph | > First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-f | +| ## [125] APPENDIX D · SAGAS · When step B needs data only st | em-dash pileup (3+ per line) | notes-paragraph | > This is the specific case you asked about, and a common source of bad designs. The orchestrator calls service A, then service B — but B ne | +| ## [136] APPENDIX E · ERRORS · gRPC errors with rich status | em-dash pileup (3+ per line) | notes-paragraph | > This is grpc-java's standard rich-error model via StatusProto — the same API surface whether you're on Spring Boot or any other JVM stack. | +| ## [140] APPENDIX E · ERRORS · GraphQL field errors with Spr | em-dash pileup (3+ per line) | notes-paragraph | > Throwing from a single field's resolver is exactly right: Spring for GraphQL's exception-resolution chain catches it, sets that field to n | +| ## [154] APPENDIX G · COUPLING · Coupling is necessary — bal | em-dash pileup (3+ per line) | notes-paragraph | > Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real sk | +| ## [157] APPENDIX G · COUPLING · Integration strength: a lad | em-dash pileup (3+ per line) | notes-paragraph | > Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the clas | +| ## [159] APPENDIX G · COUPLING · Balancing coupling: strengt | em-dash pileup (3+ per line) | notes-paragraph | > This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module | +| ## [201] APPENDIX I · L7 ROUTING · L7 routing checklist (sld | em-dash pileup (3+ per line) | notes-paragraph | > Leave the checklist up at the end. The first point is the most common organisational failure — a cluster ends up running three ingress con | +| ## [252] APPENDIX M · Take-aways and references (sldId 507) | exclamation in prose | quoted | - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bul | +| ## [252] APPENDIX M · Take-aways and references (sldId 507) | exclamation in prose | quoted | > Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: | +| ## [285] APPENDIX R · MINIKUBE · Recorded results (sldId 540 | parallel-number title | table-data | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | +| ## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native | environment-specific noun | literal-subject | > Welcome and framing. This is a working architect's tour of designing cloud-native APIs on Spring Boot 4.1.1. We build one realistic system | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | - Appendix R · Example 05 on minikube | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | > The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shu | +| ## [003] OVERVIEW · What we'll build, and the order we'll bu | environment-specific noun | literal-subject | > Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchr | +| ## [277] Example 05 on minikube (sldId 532) | environment-specific noun | literal-subject | ## [277] Example 05 on minikube (sldId 532) | +| ## [277] Example 05 on minikube (sldId 532) | environment-specific noun | literal-subject | > Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six lan | +| ## [278] APPENDIX R · MINIKUBE · From compose to a cluster ( | environment-specific noun | literal-subject | ## [278] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 533) | +| ## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | ## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId 534) | +| ## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | - No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. | +| ## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 | +| ## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the c | +| ## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikub | +| ## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh r | +| ## [280] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | ## [280] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 535) | +| ## [280] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | > From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec r | +| ## [281] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | ## [281] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 536) | +| ## [281] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | - build: → docker build, then minikube image load; imagePullPolicy: Never. | +| ## [282] APPENDIX R · MINIKUBE · One overlay per language (s | environment-specific noun | literal-subject | ## [282] APPENDIX R · MINIKUBE · One overlay per language (sldId 537) | +| ## [283] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | ## [283] APPENDIX R · MINIKUBE · Images built on the host (sldId 538) | +| ## [283] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | docker build on the host, minikube image load into the node; pods never pull | +| ## [283] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | > The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ | +| ## [284] APPENDIX R · MINIKUBE · Run it (sldId 539) | environment-specific noun | literal-subject | ## [284] APPENDIX R · MINIKUBE · Run it (sldId 539) | +| ## [284] APPENDIX R · MINIKUBE · Run it (sldId 539) | environment-specific noun | literal-subject | [chip] bash · minikube | +| ## [284] APPENDIX R · MINIKUBE · Run it (sldId 539) | environment-specific noun | literal-subject | > What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teard | +| ## [285] APPENDIX R · MINIKUBE · Recorded results (sldId 540 | environment-specific noun | literal-subject | ## [285] APPENDIX R · MINIKUBE · Recorded results (sldId 540) | +| ## [285] APPENDIX R · MINIKUBE · Recorded results (sldId 540 | environment-specific noun | literal-subject | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | + +## Designing-Cloud-Native-APIs-DotNet + +| Slide | Pattern | Reason | Text | +|---|---|---|---| +| ## [241] APPENDIX M · Distributed systems fail differently ( | lie / lies / lying (person vs machine) | machine-subject | - Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and | +| ## [262] APPENDIX M · Take-aways and references (sldId 517) | lie / lies / lying (person vs machine) | machine-subject | - Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often inf | +| ## [090] SECURITY · Choosing the right pattern (sldId 345) | deliberate (adjective) | planned-stage | > Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a | +| ## [219] APPENDIX K · STRANGLER FIG · With a shared database | deliberate (adjective) | planned-stage | ## [219] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 474) | +| ## [041] EVENT-DRIVEN · Event-driven tools landscape — Kafka | soft hype | survey-term | ## [041] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 296) | +| ## [110] APPENDIX B · VERSIONING · Evolving a .proto safely | quietly / silently | machine-subject | > Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse t | +| ## [113] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | - Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. | +| ## [113] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | > Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break | +| ## [165] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | - The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration streng | +| ## [165] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | > Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing | +| ## [025] COMPOSITION · A composing resolver in HotChocolate | em-dash pileup (3+ per line) | notes-paragraph | > The resolver makes federation concrete in HotChocolate. The top-level GetOrder Query calls order-service over REST; [ExtendObjectType CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises fo | +| ## [034] DATA · The outbox write — one transaction, no lost | em-dash pileup (3+ per line) | notes-paragraph | > For the WCF/COM+ audience this is THE most important pattern. You used to write code like 'TransactionScope: save order, write to MSMQ, co | +| ## [041] EVENT-DRIVEN · Event-driven tools landscape — Kafka | em-dash pileup (3+ per line) | notes-paragraph | > Each of the four tools does a different job. KAFKA is the SUBSTRATE — a partitioned, ordered, durable log. Consumers track their own offse | +| ## [063] OBSERVABILITY · The signals in depth — traces, metr | em-dash pileup (3+ per line) | notes-paragraph | > Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer WHERE the time went and which hop failed | +| ## [065] OBSERVABILITY · Auto-injection vs. instrumentation | em-dash pileup (3+ per line) | notes-paragraph | > This is the slide the user specifically asked for: distinguish auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation — thre | +| ## [067] OBSERVABILITY · How correlation works — context pro | em-dash pileup (3+ per line) | notes-paragraph | > Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling WITH the request, in-band, | +| ## [071] OBSERVABILITY · Choosing a sampling strategy (sldId | em-dash pileup (3+ per line) | notes-paragraph | > The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-lim | +| ## [094] ANTI-PATTERNS · The heuristics that keep you out of | em-dash pileup (3+ per line) | notes-paragraph | > These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or | +| ## [125] APPENDIX D · SAGAS · Where a saga keeps its state ( | em-dash pileup (3+ per line) | notes-paragraph | > First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a Pod restart would strand every in-f | +| ## [126] APPENDIX D · SAGAS · A persisted saga state machine | em-dash pileup (3+ per line) | notes-paragraph | > This is the DB-backed option made concrete with EF Core — the one I'd reach for first when the saga is bounded and I want to own the trans | +| ## [129] APPENDIX D · SAGAS · When step B needs data only st | em-dash pileup (3+ per line) | notes-paragraph | > This is the specific case you asked about, and a common source of bad designs. The orchestrator calls service A, then service B — but B ne | +| ## [133] APPENDIX D · SAGAS · Stateless: lighter, in-process | em-dash pileup (3+ per line) | notes-paragraph | > Walk the picture. The top row is the saga as a state machine: Created → Paid → Reserved → Shipped → Done, with each transition triggered b | +| ## [138] APPENDIX E · ERRORS · REST errors with ASP.NET Core | em-dash pileup (3+ per line) | notes-paragraph | > ASP.NET Core has had ProblemDetails (RFC 9457) built into Microsoft.AspNetCore.Mvc since .NET 6 — the wire format, the media type (applica | +| ## [140] APPENDIX E · ERRORS · gRPC errors with rich status | em-dash pileup (3+ per line) | notes-paragraph | > This is grpc-dotnet's standard rich-error model — the same wire shape as every other gRPC stack because google.rpc.Status is the cross-lan | +| ## [144] APPENDIX E · ERRORS · GraphQL field errors with Hot | em-dash pileup (3+ per line) | notes-paragraph | > Throwing from a single field's resolver is exactly right: HotChocolate's exception-resolution chain catches it, sets that field to null, a | +| ## [154] APPENDIX F · HEXAGONAL · Ports & adapters in code ( | em-dash pileup (3+ per line) | notes-paragraph | > Walk the three blocks. Ports: the domain declares plain C# interfaces, a repository and an event publisher, and depends on nothing else (n | +| ## [158] APPENDIX G · COUPLING · Coupling is necessary — bal | em-dash pileup (3+ per line) | notes-paragraph | > Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real sk | +| ## [161] APPENDIX G · COUPLING · Integration strength: a lad | em-dash pileup (3+ per line) | notes-paragraph | > Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the clas | +| ## [163] APPENDIX G · COUPLING · Balancing coupling: strengt | em-dash pileup (3+ per line) | notes-paragraph | > This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module | +| ## [172] APPENDIX H · SHUTDOWN · Graceful shutdown in ASP.NE | em-dash pileup (3+ per line) | notes-paragraph | > ASP.NET Core has clean graceful shutdown built in. When Kubernetes sends SIGTERM, the .NET host signals IHostApplicationLifetime.Applicati | +| ## [205] APPENDIX I · L7 ROUTING · L7 routing checklist (sld | em-dash pileup (3+ per line) | notes-paragraph | > Leave the checklist up at the end. The first point is the most common organisational failure — a cluster ends up running three ingress con | +| ## [220] APPENDIX K · ROUTING · Content-based routing during | em-dash pileup (3+ per line) | notes-paragraph | > The .NET shape. Three named HttpClients registered via IHttpClientFactory — the canonical pattern for managed HttpClient lifetimes in ASP. | +| ## [231] APPENDIX L · WRITE-THROUGH · Write-through in ASP.N | em-dash pileup (3+ per line) | notes-paragraph | > Two write-through flavours. The first uses IDistributedCache directly: save EF Core changes, then SetStringAsync to update the cache with | +| ## [235] APPENDIX L · WRITE-BACK · Write-back with Backgroun | em-dash pileup (3+ per line) | notes-paragraph | > Write-back in .NET. The handler writes to a Redis hash (the metric fields) and adds the key to a 'dirty set' — the dirty set tells the flu | +| ## [262] APPENDIX M · Take-aways and references (sldId 517) | exclamation in prose | quoted | - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bul | +| ## [262] APPENDIX M · Take-aways and references (sldId 517) | exclamation in prose | quoted | > Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: | +| ## [312] APPENDIX R · MINIKUBE · Recorded results (sldId 567 | parallel-number title | table-data | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | - Appendices P–R · .NET on Linux, Blazor, minikube | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | > The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shu | +| ## [003] OVERVIEW · What we'll build, and the order we'll bu | environment-specific noun | literal-subject | > Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchr | +| ## [304] Example 05 on minikube (sldId 559) | environment-specific noun | literal-subject | ## [304] Example 05 on minikube (sldId 559) | +| ## [304] Example 05 on minikube (sldId 559) | environment-specific noun | literal-subject | > Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six lan | +| ## [305] APPENDIX R · MINIKUBE · From compose to a cluster ( | environment-specific noun | literal-subject | ## [305] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 560) | +| ## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | ## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId 561) | +| ## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | - No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. | +| ## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 | +| ## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the c | +| ## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikub | +| ## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh r | +| ## [307] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | ## [307] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 562) | +| ## [307] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | > From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec r | +| ## [308] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | ## [308] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 563) | +| ## [308] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | - build: → docker build, then minikube image load; imagePullPolicy: Never. | +| ## [309] APPENDIX R · MINIKUBE · One overlay per language (s | environment-specific noun | literal-subject | ## [309] APPENDIX R · MINIKUBE · One overlay per language (sldId 564) | +| ## [310] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | ## [310] APPENDIX R · MINIKUBE · Images built on the host (sldId 565) | +| ## [310] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | docker build on the host, minikube image load into the node; pods never pull | +| ## [310] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | > The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ | +| ## [311] APPENDIX R · MINIKUBE · Run it (sldId 566) | environment-specific noun | literal-subject | ## [311] APPENDIX R · MINIKUBE · Run it (sldId 566) | +| ## [311] APPENDIX R · MINIKUBE · Run it (sldId 566) | environment-specific noun | literal-subject | [chip] bash · minikube | +| ## [311] APPENDIX R · MINIKUBE · Run it (sldId 566) | environment-specific noun | literal-subject | > What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teard | +| ## [312] APPENDIX R · MINIKUBE · Recorded results (sldId 567 | environment-specific noun | literal-subject | ## [312] APPENDIX R · MINIKUBE · Recorded results (sldId 567) | +| ## [312] APPENDIX R · MINIKUBE · Recorded results (sldId 567 | environment-specific noun | literal-subject | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | + +## Designing-Cloud-Native-APIs-Go + +| Slide | Pattern | Reason | Text | +|---|---|---|---| +| ## [227] APPENDIX M · Distributed systems fail differently ( | lie / lies / lying (person vs machine) | machine-subject | - Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | lie / lies / lying (person vs machine) | machine-subject | - Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often inf | +| ## [088] SECURITY · Choosing the right pattern (sldId 343) | deliberate (adjective) | planned-stage | > Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a | +| ## [205] APPENDIX K · STRANGLER FIG · With a shared database | deliberate (adjective) | planned-stage | ## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | soft hype | survey-term | ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) | +| ## [108] APPENDIX B · VERSIONING · Evolving a .proto safely | quietly / silently | machine-subject | > Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse t | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | - Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | > Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break | +| ## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | - The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration streng | +| ## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldI | quietly / silently | machine-subject | > Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing | +| ## [028] DATA · CQRS — Command Query Responsibility Segregat | em-dash pileup (3+ per line) | notes-paragraph | > CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises fo | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | em-dash pileup (3+ per line) | notes-paragraph | > Each of the four tools does a different job. KAFKA is the SUBSTRATE — a partitioned, ordered, durable log. Consumers track their own offse | +| ## [061] OBSERVABILITY · The signals in depth — traces, metr | em-dash pileup (3+ per line) | notes-paragraph | > Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer WHERE the time went and which hop failed | +| ## [063] OBSERVABILITY · Auto-injection vs. instrumentation | em-dash pileup (3+ per line) | notes-paragraph | > This is the slide the user specifically asked for: distinguish auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation — thre | +| ## [065] OBSERVABILITY · How correlation works — context pro | em-dash pileup (3+ per line) | notes-paragraph | > Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling WITH the request, in-band, | +| ## [069] OBSERVABILITY · Choosing a sampling strategy (sldId | em-dash pileup (3+ per line) | notes-paragraph | > The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-lim | +| ## [092] ANTI-PATTERNS · The heuristics that keep you out of | em-dash pileup (3+ per line) | notes-paragraph | > These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or | +| ## [121] APPENDIX D · SAGAS · Where a Go saga keeps its stat | em-dash pileup (3+ per line) | notes-paragraph | > First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-f | +| ## [125] APPENDIX D · SAGAS · When step B needs data only st | em-dash pileup (3+ per line) | notes-paragraph | > This is the specific case you asked about, and a common source of bad designs. The orchestrator calls service A, then service B — but B ne | +| ## [150] APPENDIX G · COUPLING · Coupling is necessary — bal | em-dash pileup (3+ per line) | notes-paragraph | > Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real sk | +| ## [153] APPENDIX G · COUPLING · Integration strength: a lad | em-dash pileup (3+ per line) | notes-paragraph | > Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the clas | +| ## [155] APPENDIX G · COUPLING · Balancing coupling: strengt | em-dash pileup (3+ per line) | notes-paragraph | > This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module | +| ## [203] APPENDIX K · STRANGLER FIG · With a proxy — step ze | em-dash pileup (3+ per line) | notes-paragraph | > The pre-condition for incremental migration: clients hit the proxy, the proxy forwards to the monolith. Day zero, behaviour is unchanged. | +| ## [215] APPENDIX L · READ-THROUGH · Read-through in Go: a g | em-dash pileup (3+ per line) | notes-paragraph | > The wrapper is small but the discipline is significant. Callers can't accidentally bypass the cache or forget to populate it — they call p | +| ## [219] APPENDIX L · WRITE-AROUND · Write-around in Go (sld | em-dash pileup (3+ per line) | notes-paragraph | > Two write variants and the standard cache-aside read for context. The pure write-around just inserts to the DB — the cache isn't touched a | +| ## [221] APPENDIX L · WRITE-BACK · Write-back with a backgro | em-dash pileup (3+ per line) | notes-paragraph | > Write-back has more moving parts than the other patterns; the slide shows the canonical shape. (1) Writes go to cache and add the key to a | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | exclamation in prose | quoted | - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bul | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | exclamation in prose | quoted | > Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | parallel-number title | table-data | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | +| ## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native | environment-specific noun | literal-subject | > Welcome and framing. This is a working architect's tour of designing cloud-native APIs — not a survey of buzzwords. Set expectations: we b | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | - Appendix R · Example 05 on minikube | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | > The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shu | +| ## [003] OVERVIEW · What we'll build, and the order we'll bu | environment-specific noun | literal-subject | > Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchr | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | ## [273] Example 05 on minikube (sldId 528) | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | > Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six lan | +| ## [274] APPENDIX R · MINIKUBE · From compose to a cluster ( | environment-specific noun | literal-subject | ## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | - No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the c | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikub | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh r | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | > From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec r | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | - build: → docker build, then minikube image load; imagePullPolicy: Never. | +| ## [278] APPENDIX R · MINIKUBE · One overlay per language (s | environment-specific noun | literal-subject | ## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | ## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | docker build on the host, minikube image load into the node; pods never pull | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | > The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | [chip] bash · minikube | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | > What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teard | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | + +## Designing-Cloud-Native-APIs-Cpp + +| Slide | Pattern | Reason | Text | +|---|---|---|---| +| ## [227] APPENDIX M · Distributed systems fail differently ( | lie / lies / lying (person vs machine) | machine-subject | - Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and | +| ## [232] APPENDIX M · COMPONENT · Gray failure (sldId 487) | lie / lies / lying (person vs machine) | machine-subject | > Gray failure is the failure that lies. The /healthz endpoint returns 200; real traffic is timing out. The node passes its own check; clien | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | lie / lies / lying (person vs machine) | machine-subject | - Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often inf | +| ## [205] APPENDIX K · STRANGLER FIG · With a shared database | deliberate (adjective) | planned-stage | ## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | soft hype | survey-term | ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) | +| ## [037] EVENT-DRIVEN · Schemas are the contract — the regis | quietly / silently | machine-subject | > Schema registry (Apicurio) is non-optional for production event-driven systems. Without it, a producer can ship a breaking schema change a | +| ## [111] APPENDIX B · VERSIONING · Principles that cut acros | quietly / silently | machine-subject | - Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. | +| ## [020] COMMUNICATIONS · The mesh handles the wire, not you | em-dash pileup (3+ per line) | notes-paragraph | > The Istio sidecar handles wire concerns: mTLS, bounded retries with backoff, timeouts, outlier detection, hop-level telemetry. The C++ tem | +| ## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka | em-dash pileup (3+ per line) | notes-paragraph | > Three layers of the event-driven stack: (1) substrate — Kafka (Strimzi on Kubernetes) and Pulsar are the durable log. Pick Kafka by defaul | +| ## [049] WORKFLOWS AND JOBS · Jobs, CronJobs and queue worke | em-dash pileup (3+ per line) | notes-paragraph | > Three Kubernetes-native patterns for non-request work. (1) Job: a one-shot batch — run a data migration, a backfill, a nightly export. The | +| ## [066] OBSERVABILITY · Baggage in practice — carry request | em-dash pileup (3+ per line) | notes-paragraph | > Baggage is a separate concern from trace context — they ride together but carry different data. Trace context (traceparent) tells you 'thi | +| ## [108] APPENDIX B · VERSIONING · Evolving a .proto safely | em-dash pileup (3+ per line) | notes-paragraph | > The four rules of safe proto evolution, in one example: (1) add new fields at new tag numbers (currency=5, state=6). (2) Use `reserved` fo | +| ## [121] APPENDIX D · SAGAS · Where a C++ saga keeps its sta | em-dash pileup (3+ per line) | notes-paragraph | > Three options for saga state durability. (1) Database row — one row per saga, row-locked for the duration of advance(); cheapest for short | +| ## [128] APPENDIX E · ERRORS · One error model, four wire fo | em-dash pileup (3+ per line) | notes-paragraph | > Five facts that every error needs to carry, regardless of protocol: (1) a STABLE machine code (STOCK_UNAVAILABLE) — never a string the cli | +| ## [131] APPENDIX E · ERRORS · gRPC: status codes + rich det | em-dash pileup (3+ per line) | notes-paragraph | > gRPC's error model is two layers: (1) one of ~16 canonical codes (OK, CANCELLED, UNAVAILABLE, NOT_FOUND, etc.) — a small enum, well-define | +| ## [137] APPENDIX E · ERRORS · How a retry storm forms — and | em-dash pileup (3+ per line) | notes-paragraph | > The retry storm is the classic distributed-systems amplification failure. A downstream service slows down; clients retry; the retries add | +| ## [151] APPENDIX G · COUPLING · Coupling has three dimensio | em-dash pileup (3+ per line) | notes-paragraph | > Three dimensions, each measurable. STRENGTH: how intensely are the two things coupled — shared memory? shared schema? shared events? share | +| ## [202] APPENDIX K · STRANGLER FIG · Identify → move → redi | em-dash pileup (3+ per line) | notes-paragraph | > Martin Fowler's strangler fig pattern is the safest large-migration approach known. Three steps per slice: (1) IDENTIFY the seam — what is | +| ## [248] APPENDIX M · Take-aways and references (sldId 503) | exclamation in prose | quoted | - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkh | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | parallel-number title | table-data | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | - Appendix R · Example 05 on minikube | +| ## [002] AGENDA · Agenda (sldId 257) | environment-specific noun | literal-subject | > Appendix R runs Example 05 on minikube with the same verify.sh as compose. | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | ## [273] Example 05 on minikube (sldId 528) | +| ## [273] Example 05 on minikube (sldId 528) | environment-specific noun | literal-subject | > Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six lan | +| ## [274] APPENDIX R · MINIKUBE · From compose to a cluster ( | environment-specific noun | literal-subject | ## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | - No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the c | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikub | +| ## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId | environment-specific noun | literal-subject | > The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh r | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) | +| ## [276] APPENDIX R · MINIKUBE · One node, two namespaces (s | environment-specific noun | literal-subject | > From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec r | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) | +| ## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldI | environment-specific noun | literal-subject | - build: → docker build, then minikube image load; imagePullPolicy: Never. | +| ## [278] APPENDIX R · MINIKUBE · One overlay per language (s | environment-specific noun | literal-subject | ## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | ## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | docker build on the host, minikube image load into the node; pods never pull | +| ## [279] APPENDIX R · MINIKUBE · Images built on the host (s | environment-specific noun | literal-subject | > The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | [chip] bash · minikube | +| ## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) | environment-specific noun | literal-subject | > What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teard | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) | +| ## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536 | environment-specific noun | literal-subject | - 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). | +| ## [107] APPENDIX B · VERSIONING · gRPC / protobuf: what is | quietly / silently | machine-subject | > protobuf's contract is the field numbers, not the field names. This means renaming a field is safe on the wire (old serialised bytes still | + +## K8s-Environment-Access-Policies + +| Slide | Pattern | Reason | Text | +|---|---|---|---| +| ## [003] THREE ENVIRONMENTS · Risk profiles by environment ( | em-dash pileup (3+ per line) | notes-paragraph | > Walk through the three environments as a risk gradient. Dev is where developers experiment, iterate, and break things — full self-service | +| ## [005] RBAC · MATRIX · Environment-specific RBAC matrix (s | em-dash pileup (3+ per line) | notes-paragraph | > Walk the RBAC matrix column by column. In dev, developers get full CRUD on pods, deployments, services, ConfigMaps, Secrets, and NetworkPo | +| ## [006] NETWORKPOLICY · ZERO TRUST · Default-deny NetworkPo | em-dash pileup (3+ per line) | notes-paragraph | > This is the single highest-impact security measure you can apply to a Kubernetes namespace. Without any NetworkPolicy, Kubernetes allows a | +| ## [007] NETWORKPOLICY · BY ENVIRONMENT · NetworkPolicy by e | em-dash pileup (3+ per line) | notes-paragraph | > The same default-deny baseline applies everywhere, but the allow rules differ by environment. In dev, allow all intra-namespace traffic so | +| ## [012] L7 ROUTING · SAFE ROLLOUTS · Traffic steering for s | em-dash pileup (3+ per line) | notes-paragraph | > L7 routing enables environment-safe deployments without modifying RBAC or network policies. Walk through four patterns: Canary deploys rou | +| ## [013] NAMESPACE ISOLATION · Namespace isolation architect | em-dash pileup (3+ per line) | notes-paragraph | > A well-governed namespace is a blast-radius boundary with four controls stacked. RBAC determines who can act in this namespace via RoleBin | +| ## [015] AUDIT & COMPLIANCE · Audit & compliance (sldId 270) | em-dash pileup (3+ per line) | notes-paragraph | > Governance requires more than access control — it requires proof that the controls are working. Walk through four audit dimensions. API se | +| ## [017] SUMMARY · Recommendations (sldId 272) | em-dash pileup (3+ per line) | notes-paragraph | > Close with the priority-ordered checklist. First, apply default-deny NetworkPolicy in every namespace — it is the single highest-impact se | diff --git a/assets/diagrams/01-factors-mapped.excalidraw b/assets/diagrams/01-factors-mapped.excalidraw index 3ffed290..c99485a5 100644 --- a/assets/diagrams/01-factors-mapped.excalidraw +++ b/assets/diagrams/01-factors-mapped.excalidraw @@ -189,13 +189,13 @@ "updated": 1, "link": null, "locked": false, - "text": "one repo \u00b7 Poetry \u00b7 one image per release", + "text": "one repo \u00b7 pinned requirements \u00b7 one image per release", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "one repo \u00b7 Poetry \u00b7 one image per release", + "originalText": "one repo \u00b7 pinned requirements \u00b7 one image per release", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/01-factors-mapped.svg b/assets/diagrams/01-factors-mapped.svg index e3676e17..c1c67d93 100644 --- a/assets/diagrams/01-factors-mapped.svg +++ b/assets/diagrams/01-factors-mapped.svg @@ -5,7 +5,7 @@ codebase · dependencies · build/release/run · parity Built once, run anywhere -one repo · Poetry · one image per release +one repo · pinned requirements · one image per release §1 diff --git a/assets/diagrams/02-mesh-handles-wire.excalidraw b/assets/diagrams/02-mesh-handles-wire.excalidraw index 0276e43f..35289d11 100644 --- a/assets/diagrams/02-mesh-handles-wire.excalidraw +++ b/assets/diagrams/02-mesh-handles-wire.excalidraw @@ -197,7 +197,7 @@ "type": "text", "x": 80, "y": 133, - "width": 127.05000000000001, + "width": 108.9, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -217,13 +217,13 @@ "updated": 1, "link": null, "locked": false, - "text": "FastAPI / grpc server", + "text": "REST / gRPC server", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "FastAPI / grpc server", + "originalText": "REST / gRPC server", "lineHeight": 1.25, "baseline": 11 }, @@ -498,7 +498,7 @@ "type": "text", "x": 700, "y": 133, - "width": 127.05000000000001, + "width": 108.9, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -518,13 +518,13 @@ "updated": 1, "link": null, "locked": false, - "text": "FastAPI / grpc server", + "text": "REST / gRPC server", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "FastAPI / grpc server", + "originalText": "REST / gRPC server", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/02-mesh-handles-wire.svg b/assets/diagrams/02-mesh-handles-wire.svg index c95f6046..78f25e51 100644 --- a/assets/diagrams/02-mesh-handles-wire.svg +++ b/assets/diagrams/02-mesh-handles-wire.svg @@ -6,7 +6,7 @@ Pod app -FastAPI / grpc server +REST / gRPC server plain localhost istio-proxy @@ -15,7 +15,7 @@ timeouts · traces app -FastAPI / grpc server +REST / gRPC server plain localhost istio-proxy diff --git a/assets/diagrams/02-pick-protocol.excalidraw b/assets/diagrams/02-pick-protocol.excalidraw index 65be3424..240014d9 100644 --- a/assets/diagrams/02-pick-protocol.excalidraw +++ b/assets/diagrams/02-pick-protocol.excalidraw @@ -176,7 +176,7 @@ "type": "text", "x": 30, "y": 133, - "width": 42.35, + "width": 102.85, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -196,13 +196,13 @@ "updated": 1, "link": null, "locked": false, - "text": "FastAPI", + "text": "OpenAPI-described", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "FastAPI", + "originalText": "OpenAPI-described", "lineHeight": 1.25, "baseline": 11 }, @@ -708,7 +708,7 @@ "type": "text", "x": 510, "y": 133, - "width": 127.05000000000001, + "width": 145.20000000000002, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -728,13 +728,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Strawberry / Graphene", + "text": "schema (SDL) + resolvers", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Strawberry / Graphene", + "originalText": "schema (SDL) + resolvers", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/02-pick-protocol.svg b/assets/diagrams/02-pick-protocol.svg index afb8f46b..67061a8c 100644 --- a/assets/diagrams/02-pick-protocol.svg +++ b/assets/diagrams/02-pick-protocol.svg @@ -5,7 +5,7 @@ public & edge surface wide reach, cacheable resource-shaped -FastAPI +OpenAPI-described sync @@ -21,7 +21,7 @@ client-driven reads compose many sources one round-trip -Strawberry / Graphene +schema (SDL) + resolvers sync diff --git a/assets/diagrams/05-sourcing-vs-streaming.excalidraw b/assets/diagrams/05-sourcing-vs-streaming.excalidraw index 33eb199b..0b83f23f 100644 --- a/assets/diagrams/05-sourcing-vs-streaming.excalidraw +++ b/assets/diagrams/05-sourcing-vs-streaming.excalidraw @@ -399,13 +399,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Event STREAMING \u2014 events are the TRANSPORT", + "text": "Event streaming \u2014 events are the transport", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Event STREAMING \u2014 events are the TRANSPORT", + "originalText": "Event streaming \u2014 events are the transport", "lineHeight": 1.25, "baseline": 14 }, @@ -504,13 +504,13 @@ "updated": 1, "link": null, "locked": false, - "text": "events are facts emitted ABOUT changes", + "text": "events are facts emitted about changes", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "events are facts emitted ABOUT changes", + "originalText": "events are facts emitted about changes", "lineHeight": 1.25, "baseline": 11 }, @@ -749,13 +749,13 @@ "updated": 1, "link": null, "locked": false, - "text": "ask: is the EVENT itself the source of truth (sourcing), or a fact PROPAGATED from a store (streaming)?", + "text": "ask: is the event itself the source of truth (sourcing), or a fact propagated from a store (streaming)?", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ask: is the EVENT itself the source of truth (sourcing), or a fact PROPAGATED from a store (streaming)?", + "originalText": "ask: is the event itself the source of truth (sourcing), or a fact propagated from a store (streaming)?", "lineHeight": 1.25, "baseline": 12 } diff --git a/assets/diagrams/05-sourcing-vs-streaming.svg b/assets/diagrams/05-sourcing-vs-streaming.svg index be80878f..bec06a78 100644 --- a/assets/diagrams/05-sourcing-vs-streaming.svg +++ b/assets/diagrams/05-sourcing-vs-streaming.svg @@ -11,15 +11,15 @@ fit: audit-heavy, financial, temporal queries where: Marten · EventStoreDB · Axon -Event STREAMING — events are the TRANSPORT +Event streaming — events are the transport current state lives in a database -events are facts emitted ABOUT changes +events are facts emitted about changes consumers react and propagate no aggregate replay, just propagation fit: integration, fan-out, decoupling teams where: Kafka · Pulsar · NATS JetStream not mutually exclusive — full CQRS often does both: an event-sourced write side, stream-fed read projections -ask: is the EVENT itself the source of truth (sourcing), or a fact PROPAGATED from a store (streaming)? +ask: is the event itself the source of truth (sourcing), or a fact propagated from a store (streaming)? \ No newline at end of file diff --git a/assets/diagrams/11-sampling.excalidraw b/assets/diagrams/11-sampling.excalidraw index 5195be30..d47abca4 100644 --- a/assets/diagrams/11-sampling.excalidraw +++ b/assets/diagrams/11-sampling.excalidraw @@ -441,13 +441,13 @@ "updated": 1, "link": null, "locked": false, - "text": "SDK sends ALL", + "text": "SDK sends all", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "SDK sends ALL", + "originalText": "SDK sends all", "lineHeight": 1.25, "baseline": 14 }, diff --git a/assets/diagrams/11-sampling.svg b/assets/diagrams/11-sampling.svg index 0f726abe..e99e8f21 100644 --- a/assets/diagrams/11-sampling.svg +++ b/assets/diagrams/11-sampling.svg @@ -13,7 +13,7 @@ Request in -SDK sends ALL +SDK sends all no decision yet Collector BUFFERS diff --git a/assets/diagrams/11-signals-in-depth.excalidraw b/assets/diagrams/11-signals-in-depth.excalidraw index c9ff529b..25faa33f 100644 --- a/assets/diagrams/11-signals-in-depth.excalidraw +++ b/assets/diagrams/11-signals-in-depth.excalidraw @@ -161,13 +161,13 @@ "updated": 1, "link": null, "locked": false, - "text": "\"WHERE did time go?\"", + "text": "\"where did the time go?\"", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "\"WHERE did time go?\"", + "originalText": "\"where did the time go?\"", "lineHeight": 1.25, "baseline": 11 }, @@ -567,13 +567,13 @@ "updated": 1, "link": null, "locked": false, - "text": "\"what EXACTLY happened?\"", + "text": "\"what happened, exactly?\"", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "\"what EXACTLY happened?\"", + "originalText": "\"what happened, exactly?\"", "lineHeight": 1.25, "baseline": 11 }, @@ -735,13 +735,13 @@ "updated": 1, "link": null, "locked": false, - "text": "carried ALONG the trace", + "text": "carried along the trace", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "carried ALONG the trace", + "originalText": "carried along the trace", "lineHeight": 1.25, "baseline": 11 }, @@ -973,13 +973,13 @@ "updated": 1, "link": null, "locked": false, - "text": "\"which FUNCTION burns CPU?\"", + "text": "\"which function burns CPU?\"", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "\"which FUNCTION burns CPU?\"", + "originalText": "\"which function burns CPU?\"", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/11-signals-in-depth.svg b/assets/diagrams/11-signals-in-depth.svg index e24e4a42..aeecf3d5 100644 --- a/assets/diagrams/11-signals-in-depth.svg +++ b/assets/diagrams/11-signals-in-depth.svg @@ -4,7 +4,7 @@ Traces request flow across services latency per span -"WHERE did time go?" +"where did the time go?" Tempo / Jaeger Metrics @@ -16,19 +16,19 @@ Logs discrete, timestamped events from one component -"what EXACTLY happened?" +"what happened, exactly?" Loki / ELK Baggage key-value context -carried ALONG the trace +carried along the trace "tenant_id downstream" (W3C Baggage) Profiles continuous CPU / mem sampling per service -"which FUNCTION burns CPU?" +"which function burns CPU?" Pyroscope / Parca correlated by trace_id + span_id + resource attributes (service.name, k8s.pod, deployment.version) diff --git a/assets/diagrams/12-valet-key.excalidraw b/assets/diagrams/12-valet-key.excalidraw index 38df13f9..b47db9b5 100644 --- a/assets/diagrams/12-valet-key.excalidraw +++ b/assets/diagrams/12-valet-key.excalidraw @@ -265,9 +265,9 @@ { "id": "t1289884137", "type": "text", - "x": 710, + "x": 694.6, "y": 203, - "width": 192.50000000000003, + "width": 223.3, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -287,13 +287,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Resource (S3, blob, file)", + "text": "Resource (object store, file)", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Resource (S3, blob, file)", + "originalText": "Resource (object store, file)", "lineHeight": 1.25, "baseline": 14 }, @@ -850,9 +850,9 @@ { "id": "t1058424935", "type": "text", - "x": 600, + "x": 636.3, "y": 428, - "width": 957.0000000000001, + "width": 884.4, "height": 15.0, "angle": 0, "strokeColor": "#2f6db5", @@ -872,13 +872,13 @@ "updated": 1, "link": null, "locked": false, - "text": "the app never proxies the bytes \u2014 no long-lived credentials at the client (S3 pre-signed URLs, Azure SAS tokens, GCS signed URLs, STS AssumeRole)", + "text": "the app never proxies the bytes \u2014 no long-lived credentials at the client (Example 12: a ticket the service signs and verifies itself)", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "the app never proxies the bytes \u2014 no long-lived credentials at the client (S3 pre-signed URLs, Azure SAS tokens, GCS signed URLs, STS AssumeRole)", + "originalText": "the app never proxies the bytes \u2014 no long-lived credentials at the client (Example 12: a ticket the service signs and verifies itself)", "lineHeight": 1.25, "baseline": 12 } diff --git a/assets/diagrams/12-valet-key.svg b/assets/diagrams/12-valet-key.svg index 70dd1132..962fcc2d 100644 --- a/assets/diagrams/12-valet-key.svg +++ b/assets/diagrams/12-valet-key.svg @@ -8,7 +8,7 @@ scoped, time-bound, operation-restricted -Resource (S3, blob, file) +Resource (object store, file) client uploads / downloads directly — no app hop @@ -28,5 +28,5 @@ 4. validate 4. validate -the app never proxies the bytes — no long-lived credentials at the client (S3 pre-signed URLs, Azure SAS tokens, GCS signed URLs, STS AssumeRole) +the app never proxies the bytes — no long-lived credentials at the client (Example 12: a ticket the service signs and verifies itself) \ No newline at end of file diff --git a/assets/diagrams/22-business-rule-routing.excalidraw b/assets/diagrams/22-business-rule-routing.excalidraw index e7e6938a..2740fee6 100644 --- a/assets/diagrams/22-business-rule-routing.excalidraw +++ b/assets/diagrams/22-business-rule-routing.excalidraw @@ -134,7 +134,7 @@ "type": "text", "x": 290, "y": 93, - "width": 200.20000000000002, + "width": 130.9, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -154,13 +154,13 @@ "updated": 1, "link": null, "locked": false, - "text": "FastAPI / Starlette router", + "text": "in-service router", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "FastAPI / Starlette router", + "originalText": "in-service router", "lineHeight": 1.25, "baseline": 14 }, @@ -204,7 +204,7 @@ "type": "text", "x": 290, "y": 128, - "width": 248.05, + "width": 260.15000000000003, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -224,13 +224,13 @@ "updated": 1, "link": null, "locked": false, - "text": "@app.post(\"/orders/route\") # front", + "text": "route(order): // front", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "@app.post(\"/orders/route\") # front", + "originalText": "route(order): // front", "lineHeight": 1.25, "baseline": 11 }, @@ -239,7 +239,7 @@ "type": "text", "x": 290, "y": 143, - "width": 199.65, + "width": 278.3, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -259,13 +259,13 @@ "updated": 1, "link": null, "locked": false, - "text": "async def route(order, producer):", + "text": " ctx = enrich(order) // context", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "async def route(order, producer):", + "originalText": " ctx = enrich(order) // context", "lineHeight": 1.25, "baseline": 11 }, @@ -274,7 +274,7 @@ "type": "text", "x": 290, "y": 158, - "width": 278.3, + "width": 284.34999999999997, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -294,13 +294,13 @@ "updated": 1, "link": null, "locked": false, - "text": " ctx = await enrich(order) # context", + "text": " dests = rules.evaluate(order, ctx) // ruleset", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": " ctx = await enrich(order) # context", + "originalText": " dests = rules.evaluate(order, ctx) // ruleset", "lineHeight": 1.25, "baseline": 11 }, @@ -309,7 +309,7 @@ "type": "text", "x": 290, "y": 173, - "width": 302.5, + "width": 284.34999999999997, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -329,13 +329,13 @@ "updated": 1, "link": null, "locked": false, - "text": " fact = post(\"orders\", order | ctx) # ruleset", + "text": " for dest in dests: // dispatch", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": " fact = post(\"orders\", order | ctx) # ruleset", + "originalText": " for dest in dests: // dispatch", "lineHeight": 1.25, "baseline": 11 }, @@ -344,7 +344,7 @@ "type": "text", "x": 290, "y": 188, - "width": 284.35, + "width": 314.6, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -364,13 +364,13 @@ "updated": 1, "link": null, "locked": false, - "text": " for dest in fact[\"where_to\"]: # dispatch", + "text": " produce(dest, order) // Kafka or HTTP", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": " for dest in fact[\"where_to\"]: # dispatch", + "originalText": " produce(dest, order) // Kafka or HTTP", "lineHeight": 1.25, "baseline": 11 }, @@ -379,7 +379,7 @@ "type": "text", "x": 290, "y": 203, - "width": 235.95000000000002, + "width": 84.70000000000002, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -399,13 +399,13 @@ "updated": 1, "link": null, "locked": false, - "text": " await producer.send(dest, body)", + "text": " return dests", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": " await producer.send(dest, body)", + "originalText": " return dests", "lineHeight": 1.25, "baseline": 11 }, @@ -442,7 +442,7 @@ "type": "text", "x": 900, "y": 158, - "width": 100.10000000000001, + "width": 84.70000000000002, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -462,13 +462,13 @@ "updated": 1, "link": null, "locked": false, - "text": "durable-rules", + "text": "rule engine", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "durable-rules", + "originalText": "rule engine", "lineHeight": 1.25, "baseline": 14 }, @@ -477,7 +477,7 @@ "type": "text", "x": 900, "y": 178, - "width": 96.80000000000001, + "width": 187.55, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -497,13 +497,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Rete engine in C", + "text": "Drools \u00b7 NRules \u00b7 durable-rules", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Rete engine in C", + "originalText": "Drools \u00b7 NRules \u00b7 durable-rules", "lineHeight": 1.25, "baseline": 11 }, @@ -512,7 +512,7 @@ "type": "text", "x": 900, "y": 193, - "width": 108.9, + "width": 102.85000000000001, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -532,13 +532,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Python ruleset DSL", + "text": "Lua in C++ and Go", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Python ruleset DSL", + "originalText": "Lua in C++ and Go", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/22-business-rule-routing.svg b/assets/diagrams/22-business-rule-routing.svg index 84125a3d..0cdafdb2 100644 --- a/assets/diagrams/22-business-rule-routing.svg +++ b/assets/diagrams/22-business-rule-routing.svg @@ -4,18 +4,18 @@ inputs REST · WebSocket · Kafka -FastAPI / Starlette router +in-service router -@app.post("/orders/route") # front -async def route(order, producer): - ctx = await enrich(order) # context - fact = post("orders", order | ctx) # ruleset - for dest in fact["where_to"]: # dispatch - await producer.send(dest, body) +route(order): // front + ctx = enrich(order) // context + dests = rules.evaluate(order, ctx) // ruleset + for dest in dests: // dispatch + produce(dest, order) // Kafka or HTTP + return dests -durable-rules -Rete engine in C -Python ruleset DSL +rule engine +Drools · NRules · durable-rules +Lua in C++ and Go (forward chaining) service A diff --git a/assets/diagrams/22-hop-cost.excalidraw b/assets/diagrams/22-hop-cost.excalidraw index aae10f20..bcd68e70 100644 --- a/assets/diagrams/22-hop-cost.excalidraw +++ b/assets/diagrams/22-hop-cost.excalidraw @@ -918,7 +918,7 @@ "type": "text", "x": 50, "y": 438, - "width": 246.40000000000003, + "width": 231.00000000000003, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -938,13 +938,13 @@ "updated": 1, "link": null, "locked": false, - "text": "In-app rule eval (durable-rules)", + "text": "In-app rule eval (rule engine)", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "In-app rule eval (durable-rules)", + "originalText": "In-app rule eval (rule engine)", "lineHeight": 1.25, "baseline": 14 }, @@ -1309,13 +1309,13 @@ "updated": 1, "link": null, "locked": false, - "text": "the budget is finite \u2014 two layers can be cheap; four layers of overlap is where p99 goes to die", + "text": "the budget is finite \u2014 two layers can be cheap; four overlapping layers are where p99 latency grows", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "the budget is finite \u2014 two layers can be cheap; four layers of overlap is where p99 goes to die", + "originalText": "the budget is finite \u2014 two layers can be cheap; four overlapping layers are where p99 latency grows", "lineHeight": 1.25, "baseline": 12 } diff --git a/assets/diagrams/22-hop-cost.svg b/assets/diagrams/22-hop-cost.svg index 92d5926e..1c0904ed 100644 --- a/assets/diagrams/22-hop-cost.svg +++ b/assets/diagrams/22-hop-cost.svg @@ -29,7 +29,7 @@ 5 – 50 ms -In-app rule eval (durable-rules) +In-app rule eval (rule engine) < 1 ms (cached) @@ -40,5 +40,5 @@ every hop must pay for itself -the budget is finite — two layers can be cheap; four layers of overlap is where p99 goes to die +the budget is finite — two layers can be cheap; four overlapping layers are where p99 latency grows \ No newline at end of file diff --git a/assets/diagrams/25-amplification.excalidraw b/assets/diagrams/25-amplification.excalidraw index 12523572..5a3f402c 100644 --- a/assets/diagrams/25-amplification.excalidraw +++ b/assets/diagrams/25-amplification.excalidraw @@ -724,13 +724,13 @@ "updated": 1, "link": null, "locked": false, - "text": "break it: backoff + jitter, a retry budget (cap % of live traffic), retry at exactly ONE layer, and a circuit breaker", + "text": "break it: backoff + jitter, a retry budget (cap % of live traffic), retry at one layer only, and a circuit breaker", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "break it: backoff + jitter, a retry budget (cap % of live traffic), retry at exactly ONE layer, and a circuit breaker", + "originalText": "break it: backoff + jitter, a retry budget (cap % of live traffic), retry at one layer only, and a circuit breaker", "lineHeight": 1.25, "baseline": 11 } diff --git a/assets/diagrams/25-amplification.svg b/assets/diagrams/25-amplification.svg index efea9108..d209302f 100644 --- a/assets/diagrams/25-amplification.svg +++ b/assets/diagrams/25-amplification.svg @@ -23,5 +23,5 @@ 9 → 27 9 → 27 retries multiply across layers (3×3×3) and synchronise into a thundering herd — the system's own defence becomes the attack -break it: backoff + jitter, a retry budget (cap % of live traffic), retry at exactly ONE layer, and a circuit breaker +break it: backoff + jitter, a retry budget (cap % of live traffic), retry at one layer only, and a circuit breaker \ No newline at end of file diff --git a/assets/diagrams/25-cascading.excalidraw b/assets/diagrams/25-cascading.excalidraw index c42ff826..e3e01110 100644 --- a/assets/diagrams/25-cascading.excalidraw +++ b/assets/diagrams/25-cascading.excalidraw @@ -654,13 +654,13 @@ "updated": 1, "link": null, "locked": false, - "text": "a slow dependency fills the caller's threads/pool; with resources exhausted it fails for ALL callers, and the wave propagates up the graph", + "text": "a slow dependency fills the caller's threads/pool; with resources exhausted it fails for all callers, and the wave propagates up the graph", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "a slow dependency fills the caller's threads/pool; with resources exhausted it fails for ALL callers, and the wave propagates up the graph", + "originalText": "a slow dependency fills the caller's threads/pool; with resources exhausted it fails for all callers, and the wave propagates up the graph", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/25-cascading.svg b/assets/diagrams/25-cascading.svg index 8ef59cef..3ef6eac9 100644 --- a/assets/diagrams/25-cascading.svg +++ b/assets/diagrams/25-cascading.svg @@ -21,6 +21,6 @@ blocks blocks -a slow dependency fills the caller's threads/pool; with resources exhausted it fails for ALL callers, and the wave propagates up the graph +a slow dependency fills the caller's threads/pool; with resources exhausted it fails for all callers, and the wave propagates up the graph each pattern breaks one link: timeouts free threads · bulkheads isolate the pool · breakers stop calling · shedding rejects at the edge \ No newline at end of file diff --git a/assets/diagrams/29-container-layers.excalidraw b/assets/diagrams/29-container-layers.excalidraw index 0f45d7c4..d9f86def 100644 --- a/assets/diagrams/29-container-layers.excalidraw +++ b/assets/diagrams/29-container-layers.excalidraw @@ -491,7 +491,7 @@ "type": "text", "x": 40, "y": 238, - "width": 242.00000000000003, + "width": 181.50000000000003, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -511,13 +511,13 @@ "updated": 1, "link": null, "locked": false, - "text": "restore from the mount, compile C# \u2192 CIL", + "text": "restore, then compile C# \u2192 CIL", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "restore from the mount, compile C# \u2192 CIL", + "originalText": "restore, then compile C# \u2192 CIL", "lineHeight": 1.25, "baseline": 11 }, @@ -1856,41 +1856,6 @@ "startArrowhead": null, "endArrowhead": "arrow" }, - { - "id": "t880934080", - "type": "text", - "x": 325.0, - "y": 227.0, - "width": 102.85000000000001, - "height": 13.75, - "angle": 0, - "strokeColor": "#b8650a", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 1, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": null, - "roundness": null, - "seed": 10142449, - "versionNonce": 1341972266, - "isDeleted": false, - "boundElements": [], - "updated": 1, - "link": null, - "locked": false, - "text": "COPY --from=build", - "fontSize": 11, - "fontFamily": 1, - "textAlign": "left", - "verticalAlign": "top", - "containerId": null, - "originalText": "COPY --from=build", - "lineHeight": 1.25, - "baseline": 11 - }, { "id": "a1784280371", "type": "arrow", diff --git a/assets/diagrams/29-container-layers.svg b/assets/diagrams/29-container-layers.svg index 3e2f338a..1b246786 100644 --- a/assets/diagrams/29-container-layers.svg +++ b/assets/diagrams/29-container-layers.svg @@ -15,7 +15,7 @@ NuGet package cache is a BuildKit cache mount dotnet publish -c Release -restore from the mount, compile C# → CIL +restore, then compile C# → CIL /app output *.dll + deps @@ -57,8 +57,6 @@ step 2 -COPY --from=build -COPY --from=build diff --git a/assets/diagrams/advisory-ci-pipeline.excalidraw b/assets/diagrams/advisory-ci-pipeline.excalidraw index 0dac9ae7..7e1791b6 100644 --- a/assets/diagrams/advisory-ci-pipeline.excalidraw +++ b/assets/diagrams/advisory-ci-pipeline.excalidraw @@ -265,9 +265,9 @@ { "id": "t1603518283", "type": "text", - "x": 320, + "x": 341.18, "y": 108, - "width": 108.9, + "width": 66.55, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -287,13 +287,13 @@ "updated": 1, "link": null, "locked": false, - "text": "oc apply --dry-run", + "text": "kubeconform", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "oc apply --dry-run", + "originalText": "kubeconform", "lineHeight": 1.25, "baseline": 11 }, @@ -426,9 +426,9 @@ { "id": "t1440249262", "type": "text", - "x": 600, + "x": 603.85, "y": 88, - "width": 107.80000000000001, + "width": 100.1, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -448,22 +448,22 @@ "updated": 1, "link": null, "locked": false, - "text": "ACS Build-time", + "text": "Baseline Diff", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ACS Build-time", + "originalText": "Baseline Diff", "lineHeight": 1.25, "baseline": 14 }, { "id": "t279528442", "type": "text", - "x": 600, + "x": 606.05, "y": 108, - "width": 114.95, + "width": 102.85, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -483,13 +483,13 @@ "updated": 1, "link": null, "locked": false, - "text": "baseline validation", + "text": "vs observed flows", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "baseline validation", + "originalText": "vs observed flows", "lineHeight": 1.25, "baseline": 11 }, @@ -657,9 +657,9 @@ { "id": "t993685379", "type": "text", - "x": 320, + "x": 318.57, "y": 218, - "width": 96.80000000000001, + "width": 102.85, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -679,13 +679,13 @@ "updated": 1, "link": null, "locked": false, - "text": "ArgoCD \u2192 staging", + "text": "Argo CD \u2192 staging", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ArgoCD \u2192 staging", + "originalText": "Argo CD \u2192 staging", "lineHeight": 1.25, "baseline": 11 }, @@ -853,9 +853,9 @@ { "id": "t1237954572", "type": "text", - "x": 600, + "x": 607.65, "y": 218, - "width": 114.95, + "width": 84.7, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -875,13 +875,13 @@ "updated": 1, "link": null, "locked": false, - "text": "ArgoCD \u2192 production", + "text": "Argo CD \u2192 prod", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ArgoCD \u2192 production", + "originalText": "Argo CD \u2192 prod", "lineHeight": 1.25, "baseline": 11 }, @@ -951,9 +951,9 @@ { "id": "t317455926", "type": "text", - "x": 740, + "x": 743.03, "y": 218, - "width": 108.9, + "width": 102.85, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -973,13 +973,13 @@ "updated": 1, "link": null, "locked": false, - "text": "ACS + OVN-K verify", + "text": "flow logs + Falco", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ACS + OVN-K verify", + "originalText": "flow logs + Falco", "lineHeight": 1.25, "baseline": 11 }, @@ -1203,8 +1203,8 @@ "type": "arrow", "x": 790, "y": 135, - "width": 0, - "height": 25, + "width": 420, + "height": 55, "angle": 0, "strokeColor": "#555555", "backgroundColor": "transparent", @@ -1232,50 +1232,15 @@ ], [ 0, - 25 - ] - ], - "lastCommittedPoint": null, - "startBinding": null, - "endBinding": null, - "startArrowhead": null, - "endArrowhead": "arrow" - }, - { - "id": "a1393519547", - "type": "arrow", - "x": 790, - "y": 160, - "width": 360, - "height": 57, - "angle": 0, - "strokeColor": "#555555", - "backgroundColor": "transparent", - "fillStyle": "solid", - "strokeWidth": 2, - "strokeStyle": "solid", - "roughness": 1, - "opacity": 100, - "groupIds": [], - "frameId": null, - "roundness": { - "type": 2 - }, - "seed": 1021987407, - "versionNonce": 1116619924, - "isDeleted": false, - "boundElements": [], - "updated": 1, - "link": null, - "locked": false, - "points": [ + 27 + ], [ - 0, - 0 + -420, + 27 ], [ - -360, - 57 + -420, + 55 ] ], "lastCommittedPoint": null, @@ -1287,8 +1252,8 @@ { "id": "t411375745", "type": "text", - "x": 610.0, - "y": 174.5, + "x": 560, + "y": 145, "width": 40, "height": 13.75, "angle": 0, @@ -1451,7 +1416,7 @@ { "id": "t1325383501", "type": "text", - "x": 500, + "x": 238.2, "y": 20, "width": 523.6, "height": 17.5, diff --git a/assets/diagrams/advisory-ci-pipeline.svg b/assets/diagrams/advisory-ci-pipeline.svg index b4a351d4..1009d064 100644 --- a/assets/diagrams/advisory-ci-pipeline.svg +++ b/assets/diagrams/advisory-ci-pipeline.svg @@ -8,41 +8,39 @@ policy repo Schema Check -oc apply --dry-run +kubeconform Policy Lint conftest / kyverno -ACS Build-time -baseline validation +Baseline Diff +vs observed flows Review platform + security GitOps Sync -ArgoCD → staging +Argo CD → staging Smoke Test connectivity checks Prod Sync -ArgoCD → production +Argo CD → prod Post-deploy -ACS + OVN-K verify +flow logs + Falco - - -merge -merge + +merge -Network policy CI/CD pipeline — every change goes through all stages +Network policy CI/CD pipeline — every change goes through all stages CODEOWNERS enforces: platform-baselines → firewall review, app-policies → platform review Fail at any stage → PR blocked. No exceptions, no bypass. \ No newline at end of file diff --git a/assets/diagrams/advisory-team-ownership.excalidraw b/assets/diagrams/advisory-team-ownership.excalidraw index fcad11ca..c12f0ea9 100644 --- a/assets/diagrams/advisory-team-ownership.excalidraw +++ b/assets/diagrams/advisory-team-ownership.excalidraw @@ -223,9 +223,9 @@ { "id": "t1344245628", "type": "text", - "x": 65, + "x": 72.7, "y": 88, - "width": 107.80000000000001, + "width": 92.4, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -245,13 +245,13 @@ "updated": 1, "link": null, "locked": false, - "text": "EgressFirewall", + "text": "Egress rules", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "EgressFirewall", + "originalText": "Egress rules", "lineHeight": 1.25, "baseline": 14 }, @@ -321,9 +321,9 @@ { "id": "t1194309722", "type": "text", - "x": 65, + "x": 41.9, "y": 158, - "width": 130.9, + "width": 177.1, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -343,13 +343,13 @@ "updated": 1, "link": null, "locked": false, - "text": "ANP / BANP review", + "text": "Admin / Baseline review", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ANP / BANP review", + "originalText": "Admin / Baseline review", "lineHeight": 1.25, "baseline": 14 }, @@ -419,9 +419,9 @@ { "id": "t408108886", "type": "text", - "x": 65, + "x": 76.55, "y": 228, - "width": 161.70000000000002, + "width": 138.6, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -441,13 +441,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Perimeter (Palo Alto)", + "text": "Perimeter firewall", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Perimeter (Palo Alto)", + "originalText": "Perimeter firewall", "lineHeight": 1.25, "baseline": 14 }, @@ -517,9 +517,9 @@ { "id": "t1043964037", "type": "text", - "x": 355, + "x": 370.4, "y": 88, - "width": 138.60000000000002, + "width": 107.8, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -539,22 +539,22 @@ "updated": 1, "link": null, "locked": false, - "text": "AdminNetworkPolicy", + "text": "CNP Admin tier", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "AdminNetworkPolicy", + "originalText": "CNP Admin tier", "lineHeight": 1.25, "baseline": 14 }, { "id": "t1660330960", "type": "text", - "x": 355, + "x": 351.98, "y": 108, - "width": 211.75000000000003, + "width": 217.8, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -574,13 +574,13 @@ "updated": 1, "link": null, "locked": false, - "text": "cluster guardrails, Allow/Deny/Pass", + "text": "cluster guardrails, Accept/Deny/Pass", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "cluster guardrails, Allow/Deny/Pass", + "originalText": "cluster guardrails, Accept/Deny/Pass", "lineHeight": 1.25, "baseline": 11 }, @@ -615,9 +615,9 @@ { "id": "t1689731681", "type": "text", - "x": 355, + "x": 290.0, "y": 158, - "width": 40, + "width": 170.0, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -637,13 +637,13 @@ "updated": 1, "link": null, "locked": false, - "text": "BANP", + "text": "CNP Baseline tier", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "BANP", + "originalText": "CNP Baseline tier", "lineHeight": 1.25, "baseline": 14 }, @@ -1007,9 +1007,9 @@ { "id": "t196612729", "type": "text", - "x": 645, + "x": 625.75, "y": 158, - "width": 100.10000000000001, + "width": 138.6, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -1029,13 +1029,13 @@ "updated": 1, "link": null, "locked": false, - "text": "ACS baselines", + "text": "Observed baselines", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ACS baselines", + "originalText": "Observed baselines", "lineHeight": 1.25, "baseline": 14 }, @@ -1409,7 +1409,7 @@ { "id": "t555336924", "type": "text", - "x": 450, + "x": 207.45, "y": 10, "width": 485.1, "height": 17.5, diff --git a/assets/diagrams/advisory-team-ownership.svg b/assets/diagrams/advisory-team-ownership.svg index 75495b08..714dc6b1 100644 --- a/assets/diagrams/advisory-team-ownership.svg +++ b/assets/diagrams/advisory-team-ownership.svg @@ -7,19 +7,19 @@ App Teams -EgressFirewall +Egress rules external destination rules -ANP / BANP review +Admin / Baseline review cluster-scoped policy approval -Perimeter (Palo Alto) +Perimeter firewall north-south, compliance logs -AdminNetworkPolicy -cluster guardrails, Allow/Deny/Pass +CNP Admin tier +cluster guardrails, Accept/Deny/Pass -BANP +CNP Baseline tier default-deny safety net Starter templates @@ -31,7 +31,7 @@ NetworkPolicy namespace-scoped, per-service -ACS baselines +Observed baselines review generated policies PR submission @@ -45,7 +45,7 @@ templates templates -Policy ownership model — who authors, who reviews, who approves +Policy ownership model — who authors, who reviews, who approves Gatekeeper → architect Keeps north-south edge Controls cluster posture diff --git a/assets/diagrams/advisory-tier-evaluation.excalidraw b/assets/diagrams/advisory-tier-evaluation.excalidraw index 906de122..8e1a1cc4 100644 --- a/assets/diagrams/advisory-tier-evaluation.excalidraw +++ b/assets/diagrams/advisory-tier-evaluation.excalidraw @@ -34,9 +34,9 @@ { "id": "t1132191952", "type": "text", - "x": 42, + "x": 55.2, "y": 56, - "width": 178.20000000000002, + "width": 151.8, "height": 15.0, "angle": 0, "strokeColor": "#555555", @@ -56,13 +56,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Tier 1 \u2014 AdminNetworkPolicy", + "text": "Tier 1 \u2014 CNP Admin tier", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Tier 1 \u2014 AdminNetworkPolicy", + "originalText": "Tier 1 \u2014 CNP Admin tier", "lineHeight": 1.25, "baseline": 12 }, @@ -160,9 +160,9 @@ { "id": "t1159839522", "type": "text", - "x": 622, + "x": 579.1, "y": 56, - "width": 85.80000000000001, + "width": 171.6, "height": 15.0, "angle": 0, "strokeColor": "#555555", @@ -182,13 +182,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Tier 3 \u2014 BANP", + "text": "Tier 3 \u2014 CNP Baseline tier", "fontSize": 12, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Tier 3 \u2014 BANP", + "originalText": "Tier 3 \u2014 CNP Baseline tier", "lineHeight": 1.25, "baseline": 12 }, @@ -223,9 +223,9 @@ { "id": "t1322002684", "type": "text", - "x": 70, + "x": 43.05, "y": 98, - "width": 100.10000000000001, + "width": 154.0, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -245,22 +245,22 @@ "updated": 1, "link": null, "locked": false, - "text": "ANP evaluates", + "text": "Admin tier evaluates", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ANP evaluates", + "originalText": "Admin tier evaluates", "lineHeight": 1.25, "baseline": 14 }, { "id": "t340439633", "type": "text", - "x": 70, + "x": 63.95, "y": 118, - "width": 175.45000000000002, + "width": 187.55, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -280,13 +280,13 @@ "updated": 1, "link": null, "locked": false, - "text": "cluster-scoped, priority 0-99", + "text": "cluster-scoped, priority 0-1000", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "cluster-scoped, priority 0-99", + "originalText": "cluster-scoped, priority 0-1000", "lineHeight": 1.25, "baseline": 11 }, @@ -321,9 +321,9 @@ { "id": "t1878923591", "type": "text", - "x": 70, + "x": 66.0, "y": 178, - "width": 40, + "width": 48.0, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -343,13 +343,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Allow", + "text": "Accept", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Allow", + "originalText": "Accept", "lineHeight": 1.25, "baseline": 14 }, @@ -909,9 +909,9 @@ { "id": "t1462867075", "type": "text", - "x": 650, + "x": 615.35, "y": 98, - "width": 107.80000000000001, + "width": 177.1, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -931,22 +931,22 @@ "updated": 1, "link": null, "locked": false, - "text": "BANP evaluates", + "text": "Baseline tier evaluates", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "BANP evaluates", + "originalText": "Baseline tier evaluates", "lineHeight": 1.25, "baseline": 14 }, { "id": "t1984396970", "type": "text", - "x": 650, + "x": 659.08, "y": 118, - "width": 175.45000000000002, + "width": 157.3, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -966,13 +966,13 @@ "updated": 1, "link": null, "locked": false, - "text": "cluster singleton, safety net", + "text": "cluster-scoped, safety net", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "cluster singleton, safety net", + "originalText": "cluster-scoped, safety net", "lineHeight": 1.25, "baseline": 11 }, @@ -1007,9 +1007,9 @@ { "id": "t914393078", "type": "text", - "x": 650, + "x": 646.0, "y": 178, - "width": 40, + "width": 48.0, "height": 17.5, "angle": 0, "strokeColor": "#111111", @@ -1029,13 +1029,13 @@ "updated": 1, "link": null, "locked": false, - "text": "Allow", + "text": "Accept", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "Allow", + "originalText": "Accept", "lineHeight": 1.25, "baseline": 14 }, @@ -1331,9 +1331,9 @@ { "id": "t950593550", "type": "text", - "x": 450, + "x": 288.3, "y": 20, - "width": 331.1, + "width": 323.4, "height": 17.5, "angle": 0, "strokeColor": "#1d1d1d", @@ -1353,13 +1353,13 @@ "updated": 1, "link": null, "locked": false, - "text": "OVN-Kubernetes three-tier policy evaluation", + "text": "ClusterNetworkPolicy three-tier evaluation", "fontSize": 14, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "OVN-Kubernetes three-tier policy evaluation", + "originalText": "ClusterNetworkPolicy three-tier evaluation", "lineHeight": 1.25, "baseline": 14 }, @@ -1436,9 +1436,9 @@ { "id": "t1160082258", "type": "text", - "x": 640, + "x": 600.67, "y": 360, - "width": 187.55, + "width": 266.2, "height": 13.75, "angle": 0, "strokeColor": "#666", @@ -1458,22 +1458,22 @@ "updated": 1, "link": null, "locked": false, - "text": "BANP catches unmatched traffic.", + "text": "The Baseline tier catches unmatched traffic.", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "BANP catches unmatched traffic.", + "originalText": "The Baseline tier catches unmatched traffic.", "lineHeight": 1.25, "baseline": 11 }, { "id": "t877870349", "type": "text", - "x": 60, + "x": 96.3, "y": 385, - "width": 308.55, + "width": 235.95, "height": 13.75, "angle": 0, "strokeColor": "#666", @@ -1493,22 +1493,22 @@ "updated": 1, "link": null, "locked": false, - "text": "ANP Allow/Deny are final \u2014 NP and BANP are skipped.", + "text": "Admin Accept/Deny skip NP and Baseline.", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "ANP Allow/Deny are final \u2014 NP and BANP are skipped.", + "originalText": "Admin Accept/Deny skip NP and Baseline.", "lineHeight": 1.25, "baseline": 11 }, { "id": "t633931798", "type": "text", - "x": 640, + "x": 627.9, "y": 385, - "width": 223.85000000000002, + "width": 248.05, "height": 13.75, "angle": 0, "strokeColor": "#666", @@ -1528,13 +1528,13 @@ "updated": 1, "link": null, "locked": false, - "text": "BANP is overridable by NetworkPolicy.", + "text": "Baseline is overridable by NetworkPolicy.", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "BANP is overridable by NetworkPolicy.", + "originalText": "Baseline is overridable by NetworkPolicy.", "lineHeight": 1.25, "baseline": 11 } diff --git a/assets/diagrams/advisory-tier-evaluation.svg b/assets/diagrams/advisory-tier-evaluation.svg index a38f32d1..3d5f765d 100644 --- a/assets/diagrams/advisory-tier-evaluation.svg +++ b/assets/diagrams/advisory-tier-evaluation.svg @@ -1,16 +1,16 @@ -Tier 1 — AdminNetworkPolicy +Tier 1 — CNP Admin tier Tier 2 — NetworkPolicy -Tier 3 — BANP +Tier 3 — CNP Baseline tier -ANP evaluates -cluster-scoped, priority 0-99 +Admin tier evaluates +cluster-scoped, priority 0-1000 -Allow +Accept traffic flows immediately Deny @@ -28,10 +28,10 @@ No NP exists hand to tier 3 -BANP evaluates -cluster singleton, safety net +Baseline tier evaluates +cluster-scoped, safety net -Allow +Accept traffic flows Deny @@ -42,10 +42,10 @@ no match no match -OVN-Kubernetes three-tier policy evaluation +ClusterNetworkPolicy three-tier evaluation Platform team controls tiers 1 and 3 (guardrails). App teams control tier 2 (their policies). -BANP catches unmatched traffic. -ANP Allow/Deny are final — NP and BANP are skipped. -BANP is overridable by NetworkPolicy. +The Baseline tier catches unmatched traffic. +Admin Accept/Deny skip NP and Baseline. +Baseline is overridable by NetworkPolicy. \ No newline at end of file diff --git a/assets/diagrams/policy-ci-pipeline.excalidraw b/assets/diagrams/policy-ci-pipeline.excalidraw index 7ec7a57f..28615373 100644 --- a/assets/diagrams/policy-ci-pipeline.excalidraw +++ b/assets/diagrams/policy-ci-pipeline.excalidraw @@ -69,9 +69,9 @@ { "id": "t56660335", "type": "text", - "x": 20, + "x": 29.08, "y": 88, - "width": 127.05000000000001, + "width": 108.9, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -91,13 +91,13 @@ "updated": 1, "link": null, "locked": false, - "text": "kubeval / kubeconform", + "text": "kubeconform v0.8.0", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "kubeval / kubeconform", + "originalText": "kubeconform v0.8.0", "lineHeight": 1.25, "baseline": 11 }, diff --git a/assets/diagrams/policy-ci-pipeline.svg b/assets/diagrams/policy-ci-pipeline.svg index 182a3d09..6b614052 100644 --- a/assets/diagrams/policy-ci-pipeline.svg +++ b/assets/diagrams/policy-ci-pipeline.svg @@ -2,7 +2,7 @@ Schema Check -kubeval / kubeconform +kubeconform v0.8.0 Policy Lint conftest / kyverno diff --git a/assets/diagrams/policy-gitops-flow.excalidraw b/assets/diagrams/policy-gitops-flow.excalidraw index 59379a6b..ea30a71b 100644 --- a/assets/diagrams/policy-gitops-flow.excalidraw +++ b/assets/diagrams/policy-gitops-flow.excalidraw @@ -231,7 +231,7 @@ "id": "t1232950888", "type": "text", "x": 270, - "y": 58, + "y": 53, "width": 53.900000000000006, "height": 17.5, "angle": 0, @@ -265,9 +265,9 @@ { "id": "t901273671", "type": "text", - "x": 270, - "y": 78, - "width": 102.85000000000001, + "x": 276.73, + "y": 71, + "width": 66.55, "height": 13.75, "angle": 0, "strokeColor": "#555555", @@ -287,13 +287,13 @@ "updated": 1, "link": null, "locked": false, - "text": "kubeval, conftest", + "text": "kubeconform", "fontSize": 11, "fontFamily": 1, "textAlign": "left", "verticalAlign": "top", "containerId": null, - "originalText": "kubeval, conftest", + "originalText": "kubeconform", "lineHeight": 1.25, "baseline": 11 }, @@ -1180,6 +1180,41 @@ "originalText": "No human runs kubectl apply in production \u2014 every change is a Git commit", "lineHeight": 1.25, "baseline": 11 + }, + { + "id": "t901273672", + "type": "text", + "x": 285.8, + "y": 84, + "width": 48.4, + "height": 13.75, + "angle": 0, + "strokeColor": "#555555", + "backgroundColor": "transparent", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 1663387367, + "versionNonce": 3661076, + "isDeleted": false, + "boundElements": [], + "updated": 1, + "link": null, + "locked": false, + "text": "conftest", + "fontSize": 11, + "fontFamily": 1, + "textAlign": "left", + "verticalAlign": "top", + "containerId": null, + "originalText": "conftest", + "lineHeight": 1.25, + "baseline": 11 } ], "appState": { diff --git a/assets/diagrams/policy-gitops-flow.svg b/assets/diagrams/policy-gitops-flow.svg index 793706a0..d15e52e9 100644 --- a/assets/diagrams/policy-gitops-flow.svg +++ b/assets/diagrams/policy-gitops-flow.svg @@ -7,8 +7,9 @@ Git Repo env manifests -CI Lint -kubeval, conftest +CI Lint +kubeconform +conftest Staging deploy + test diff --git a/examples/01-cloud-native-principles/README.md b/examples/01-cloud-native-principles/README.md index eef7cc4b..0bdbae32 100644 --- a/examples/01-cloud-native-principles/README.md +++ b/examples/01-cloud-native-principles/README.md @@ -19,7 +19,7 @@ credentials, and the container naming convention. |---------|-------|------| | Env-based config | environment variables | `DATABASE_URL`, `KAFKA_BOOTSTRAP`, `SERVICE_VERSION` — one image, many environments | | Liveness probe | `/healthz` | Always returns ok if the process is up — never checks dependencies | -| Readiness probe | `/readyz` | Checks the database — fails when DB is down, recovers when DB recovers | +| Readiness probe | `/readyz` | Checks the database — answers 503 with `"status":"down"` when DB is down, 200 when it recovers | | OTel instrumentation | auto-instrumented | Traces and metrics sent to the LGTM stack | ## Architecture @@ -63,7 +63,7 @@ curl -s localhost:8080/orders | jq . # Stop Postgres and watch readiness flip (liveness stays ok) docker stop cndp-postgres -curl -s localhost:8080/readyz | jq . # → "status": "down" +curl -s -w '%{http_code}\n' localhost:8080/readyz # → 503, "status": "down" curl -s localhost:8080/healthz | jq . # → "status": "ok" # Restart Postgres and watch readiness recover diff --git a/examples/01-cloud-native-principles/cpp/README.md b/examples/01-cloud-native-principles/cpp/README.md index 78409066..2a862a9a 100644 --- a/examples/01-cloud-native-principles/cpp/README.md +++ b/examples/01-cloud-native-principles/cpp/README.md @@ -43,7 +43,7 @@ GET /healthz # Readiness probe (checks DB) GET /readyz → {"status":"ready","checks":{"database":"ok"}} -→ {"status":"down","checks":{"database":"unreachable"}} # when DB is down +→ 503 {"status":"down","checks":{"database":"unreachable"}} # when DB is down # Create order POST /orders?customer=alice&total=42.50 @@ -103,14 +103,14 @@ The connection pool patterns are identical; only the connection type differs (`P ## Cross-check it yourself -Run `./verify.sh` from the parent directory. It tests all 10 checks from the tutorial: +Run `./verify.sh` from the parent directory. It runs the 13 checks of the tutorial, grouped as: 1. Root endpoint returns service info from environment 2. Liveness probe always returns ok 3. Readiness probe checks DB connectivity 4. Create order via POST with query params 5. List orders returns JSON array -6. Readiness flips to down when DB is stopped +6. Readiness flips to 503 (status down) when DB is stopped 7. Liveness still returns ok when DB is stopped 8. Readiness recovers after DB restart -*Verification status: pending first successful run* +*Verification status: verified, 13/13 on Docker Engine (2026-10-12)* diff --git a/examples/01-cloud-native-principles/cpp/src/main.cpp b/examples/01-cloud-native-principles/cpp/src/main.cpp index ed46c7e4..378adaba 100644 --- a/examples/01-cloud-native-principles/cpp/src/main.cpp +++ b/examples/01-cloud-native-principles/cpp/src/main.cpp @@ -108,7 +108,9 @@ int main() { j["status"] = "down"; } j["checks"] = checks; - cb(HttpResponse::newHttpJsonResponse(j)); + auto resp = HttpResponse::newHttpJsonResponse(j); + if (!db_ok) resp->setStatusCode(k503ServiceUnavailable); + cb(resp); }, {Get}); diff --git a/examples/01-cloud-native-principles/dotnet/OrderService/Program.cs b/examples/01-cloud-native-principles/dotnet/OrderService/Program.cs index 23ca405d..c350bf1e 100644 --- a/examples/01-cloud-native-principles/dotnet/OrderService/Program.cs +++ b/examples/01-cloud-native-principles/dotnet/OrderService/Program.cs @@ -47,7 +47,9 @@ } catch { - return Results.Ok(new { status = "down", checks = new { database = "unreachable" } }); + return Results.Json( + new { status = "down", checks = new { database = "unreachable" } }, + statusCode: StatusCodes.Status503ServiceUnavailable); } }); diff --git a/examples/01-cloud-native-principles/go/cmd/server/main.go b/examples/01-cloud-native-principles/go/cmd/server/main.go index 46d4e6c7..ecb090a4 100644 --- a/examples/01-cloud-native-principles/go/cmd/server/main.go +++ b/examples/01-cloud-native-principles/go/cmd/server/main.go @@ -64,6 +64,7 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { if err := s.pool.Ping(ctx); err != nil { slog.ErrorContext(ctx, "readiness check failed", "error", err) + w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(ReadinessResponse{ Status: "down", Checks: map[string]string{ diff --git a/examples/01-cloud-native-principles/python/main.py b/examples/01-cloud-native-principles/python/main.py index 3499e3fb..bb6300ef 100644 --- a/examples/01-cloud-native-principles/python/main.py +++ b/examples/01-cloud-native-principles/python/main.py @@ -5,6 +5,7 @@ import asyncpg from fastapi import FastAPI +from fastapi.responses import JSONResponse from pydantic_settings import BaseSettings @@ -56,7 +57,10 @@ async def readyz(): async with pool.acquire() as conn: await conn.fetchval("SELECT 1") except Exception: - return {"status": "down", "checks": {"database": "unreachable"}} + return JSONResponse( + status_code=503, + content={"status": "down", "checks": {"database": "unreachable"}}, + ) return {"status": "ready", "checks": {"database": "ok"}} diff --git a/examples/01-cloud-native-principles/quarkus/src/main/java/com/cndp/order/HealthResource.java b/examples/01-cloud-native-principles/quarkus/src/main/java/com/cndp/order/HealthResource.java index 0268f669..332aa0ca 100644 --- a/examples/01-cloud-native-principles/quarkus/src/main/java/com/cndp/order/HealthResource.java +++ b/examples/01-cloud-native-principles/quarkus/src/main/java/com/cndp/order/HealthResource.java @@ -7,6 +7,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -43,18 +44,20 @@ public Map healthz() { @GET @Path("readyz") - public Map readyz() { + public Response readyz() { for (int attempt = 0; attempt < 2; attempt++) { try (var conn = DriverManager.getConnection(jdbcUrl, dbUser, dbPass); var stmt = conn.createStatement(); var rs = stmt.executeQuery("SELECT 1")) { - return Map.of("status", "ready", "checks", Map.of("database", "ok")); + return Response.ok(Map.of("status", "ready", "checks", Map.of("database", "ok"))).build(); } catch (Exception e) { if (attempt == 0) { try { Thread.sleep(1000); } catch (InterruptedException ie) { break; } } } } - return Map.of("status", "down", "checks", Map.of("database", "unreachable")); + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("status", "down", "checks", Map.of("database", "unreachable"))) + .build(); } } diff --git a/examples/01-cloud-native-principles/spring-boot/src/main/java/com/cndp/order/HealthController.java b/examples/01-cloud-native-principles/spring-boot/src/main/java/com/cndp/order/HealthController.java index cfbd7dbc..630f6419 100644 --- a/examples/01-cloud-native-principles/spring-boot/src/main/java/com/cndp/order/HealthController.java +++ b/examples/01-cloud-native-principles/spring-boot/src/main/java/com/cndp/order/HealthController.java @@ -5,6 +5,8 @@ import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -26,13 +28,14 @@ public Map healthz() { } @GetMapping("/readyz") - public Map readyz() { + public ResponseEntity> readyz() { try (var conn = dataSource.getConnection()) { conn.createStatement().executeQuery("SELECT 1").close(); } catch (Exception e) { - return Map.of("status", "down", "checks", Map.of("database", "unreachable")); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("status", "down", "checks", Map.of("database", "unreachable"))); } - return Map.of("status", "ready", "checks", Map.of("database", "ok")); + return ResponseEntity.ok(Map.of("status", "ready", "checks", Map.of("database", "ok"))); } @GetMapping("/") diff --git a/examples/01-cloud-native-principles/verify.sh b/examples/01-cloud-native-principles/verify.sh index 2305b9fb..64d92b40 100755 --- a/examples/01-cloud-native-principles/verify.sh +++ b/examples/01-cloud-native-principles/verify.sh @@ -60,6 +60,10 @@ check "liveness probe returns ok" \ '"status":"ok"' # Readiness probe (DB is up) +check "readiness probe returns 200 when DB is up" \ + "curl $CURL_OPTS -s -o /dev/null -w '%{http_code}' $BASE/readyz" \ + '^200$' + check "readiness probe returns ready when DB is up" \ "curl $CURL_OPTS -sf $BASE/readyz" \ '"status":"ready"' @@ -78,10 +82,18 @@ printf '\n → stopping postgres to test readiness flip...\n' ctr_stop cndp-postgres >/dev/null 2>&1 sleep 2 -check "readiness returns down when DB is stopped" \ - "curl $CURL_OPTS -sf $BASE/readyz" \ +check "readiness returns 503 when DB is stopped" \ + "curl $CURL_OPTS -s -o /dev/null -w '%{http_code}' $BASE/readyz" \ + '^503$' + +check "readiness body reports down when DB is stopped" \ + "curl $CURL_OPTS -s $BASE/readyz" \ '"status":"down"' +check "liveness still returns 200 when DB is stopped" \ + "curl $CURL_OPTS -s -o /dev/null -w '%{http_code}' $BASE/healthz" \ + '^200$' + check "liveness still returns ok when DB is stopped" \ "curl $CURL_OPTS -sf $BASE/healthz" \ '"status":"ok"' diff --git a/examples/05-event-driven/k8s/setup-profile.sh b/examples/05-event-driven/k8s/setup-profile.sh index 3976d6fa..7dc91219 100755 --- a/examples/05-event-driven/k8s/setup-profile.sh +++ b/examples/05-event-driven/k8s/setup-profile.sh @@ -58,7 +58,8 @@ if [[ -n "$others" ]]; then while read -r n; do printf ' - %s\n' "$n" >&2; done <<<"$others" fail "stop them yourself (minikube stop -p ) and re-run; this script never stops another profile" fi -foreign="$(docker ps --format '{{.Names}}' | grep -vx "$PROFILE" || true)" +# The quarkus-agent MCP's doc-search containers (label quarkus-agent-mcp) are tooling, not a workshop. +foreign="$(docker ps --format '{{.Names}}\t{{.Label "quarkus-agent-mcp"}}' | awk -F'\t' '$2 == "" {print $1}' | grep -vx "$PROFILE" || true)" if [[ -n "$foreign" ]]; then printf 'Containers are running:\n' >&2 while read -r n; do printf ' - %s\n' "$n" >&2; done <<<"$foreign" diff --git a/examples/05-event-driven/k8s/verify-matrix.sh b/examples/05-event-driven/k8s/verify-matrix.sh index 55813346..09deb0b5 100755 --- a/examples/05-event-driven/k8s/verify-matrix.sh +++ b/examples/05-event-driven/k8s/verify-matrix.sh @@ -23,7 +23,8 @@ selected=("$@") for l in "${selected[@]}"; do valid_lang "$l" || fail "unknown language '$l' (${LANGS[*]})"; done minikube status -p "$PROFILE" >/dev/null 2>&1 || fail "profile $PROFILE is not running. Run k8s/setup-profile.sh first." -foreign="$(docker ps --format '{{.Names}}' | grep -vx "$PROFILE" || true)" +# The quarkus-agent MCP's doc-search containers (label quarkus-agent-mcp) are tooling, not a workshop. +foreign="$(docker ps --format '{{.Names}}\t{{.Label "quarkus-agent-mcp"}}' | awk -F'\t' '$2 == "" {print $1}' | grep -vx "$PROFILE" || true)" [[ -z "$foreign" ]] || fail "containers are running (one workshop at a time): $(tr '\n' ' ' <<<"$foreign")" # How many distinct service names Tempo reports in a service.name values reply (0 on any error). diff --git a/examples/05-event-driven/openshift/lib.sh b/examples/05-event-driven/openshift/lib.sh index 0c7b5f34..065a8cf1 100755 --- a/examples/05-event-driven/openshift/lib.sh +++ b/examples/05-event-driven/openshift/lib.sh @@ -74,7 +74,8 @@ require_alone() { local running containers running="$(running_minikube_profiles)" [[ -z "$running" ]] || fail "minikube profile(s) running: $(tr '\n' ' ' <<<"$running"). Stop them first (minikube stop -p ); run one cluster at a time" - containers="$(docker ps --format '{{.Names}}' 2>/dev/null || true)" + # The quarkus-agent MCP's doc-search containers (label quarkus-agent-mcp) are tooling, not a workshop. + containers="$(docker ps --format '{{.Names}}\t{{.Label "quarkus-agent-mcp"}}' 2>/dev/null | awk -F'\t' '$2 == "" {print $1}' || true)" [[ -z "$containers" ]] || fail "containers are running (one workshop at a time): $(tr '\n' ' ' <<<"$containers")" } diff --git a/examples/21-graceful-shutdown/README.md b/examples/21-graceful-shutdown/README.md index 3290e7ac..c3a1f5eb 100644 --- a/examples/21-graceful-shutdown/README.md +++ b/examples/21-graceful-shutdown/README.md @@ -2,7 +2,10 @@ Demonstrates the **Kubernetes-aware graceful shutdown protocol**: on SIGTERM, the service flips readiness to 503 (so the load balancer stops sending new -traffic), drains in-flight requests, and then closes resources cleanly. +traffic), keeps serving for a short drain delay while endpoints update, stops accepting +connections, finishes in-flight requests, closes resources and exits 0. Kubernetes sends one +SIGTERM and follows with SIGKILL when `terminationGracePeriodSeconds` runs out, so the whole +sequence starts on that single signal and fits inside the grace period. ## Prerequisites @@ -18,9 +21,10 @@ credentials, and the container naming convention. | Pattern | Where | What | |---------|-------|------| -| SIGTERM handler | signal handler | Sets `shutting_down` flag on signal | -| Readiness flip | `/readyz` | Returns 503 when shutting down | -| In-flight drain | shutdown hook | Waits for `in_flight == 0` before closing pool | +| SIGTERM handler | signal handler | One signal starts the drain: sets the `shutting_down` flag | +| Readiness flip | `/readyz` | Returns 503 when shutting down; `/healthz` stays 200 | +| Drain delay | `DRAIN_DELAY_SECONDS` (default 5) | Keeps serving while endpoint removal propagates (the preStop sleep) | +| In-flight drain | shutdown hook | Stops accepting, finishes in-flight requests (20 s bound), closes the pool, exits 0 | | Data survives restart | verify.sh | Orders placed before restart are still queryable after | ## Architecture @@ -52,11 +56,12 @@ curl -s -X POST -H 'Content-Type: application/json' \ -d '{"sku":"widget","quantity":1}' \ localhost:8080/orders | jq . -# Send SIGTERM -docker exec cndp-order-service kill -SIGTERM 1 +# Send one SIGTERM (what the kubelet sends) +docker kill --signal SIGTERM cndp-order-service -# Readiness should flip +# Readiness flips at once; the service keeps serving for DRAIN_DELAY_SECONDS, then exits 0 curl -s localhost:8080/readyz | jq . +docker wait cndp-order-service # Check internal state curl -s localhost:8080/debug/state | jq . @@ -71,6 +76,10 @@ cd .. # if you're in a language directory ./verify.sh ``` +`verify.sh` sends the single SIGTERM, checks the 503 flip while the service is still up, +waits for the container to exit by itself with code 0 inside the 30 s grace period, restarts it, +and finishes with a `docker stop -t 30` timing check (exit 0, not 137). + ## Ports | Service | Port | diff --git a/examples/21-graceful-shutdown/cpp/README.md b/examples/21-graceful-shutdown/cpp/README.md index 56e97475..44b50426 100644 --- a/examples/21-graceful-shutdown/cpp/README.md +++ b/examples/21-graceful-shutdown/cpp/README.md @@ -13,8 +13,11 @@ handling, readiness flip, and in-flight request draining. ## Key implementation details 1. **SIGTERM handler**: Uses `std::signal(SIGTERM, handler)` with `std::atomic - g_shutting_down`. The handler sets the flag and logs "SIGTERM received". Drogon - continues running so readyz can respond with 503. + g_shutting_down`. The handler is async-signal-safe: it only stores to the lock-free + atomic. A watcher thread observes the flag, logs "SIGTERM received", keeps serving for + `DRAIN_DELAY_SECONDS` (default 5) so endpoints update and readyz answers 503, waits for + in-flight requests to reach zero (20 s bound) and calls `app().quit()`. One SIGTERM drives + the whole sequence. 2. **In-flight tracking**: `std::atomic g_in_flight` incremented at handler entry, decremented via RAII guard (`InFlightGuard`). @@ -26,8 +29,7 @@ handling, readiness flip, and in-flight request draining. 5. **POST /orders**: Parses JSON body, generates UUID, inserts into DB, returns 201. -6. **Graceful drain**: After `app().run()` returns, waits for `g_in_flight` to reach zero - before exiting. +6. **Graceful drain**: After `app().run()` returns, closes the connection pool and exits 0. ## Running @@ -51,9 +53,11 @@ The `verify.sh` script checks: 1. Health and readiness endpoints 2. Order creation (POST /orders) -3. SIGTERM → readyz flips to 503, logs show "SIGTERM received" +3. One SIGTERM → readyz flips to 503, logs show "SIGTERM received" 4. Debug state shows `shutting_down:true` -5. Service restart → recovery (readyz 200, orders persist) +5. The container exits by itself with code 0 inside the 30 s grace period +6. Service restart → recovery (readyz 200, orders persist) +7. `docker stop -t 30` → exit code 0 before the timeout ## Container naming diff --git a/examples/21-graceful-shutdown/cpp/compose.yaml b/examples/21-graceful-shutdown/cpp/compose.yaml index b9a2113b..192064b4 100644 --- a/examples/21-graceful-shutdown/cpp/compose.yaml +++ b/examples/21-graceful-shutdown/cpp/compose.yaml @@ -24,11 +24,13 @@ services: environment: - PG_CONNINFO=postgresql://appuser:apppass@postgres:5432/appdb - PG_POOL_SIZE=4 + - DRAIN_DELAY_SECONDS=5 - SERVICE_VERSION=1.0.0 - OTEL_SERVICE_NAME=order-service - OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4318 - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf - OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local,service.version=1.0.0 + stop_grace_period: 30s healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/healthz"] interval: 5s diff --git a/examples/21-graceful-shutdown/cpp/src/main.cpp b/examples/21-graceful-shutdown/cpp/src/main.cpp index 2aaf5db1..8763ce9e 100644 --- a/examples/21-graceful-shutdown/cpp/src/main.cpp +++ b/examples/21-graceful-shutdown/cpp/src/main.cpp @@ -22,7 +22,12 @@ namespace { std::unique_ptr g_pool; std::string g_version; +// Written by the signal handler, so it must be lock-free: the handler does +// nothing but store to it. std::atomic g_shutting_down{false}; +static_assert(std::atomic::is_always_lock_free); +// Tells the watcher thread to exit when the server stops for another reason. +std::atomic g_watcher_stop{false}; std::atomic g_in_flight{0}; const char* env(const char* name, const char* fallback) { @@ -60,10 +65,36 @@ std::string generate_uuid() { return oss.str(); } -void sigterm_handler(int) { - spdlog::info("SIGTERM received, initiating graceful shutdown"); +// Async-signal-safe: a single lock-free atomic store. No logging, allocation +// or locking here; the watcher thread observes the flag and does the rest. +extern "C" void sigterm_handler(int) { g_shutting_down.store(true, std::memory_order_release); - // Do NOT call app().quit() here — we want to keep running to serve readyz +} + +// Runs the shutdown sequence outside signal context, driven by the single +// SIGTERM. Readiness already reads g_shutting_down, so /readyz returns 503 from +// the moment the handler ran. The watcher then keeps serving for the pre-stop +// delay (so endpoints update), waits for in-flight requests (bounded to fit the +// stop grace period) and asks Drogon to quit: it stops the listeners and +// app().run() returns. +void shutdown_watcher() { + using namespace std::chrono_literals; + while (!g_shutting_down.load(std::memory_order_acquire)) { + if (g_watcher_stop.load(std::memory_order_acquire)) return; + std::this_thread::sleep_for(50ms); + } + const int delay = std::atoi(env("DRAIN_DELAY_SECONDS", "5")); + spdlog::info("SIGTERM received, readiness flipped, waiting {}s for endpoints to update", delay); + std::this_thread::sleep_for(std::chrono::seconds(delay)); + + spdlog::info("draining in-flight requests (in_flight={})", g_in_flight.load()); + const auto deadline = std::chrono::steady_clock::now() + 20s; + while (g_in_flight.load(std::memory_order_acquire) > 0 && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(100ms); + } + spdlog::info("shutting down HTTP server (in_flight={})", g_in_flight.load()); + app().quit(); } } // namespace @@ -82,8 +113,9 @@ int main() { app().setUploadPath("/tmp"); // Re-install SIGTERM handler AFTER Drogon's init — Drogon's run() installs - // its own SIGTERM handler that calls quit(). We override it so the process - // stays alive and keeps serving while readyz flips to 503. + // its own SIGTERM handler that calls quit(). We override it with one that + // only sets an atomic flag, so the process stays alive and keeps serving + // while readyz flips to 503. SIGINT keeps Drogon's handler (quit). app().registerBeginningAdvice([]() { std::signal(SIGTERM, sigterm_handler); spdlog::info("SIGTERM handler installed (graceful shutdown mode)"); @@ -276,14 +308,20 @@ int main() { app().setThreadNum(4); app().setLogLevel(trantor::Logger::kInfo); + std::thread watcher(shutdown_watcher); + spdlog::info("listening on 0.0.0.0:8080"); app().run(); + g_watcher_stop.store(true, std::memory_order_release); + watcher.join(); + // After app().run() returns (e.g., from SIGINT or explicit quit) spdlog::info("waiting for in-flight requests to complete..."); while (g_in_flight.load(std::memory_order_acquire) > 0) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } + g_pool.reset(); // close the database connections spdlog::info("graceful shutdown complete"); return 0; diff --git a/examples/21-graceful-shutdown/dotnet/OrderService/Program.cs b/examples/21-graceful-shutdown/dotnet/OrderService/Program.cs index ec961ef8..a20d9583 100644 --- a/examples/21-graceful-shutdown/dotnet/OrderService/Program.cs +++ b/examples/21-graceful-shutdown/dotnet/OrderService/Program.cs @@ -10,10 +10,11 @@ var dataSource = new NpgsqlDataSourceBuilder(connString).Build(); builder.Services.AddSingleton(dataSource); -// Replace ConsoleLifetime so SIGTERM doesn't auto-shutdown the server. -// We handle SIGTERM ourselves: flip readiness, drain, then let the -// container runtime SIGKILL after stop_grace_period expires. +// Replace ConsoleLifetime so SIGTERM doesn't stop the server immediately. +// We handle SIGTERM ourselves: flip readiness, wait the pre-stop delay so endpoints update, +// then stop the host (Kestrel stops accepting and finishes in-flight requests). builder.Services.AddSingleton(); +builder.Services.Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20)); var app = builder.Build(); var logger = app.Services.GetRequiredService().CreateLogger("order-service"); @@ -21,11 +22,27 @@ var shuttingDown = false; var inFlight = 0; +var drainDelay = int.TryParse(Environment.GetEnvironmentVariable("DRAIN_DELAY_SECONDS"), out var d) && d >= 0 ? d : 5; + PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => { ctx.Cancel = true; + if (shuttingDown) return; // already draining shuttingDown = true; logger.LogInformation("SIGTERM received — readiness flipped, draining in-flight requests"); + logger.LogInformation("waiting {Delay}s for endpoints to update", drainDelay); + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(drainDelay)); + logger.LogInformation("shutting down HTTP server (in_flight={InFlight})", inFlight); + app.Lifetime.StopApplication(); + }); +}); + +app.Lifetime.ApplicationStopped.Register(() => +{ + dataSource.Dispose(); + logger.LogInformation("order-service shutdown complete"); }); app.MapGet("/healthz", () => new { status = "ok" }); diff --git a/examples/21-graceful-shutdown/dotnet/README.md b/examples/21-graceful-shutdown/dotnet/README.md index 623379f0..ed24dbf2 100644 --- a/examples/21-graceful-shutdown/dotnet/README.md +++ b/examples/21-graceful-shutdown/dotnet/README.md @@ -24,17 +24,17 @@ cd .. && bash verify.sh | Path | Description | |------|-------------| -| `OrderService/` | Program.cs — IHostApplicationLifetime.ApplicationStopping callback flips readiness, graceful drain via HostOptions.ShutdownTimeout | +| `OrderService/` | Program.cs — a SIGTERM registration flips readiness, waits the drain delay, then calls StopApplication; graceful drain via HostOptions.ShutdownTimeout | ## Implementation notes -Uses `IHostApplicationLifetime.ApplicationStopping` to hook SIGTERM. `HostOptions.ShutdownTimeout` controls drain window. Readiness endpoint returns 503 during drain. +A `PosixSignalRegistration` for SIGTERM (with `ManualLifetime` replacing `ConsoleLifetime`) flips readiness, waits `DRAIN_DELAY_SECONDS` (default 5), then calls `StopApplication()`: Kestrel stops accepting and finishes in-flight requests within `HostOptions.ShutdownTimeout` (20 s), the data source is disposed and the process exits 0. One SIGTERM, no second signal. ## Environment variables Key variables (set by compose.yaml, override for local dev): -`DATABASE_URL` +`ConnectionStrings__Default`, `DRAIN_DELAY_SECONDS` ## Local development (without containers) diff --git a/examples/21-graceful-shutdown/dotnet/compose.yaml b/examples/21-graceful-shutdown/dotnet/compose.yaml index 1a1f6f41..c8578b6c 100644 --- a/examples/21-graceful-shutdown/dotnet/compose.yaml +++ b/examples/21-graceful-shutdown/dotnet/compose.yaml @@ -19,6 +19,7 @@ services: - "8080:8080" environment: - ConnectionStrings__Default=Host=postgres;Port=5432;Database=appdb;Username=appuser;Password=apppass + - DRAIN_DELAY_SECONDS=5 stop_grace_period: 30s healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/healthz"] diff --git a/examples/21-graceful-shutdown/go/cmd/server/main.go b/examples/21-graceful-shutdown/go/cmd/server/main.go index 4e4319d2..63470ced 100644 --- a/examples/21-graceful-shutdown/go/cmd/server/main.go +++ b/examples/21-graceful-shutdown/go/cmd/server/main.go @@ -3,11 +3,11 @@ package main import ( "context" "encoding/json" - "fmt" "log/slog" "net/http" "os" "os/signal" + "strconv" "sync/atomic" "syscall" "time" @@ -120,6 +120,18 @@ func (s *server) listOrders(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(orders) } +// shutdownTimeout bounds the connection drain; drain delay + shutdownTimeout stays under the +// 30s stop grace period. +const shutdownTimeout = 20 * time.Second + +// drainDelay is the pre-stop delay that lets endpoint removal propagate (DRAIN_DELAY_SECONDS). +func drainDelay() time.Duration { + if v, err := strconv.Atoi(os.Getenv("DRAIN_DELAY_SECONDS")); err == nil && v >= 0 { + return time.Duration(v) * time.Second + } + return 5 * time.Second +} + func main() { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { @@ -148,7 +160,8 @@ func main() { Handler: mux, } - // First signal cancels ctx. The second one is caught by a fresh NotifyContext below. + // One SIGTERM starts the whole drain. Kubernetes sends it once and follows with SIGKILL + // when terminationGracePeriodSeconds runs out. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) defer stop() @@ -160,19 +173,26 @@ func main() { } }() - // First signal: flip readiness, keep serving during drain + // 1. Flip readiness so the endpoints controller removes this pod. <-ctx.Done() + stop() // restore default handling: a later signal terminates immediately s.shuttingDown.Store(true) slog.Info("SIGTERM received — readiness flipped, draining in-flight requests") - // Second signal (from docker stop): shut down the HTTP server - second, stopSecond := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) - defer stopSecond() - stop() // release the first registration; later signals reach only `second` - <-second.Done() - slog.Info(fmt.Sprintf("second signal received (in_flight=%d), shutting down HTTP server", s.inFlight.Load())) - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + // 2. Keep serving while endpoints propagate (the pre-stop delay). + delay := drainDelay() + slog.Info("waiting for endpoints to update", "delay", delay.String()) + time.Sleep(delay) + + // 3. Stop accepting connections and finish in-flight requests, bounded so the whole + // sequence fits inside terminationGracePeriodSeconds. + slog.Info("shutting down HTTP server", "in_flight", s.inFlight.Load()) + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() - httpServer.Shutdown(shutdownCtx) + if err := httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown incomplete", "err", err, "in_flight", s.inFlight.Load()) + } + + // 4. Cleanup (deferred pool.Close) and exit 0. slog.Info("order-service shutdown complete") } diff --git a/examples/21-graceful-shutdown/go/compose.yaml b/examples/21-graceful-shutdown/go/compose.yaml index 5c72f5cc..f977992e 100644 --- a/examples/21-graceful-shutdown/go/compose.yaml +++ b/examples/21-graceful-shutdown/go/compose.yaml @@ -20,6 +20,7 @@ services: - "8080:8080" environment: - DATABASE_URL=postgresql://appuser:apppass@postgres:5432/appdb + - DRAIN_DELAY_SECONDS=5 stop_grace_period: 30s healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/healthz"] diff --git a/examples/21-graceful-shutdown/python/README.md b/examples/21-graceful-shutdown/python/README.md index 2a446fee..17f0f86a 100644 --- a/examples/21-graceful-shutdown/python/README.md +++ b/examples/21-graceful-shutdown/python/README.md @@ -28,13 +28,13 @@ cd .. && bash verify.sh ## Implementation notes -Uses `signal.signal(SIGTERM, ...)` to set a shutdown flag. Readiness probe returns 503 during drain. Uvicorn's graceful shutdown timeout allows in-flight requests to complete. +The SIGTERM handler keeps uvicorn's own handler and chains to it. One SIGTERM flips readiness to 503 at once; after `DRAIN_DELAY_SECONDS` (default 5) the handler calls uvicorn's, which stops accepting, finishes in-flight requests (`--timeout-graceful-shutdown 20`) and runs the lifespan exit that closes the pool. The process exits 0. A handler installed before uvicorn starts would replace uvicorn's for good, so it is installed in the lifespan. ## Environment variables Key variables (set by compose.yaml, override for local dev): -`DATABASE_URL` +`DATABASE_URL`, `DRAIN_DELAY_SECONDS` ## Local development (without containers) diff --git a/examples/21-graceful-shutdown/python/compose.yaml b/examples/21-graceful-shutdown/python/compose.yaml index 416bb68b..12a61484 100644 --- a/examples/21-graceful-shutdown/python/compose.yaml +++ b/examples/21-graceful-shutdown/python/compose.yaml @@ -19,6 +19,7 @@ services: - "8080:8080" environment: - DATABASE_URL=postgres://appuser:apppass@postgres:5432/appdb + - DRAIN_DELAY_SECONDS=5 stop_grace_period: 30s healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/healthz"] diff --git a/examples/21-graceful-shutdown/python/order-service/Containerfile b/examples/21-graceful-shutdown/python/order-service/Containerfile index 3d7c0db1..2637110c 100644 --- a/examples/21-graceful-shutdown/python/order-service/Containerfile +++ b/examples/21-graceful-shutdown/python/order-service/Containerfile @@ -5,4 +5,4 @@ RUN --mount=type=cache,target=/opt/app-root/src/.cache/pip,uid=1001 \ PIP_NO_CACHE_DIR= PIP_DISABLE_PIP_VERSION_CHECK=1 pip install -r requirements.txt COPY --chown=1001:0 --chmod=g=u main.py . EXPOSE 8080 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--timeout-graceful-shutdown", "20"] diff --git a/examples/21-graceful-shutdown/python/order-service/main.py b/examples/21-graceful-shutdown/python/order-service/main.py index 62be7e8d..bb46d7e7 100644 --- a/examples/21-graceful-shutdown/python/order-service/main.py +++ b/examples/21-graceful-shutdown/python/order-service/main.py @@ -26,16 +26,37 @@ class OrderIn(BaseModel): quantity: int = Field(gt=0) -def handle_sigterm(*_): +DRAIN_DELAY_SECONDS = float(os.getenv("DRAIN_DELAY_SECONDS", "5")) + +# uvicorn's own SIGTERM handler, saved when ours is installed: it starts uvicorn's graceful +# shutdown (stop accepting, finish open requests, then run the lifespan exit below). +uvicorn_sigterm = None +loop: Optional[asyncio.AbstractEventLoop] = None + + +def stop_server(signum, frame): + log.info("shutting down HTTP server (in_flight=%d)", in_flight) + uvicorn_sigterm(signum, frame) + + +def handle_sigterm(signum, frame): + """One SIGTERM drives the whole drain: readiness flips now, uvicorn's shutdown starts after the delay.""" global shutting_down + if shutting_down: + return # already draining shutting_down = True log.info("SIGTERM received — readiness flipped, draining in-flight requests") + log.info("waiting %.0fs for endpoints to update", DRAIN_DELAY_SECONDS) + # Keep serving during the delay, then hand over to uvicorn (stop accepting, finish in-flight). + loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS, stop_server, signum, frame) @asynccontextmanager async def lifespan(app: FastAPI): - global db_pool - signal.signal(signal.SIGTERM, handle_sigterm) + global db_pool, uvicorn_sigterm, loop + loop = asyncio.get_running_loop() + # uvicorn installs its handlers before the lifespan starts; keep it and chain to it. + uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm) db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) log.info("order-service started (PID=%d)", os.getpid()) yield diff --git a/examples/21-graceful-shutdown/quarkus/README.md b/examples/21-graceful-shutdown/quarkus/README.md index 0d4dca99..4a79c760 100644 --- a/examples/21-graceful-shutdown/quarkus/README.md +++ b/examples/21-graceful-shutdown/quarkus/README.md @@ -24,7 +24,7 @@ cd .. && ./verify.sh | Path | Description | |------|-------------| -| `order-service/SignalHandler.java` | Registers a `sun.misc.Signal` handler on `StartupEvent` that replaces the JVM default SIGTERM handler — the process stays alive after SIGTERM | +| `order-service/SignalHandler.java` | Registers a `sun.misc.Signal` handler on `StartupEvent` that replaces the JVM default SIGTERM handler, flips readiness, waits `DRAIN_DELAY_SECONDS` (default 5), then calls `Quarkus.asyncExit(0)` | | `order-service/ShutdownState.java` | CDI `@ApplicationScoped` bean tracking `shuttingDown` flag and `inFlight` counter via atomics | | `order-service/OrderResource.java` | REST API — `/healthz`, `/readyz` (flips to 503 on SIGTERM), `/orders`, `/debug/state` | @@ -33,14 +33,14 @@ cd .. && ./verify.sh 1. `docker kill --signal SIGTERM cndp-order-service` fires 2. `SignalHandler` catches SIGTERM, calls `shutdownState.markShuttingDown()` 3. `/readyz` immediately returns 503 — the load balancer (or Kubernetes kubelet) stops routing new traffic -4. In-flight requests finish normally (tracked by the `inFlight` counter) -5. The process remains alive until `docker stop` sends SIGKILL after `stop_grace_period` (30 s) +4. The service keeps serving for `DRAIN_DELAY_SECONDS` (default 5) so endpoints update +5. The handler calls `Quarkus.asyncExit(0)`: Quarkus stops accepting connections, waits for in-flight requests (`quarkus.shutdown.timeout=20s`), closes the pool and exits 0, inside the 30 s `stop_grace_period` ## Environment variables Key variables (set by compose.yaml, override for local dev): -`QUARKUS_DATASOURCE_JDBC_URL, QUARKUS_DATASOURCE_USERNAME, QUARKUS_DATASOURCE_PASSWORD` +`QUARKUS_DATASOURCE_JDBC_URL, QUARKUS_DATASOURCE_USERNAME, QUARKUS_DATASOURCE_PASSWORD, DRAIN_DELAY_SECONDS` ## Local development (without containers) diff --git a/examples/21-graceful-shutdown/quarkus/compose.yaml b/examples/21-graceful-shutdown/quarkus/compose.yaml index c307b281..2c81b355 100644 --- a/examples/21-graceful-shutdown/quarkus/compose.yaml +++ b/examples/21-graceful-shutdown/quarkus/compose.yaml @@ -21,6 +21,7 @@ services: - QUARKUS_DATASOURCE_JDBC_URL=jdbc:postgresql://postgres:5432/appdb - QUARKUS_DATASOURCE_USERNAME=appuser - QUARKUS_DATASOURCE_PASSWORD=apppass + - DRAIN_DELAY_SECONDS=5 stop_grace_period: 30s healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/healthz"] diff --git a/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/ShutdownState.java b/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/ShutdownState.java index 093c40f2..1db400f8 100644 --- a/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/ShutdownState.java +++ b/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/ShutdownState.java @@ -11,8 +11,9 @@ public class ShutdownState { private final AtomicBoolean shuttingDown = new AtomicBoolean(false); private final AtomicInteger inFlight = new AtomicInteger(0); - public void markShuttingDown() { - shuttingDown.set(true); + /** Returns true only for the call that flips the flag. */ + public boolean markShuttingDown() { + return shuttingDown.compareAndSet(false, true); } public boolean isShuttingDown() { diff --git a/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/SignalHandler.java b/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/SignalHandler.java index 5e9866a2..84e1de8f 100644 --- a/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/SignalHandler.java +++ b/examples/21-graceful-shutdown/quarkus/order-service/src/main/java/com/cndp/order/SignalHandler.java @@ -1,6 +1,7 @@ package com.cndp.order; import io.quarkus.logging.Log; +import io.quarkus.runtime.Quarkus; import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; @@ -13,10 +14,35 @@ public class SignalHandler { @Inject ShutdownState shutdownState; + private static long drainDelaySeconds() { + try { + return Math.max(0, Long.parseLong(System.getenv().getOrDefault("DRAIN_DELAY_SECONDS", "5"))); + } catch (NumberFormatException e) { + return 5; + } + } + void onStart(@Observes StartupEvent ev) { + // One SIGTERM drives the whole drain: flip readiness now, keep serving for the pre-stop + // delay so endpoints update, then ask Quarkus to exit. Its shutdown stops accepting + // connections, waits for in-flight requests (quarkus.shutdown.timeout) and closes the pool. Signal.handle(new Signal("TERM"), signal -> { - shutdownState.markShuttingDown(); + if (!shutdownState.markShuttingDown()) { + return; // already draining + } Log.info("SIGTERM received — readiness flipped to 503, draining..."); + long delay = drainDelaySeconds(); + Log.infof("waiting %ds for endpoints to update", delay); + Thread drain = new Thread(() -> { + try { + Thread.sleep(delay * 1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + Log.infof("shutting down HTTP server (in_flight=%d)", shutdownState.getInFlight()); + Quarkus.asyncExit(0); + }, "drain"); + drain.start(); }); Log.infof("order-service started (PID=%d)", ProcessHandle.current().pid()); } diff --git a/examples/21-graceful-shutdown/quarkus/order-service/src/main/resources/application.properties b/examples/21-graceful-shutdown/quarkus/order-service/src/main/resources/application.properties index bc2fa436..cd884a96 100644 --- a/examples/21-graceful-shutdown/quarkus/order-service/src/main/resources/application.properties +++ b/examples/21-graceful-shutdown/quarkus/order-service/src/main/resources/application.properties @@ -1,6 +1,6 @@ quarkus.application.name=order-service quarkus.http.port=8080 -quarkus.shutdown.timeout=25s +quarkus.shutdown.timeout=20s quarkus.datasource.db-kind=postgresql quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/appdb diff --git a/examples/21-graceful-shutdown/spring-boot/README.md b/examples/21-graceful-shutdown/spring-boot/README.md index 9e757257..a1c0e2a1 100644 --- a/examples/21-graceful-shutdown/spring-boot/README.md +++ b/examples/21-graceful-shutdown/spring-boot/README.md @@ -28,13 +28,13 @@ cd .. && bash verify.sh ## Implementation notes -Spring Boot's built-in graceful shutdown handles SIGTERM: stops accepting new connections, drains in-flight requests up to the configured timeout, then closes the ApplicationContext. +A `sun.misc.Signal` handler registered before Spring starts takes the single SIGTERM: it flips readiness, waits `DRAIN_DELAY_SECONDS` (default 5) while endpoints update, then calls `System.exit(0)`. Spring's shutdown hook and built-in graceful shutdown stop accepting new connections, drain in-flight requests up to `spring.lifecycle.timeout-per-shutdown-phase` (20 s) and close the ApplicationContext. The process exits 0. ## Environment variables Key variables (set by compose.yaml, override for local dev): -`spring.datasource.url, server.shutdown=graceful` +`spring.datasource.url, server.shutdown=graceful, DRAIN_DELAY_SECONDS` ## Local development (without containers) diff --git a/examples/21-graceful-shutdown/spring-boot/compose.yaml b/examples/21-graceful-shutdown/spring-boot/compose.yaml index 725f8899..26c1165b 100644 --- a/examples/21-graceful-shutdown/spring-boot/compose.yaml +++ b/examples/21-graceful-shutdown/spring-boot/compose.yaml @@ -21,6 +21,7 @@ services: - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/appdb - SPRING_DATASOURCE_USERNAME=appuser - SPRING_DATASOURCE_PASSWORD=apppass + - DRAIN_DELAY_SECONDS=5 stop_grace_period: 30s healthcheck: test: ["CMD", "curl", "-sf", "http://localhost:8080/healthz"] diff --git a/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/OrderServiceApplication.java b/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/OrderServiceApplication.java index df2ec553..568a355f 100644 --- a/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/OrderServiceApplication.java +++ b/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/OrderServiceApplication.java @@ -11,15 +11,37 @@ public class OrderServiceApplication { private static final Logger log = LoggerFactory.getLogger(OrderServiceApplication.class); + private static long drainDelaySeconds() { + try { + return Math.max(0, Long.parseLong(System.getenv().getOrDefault("DRAIN_DELAY_SECONDS", "5"))); + } catch (NumberFormatException e) { + return 5; + } + } + public static void main(String[] args) { - // Register SIGTERM handler BEFORE Spring starts. - // This replaces the JVM default handler (which would trigger shutdown hooks), - // so Spring does NOT start shutting down the embedded server. - // The service keeps running but flips the shutting_down flag. + // Register SIGTERM handler BEFORE Spring starts. One SIGTERM drives the whole drain: + // flip readiness now, keep serving for the pre-stop delay so endpoints update, then + // System.exit(0) runs Spring's shutdown hook: stop accepting connections, finish in-flight + // requests (spring.lifecycle.timeout-per-shutdown-phase), close the pool. Signal.handle(new Signal("TERM"), signal -> { ShutdownState state = ShutdownState.getInstance(); - state.markShuttingDown(); + if (!state.markShuttingDown()) { + return; // already draining + } log.info("SIGTERM received — readiness flipped, draining in-flight requests"); + long delay = drainDelaySeconds(); + log.info("waiting {}s for endpoints to update", delay); + Thread drain = new Thread(() -> { + try { + Thread.sleep(delay * 1000); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + log.info("shutting down HTTP server (in_flight={})", state.getInFlight()); + System.exit(0); + }, "drain"); + drain.start(); }); SpringApplication.run(OrderServiceApplication.class, args); diff --git a/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/ShutdownState.java b/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/ShutdownState.java index 360aa425..ff65a8de 100644 --- a/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/ShutdownState.java +++ b/examples/21-graceful-shutdown/spring-boot/order-service/src/main/java/com/example/orderservice/ShutdownState.java @@ -21,8 +21,9 @@ public static ShutdownState getInstance() { return INSTANCE; } - public void markShuttingDown() { - shuttingDown.set(true); + /** Returns true only for the call that flips the flag. */ + public boolean markShuttingDown() { + return shuttingDown.compareAndSet(false, true); } public boolean isShuttingDown() { diff --git a/examples/21-graceful-shutdown/spring-boot/order-service/src/main/resources/application.properties b/examples/21-graceful-shutdown/spring-boot/order-service/src/main/resources/application.properties index c36992bd..803c39d3 100644 --- a/examples/21-graceful-shutdown/spring-boot/order-service/src/main/resources/application.properties +++ b/examples/21-graceful-shutdown/spring-boot/order-service/src/main/resources/application.properties @@ -1,6 +1,6 @@ server.port=8080 server.shutdown=graceful -spring.lifecycle.timeout-per-shutdown-phase=25s +spring.lifecycle.timeout-per-shutdown-phase=20s spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/appdb} spring.datasource.username=${SPRING_DATASOURCE_USERNAME:appuser} diff --git a/examples/21-graceful-shutdown/verify.sh b/examples/21-graceful-shutdown/verify.sh index 34b4b618..d2acd7df 100755 --- a/examples/21-graceful-shutdown/verify.sh +++ b/examples/21-graceful-shutdown/verify.sh @@ -23,6 +23,9 @@ ctr_kill() { docker kill --signal "${2:-KILL}" "$1"; } # ctr_kill /dev/null 2>&1 sleep 2 @@ -84,16 +90,44 @@ check "readyz body says shutting down" \ "curl $CURL_OPTS -sf $BASE/readyz 2>/dev/null || curl $CURL_OPTS -s $BASE/readyz" \ '"shutting down"' -# --- Verify logs show the drain protocol (capture before restart) --- +check_status "healthz still returns 200 while draining" \ + "curl $CURL_OPTS -s -o /dev/null -w '%{http_code}' $BASE/healthz" \ + "200" + +# --- The container exits by itself, with code 0, inside the grace period --- +wait_exit() { + for _ in $(seq 1 60); do + [ "$(ctr_inspect -f '{{.State.Running}}' cndp-order-service 2>/dev/null)" = false ] && return 0 + sleep 1 + done + return 1 +} +wait_exit || true +elapsed=$(( $(date +%s) - t0 )) + +check "container exited after one SIGTERM (no second signal)" \ + "ctr_inspect -f '{{.State.Running}}' cndp-order-service" \ + '^false$' + +check "exit code is 0" \ + "ctr_inspect -f '{{.State.ExitCode}}' cndp-order-service" \ + '^0$' + +check_status "drain finished within the 30s grace period (${elapsed}s)" \ + "[ $elapsed -lt $GRACE ] && echo ok" \ + "ok" + +# --- Verify logs show the drain protocol (the container is stopped, logs remain) --- check "logs show SIGTERM received" \ "ctr_logs cndp-order-service 2>&1" \ "SIGTERM received" +check "logs show the HTTP server stopping" \ + "ctr_logs cndp-order-service 2>&1" \ + "shutting down HTTP server" + # --- Restart and verify recovery --- printf ' \xe2\x86\x92 restarting service and verifying recovery...\n' -# Start the same container again (it exited after draining), whichever -# language is running, and wait for it to report ready. -ctr_stop cndp-order-service >/dev/null 2>&1 || true ctr_start cndp-order-service >/dev/null 2>&1 || true for _ in $(seq 1 30); do [ "$(curl $CURL_OPTS -s -o /dev/null -w '%{http_code}' "$BASE/readyz" 2>/dev/null)" = 200 ] && break @@ -113,5 +147,19 @@ check "orders from before restart are still present" \ "curl $CURL_OPTS -sf $BASE/orders" \ 'widget-before-restart' +# --- docker stop sends one SIGTERM, then SIGKILL at the timeout: exit 0 before the timeout --- +printf ' \xe2\x86\x92 docker stop (one SIGTERM, SIGKILL after %ss)...\n' "$GRACE" +t0=$(date +%s) +ctr_stop -t "$GRACE" cndp-order-service >/dev/null 2>&1 || true +elapsed=$(( $(date +%s) - t0 )) + +check "docker stop: exit code is 0 (not 137 from SIGKILL)" \ + "ctr_inspect -f '{{.State.ExitCode}}' cndp-order-service" \ + '^0$' + +check_status "docker stop: drained before the ${GRACE}s timeout (${elapsed}s)" \ + "[ $elapsed -lt $GRACE ] && echo ok" \ + "ok" + printf '\n==> Results: %d passed, %d failed\n' "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] && printf '==> PASS\n' || { printf '==> FAIL\n'; exit 1; } diff --git a/examples/25-caching/README.md b/examples/25-caching/README.md index 8436b7f0..f3ac8f53 100644 --- a/examples/25-caching/README.md +++ b/examples/25-caching/README.md @@ -23,7 +23,7 @@ credentials, and the container naming convention. | Read-through | `GET /read-through/products/{pid}` | Wrapper class hides the cache/DB decision from callers | | Write-through | `PUT /write-through/products/{pid}` | Writes DB first, then SETs cache (never stale for writer) | | Write-around | `POST /write-around/events`, `GET .../events/{eid}` | Writes skip cache; reads populate lazily | -| Write-back | `PUT /write-back/metrics/{mid}` | Writes land in cache; background flusher persists to DB | +| Write-back | `PUT /write-back/metrics/{mid}` | Answers 202 Accepted (queued, not yet durable); background flusher upserts to DB, re-queues failed entries with backoff and a bounded retry count | | Refresh-ahead | `GET /refresh-ahead/products/{pid}` | Background task pre-warms hot keys before TTL expires | ## Architecture @@ -57,10 +57,10 @@ curl -s -X PUT -H 'Content-Type: application/json' \ localhost:8080/write-through/products/p3 | jq . curl -s localhost:8080/write-through/products/p3 | jq . -# Write-back: write to cache, wait for flush -curl -s -X PUT -H 'Content-Type: application/json' \ +# Write-back: write to cache (202 Accepted), wait for flush +curl -s -w '%{http_code}\n' -X PUT -H 'Content-Type: application/json' \ -d '{"value":99.9,"tags":{"env":"test"}}' \ - localhost:8080/write-back/metrics/m1 | jq . + localhost:8080/write-back/metrics/m1 sleep 3 curl -s localhost:8080/write-back/flush-status | jq . diff --git a/examples/25-caching/cpp/cache-service/src/main.cpp b/examples/25-caching/cpp/cache-service/src/main.cpp index 1478a226..20e2a306 100644 --- a/examples/25-caching/cpp/cache-service/src/main.cpp +++ b/examples/25-caching/cpp/cache-service/src/main.cpp @@ -1,7 +1,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -15,7 +17,6 @@ using namespace sw::redis; // Global state std::unique_ptr g_redis; -std::atomic g_persisted_rows{0}; std::atomic g_shutdown{false}; std::mutex g_redis_mutex; @@ -210,12 +211,23 @@ std::vector redis_keys(const std::string& pattern) { } } -// Background flusher thread +// Background flusher thread. +// Write-back flush policy: an entry stays on the dirty set until Postgres has it, so a failed +// upsert is retried on the next cycle. Retries back off exponentially between cycles and are +// bounded per key: after kMaxFlushAttempts the key moves to writeback:failed (its value stays in +// the cache) and is logged, never dropped silently. void writeback_flusher() { + constexpr int kMaxFlushAttempts = 5; + constexpr int kMaxBackoffSeconds = 8; + std::map attempts; + int failures = 0; while (!g_shutdown) { - std::this_thread::sleep_for(std::chrono::seconds(2)); + const int wait = std::min(2 << failures, kMaxBackoffSeconds); + for (int i = 0; i < wait * 10 && !g_shutdown; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); auto dirty_keys = redis_smembers("writeback:dirty"); + bool any_failed = false; for (const auto& key : dirty_keys) { auto val = redis_get(key); @@ -235,13 +247,26 @@ void writeback_flusher() { auto res = g_flush_db->exec( "INSERT INTO metrics (id, payload) VALUES ($1, $2) " - "ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload", + "ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, ts = NOW()", {id, payload}); if (res && PQresultStatus(res.get()) == PGRES_COMMAND_OK) { - g_persisted_rows++; redis_srem("writeback:dirty", key); + attempts.erase(key); + continue; + } + + any_failed = true; + const int n = ++attempts[key]; + LOG_ERROR << "flusher: flush failed for " << id << " (attempt " << n << "/" + << kMaxFlushAttempts << ")"; + if (n >= kMaxFlushAttempts) { + redis_sadd("writeback:failed", key); + redis_srem("writeback:dirty", key); + attempts.erase(key); + LOG_ERROR << "flusher: giving up on " << id << ", parked on writeback:failed"; } } + failures = any_failed ? std::min(failures + 1, 2) : 0; } } @@ -544,13 +569,19 @@ int main() { cache_val["payload"] = *json; Json::FastWriter writer; - redis_set("metric:" + id, writer.write(cache_val)); + // A queued write must outlive a database outage, so no 60 s default TTL here. + redis_set("metric:" + id, writer.write(cache_val), std::chrono::hours(1)); // Mark as dirty for background flush redis_sadd("writeback:dirty", "metric:" + id); - auto resp = HttpResponse::newHttpResponse(); - resp->setStatusCode(k200OK); + // 202: queued in the cache, not yet durable in Postgres + Json::Value body; + body["ok"] = true; + body["pattern"] = "write-back"; + body["action"] = "cached-for-flush"; + auto resp = HttpResponse::newHttpJsonResponse(body); + resp->setStatusCode(k202Accepted); callback(resp); }, {Put}); @@ -588,7 +619,8 @@ int main() { app().registerHandler("/write-back/flush-status", [](const HttpRequestPtr&, std::function&& callback) { Json::Value resp; - resp["persisted_rows"] = g_persisted_rows.load(); + auto res = g_db->exec("SELECT count(*) FROM metrics", {}); + resp["persisted_rows"] = ok_rows(res) ? std::atoi(PQgetvalue(res.get(), 0, 0)) : -1; auto http_resp = HttpResponse::newHttpJsonResponse(resp); callback(http_resp); }, diff --git a/examples/25-caching/dotnet/CacheService/Program.cs b/examples/25-caching/dotnet/CacheService/Program.cs index e8f1fd91..93ebac3a 100644 --- a/examples/25-caching/dotnet/CacheService/Program.cs +++ b/examples/25-caching/dotnet/CacheService/Program.cs @@ -40,11 +40,38 @@ // --- Background tasks --- var cts = new CancellationTokenSource(); +// Write-back flush policy: a failed upsert is re-queued, with exponential backoff between flush +// cycles and a bounded number of attempts per id. After MaxFlushAttempts the id moves to +// metric:failed (its value stays in the cache) and is logged, never dropped silently. +const int MaxFlushAttempts = 5; +const int FlushPeriodMs = 1000; +const int MaxFlushBackoffMs = 8000; + +async Task Requeue(IDatabase db, IEnumerable failed) +{ + foreach (var mid in failed) + { + var n = await db.HashIncrementAsync("metric:attempts", mid, 1); + if (n >= MaxFlushAttempts) + { + await db.SetAddAsync("metric:failed", mid); + await db.HashDeleteAsync("metric:attempts", mid); + logger.LogError("flusher: giving up on {Id} after {Attempts} attempts, parked on metric:failed", (string?)mid, n); + } + else + { + await db.SetAddAsync("metric:dirty", mid); + logger.LogWarning("flusher: re-queued {Id} (attempt {Attempt}/{Max})", (string?)mid, n, MaxFlushAttempts); + } + } +} + _ = Task.Run(async () => { + var failures = 0; while (!cts.Token.IsCancellationRequested) { - await Task.Delay(1000, cts.Token).ConfigureAwait(false); + await Task.Delay(Math.Min(FlushPeriodMs << failures, MaxFlushBackoffMs), cts.Token).ConfigureAwait(false); try { var db = GetRedis(); @@ -53,23 +80,39 @@ var ids = await db.SetPopAsync("metric:dirty", 100); if (ids.Length == 0) continue; - await using var conn = await dataSource.OpenConnectionAsync(); + var failed = new List(); foreach (var mid in ids) { - var data = await db.HashGetAllAsync($"metric:{mid}"); - if (data.Length > 0) + try { - var payload = JsonSerializer.Serialize( - data.ToDictionary(e => (string)e.Name!, e => (string)e.Value!)); - await using var cmd = new NpgsqlCommand( - "INSERT INTO metrics (id, payload) VALUES ($1, $2::jsonb) " + - "ON CONFLICT (id) DO UPDATE SET payload = $2::jsonb, ts = NOW()", conn); - cmd.Parameters.AddWithValue((string)mid!); - cmd.Parameters.AddWithValue(payload); - await cmd.ExecuteNonQueryAsync(); + var data = await db.HashGetAllAsync($"metric:{mid}"); + if (data.Length > 0) + { + var payload = JsonSerializer.Serialize( + data.ToDictionary(e => (string)e.Name!, e => (string)e.Value!)); + await using var conn = await dataSource.OpenConnectionAsync(); + await using var cmd = new NpgsqlCommand( + "INSERT INTO metrics (id, payload) VALUES ($1, $2::jsonb) " + + "ON CONFLICT (id) DO UPDATE SET payload = $2::jsonb, ts = NOW()", conn); + cmd.Parameters.AddWithValue((string)mid!); + cmd.Parameters.AddWithValue(payload); + await cmd.ExecuteNonQueryAsync(); + } + await db.HashDeleteAsync("metric:attempts", mid); } + catch (Exception ex) + { + logger.LogError(ex, "flusher: flush failed for {Id}", (string?)mid); + failed.Add(mid); + } + } + logger.LogInformation("flusher: persisted {Count} metrics", ids.Length - failed.Count); + if (failed.Count == 0) { failures = 0; } + else + { + failures = Math.Min(failures + 1, 3); + await Requeue(db, failed); } - logger.LogInformation("flusher: persisted {Count} metrics", ids.Length); } catch (Exception ex) { @@ -323,7 +366,9 @@ await db.HashSetAsync($"metric:{mid}", [ new HashEntry("tags", tags) ]); await db.SetAddAsync("metric:dirty", mid); - return Results.Json(new { ok = true, pattern = "write-back", action = "cached-for-flush" }); + // 202: queued in the cache, not yet durable in Postgres + return Results.Json(new { ok = true, pattern = "write-back", action = "cached-for-flush" }, + statusCode: StatusCodes.Status202Accepted); }); app.MapGet("/write-back/metrics/{mid}", async (string mid, NpgsqlDataSource dbSource) => diff --git a/examples/25-caching/go/cache-service/cmd/server/main.go b/examples/25-caching/go/cache-service/cmd/server/main.go index 38d77c88..180b9789 100644 --- a/examples/25-caching/go/cache-service/cmd/server/main.go +++ b/examples/25-caching/go/cache-service/cmd/server/main.go @@ -272,7 +272,8 @@ func handleWriteBackPut(w http.ResponseWriter, r *http.Request) { safeSet(r.Context(), key, string(data), 0) rdb.SAdd(r.Context(), "metric:dirty", key) - writeJSON(w, 200, parsed) + // 202: queued in the cache, not yet durable in Postgres + writeJSON(w, 202, parsed) } func handleWriteBackGet(w http.ResponseWriter, r *http.Request) { @@ -342,33 +343,75 @@ func handleCacheKeys(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"keys": keys}) } +// Write-back flush policy: a failed upsert is re-queued, with exponential backoff between +// flush cycles and a bounded number of attempts per key. After maxFlushAttempts the key moves +// to metric:failed (its value stays in the cache) and is logged, never dropped silently. +const ( + flushBatch = 100 + flushPeriod = 1 * time.Second + maxFlushBackoff = 8 * time.Second + maxFlushAttempts = 5 +) + func flusher(ctx context.Context) { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() + failures := 0 for { + wait := flushPeriod + if failures > 0 { + wait = min(flushPeriod<= maxFlushAttempts { + rdb.SAdd(ctx, "metric:failed", key) + rdb.HDel(ctx, "metric:attempts", key) + slog.Error("flush gave up; key parked on metric:failed", "key", key, "attempts", n) + continue } + rdb.SAdd(ctx, "metric:dirty", key) + slog.Warn("flush failed; re-queued", "key", key, "attempt", n, "max", maxFlushAttempts) } } diff --git a/examples/25-caching/python/cache-service/main.py b/examples/25-caching/python/cache-service/main.py index c8c6e58f..50592f08 100644 --- a/examples/25-caching/python/cache-service/main.py +++ b/examples/25-caching/python/cache-service/main.py @@ -9,6 +9,7 @@ import asyncpg import redis.asyncio as aioredis from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse from pydantic import BaseModel log = logging.getLogger("cache-service") @@ -67,34 +68,62 @@ async def get(self, id: str) -> Optional[dict]: REFRESH_BEFORE_S = 10 +# Write-back flush policy: a failed upsert is re-queued, with exponential backoff between flush +# cycles and a bounded number of attempts per id. After MAX_FLUSH_ATTEMPTS the id moves to +# metric:failed (its value stays in the cache) and is logged, never dropped silently. +MAX_FLUSH_ATTEMPTS = 5 +MAX_FLUSH_BACKOFF = 8 + + +async def requeue(ids): + for mid in ids: + n = await cache.hincrby("metric:attempts", mid, 1) + if n >= MAX_FLUSH_ATTEMPTS: + await cache.sadd("metric:failed", mid) + await cache.hdel("metric:attempts", mid) + log.error("flusher: giving up on %s after %d attempts, parked on metric:failed", mid, n) + else: + await cache.sadd("metric:dirty", mid) + log.warning("flusher: re-queued %s (attempt %d/%d)", mid, n, MAX_FLUSH_ATTEMPTS) + + async def flusher(): - """Write-back flusher: drain dirty set to Postgres in batches.""" + """Write-back flusher: drain dirty set to Postgres in batches; re-queue failures.""" + failures = 0 while True: - await asyncio.sleep(FLUSH_PERIOD) + await asyncio.sleep(min(FLUSH_PERIOD * 2**failures, MAX_FLUSH_BACKOFF)) + ids = [] try: ids = await cache.spop("metric:dirty", FLUSH_BATCH) if not ids: continue - async with pool.acquire() as conn: - for mid_b in ids: - mid = mid_b if isinstance(mid_b, str) else mid_b.decode() - data = await cache.hgetall(f"metric:{mid}") - if data: - payload = { - (k if isinstance(k, str) else k.decode()): ( - v if isinstance(v, str) else v.decode() - ) - for k, v in data.items() - } - await conn.execute( - "INSERT INTO metrics (id, payload) VALUES ($1, $2) " - "ON CONFLICT (id) DO UPDATE SET payload = $2, ts = NOW()", - mid, - json.dumps(payload), + mids = [m if isinstance(m, str) else m.decode() for m in ids] + rows = [] + for mid in mids: + data = await cache.hgetall(f"metric:{mid}") + if data: + payload = { + (k if isinstance(k, str) else k.decode()): ( + v if isinstance(v, str) else v.decode() ) - log.info("flusher: persisted %d metrics", len(ids)) + for k, v in data.items() + } + rows.append((mid, json.dumps(payload))) + if rows: + async with pool.acquire() as conn: + await conn.executemany( + "INSERT INTO metrics (id, payload) VALUES ($1, $2) " + "ON CONFLICT (id) DO UPDATE SET payload = $2, ts = NOW()", + rows, + ) + await cache.hdel("metric:attempts", *[m for m, _ in rows]) + log.info("flusher: persisted %d metrics", len(rows)) + failures = 0 except Exception: - log.exception("flusher error") + log.exception("flusher error: flush failed") + failures += 1 + if ids: + await requeue([m if isinstance(m, str) else m.decode() for m in ids]) async def refresher(): @@ -308,7 +337,11 @@ async def write_back_write(mid: str, body: MetricIn): await cache.sadd("metric:dirty", mid) except Exception: raise HTTPException(503, "cache unavailable") - return {"ok": True, "pattern": "write-back", "action": "cached-for-flush"} + # 202: queued in the cache, not yet durable in Postgres + return JSONResponse( + status_code=202, + content={"ok": True, "pattern": "write-back", "action": "cached-for-flush"}, + ) @app.get("/write-back/metrics/{mid}") diff --git a/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/BackgroundTasks.java b/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/BackgroundTasks.java index 4cc72590..48f77469 100644 --- a/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/BackgroundTasks.java +++ b/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/BackgroundTasks.java @@ -1,6 +1,8 @@ package com.cndp.cache; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -18,6 +20,13 @@ public class BackgroundTasks { private static final int FLUSH_BATCH = 100; + private static final int MAX_FLUSH_ATTEMPTS = 5; + private static final long MAX_FLUSH_BACKOFF_MS = 8000; + private static final long FLUSH_PERIOD_MS = 1000; + + // Touched only by the flusher (concurrent runs are skipped). + private volatile int failures; + private volatile long nextFlushAt; @Inject SafeRedis cache; @@ -34,35 +43,74 @@ public class BackgroundTasks { @ConfigProperty(name = "cache-service.refresh-before-seconds", defaultValue = "10") long refreshBefore; - @Scheduled(every = "${cache-service.flush-period:1s}") + // Write-back flush policy: a failed upsert is re-queued, with exponential backoff between + // flush cycles and a bounded number of attempts per id. After MAX_FLUSH_ATTEMPTS the id + // moves to metric:failed (its value stays in the cache) and is logged, never dropped silently. + @Scheduled(every = "${cache-service.flush-period:1s}", concurrentExecution = Scheduled.ConcurrentExecution.SKIP) void flusher() { + if (System.currentTimeMillis() < nextFlushAt) { + return; // backing off after a failed flush + } try { Set ids = cache.spop("metric:dirty", FLUSH_BATCH); if (ids == null || ids.isEmpty()) { return; } + List failed = new ArrayList<>(); for (String mid : ids) { - Map data = cache.hgetall("metric:" + mid); - if (!data.isEmpty()) { - String payloadJson = toJson(data); - try (var conn = dataSource.getConnection(); - var ps = conn.prepareStatement( - "INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " - + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()")) { - ps.setString(1, mid); - ps.setString(2, payloadJson); - ps.setString(3, payloadJson); - ps.executeUpdate(); + try { + Map data = cache.hgetall("metric:" + mid); + if (!data.isEmpty()) { + String payloadJson = toJson(data); + try (var conn = dataSource.getConnection(); + var ps = conn.prepareStatement( + "INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " + + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()")) { + ps.setString(1, mid); + ps.setString(2, payloadJson); + ps.setString(3, payloadJson); + ps.executeUpdate(); + } } + cache.hdel("metric:attempts", mid); + } catch (Exception e) { + Log.errorf("flusher: flush failed for %s: %s", mid, e.getMessage()); + failed.add(mid); } } - Log.infof("flusher: persisted %d metrics", ids.size()); + Log.infof("flusher: persisted %d metrics", ids.size() - failed.size()); + if (failed.isEmpty()) { + failures = 0; + } else { + failures++; + nextFlushAt = System.currentTimeMillis() + + Math.min(FLUSH_PERIOD_MS << failures, MAX_FLUSH_BACKOFF_MS); + requeue(failed); + } } catch (Exception e) { Log.errorf("flusher error: %s", e.getMessage()); } } + private void requeue(List ids) { + for (String mid : ids) { + long n = cache.hincrby("metric:attempts", mid, 1); + try { + if (n >= MAX_FLUSH_ATTEMPTS) { + cache.sadd("metric:failed", mid); + cache.hdel("metric:attempts", mid); + Log.errorf("flusher: giving up on %s after %d attempts, parked on metric:failed", mid, n); + } else { + cache.sadd("metric:dirty", mid); + Log.warnf("flusher: re-queued %s (attempt %d/%d)", mid, n, MAX_FLUSH_ATTEMPTS); + } + } catch (Exception e) { + Log.errorf("flusher: could not re-queue %s: %s", mid, e.getMessage()); + } + } + } + @Scheduled(every = "${cache-service.refresh-period:5s}") void refresher() { try { diff --git a/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/CacheResource.java b/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/CacheResource.java index 31c96e5d..cfa07f58 100644 --- a/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/CacheResource.java +++ b/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/CacheResource.java @@ -236,7 +236,8 @@ public Response writeBackWrite(@PathParam("mid") String mid, Map return Response.status(503) .entity(Map.of("error", "cache unavailable")).build(); } - return Response.ok(Map.of( + // 202: queued in the cache, not yet durable in Postgres + return Response.accepted(Map.of( "ok", true, "pattern", "write-back", "action", "cached-for-flush")).build(); diff --git a/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/SafeRedis.java b/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/SafeRedis.java index 643ddda8..ac754566 100644 --- a/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/SafeRedis.java +++ b/examples/25-caching/quarkus/cache-service/src/main/java/com/cndp/cache/SafeRedis.java @@ -81,6 +81,23 @@ public Map hgetall(String key) { } } + public long hincrby(String key, String field, long delta) { + try { + return hashes.hincrby(key, field, delta); + } catch (Exception e) { + Log.warnf("Redis HINCRBY failed for key=%s: %s", key, e.getMessage()); + return 1L; + } + } + + public void hdel(String key, String field) { + try { + hashes.hdel(key, field); + } catch (Exception e) { + Log.warnf("Redis HDEL failed for key=%s: %s", key, e.getMessage()); + } + } + public void sadd(String key, String member) { try { sets.sadd(key, member); diff --git a/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/BackgroundTasks.java b/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/BackgroundTasks.java index e01f6b40..48bda3da 100644 --- a/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/BackgroundTasks.java +++ b/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/BackgroundTasks.java @@ -1,6 +1,8 @@ package com.example.cache; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -22,6 +24,12 @@ public class BackgroundTasks { private static final Logger log = LoggerFactory.getLogger(BackgroundTasks.class); private static final int FLUSH_BATCH = 100; + private static final int MAX_FLUSH_ATTEMPTS = 5; + private static final long MAX_FLUSH_BACKOFF_MS = 8000; + + // Touched only by the scheduler thread (fixed-delay runs never overlap). + private int failures; + private long nextFlushAt; private final SafeRedis cache; private final JdbcClient jdbc; @@ -30,6 +38,9 @@ public class BackgroundTasks { @Value("${cache-service.ttl-seconds:60}") private long ttl; + @Value("${cache-service.flush-period-ms:1000}") + private long flushPeriodMs; + @Value("${cache-service.refresh-before-seconds:10}") private long refreshBefore; @@ -40,35 +51,74 @@ public BackgroundTasks(SafeRedis cache, JdbcClient jdbc, JsonMapper mapper) { } // ----------------------------------------------------------------------- - // Write-back flusher: drain dirty set to Postgres in batches + // Write-back flusher: drain dirty set to Postgres in batches. + // A failed upsert is re-queued, with exponential backoff between flush cycles and a + // bounded number of attempts per id. After MAX_FLUSH_ATTEMPTS the id moves to + // metric:failed (its value stays in the cache) and is logged, never dropped silently. // ----------------------------------------------------------------------- @Scheduled(fixedDelayString = "${cache-service.flush-period-ms:1000}") public void flusher() { + if (System.currentTimeMillis() < nextFlushAt) { + return; // backing off after a failed flush + } try { Set ids = cache.spop("metric:dirty", FLUSH_BATCH); if (ids == null || ids.isEmpty()) { return; } + List failed = new ArrayList<>(); for (String mid : ids) { - Map data = cache.hgetall("metric:" + mid); - if (!data.isEmpty()) { - String payloadJson = toJson(data); - jdbc.sql("INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " - + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()") - .param(mid) - .param(payloadJson) - .param(payloadJson) - .update(); + try { + Map data = cache.hgetall("metric:" + mid); + if (!data.isEmpty()) { + String payloadJson = toJson(data); + jdbc.sql("INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " + + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()") + .param(mid) + .param(payloadJson) + .param(payloadJson) + .update(); + } + cache.hdel("metric:attempts", mid); + } catch (Exception e) { + log.error("flusher: flush failed for {}: {}", mid, e.getMessage()); + failed.add(mid); } } - log.info("flusher: persisted {} metrics", ids.size()); + log.info("flusher: persisted {} metrics", ids.size() - failed.size()); + if (failed.isEmpty()) { + failures = 0; + } else { + failures++; + nextFlushAt = System.currentTimeMillis() + + Math.min(flushPeriodMs << failures, MAX_FLUSH_BACKOFF_MS); + requeue(failed); + } } catch (Exception e) { log.error("flusher error", e); } } + private void requeue(List ids) { + for (String mid : ids) { + long n = cache.hincrby("metric:attempts", mid, 1); + try { + if (n >= MAX_FLUSH_ATTEMPTS) { + cache.sadd("metric:failed", mid); + cache.hdel("metric:attempts", mid); + log.error("flusher: giving up on {} after {} attempts, parked on metric:failed", mid, n); + } else { + cache.sadd("metric:dirty", mid); + log.warn("flusher: re-queued {} (attempt {}/{})", mid, n, MAX_FLUSH_ATTEMPTS); + } + } catch (Exception e) { + log.error("flusher: could not re-queue {}: {}", mid, e.getMessage()); + } + } + } + // ----------------------------------------------------------------------- // Refresh-ahead: re-warm hot keys before their TTL fires // ----------------------------------------------------------------------- diff --git a/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/CacheController.java b/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/CacheController.java index ad37b1f7..c61e5c85 100644 --- a/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/CacheController.java +++ b/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/CacheController.java @@ -234,7 +234,8 @@ public ResponseEntity> writeBackWrite( return ResponseEntity.status(503) .body(Map.of("error", "cache unavailable")); } - return ResponseEntity.ok(Map.of( + // 202: queued in the cache, not yet durable in Postgres + return ResponseEntity.accepted().body(Map.of( "ok", true, "pattern", "write-back", "action", "cached-for-flush")); diff --git a/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/SafeRedis.java b/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/SafeRedis.java index b628d59e..60184b8c 100644 --- a/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/SafeRedis.java +++ b/examples/25-caching/spring-boot/cache-service/src/main/java/com/example/cache/SafeRedis.java @@ -84,6 +84,24 @@ public Map hgetall(String key) { } } + public long hincrby(String key, String field, long delta) { + try { + Long v = redis.opsForHash().increment(key, field, delta); + return v == null ? 1L : v; + } catch (Exception e) { + log.warn("Redis HINCRBY failed for key={}: {}", key, e.getMessage()); + return 1L; + } + } + + public void hdel(String key, String field) { + try { + redis.opsForHash().delete(key, field); + } catch (Exception e) { + log.warn("Redis HDEL failed for key={}: {}", key, e.getMessage()); + } + } + // -- Set ops ------------------------------------------------------------- public void sadd(String key, String member) { diff --git a/examples/25-caching/spring-boot/cache-service/src/main/resources/application.yaml b/examples/25-caching/spring-boot/cache-service/src/main/resources/application.yaml index 99e4cb22..9e21f254 100644 --- a/examples/25-caching/spring-boot/cache-service/src/main/resources/application.yaml +++ b/examples/25-caching/spring-boot/cache-service/src/main/resources/application.yaml @@ -3,6 +3,8 @@ spring: url: jdbc:postgresql://localhost:5432/appdb username: appuser password: apppass + hikari: + connection-timeout: 2000 # fail fast so a flush against a down database is re-queued data: redis: host: localhost diff --git a/examples/25-caching/verify.sh b/examples/25-caching/verify.sh index c22c684c..4d45c1a2 100755 --- a/examples/25-caching/verify.sh +++ b/examples/25-caching/verify.sh @@ -146,10 +146,10 @@ check "event payload is correct" \ # =================================================================== printf '\n--- 5. Write-back ---\n' -check_status "write-back PUT returns 200" \ +check_status "write-back PUT returns 202 (queued, not yet durable)" \ "curl $CURL_OPTS -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' \ -d '{\"value\":42.5,\"tags\":{\"host\":\"node-1\"}}' $BASE/write-back/metrics/m1" \ - "200" + "202" check "metric readable from cache immediately" \ "curl $CURL_OPTS -sf $BASE/write-back/metrics/m1" \ @@ -162,6 +162,32 @@ check "metric flushed to DB" \ "curl $CURL_OPTS -sf $BASE/write-back/flush-status" \ '"persisted_rows":1' +# A failed flush is re-queued and retried, not dropped: write while Postgres is down, +# bring it back, and the entry reaches the table. +printf ' \xe2\x86\x92 stopping Postgres to test the flush retry...\n' +ctr_stop cndp-postgres >/dev/null 2>&1 + +check_status "write-back PUT returns 202 while the DB is down" \ + "curl $CURL_OPTS -s -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' \ + -d '{\"value\":7.5,\"tags\":{\"host\":\"node-2\"}}' $BASE/write-back/metrics/m2" \ + "202" + +sleep 5 +printf ' \xe2\x86\x92 restarting Postgres...\n' +ctr_start cndp-postgres >/dev/null 2>&1 +for _ in $(seq 1 45); do + curl $CURL_OPTS -s $BASE/write-back/flush-status 2>/dev/null | grep -q '"persisted_rows":2' && break + sleep 2 +done + +check "failed flush is retried and persisted after the DB recovers" \ + "curl $CURL_OPTS -sf $BASE/write-back/flush-status" \ + '"persisted_rows":2' + +check "logs show the failed flush" \ + "ctr_logs cndp-cache-service 2>&1" \ + 'flush failed\|flusher error' + # =================================================================== # 6. Refresh-ahead # =================================================================== diff --git a/lgtm-presentation/BUILD.md b/lgtm-presentation/BUILD.md index fdf3e345..e1752198 100644 --- a/lgtm-presentation/BUILD.md +++ b/lgtm-presentation/BUILD.md @@ -1,67 +1,112 @@ # Designing Cloud-Native APIs — deck build record Red Hat-branded 16:9 `.pptx` decks in the house style (Overpass / Red Hat Text / -Red Hat Mono, `#EE0000` accents, logo on every slide, thick speaker notes). One -deck per language; the cover, agenda, overview, section dividers, and every +Red Hat Mono, `#EE0000` accents, logo on every slide, thick speaker notes). There is +one deck per language; the cover, agenda, overview, section dividers and every diagram are language-agnostic and shared, so the decks differ only in their code -slides, language chips, code-specific captions, and per-language speaker notes. - -| Deck | Language / stack | Slides | Rev | Build method | -|------|------------------|-------:|-----|--------------| -| `Designing-Cloud-Native-APIs-Python.pptx` | Python · FastAPI | 272 | r32 | pptxgenjs builder + OPC merge (App. N/O) — **canonical source** | -| `Designing-Cloud-Native-APIs-Spring.pptx` | Spring Boot | 276 | r32 | pptxgenjs builder + merge | -| `Designing-Cloud-Native-APIs-Quarkus.pptx` | Quarkus | 272 | r32 | pptxgenjs builder + merge | -| `Designing-Cloud-Native-APIs-DotNet.pptx` | .NET | 286 | r32 | pptxgenjs builder + merge (extra Appendix J) | -| `Designing-Cloud-Native-APIs-Cpp.pptx` | C++20/23 | 272 | r32 | pptxgenjs builder + merge | -| `Designing-Cloud-Native-APIs-Go.pptx` | **Go 1.26** | **272** | **r01.0** | **in-place retarget of the Python deck** | - -Versioned deliverables ship as `-rNN.x.pptx` to `/mnt/user-data/outputs/`; -these repo copies are the current bare-named builds. Python is r32; the four -sibling language decks track it; **Go is the newest deck and carries its own -counter starting at r01.0.** - -## Go deck (r01.0) — how it was built - -The Go deck was produced by copying the canonical Python deck and editing only -language-specific surfaces, leaving all shared slides byte-identical. Approach -chosen because Go's gap profile and appendix sequence (`A B C D E F G H I K L M N O`) -match C++ exactly, so no structural divergence was needed. - -- **43 code slides** retargeted to idiomatic Go 1.26 (error-returning handlers and - explicit `if err != nil`, not brevity-eliding). Each slide is a teaching - fragment of **≤ 22 lines** so it renders inside the fixed code box at 10.5–12.5pt. -- **17 language-agnostic code slides** (Istio / KEDA / OPA YAML, proto, flagd / - Postman JSON, HAProxy / nginx, GitHub Actions, newman) were left untouched. -- Language chips, code-specific captions, the acronym glossary (runtime row: - `no WSGI/ASGI split`, `no GIL`, `go.mod`), the L7-routing appendix, and the - speaker notes were all retargeted; no Python framework names remain. - -### Locked Go stack (Robert-approved) - -Go 1.26, idiomatic "Effective/Idiomatic Go"; **net/http** stdlib only (1.22 -`ServeMux`, no chi/gin); **franz-go** (Kafka); **pgx v5** (Postgres); **go-redis v9**; -**gqlgen** (GraphQL, schema-first); **golang-jwt v5**; **OTel Go SDK + log/slog** -(otelhttp/otelslog, no auto-agent); **sony/gobreaker** + **cenkalti/backoff**; -**gopher-lua** (rule routing, parallels C++ sol2); **goka** (stream processing, -the Faust/Streams analogue — windowing is manual); **minio-go** (S3-compatible -object store, chosen over an AWS SDK for the no-managed-cloud constraint); -**coder/websocket**; **OpenFeature Go SDK + flagd**; `signal.NotifyContext` + -`atomic.Bool` for graceful shutdown; generics where they earn it (e.g. -`ReadThrough[T]`). - -### Honest ecosystem framing (where Go has gaps) - -- No Faust/Kafka-Streams-native windowing → goka with manual time-bucketing. -- No durable-rules engine → gopher-lua embedding a Lua ruleset (the sol2 parallel). -- No `-javaagent`-style OTel auto-attach → explicit `otelhttp` / `otelpgx` wrapping. - -## QA (r01.0) - -Full visual pass over all 272 slides + structural checks, all green: - -- 0 code slides over 22 lines; no code-box clipping (every code slide rendered - and inspected). -- 0 Python framework/library names in slide-visible text; 0 `python ·` chips. -- Edited prose / glossary slides checked for overflow — clean. -- Speaker notes present on 272/272 slides; the Red Hat logo on every slide. -- Comment coloring verified for `//` (Go/proto), `--` (Lua), and `#` (bash/graphql). +slides, language chips, code-specific captions and per-language speaker notes. Two +policy decks and their Markdown and Word companions sit beside them. + +## The eight decks + +| Deck | Language / stack | Slides | +|------|------------------|-------:| +| `Designing-Cloud-Native-APIs-Python.pptx` | Python 3.14 · FastAPI, aiokafka, faust-streaming | 281 | +| `Designing-Cloud-Native-APIs-Spring.pptx` | Spring Boot 4.1.1 · Java 25 | 285 | +| `Designing-Cloud-Native-APIs-Quarkus.pptx` | Quarkus 3.40.1 · Java 25 | 281 | +| `Designing-Cloud-Native-APIs-DotNet.pptx` | .NET 10.0.12 · C# 14 (extra Appendices J, P, Q) | 312 | +| `Designing-Cloud-Native-APIs-Go.pptx` | Go 1.27.2 | 281 | +| `Designing-Cloud-Native-APIs-Cpp.pptx` | C++23 · GCC 15 · rdkafka++ | 281 | +| `K8s-Environment-Access-Policies.pptx` | Kubernetes environment access policies | 18 | +| `Network-Policy-Process-Advisory.pptx` | Network policy process advisory | 20 | + +The slide counts are the number of `## [NNN]` sections in the matching file under +`text/`. The two policy decks are accompanied by `k8s-environment-policies.md` and +`network-policy-process-advisory.md` (the source of the `.docx` of the same name). + +Every language deck ends with Appendix R (Example 05 on minikube: a divider and eight +numbered slides) and its agenda lists it; the .NET deck lists Appendices P–R. There are no Appendix S slides: +OpenShift Local stays on the site, and nothing in the decks depends on it. + +## How the decks are maintained + +No generator source exists for these decks, so every change is an in-place edit of the +committed `.pptx` and `.docx` files with a small toolkit under `scripts/`: + +| Tool | Role | +|------|------| +| `scripts/deck_text.py` | pptx/docx to Markdown (standard library only); `--write` regenerates `text/`, `--check` fails on drift, a missing or extra copy, or a slide without notes | +| `scripts/deck_edit.py` | applies an edit file to a deck in place (python-pptx and lxml): `replace`, `set_notes`, `set_code`, `set_text`, `clone_slide`, `replace_picture` and layout operations; slides are addressed by `sldId`; `--dry-run` prints the text diff | +| `scripts/deck_render.sh` | soffice to PDF to PNG for selected slides; refuses to run without the brand fonts | +| `scripts/deck_fit.py` | checks a render for text outside its frame, over the page number or logo, off the slide, or on top of other text | +| `lgtm-presentation/edits/` | the committed edit files: `_shared.json` (replacements that match in all six language decks), `.json` (the rest of that deck's edits in apply order), `.fixN.json` (later repairs, each applied once) and `png/` with the replacement figures and their SVG sources in `png/src/` | + +Rules for an edit: + +- Edit files are applied once, to the committed deck. Every `replace` carries the exact + match count it expects, so a second run fails instead of changing anything. +- New slides are clones of existing slides of the same deck; their figures come from + `assets/diagrams/` or `png/src/`, rendered with cairosvg. +- `examples/` is the authority for code. A code slide is an excerpt of an example file + (at most 22 lines, 95 columns) and names the example in its first comment line. +- After an edit run `python3 scripts/deck_text.py --write`, then the CI checks (`validate_site.py`, `voice-scan.sh`, `deck_text.py --check`), and + render every changed slide with the brand fonts installed + (`sudo dnf install -y redhat-text-fonts redhat-display-fonts overpass-fonts`). + +Run the tools from the repository root. `deck_edit.py` and `deck_fit.py` need +python-pptx 1.0.2 (lxml comes with it); the figure renders use cairosvg 2.9.1; keep them +in a virtual environment, never global. `deck_text.py` runs with the system Python. +`DECK_BACKUP_DIR` keeps the pre-edit backup somewhere other than the default, and +`LO_PROFILE` selects the LibreOffice profile for rendering. + +## The text copies and the CI gate + +`text/.md` is a Markdown copy of every deck and document: one section per slide +(`## [NNN] EYEBROW · Title (sldId N)`), code boxes as fenced blocks tagged with the chip +language, speaker notes as a `>` block. The `content-ok` job runs +`python3 scripts/deck_text.py --check`, the voice scan (ban tier) over `lgtm-presentation` +and `scripts/validate_site.py`, which applies the positional and old-engine checks and +the stale-stack denylist (`scripts/deck-denylist.json`) to the text copies, so a +deck cannot drift from its text, regress to a replaced library, or reintroduce hype or +positional language unnoticed. + +## Pins + +The decks name the same versions as the site and `examples/`: + +- Languages and frameworks: Python 3.14 (FastAPI 0.143.0, aiokafka 0.14.0, faust-streaming + 0.15.3), Spring Boot 4.1.1 with Jackson 3, Quarkus 3.40.1, Java 25, .NET 10.0.12 with + Confluent.Kafka 2.16.0 and Streamiz 1.8.2, Go 1.27.2 (franz-go 1.22.1, pgx 5.11.0, + golang-jwt 5.3.1, goka 1.1.17), C++23 on GCC 15 with CMake 4.4.4 and librdkafka 2.14.2. +- Platform: Kafka 4.3.1, Postgres 18, Redis 8.10.2 (AGPLv3 option), Debezium 3.7.0.Final, + Apicurio Registry 3.3.3, flagd v0.17.0, grafana/otel-lgtm 0.36.0, Envoy v1.39.3. +- Kubernetes: minikube v1.39.0 with Kubernetes v1.36.5, Strimzi 1.2.0, Helm v4.3.0, + kubeconform v0.8.0. +- Tests and CI: newman 6.2.3, `actions/checkout@v7`, `actions/upload-artifact@v7`. +- Container runtime: Docker Engine with `docker compose`; images are UBI 10 with Java 25 + and Python 3.14 (UBI 9 for .NET, which has no UBI 10 image). + +The locked language stacks are listed in `CLAUDE.md`; the full pin table is in +`_plans/modernization-plan.md`. + +## Language stacks + +- **Go** uses `net/http` (1.22 `ServeMux`), franz-go, pgx v5, go-redis v9, gqlgen, + golang-jwt v5, the OTel Go SDK with `log/slog`, sony/gobreaker v2 and cenkalti/backoff, + gopher-lua, goka, coder/websocket and the OpenFeature Go SDK with flagd. Windowing in + goka is manual time-bucketing, and OTel is wired explicitly with `otelhttp` and `otelpgx`. +- **C++** uses C++23, Drogon, grpc++, cppgraphqlgen, the librdkafka C++ API (`rdkafka++`), + libpq, redis-plus-plus, jwt-cpp, boost::sml, sol2 with Lua, and OpenTelemetry C++ with spdlog. +- **Valet key and claim check** follow `examples/12-security/` in every deck: a + signed ticket the service mints and verifies itself (an HMAC-SHA256 token, an HS256 JWT in + Go and C++), with object storage described generically. No cloud SDK appears anywhere. + +## QA record + +- `deck_text.py --check` passes for all eight decks, and every slide has notes. +- The voice scan reports no ban-tier hits over `lgtm-presentation`; the watch-tier hits + that remain are recorded in `_plans/s12-manifest/watch-ledger.md` with a reason each. +- Every changed or added slide was rendered with the brand fonts and inspected; + `deck_fit.py` is the layout check for a render. +- Code slides stay within 22 lines and their box; comment colouring is checked for `//`, + `#`, `--` and `/* */` comments. diff --git a/lgtm-presentation/Designing-Cloud-Native-APIs-Cpp.pptx b/lgtm-presentation/Designing-Cloud-Native-APIs-Cpp.pptx index d900499c..fe349221 100644 Binary files a/lgtm-presentation/Designing-Cloud-Native-APIs-Cpp.pptx and b/lgtm-presentation/Designing-Cloud-Native-APIs-Cpp.pptx differ diff --git a/lgtm-presentation/Designing-Cloud-Native-APIs-DotNet.pptx b/lgtm-presentation/Designing-Cloud-Native-APIs-DotNet.pptx index 77805f9b..7e84920b 100644 Binary files a/lgtm-presentation/Designing-Cloud-Native-APIs-DotNet.pptx and b/lgtm-presentation/Designing-Cloud-Native-APIs-DotNet.pptx differ diff --git a/lgtm-presentation/Designing-Cloud-Native-APIs-Go.pptx b/lgtm-presentation/Designing-Cloud-Native-APIs-Go.pptx index 855b2ad0..fd61541b 100644 Binary files a/lgtm-presentation/Designing-Cloud-Native-APIs-Go.pptx and b/lgtm-presentation/Designing-Cloud-Native-APIs-Go.pptx differ diff --git a/lgtm-presentation/Designing-Cloud-Native-APIs-Python.pptx b/lgtm-presentation/Designing-Cloud-Native-APIs-Python.pptx index a67a71b8..0a0e4a9a 100644 Binary files a/lgtm-presentation/Designing-Cloud-Native-APIs-Python.pptx and b/lgtm-presentation/Designing-Cloud-Native-APIs-Python.pptx differ diff --git a/lgtm-presentation/Designing-Cloud-Native-APIs-Quarkus.pptx b/lgtm-presentation/Designing-Cloud-Native-APIs-Quarkus.pptx index 982fd2e6..4721bf8d 100644 Binary files a/lgtm-presentation/Designing-Cloud-Native-APIs-Quarkus.pptx and b/lgtm-presentation/Designing-Cloud-Native-APIs-Quarkus.pptx differ diff --git a/lgtm-presentation/Designing-Cloud-Native-APIs-Spring.pptx b/lgtm-presentation/Designing-Cloud-Native-APIs-Spring.pptx index a62895fb..ab2930ae 100644 Binary files a/lgtm-presentation/Designing-Cloud-Native-APIs-Spring.pptx and b/lgtm-presentation/Designing-Cloud-Native-APIs-Spring.pptx differ diff --git a/lgtm-presentation/K8s-Environment-Access-Policies.pptx b/lgtm-presentation/K8s-Environment-Access-Policies.pptx index c16416f1..95595667 100644 Binary files a/lgtm-presentation/K8s-Environment-Access-Policies.pptx and b/lgtm-presentation/K8s-Environment-Access-Policies.pptx differ diff --git a/lgtm-presentation/Network-Policy-Process-Advisory.pptx b/lgtm-presentation/Network-Policy-Process-Advisory.pptx index 33ddb9bb..4e5027df 100644 Binary files a/lgtm-presentation/Network-Policy-Process-Advisory.pptx and b/lgtm-presentation/Network-Policy-Process-Advisory.pptx differ diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix1.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix1.json new file mode 100644 index 00000000..be0d9d9d --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix1.json @@ -0,0 +1,12 @@ +{ + "ops": [ + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox-cpp.png", + "cat": "voice", + "_why": "figure footer: the absolute 'can never be lost' claim softened (src/04-dual-write-outbox.svg)" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix2.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix2.json new file mode 100644 index 00000000..ce15f757 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix2.json @@ -0,0 +1,472 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 535 + ], + "find": "Fallback: the recorded results that follow.", + "repl": "Fallback: the recorded results.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "(STOCK_UNAVAILABLE) — never a string", + "repl": "(STOCK_UNAVAILABLE), never a string", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "A safe human message — no SQL", + "repl": "A safe human message: no SQL", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "A trace_id — so server-side", + "repl": "A trace_id, so server-side", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "A retryable flag — does the client", + "repl": "A retryable flag: does the client", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "Field details — for validation", + "repl": "Field details: for validation", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "encode these differently — RFC 9457", + "repl": "encode these differently: RFC 9457", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "set_code", + "sldId": 269, + "shape_id": 155, + "cat": "code", + "_slide": 14, + "_why": "examples/01-cloud-native-principles/cpp/src/main.cpp: env() helper with defaults, PG_CONNINFO/PG_POOL_SIZE/SERVICE_VERSION, no Kafka; the old code built std::string from a possibly-null getenv, used Json::Value brace-init and generic lambdas Drogon cannot bind, and called undefined check_db/check_kafka", + "lines": [ + "// main.cpp (excerpt), examples/01-cloud-native-principles/cpp; conan.lock pins every library", + "using Callback = std::function;", + "const char* env(const char* name, const char* fallback) { // config from env, factor III", + " const char* v = std::getenv(name);", + " return v ? v : fallback; // unset: the default, never a null pointer", + "}", + "int main() {", + " g_version = env(\"SERVICE_VERSION\", \"1.0.0\");", + " g_pool = std::make_unique(", + " env(\"PG_CONNINFO\", \"postgresql://appuser:apppass@postgres:5432/appdb\"),", + " std::atoi(env(\"PG_POOL_SIZE\", \"4\")));", + " app().registerHandler(\"/healthz\", [](const HttpRequestPtr&, Callback&& cb) {", + " Json::Value j; j[\"status\"] = \"ok\"; // liveness: the process is up", + " cb(HttpResponse::newHttpJsonResponse(j));", + " }, {Get});", + " app().registerHandler(\"/readyz\", [](const HttpRequestPtr&, Callback&& cb) {", + " Json::Value j; // readiness: SELECT 1 through the libpq pool", + " j[\"status\"] = db_ready() ? \"ready\" : \"down\"; // (the example's retry loop, elided)", + " cb(HttpResponse::newHttpJsonResponse(j));", + " }, {Get});", + " app().addListener(\"0.0.0.0\", 8080).run(); // factor VII: port binding", + "}" + ], + "_src": "content" + }, + { + "op": "set_notes", + "sldId": 269, + "cat": "notes", + "_slide": 14, + "_why": "describe the example's code (the old notes described the Settings struct and constinit version)", + "paragraphs": [ + "A 12-factor Drogon service, from Example 01. Configuration comes from the environment (factor III): a small env() helper returns the variable or a default, so an unset variable never turns into a null pointer, and main() reads PG_CONNINFO, PG_POOL_SIZE and SERVICE_VERSION once at start-up.", + "/healthz answers while the process is up (liveness); /readyz runs SELECT 1 through the libpq pool and reports ready or down (readiness), so the probe that takes a Pod out of rotation is not the probe that restarts it. Handlers are lambdas with the request and callback types spelled out, which Drogon's handler binding needs.", + "Port binding (factor VII) is explicit in main(): the binary is self-contained, with no external web server in front of it. conan.lock pins every library, and the Containerfile builds with GCC 15 on UBI 10." + ], + "_src": "content" + }, + { + "op": "set_code", + "sldId": 291, + "shape_id": 407, + "cat": "code", + "_slide": 36, + "_why": "the while loop was never closed (22 lines, no closing brace); examples/05-event-driven/cpp/order-service sets acks=all and dr_cb but not enable.idempotence, so that line goes and the brace fits", + "lines": [ + "// Producer (excerpt): process-scoped, built once in main() (librdkafka C++ API, rdkafka++)", + "std::unique_ptr conf(RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL));", + "conf->set(\"bootstrap.servers\", cfg.kafka_bootstrap, err);", + "conf->set(\"acks\", \"all\", err); // wait for ISR ack: durable", + "conf->set(\"dr_cb\", &delivery_report, err); // DeliveryReportCb logs failures", + "std::unique_ptr producer(RdKafka::Producer::create(conf.get(), err));", + "void publish_order_placed(const Order& o) {", + " auto value = serialize(o);", + " producer->produce(\"order.placed\", RdKafka::Topic::PARTITION_UA,", + " RdKafka::Producer::RK_MSG_COPY, value.data(), value.size(),", + " o.id.data(), o.id.size(), 0, nullptr); // key = order id", + " producer->poll(0); // serve delivery reports", + "}", + "std::unique_ptr consumer(", + " RdKafka::KafkaConsumer::create(cconf.get(), err));", + "consumer->subscribe({\"order.placed\"}); // notification-group, enable.auto.commit=false", + "while (running) {", + " std::unique_ptr msg(consumer->consume(1000));", + " if (msg->err() != RdKafka::ERR_NO_ERROR) continue; // timeout / partition EOF", + " send_email(deserialize(payload_of(*msg))); // idempotent handler", + " consumer->commitSync(msg.get()); // after the side-effect", + "}" + ], + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "expect": 1, + "cat": "notes", + "_slide": 36, + "find": "(2) enable.idempotence=true with acks=all deduplicates producer retries, so a retry does not write the record twice.", + "repl": "(2) acks=all waits for every in-sync replica before the delivery report; add enable.idempotence=true when a producer retry must never write the record twice.", + "_why": "the code no longer sets enable.idempotence (the example does not)", + "_src": "content" + }, + { + "op": "set_text", + "sldId": 340, + "shape_id": 943, + "paragraphs": [ + "Per-tenant bulkheads with an atomic counter" + ], + "cat": "code", + "_slide": 85, + "_why": "examples/12-security/cpp uses a lock-free std::atomic counter per tenant, not std::counting_semaphore", + "_src": "content" + }, + { + "op": "set_text", + "sldId": 340, + "shape_id": 945, + "paragraphs": [ + "c++ · std::atomic" + ], + "cat": "code", + "_slide": 85, + "_why": "chip follows the code", + "_src": "content" + }, + { + "op": "set_code", + "sldId": 340, + "shape_id": 946, + "cat": "code", + "_slide": 85, + "_why": "examples/12-security/cpp/order-service/src/main.cpp and the _docs/12 C++ codetab: TenantBulkhead (CAPACITY 5, CAS loop), 429 'tenant capacity exceeded'; the old slide used absl::Cleanup and a non-existent drogon HttpException, a 500 ms wait and 503", + "lines": [ + "// main.cpp (excerpt), examples/12-security/cpp: one lock-free counter per tenant", + "struct TenantBulkhead {", + " static constexpr int CAPACITY = 5; // 5 concurrent per tenant", + " std::atomic active{0};", + " bool try_acquire() {", + " int current = active.load();", + " while (current < CAPACITY) {", + " if (active.compare_exchange_weak(current, current + 1)) return true;", + " }", + " return false; // full: shed, don't queue", + " }", + " void release() { active.fetch_sub(1); }", + "};", + "auto& bulkhead = bulkhead_manager.get_or_create(tenant); // POST /orders: per tenant", + "if (!bulkhead.try_acquire()) {", + " Json::Value error;", + " error[\"detail\"] = \"tenant capacity exceeded\";", + " auto resp = HttpResponse::newHttpJsonResponse(error);", + " resp->setStatusCode(k429TooManyRequests);", + " callback(resp);", + " return;", + "}" + ], + "_src": "content" + }, + { + "op": "set_text", + "sldId": 340, + "shape_id": 947, + "cat": "code", + "_slide": 85, + "_why": "caption follows the code", + "paragraphs": [ + "5 concurrent requests per tenant; the sixth gets 429 at once instead of waiting in a queue." + ], + "_src": "content" + }, + { + "op": "set_notes", + "sldId": 340, + "cat": "notes", + "_slide": 85, + "_why": "notes follow the example's bulkhead", + "paragraphs": [ + "The bulkhead from Example 12: one TenantBulkhead per tenant, created on first use in a map guarded by a mutex. Each holds a lock-free std::atomic with a capacity of five; try_acquire increments it with a compare-and-swap loop only while it is under capacity, so a full tenant is refused without blocking a Drogon worker.", + "A refused request gets 429 with \"tenant capacity exceeded\" at once: shed, don't queue, because a queue in front of a saturated tenant becomes the bottleneck. The example releases the slot after the order is stored; in production code an RAII guard makes the release exception-safe. One tenant's storm fills only its own five slots; other tenants keep their capacity.", + "Per-tenant connection pools at the libpq or gRPC layer are a separate bulkhead: the counter caps concurrency, the pool caps sockets." + ], + "_src": "content" + }, + { + "op": "set_code", + "sldId": 419, + "shape_id": 1809, + "cat": "code", + "_slide": 164, + "_why": "Drogon binds handlers through their parameter types, so a generic lambda (auto&& cb) does not compile; extern \"C\" as in examples/21-graceful-shutdown/cpp", + "lines": [ + "// order-service (excerpt): SIGTERM flips readiness; Drogon keeps serving", + "std::atomic g_shutting_down{false};", + "std::atomic g_in_flight{0};", + "struct InFlightGuard { // RAII: one per request", + " InFlightGuard() { ++g_in_flight; }", + " ~InFlightGuard() { --g_in_flight; }", + "};", + "extern \"C\" void sigterm_handler(int) {", + " g_shutting_down.store(true, std::memory_order_release); // fail readiness first", + "}", + "using Callback = std::function;", + "int main() {", + " app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); }); // after init", + " app().registerHandler(\"/readyz\", [](const HttpRequestPtr&, Callback&& cb) {", + " auto r = HttpResponse::newHttpResponse();", + " r->setStatusCode(g_shutting_down.load() ? k503ServiceUnavailable : k200OK);", + " cb(r);", + " });", + " app().addListener(\"0.0.0.0\", 8080).run(); // returns on quit / SIGINT", + " while (g_in_flight.load() > 0) // drain in-flight requests", + " std::this_thread::sleep_for(std::chrono::milliseconds(100));", + "} // RAII closes the libpq pool" + ], + "_src": "content" + }, + { + "op": "set_text", + "sldId": 476, + "shape_id": 2447, + "paragraphs": [ + "c++ · sw/redis++ + libpq" + ], + "cat": "code", + "_slide": 221, + "_why": "chip follows the example's code", + "_src": "content" + }, + { + "op": "resize_shape", + "sldId": 476, + "shape_id": 2447, + "cx": 2286000, + "keep": "right", + "cat": "fit", + "_slide": 221, + "_fit": "the longer chip wrapped; widened to the left onto one line", + "_src": "content" + }, + { + "op": "set_code", + "sldId": 476, + "shape_id": 2448, + "cat": "code", + "_slide": 221, + "_why": "examples/25-caching/cpp/cache-service/src/main.cpp: PUT /write-back/metrics/{id} sets metric: and SADDs writeback:dirty; a std::thread flusher every 2 s upserts and SREMs only on success (the old slide's hset/SPOP batch/jthread design is not in the example)", + "lines": [ + "// cache-service main.cpp (excerpt), examples/25-caching/cpp: write-back", + "using Callback = std::function;", + "app().registerHandler(\"/write-back/metrics/{id}\",", + " [](const HttpRequestPtr& req, Callback&& callback, const std::string& id) {", + " Json::Value cache_val; // (400 when the body is not JSON)", + " cache_val[\"id\"] = id;", + " cache_val[\"payload\"] = *req->getJsonObject();", + " redis_set(\"metric:\" + id, Json::FastWriter{}.write(cache_val)); // 1. to the cache", + " redis_sadd(\"writeback:dirty\", \"metric:\" + id); // 2. mark dirty", + " callback(HttpResponse::newHttpResponse()); // 3. 200 at cache speed", + " }, {Put});", + "void writeback_flusher() { // std::thread, joined at shutdown", + " while (!g_shutdown) {", + " std::this_thread::sleep_for(std::chrono::seconds(2));", + " for (const auto& key : redis_smembers(\"writeback:dirty\")) { // key -> id, cached payload", + " auto res = g_flush_db->exec(\"INSERT INTO metrics (id, payload) VALUES ($1, $2) \"", + " \"ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload\", {id, payload});", + " if (res && PQresultStatus(res.get()) == PGRES_COMMAND_OK)", + " redis_srem(\"writeback:dirty\", key); // clear only after the DB has it", + " }", + " }", + "}" + ], + "_src": "content" + }, + { + "op": "set_text", + "sldId": 476, + "shape_id": 2449, + "cat": "code", + "_slide": 221, + "_why": "caption follows the code", + "paragraphs": [ + "Write to Redis and mark dirty; a flusher upserts every 2 s and clears a mark only once the DB has the row." + ], + "_src": "content" + }, + { + "op": "set_notes", + "sldId": 476, + "cat": "notes", + "_slide": 221, + "_why": "notes follow the example's write-back", + "paragraphs": [ + "Write-back from Example 25: PUT /write-back/metrics/{id} stores the payload in Redis under metric:, adds the key to the writeback:dirty set and answers at cache speed. A background std::thread wakes every two seconds, reads the dirty set, upserts each metric into Postgres with INSERT ... ON CONFLICT DO UPDATE, and removes the key from the set only after the upsert succeeds, so a failed write is retried on the next cycle.", + "The upsert makes a repeated flush harmless. The flusher has its own libpq connection, separate from the request handlers', and main() joins it after the shutdown flag is set (Appendix H). The risk is the one every write-back carries: a write that was acknowledged but not yet flushed is lost if Redis loses it, so keep write-back for data that tolerates that window, such as metrics." + ], + "_src": "content" + }, + { + "op": "set_code", + "sldId": 310, + "lines": [ + "// publish_schema.cc — invoked from CI after the build, before the merge.", + "// Apicurio answers 400 when a version breaks the artifact's rule (e.g. BACKWARD).", + "#include ", + "#include ", + "#include ", + "int publish(const std::string& group, const std::string& artifact_id,", + " const std::string& schema) {", + " httplib::Client cli{\"http://apicurio.registry.svc\"};", + " cli.set_read_timeout(10);", + " auto path = \"/apis/registry/v3/groups/\" + group +", + " \"/artifacts/\" + artifact_id + \"/versions\";", + " nlohmann::json body = {{\"content\", {{\"content\", schema}, // v3 CreateVersion", + " {\"contentType\", \"application/json\"}}}};", + " auto res = cli.Post(path, body.dump(), \"application/json\");", + " if (!res) return 2; // unreachable: retry or page on-call", + " if (res->status == 400) { // RuleViolationException", + " spdlog::error(\"incompatible schema change, blocking the merge: {}\", res->body);", + " return 1;", + " }", + " if (res->status >= 300) { spdlog::error(\"{}\", res->body); return 2; }", + " return 0; // 200: compatible, version created", + "}" + ], + "cat": "code", + "_why": "Apicurio Registry 3.3.3 REST v3: POST .../versions takes a CreateVersion JSON body (common/src/main/resources/META-INF/openapi.json); a raw schema body is the v2 shape. examples/09-api-registry/verify.sh sends the same body and expects 400 RuleViolationException; spdlog is the house logger (fmt-style), LOG_ERROR is Drogon's stream macro", + "_src": "registry" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "The actual schema body is your .proto, OpenAPI YAML, or AsyncAPI YAML serialised — Apicurio stores it verbatim.", + "repl": "The schema text travels as the content field of the v3 CreateVersion body with its content type (application/json for Avro and JSON Schema, application/x-yaml for OpenAPI or AsyncAPI, application/x-protobuf for a .proto); Apicurio stores it as given.", + "expect": 1, + "cat": "notes", + "_why": "v3 body shape", + "_src": "registry" + }, + { + "op": "replace_picture", + "sldId": 487, + "shape": "Google Shape;2567;p234", + "png": "lgtm-presentation/edits/png/25-gray-failure.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (PERCEIVED DIFFERENTLY, NOT fully down, OBSERVE FROM THE CLIENT, GREEN, OUTLIER DETECTION); replaced by the site figure assets/diagrams/25-gray-failure.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 293, + "shape": "Google Shape;429;p40", + "png": "lgtm-presentation/edits/png/05-sourcing-vs-streaming.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (SOURCING/STREAMING, ARE, IS, TRANSPORT, ABOUT, NOT, EVENT, PROPAGATED); replaced by the site figure assets/diagrams/05-sourcing-vs-streaming.svg (caps lowered there too)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 294, + "shape": "Google Shape;440;p41", + "png": "lgtm-presentation/edits/png/05-tools-landscape.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (COMPLEMENT); replaced by the site figure assets/diagrams/05-tools-landscape.svg (SUBSTRATE/COMPUTE/MOVEMENT are design labels)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 410, + "shape": "Google Shape;1714;p157", + "png": "lgtm-presentation/edits/png/20-coupling-quadrant.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure ('strong OR distant'); replaced by the site figure assets/diagrams/20-coupling-quadrant.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost-cpp.png", + "cat": "voice", + "_why": "figure footer: 'where p99 goes to die' rewritten without the drama", + "_src": "hop" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix3.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix3.json new file mode 100644 index 00000000..64f60857 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.fix3.json @@ -0,0 +1,218 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 269 + ], + "find": "/readyz runs SELECT 1 through the libpq pool and reports ready or down (readiness), so the probe", + "repl": "/readyz runs SELECT 1 through the libpq pool and answers 200 with status ready, or 503 with status down (readiness; the probe reads the status code, not the body), so the probe", + "expect": 1, + "cat": "notes", + "_slide": 14, + "_why": "examples/01 cpp: readiness answers 503 when the database is down (937f8d7)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 269, + "lines": [ + "// main.cpp (excerpt), examples/01-cloud-native-principles/cpp; conan.lock pins every library", + "using Callback = std::function;", + "const char* env(const char* name, const char* fallback) { // config from env, factor III", + " const char* v = std::getenv(name); return v ? v : fallback; // unset: the default", + "}", + "int main() {", + " g_version = env(\"SERVICE_VERSION\", \"1.0.0\");", + " g_pool = std::make_unique(", + " env(\"PG_CONNINFO\", \"postgresql://appuser:apppass@postgres:5432/appdb\"),", + " std::atoi(env(\"PG_POOL_SIZE\", \"4\")));", + " app().registerHandler(\"/healthz\", [](const HttpRequestPtr&, Callback&& cb) {", + " Json::Value j; j[\"status\"] = \"ok\"; cb(HttpResponse::newHttpJsonResponse(j)); // liveness", + " }, {Get});", + " app().registerHandler(\"/readyz\", [](const HttpRequestPtr&, Callback&& cb) {", + " const bool db_ok = db_ready(); // SELECT 1 via the libpq pool (retry elided)", + " Json::Value j; j[\"status\"] = db_ok ? \"ready\" : \"down\";", + " auto resp = HttpResponse::newHttpJsonResponse(j);", + " if (!db_ok) resp->setStatusCode(k503ServiceUnavailable); // down: out of rotation", + " cb(resp);", + " }, {Get});", + " app().addListener(\"0.0.0.0\", 8080).run(); // factor VII: port binding", + "}" + ], + "cat": "code", + "_slide": 14, + "_why": "examples/01: hand-written /healthz and /readyz; readiness answers 503 when the database is down", + "_src": "align" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "SIGTERM → fail readiness → keep serving while endpoints update → drain in-flight → RAII closes the pool.", + "repl": "One SIGTERM → fail readiness → serve DRAIN_DELAY_SECONDS → drain in-flight (20 s) → quit → close the pool.", + "expect": 1, + "cat": "code", + "_slide": 164, + "_why": "examples/21 cpp: watcher thread, drain delay, bounded wait, app().quit() (9676f32)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 419, + "lines": [ + "// order-service (excerpt), examples/21-graceful-shutdown/cpp: one SIGTERM drives the drain", + "std::atomic g_shutting_down{false}; // /readyz answers 503 once it is set", + "std::atomic g_in_flight{0}; // RAII InFlightGuard: one per request", + "extern \"C\" void sigterm_handler(int) { // async-signal-safe: one atomic store", + " g_shutting_down.store(true, std::memory_order_release);", + "}", + "void shutdown_watcher() { // outside signal context", + " while (!g_shutting_down.load(std::memory_order_acquire))", + " std::this_thread::sleep_for(50ms); // (also returns on g_watcher_stop)", + " const int delay = std::atoi(env(\"DRAIN_DELAY_SECONDS\", \"5\"));", + " std::this_thread::sleep_for(std::chrono::seconds(delay)); // endpoints update", + " const auto deadline = std::chrono::steady_clock::now() + 20s; // bounded in-flight wait", + " while (g_in_flight.load() > 0 && std::chrono::steady_clock::now() < deadline)", + " std::this_thread::sleep_for(100ms);", + " app().quit(); // stop the listeners: run() returns", + "}", + "int main() {", + " app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); }); // after init", + " std::thread watcher(shutdown_watcher); // ... handlers, addListener(\"0.0.0.0\", 8080)", + " app().run(); g_watcher_stop.store(true); watcher.join();", + " g_pool.reset(); // close the database connections; exit 0", + "}" + ], + "cat": "code", + "_slide": 164, + "_why": "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight drain within 20 s -> cleanup -> exit 0", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 419, + "cat": "notes", + "_slide": 164, + "_why": "notes follow the code", + "_src": "align", + "paragraphs": [ + "The application side of graceful shutdown, from the C++ example; one SIGTERM, the only signal Kubernetes sends before SIGKILL, drives all of it. (1) The SIGTERM handler only stores an atomic flag, which is async-signal-safe: no locks, no allocation, no destructors. Drogon installs its own SIGTERM handler that quits at once, so the example replaces it after initialisation with registerBeginningAdvice. /readyz answers 503 once the flag is set, which removes the pod from the Service endpoints while the process keeps serving the requests already routed to it.", + "(2) A watcher thread does the rest outside signal context: it sees the flag, keeps serving for DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) so the endpoint removal propagates, waits up to 20 seconds for the RAII InFlightGuard count to reach zero, and calls app().quit(), which stops the listeners so run() returns. (3) main() joins the watcher, closes the libpq pool and returns 0, inside the 30-second grace period. std::stop_token and std::jthread extend the same pattern to worker threads such as Kafka consumers: the watcher calls request_stop() after it sees the flag, never inside the handler. Deterministic destruction makes the teardown order predictable." + ] + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "Write to Redis and mark dirty; a flusher upserts every 2 s and clears a mark only once the DB has the row.", + "repl": "PUT answers 202; the flusher upserts every 2 s, clears a mark once the DB has the row, and parks a key after 5 failures.", + "expect": 1, + "cat": "code", + "_slide": 221, + "_why": "examples/25 cpp: 202 and bounded retry (ebca295)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 476, + "lines": [ + "// cache-service main.cpp (excerpt), examples/25-caching/cpp: write-back", + "app().registerHandler(\"/write-back/metrics/{id}\", // cache_val {id, payload}; body {ok, ...}", + " [](const HttpRequestPtr& req, Callback&& callback, const std::string& id) {", + " redis_set(\"metric:\" + id, writer.write(cache_val), std::chrono::hours(1)); // 1. cache", + " redis_sadd(\"writeback:dirty\", \"metric:\" + id); // 2. mark dirty", + " auto resp = HttpResponse::newHttpJsonResponse(body); resp->setStatusCode(k202Accepted);", + " callback(resp); // 3. 202: queued, not yet durable", + " }, {Put});", + "void writeback_flusher() { // std::thread, joined at shutdown", + " std::map attempts; int failures = 0; // per-key failure count", + " while (!g_shutdown) { // ... each cycle first sleeps std::min(2 << failures, 8) s", + " for (const auto& key : redis_smembers(\"writeback:dirty\")) { // key -> id, cached payload", + " auto res = g_flush_db->exec(\"INSERT INTO metrics (id, payload) VALUES ($1, $2) \"", + " \"ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, ts = NOW()\",", + " {id, payload});", + " if (res && PQresultStatus(res.get()) == PGRES_COMMAND_OK) {", + " redis_srem(\"writeback:dirty\", key); attempts.erase(key); continue; // DB has it", + " } // failed: stays dirty, retried next cycle", + " if (++attempts[key] >= kMaxFlushAttempts) // bounded: park after 5 attempts", + " { redis_sadd(\"writeback:failed\", key); redis_srem(\"writeback:dirty\", key); }", + " } // ... failures + 1 (at most 2) after a cycle with a failure, 0 after a clean one", + " }" + ], + "cat": "code", + "_slide": 221, + "_why": "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, metric:failed, backoff)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 476, + "cat": "notes", + "_slide": 221, + "_why": "notes follow the code", + "_src": "align", + "paragraphs": [ + "Write-back from Example 25: PUT /write-back/metrics/{id} stores the payload in Redis under metric: with a one-hour TTL (a queued write has to outlive a database outage), adds the key to the writeback:dirty set and answers 202 Accepted: the write is queued, not yet durable. A background std::thread wakes every two seconds, reads the dirty set, upserts each metric into Postgres with INSERT ... ON CONFLICT DO UPDATE, and removes the key from the set only after the upsert succeeds, so a failed write stays dirty and is retried on the next cycle.", + "Retries are bounded: a per-key counter parks a key on writeback:failed after five failed attempts, with its value still in the cache and an error in the log, and the cycle time backs off to four and then eight seconds while flushes fail. The upsert makes a repeated flush harmless. The flusher has its own libpq connection, separate from the request handlers', and main() joins it after the shutdown flag is set (Appendix H). The risk is the one every write-back carries: a write that was acknowledged but not yet flushed is lost if Redis loses it, so keep write-back for data that tolerates that window, such as metrics." + ] + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 500 + ], + "find": "Shutdown is a failure mode: readiness flip, drain in-flight requests, RAII cleanup.", + "repl": "Shutdown is a failure mode: readiness flip, drain delay, bounded in-flight drain, then cleanup.", + "expect": 1, + "cat": "code", + "_slide": 245, + "_why": "examples/21 cpp (9676f32)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 500, + "lines": [ + "// Recap of Appendix H in the failure-mode context: shutdown is a failure mode.", + "// A pod that dies mid-request causes 5xx; a pod that drains cleanly does not.", + "std::atomic g_shutting_down{false}; // /readyz answers 503 once it is set", + "std::atomic g_in_flight{0}; // InFlightGuard per request", + "extern \"C\" void sigterm_handler(int) { // async-signal-safe: one atomic store", + " g_shutting_down.store(true, std::memory_order_release);", + "}", + "void shutdown_watcher() { // one SIGTERM drives the rest", + " // ... wait for the flag, sleep DRAIN_DELAY_SECONDS (5), wait for g_in_flight == 0 (20 s)", + " app().quit(); // stop the listeners: run() returns", + "}", + "int main() {", + " app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); });", + " std::thread watcher(shutdown_watcher);", + " app().addListener(\"0.0.0.0\", 8080).run();", + " watcher.join();", + " g_pool.reset(); // close the pool, then exit 0", + "}", + "// K8s pod spec: terminationGracePeriodSeconds: 30 (>= 5 s delay + 20 s drain + buffer)" + ], + "cat": "code", + "_slide": 245, + "_why": "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 500, + "cat": "notes", + "_slide": 245, + "_why": "notes follow the code", + "_src": "align", + "text": "Recap of App H in the failure-mode context: pod shutdown is itself a failure mode that graceful drain converts into a non-failure. The C++ disposability discipline of Example 21 (one SIGTERM, an async-signal-safe flag, /readyz answering 503, the drain delay, a bounded in-flight wait, app().quit(), the pool closed and exit 0) is what makes rolling deploys clean." + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.json new file mode 100644 index 00000000..b3bff98b --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Cpp.json @@ -0,0 +1,6299 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "both", + "find": "Only this step is language-specific; the Newman command is identical for all five.", + "repl": "Only this step is language-specific; the Newman command is identical for all six.", + "expect": 1, + "sldIds": [ + 521 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "REST, gRPC and GraphQL — how they actually differ.", + "repl": "REST, gRPC and GraphQL: how they differ.", + "expect": 2, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Bound every retry — timeout, small max, backoff + jitter, circuit breaker, retry budget — and make handlers idempotent.", + "repl": "Bound every retry (timeout, small max, backoff + jitter, circuit breaker, retry budget) and make handlers idempotent.", + "expect": 1, + "sldIds": [ + 393 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The data split is the genuinely hard part; doing it in one process is much cheaper than coordinating across services.", + "repl": "The data split is the hard part; doing it in one process is much cheaper than coordinating across services.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most powerful for vendor / 3rd-party / legacy-mainframe wrappers.", + "repl": "Most useful for vendor / 3rd-party / legacy-mainframe wrappers.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pick the pattern that matches the consistency you actually need — the next six slides.", + "repl": "Pick the pattern that matches the consistency you need from the six patterns in this appendix.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Knowing the patterns — and the C++ idioms that implement them (RAII, value semantics, PMR arenas, ports & adapters) — is what lets you accept or reject what a model proposes.", + "repl": "Knowing the patterns, and the C++ idioms that implement them (RAII, value semantics, PMR arenas, ports & adapters), is what lets you accept or reject what a model proposes.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Specifically C++-flavoured note: at the bottom layer, C++ has extra patterns that don't exist in managed-runtime languages — RAII, value semantics, type erasure, CRTP — and they're the vocabulary you need to argue about ownership and lifetime with an AI tool.", + "repl": "C++ adds patterns at the bottom layer that managed-runtime languages do not have: RAII, value semantics, type erasure, CRTP. They are the vocabulary for arguing about ownership and lifetime with an AI tool.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Protocols per service are deliberate, not uniform: order-service exposes REST at the edge and calls others over gRPC; inventory/payment/shipping speak gRPC + GraphQL + Kafka; the gateway federates reads.", + "repl": "Protocols differ per service by design: order-service exposes REST at the edge and calls others over gRPC; inventory/payment/shipping speak gRPC + GraphQL + Kafka; the gateway federates reads.", + "expect": 1, + "sldIds": [ + 261 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Apicurio (schema registry) and OpenMetadata (metadata) remain external — seams, not in-process — the same posture every deck in the series takes.", + "repl": "Apicurio (schema registry) and OpenMetadata (metadata) remain external seams, not in-process, the same posture every deck in the series takes.", + "expect": 1, + "sldIds": [ + 261 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Contract-first tooling in C++ is uneven across the four protocols — be deliberate about which one carries codegen and which one stays at the documentation/registry layer.", + "repl": "Contract-first tooling in C++ is uneven across the four protocols: decide which one carries codegen and which one stays at the documentation/registry layer.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Honest pass at design-first in C++.", + "repl": "Design-first in C++, protocol by protocol.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The four protocols differ sharply in how much code the contract gives you for free. gRPC and GraphQL have first-class C++ generators — point protoc or cppgraphqlgen at the contract and you get stubs.", + "repl": "The four protocols differ sharply in how much code the contract generates. gRPC and GraphQL have first-class C++ generators: point protoc or cppgraphqlgen at the contract and you get stubs.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Treat it as a pre-flight check — 'will this survive the platform?' — the next slides list the twelve factors and map each to where it lives in our architecture.", + "repl": "Treat it as a pre-flight check: will this survive the platform? Each factor maps to a place in our architecture.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For C++ the highest-yield factors to internalize are III (Config), IV (Backing services), VI (Processes — be stateless), IX (Disposability — fast startup, graceful shutdown), and XI (Logs — stdout only, never spdlog's basic_logger_mt which opens a file and hits EROFS on a read-only rootfs).", + "repl": "For C++ the highest-yield factors to internalize are III (Config), IV (Backing services), VI (Processes: be stateless), IX (Disposability: fast startup, graceful shutdown), and XI (Logs: stdout only, never spdlog's basic_logger_mt, which opens a file and hits EROFS on a read-only rootfs).", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide groups them by what each actually asks of you in the C++/Kubernetes world we live in now.", + "repl": "The grouping that follows sorts them by what each asks of you in a C++ service on Kubernetes.", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Notes on what's idiomatic here vs the Python equivalent: (1) Settings parsed once into a struct and held as a top-level const — no scattered getenv calls anywhere else in the codebase; (2) kVersion is constinit so it's resolved at compile time with no static-init order risk; (3) handlers are registered with lambdas — Drogon's coroutine API works equally well if you'd rather co_await; (4) port binding (factor VII) is explicit in main() — the binary is self-contained, no external web server fronting it.", + "repl": "Notes on what's idiomatic here vs the Python equivalent: (1) Settings parsed once into a struct and held as a top-level const, with no scattered getenv calls anywhere else in the codebase; (2) kVersion is constinit so it's resolved at compile time with no static-init order risk; (3) handlers are registered with lambdas, and Drogon's coroutine API works equally well if you'd rather co_await; (4) port binding (factor VII) is explicit in main(): the binary is self-contained, with no external web server fronting it.", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ specifics: Drogon at the REST edge (or cpp-httplib for minimal cases); grpc::Server for the gRPC surface; modern-cpp-kafka over librdkafka for the async producer/consumer; cppgraphqlgen for native GraphQL exposure, OR — and this is the simpler default we'll revisit in §3 — fronting the REST + gRPC backends with a Node/Apollo gateway and skipping native C++ GraphQL entirely.", + "repl": "C++ specifics: Drogon at the REST edge (or cpp-httplib for minimal cases); grpc::Server for the gRPC surface; librdkafka's C++ API (rdkafka++) for the producer and consumer; cppgraphqlgen for native GraphQL or, as the simpler default covered in §3, a Node/Apollo gateway in front of the REST + gRPC backends with no native C++ GraphQL.", + "expect": 1, + "sldIds": [ + 271 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "Three idioms worth calling out: (1) sync API — we use the blocking Service base class, not the callback or async API; the cpp-stateless compendium covers the trade-off but for most read/reserve paths the sync API is simpler and the perf gap is small.", + "repl": "Three idioms: (1) sync API: we use the blocking Service base class, not the callback or async API; for most read/reserve paths the sync API is simpler and the performance gap is small.", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The producer is bounded — bounded queue, bounded retries, bounded memory — consistent with the cpp-stateless 'bound everything' rule.", + "repl": "The producer is bounded in queue, retries and memory, consistent with the 'bound everything' rule.", + "expect": 1, + "sldIds": [ + 274 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What stays in your C++ code: ONE safe retry where the call is genuinely idempotent (e.g., with an idempotency key); deadline propagation via gRPC ClientContext::set_deadline; circuit breaking is the mesh's job, not yours.", + "repl": "What stays in your C++ code: one safe retry where the call is idempotent (for example, with an idempotency key); deadline propagation via gRPC ClientContext::set_deadline; circuit breaking is the mesh's job, not yours.", + "expect": 1, + "sldIds": [ + 276 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The honest trade-off: cppgraphqlgen is well-maintained but the C++ resolver code is verbose compared to Strawberry or Apollo.", + "repl": "The trade-off: cppgraphqlgen is well maintained, but the C++ resolver code is verbose compared to Strawberry or Apollo.", + "expect": 1, + "sldIds": [ + 279 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Apollo gateway or cppgraphqlgen-on-Drogon — picked in the previous slide.", + "repl": "Apollo gateway or cppgraphqlgen on Drogon, as compared in the federation discussion.", + "expect": 1, + "sldIds": [ + 280 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Projections — built by consuming events from the write side — keep the read store in sync.", + "repl": "Projections, built by consuming events from the write side, keep the read store in sync.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Projection: a modern-cpp-kafka consumer applies events to keep the read model fresh, committing offsets AFTER the upsert succeeds — at-least-once delivery with idempotent ON CONFLICT DO UPDATE is the standard pattern.", + "repl": "Projection: a librdkafka consumer applies events to keep the read model fresh and commits offsets after the upsert succeeds; at-least-once delivery with an idempotent ON CONFLICT DO UPDATE is the standard pattern.", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the pattern that makes 'exactly-once effect' achievable from at-least-once delivery: the database commit IS the source of truth, the Kafka event is just its replication.", + "repl": "This is the pattern that makes 'exactly-once effect' achievable from at-least-once delivery: the database commit is the source of truth, and the Kafka event is its replication.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Event SOURCING means events ARE the state — you don't store the current order; you store the sequence of events that produced it, and you derive current state by replay.", + "repl": "Event sourcing means events are the state: you don't store the current order; you store the sequence of events that produced it, and you derive current state by replay.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Event SOURCING is a much bigger commitment — your storage IS the event log, your queries are replays or projections, and snapshotting becomes a first-class concern.", + "repl": "Event sourcing is a much bigger commitment: your storage is the event log, your queries are replays or projections, and snapshotting becomes a first-class concern.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Adopt event sourcing deliberately, where the audit log AND the temporal queries are the primary use case (financial ledgers, regulated workflows, debugging-heavy domains).", + "repl": "Adopt event sourcing where the audit log and the temporal queries are the primary use case (financial ledgers, regulated workflows, debugging-heavy domains).", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pick deliberately.", + "repl": "Choose retention per topic.", + "expect": 1, + "sldIds": [ + 295 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For C++ specifically, the honest framing carries forward from turn 1: heavyweight stream processing belongs in Flink (JVM).", + "repl": "For C++ specifically, heavyweight stream processing belongs in Flink (JVM).", + "expect": 1, + "sldIds": [ + 297 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows that in-process pattern; production-grade joins/CEP/large state should ride on Flink with C++ services as producers and consumers.", + "repl": "The in-process pattern fits small, bounded state; production-grade joins, CEP and large state belong in Flink, with C++ services as producers and consumers.", + "expect": 1, + "sldIds": [ + 297 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Why this matters for C++ specifically: a Drogon binary cold-starts in tens of milliseconds, which makes scale-to-zero actually viable — the user-visible latency of the first request after a cold start is well within typical HTTP timeout budgets.", + "repl": "Why this matters for C++: a Drogon binary cold-starts in tens of milliseconds, which makes scale-to-zero viable; the latency of the first request after a cold start is well within typical HTTP timeout budgets.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "API management isn't one product — it's four planes that have to cooperate.", + "repl": "API management is four planes that have to cooperate.", + "expect": 1, + "sldIds": [ + 306 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "OpenAPI and AsyncAPI in C++ are documentation-first — Apicurio still holds them with compatibility rules, but the codegen pipeline is partial or absent (we covered this honestly in §1 design-first).", + "repl": "OpenAPI and AsyncAPI in C++ are documentation-first: Apicurio still holds them with compatibility rules, but the codegen pipeline is partial or absent (see design-first in §1).", + "expect": 1, + "sldIds": [ + 309 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Four concrete things a catalog gives you, plus the discipline that's required for them to be honest: ownership and freshness must be enforced at ingest time, not just collected.", + "repl": "Four concrete things a catalog gives you, plus the discipline they depend on: ownership and freshness are enforced at ingest time, not just collected.", + "expect": 1, + "sldIds": [ + 313 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Critical config: use the Batch span processor (NEVER Simple in production — Simple flushes synchronously and collapses throughput).", + "repl": "Critical config: use the Batch span processor, never Simple in production, because Simple flushes synchronously and collapses throughput.", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Five signals, five questions.", + "repl": "One question per signal.", + "expect": 1, + "sldIds": [ + 317 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Honest framing of the C++ OTel auto-instrumentation story.", + "repl": "Where C++ OTel auto-instrumentation stands.", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is more work but produces cleaner traces — fewer spurious framework spans, every span you see has a deliberate reason for existing.", + "repl": "This is more work but produces cleaner traces: fewer spurious framework spans, and every span you see exists for a reason.", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two parts, both essential. init_tracing() goes in main() before any handlers register — it sets the global TracerProvider with the Batch span processor (NEVER Simple — that flushes synchronously and crushes throughput; this is the #1 cpp-stateless §10 footgun).", + "repl": "Two parts, both essential. init_tracing() goes in main() before any handlers register; it sets the global TracerProvider with the Batch span processor (never Simple, which flushes synchronously and crushes throughput).", + "expect": 1, + "sldIds": [ + 319 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what 'instrument once, correlate everything' actually looks like.", + "repl": "This is what 'instrument once, correlate everything' looks like.", + "expect": 1, + "sldIds": [ + 322 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two families: HEAD sampling decides at the start of the trace (cheap, but blind — you don't yet know if this trace will fail or be slow); TAIL sampling decides at the END after buffering the whole trace at the Collector (smart — keep ALL errors, ALL slow traces, plus a percentage of the rest).", + "repl": "Two families: head sampling decides at the start of the trace (cheap, but blind, since you don't yet know if this trace will fail or be slow); tail sampling decides at the end, after buffering the whole trace at the Collector (smart: keep all errors, all slow traces, plus a percentage of the rest).", + "expect": 1, + "sldIds": [ + 323 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ specifics: the OpenTelemetry C++ SDK supports all of these sampler types — AlwaysOnSampler, AlwaysOffSampler, ParentBasedSampler, TraceIdRatioBasedSampler — configured at TracerProvider construction time.", + "repl": "C++ specifics: the OpenTelemetry C++ SDK supports all of these sampler types (AlwaysOnSampler, AlwaysOffSampler, ParentBasedSampler, TraceIdRatioBasedSampler), configured at TracerProvider construction time.", + "expect": 1, + "sldIds": [ + 324 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Metrics are aggregates over EVERY request; if you sample at 10%, your error-rate metric is off by 10x.", + "repl": "Metrics are aggregates over every request; if you sample at 10%, your error-rate metric is off by 10x.", + "expect": 1, + "sldIds": [ + 324 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Container — minimal base images (distroless, UBI-minimal — C++ statically linked can run on scratch), non-root, read-only rootfs, image signing (Cosign), vuln scanning (Trivy).", + "repl": "Container — minimal base images (distroless, UBI-minimal; statically linked C++ can run on scratch), non-root, read-only rootfs, image signing (Cosign), vuln scanning (Trivy).", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code — parameterised queries (libpq with $1, $2 — never string concat), input validation at the trust boundary, dependency scanning (Conan + OSV-Scanner), no secrets in source, threat modeling.", + "repl": "Code — parameterised queries (libpq with $1, $2, never string concatenation), input validation at the trust boundary, dependency scanning (Conan + OSV-Scanner), no secrets in source, threat modeling.", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cross-cutting rule at the bottom is the headline: defence in depth means controls at EVERY layer, not just the one you find convenient.", + "repl": "The cross-cutting rule is the headline: defence in depth means controls at every layer, not just the one you find convenient.", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They're not a checklist — production systems use combinations deliberately.", + "repl": "They're not a checklist; production systems combine them per concern.", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The sidecar is the security pattern with the highest leverage in a Kubernetes deployment.", + "repl": "The sidecar is the security pattern that covers the most ground in a Kubernetes deployment.", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Critically: the app NEVER re-implements what the sidecar does.", + "repl": "The app never re-implements what the sidecar does.", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The app reads sub, aud, scope — it NEVER re-validates the signature.", + "repl": "The app reads sub, aud, scope; it never re-validates the signature.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The default — validating tokens from the client — is the sidecar's job, not yours.", + "repl": "The default case, validating tokens from the client, is the sidecar's job, not yours.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Critical: ALWAYS pair the sidecar headers with a NetworkPolicy.", + "repl": "Always pair the sidecar headers with a NetworkPolicy.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three properties of the URL that matter: (1) scoped — the key includes the user_id, so even if the URL leaks the blast radius is bounded to that user's namespace; (2) time-bound — 300 seconds, after which S3 rejects the request; (3) op-restricted — the signature binds the URL to PutObject for that specific bucket+key+content-type.", + "repl": "Three properties of the URL matter: (1) scoped: the key includes the user_id, so a leaked URL exposes only that user's namespace; (2) time-bound: 300 seconds, after which S3 rejects the request; (3) op-restricted: the signature binds the URL to a PUT of that bucket and key.", + "expect": 1, + "sldIds": [ + 333 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For C++ services: the new auth path is sidecar-validated (previous slide) — your Drogon code reads X-Forwarded-Client-Cert and JWT claim headers, never the legacy session cookie.", + "repl": "For C++ services: the new auth path is sidecar-validated (see the sidecar pattern); your Drogon code reads X-Forwarded-Client-Cert and JWT claim headers, never the legacy session cookie.", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Zero-trust isn't one product — it's a posture implemented across four layers.", + "repl": "Zero-trust is a posture implemented across four layers, not one product.", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For a C++ shop the policy authoring isn't different from any other shop — Rego or Kyverno-flavoured YAML — but the policies you write often LOOK at C++-specific concerns: image must come from registry.acme.com, base image must be distroless, must run non-root, must specify CPU/memory limits.", + "repl": "For a C++ shop the policy authoring is the same as for any other shop (Rego or Kyverno-flavoured YAML), but the policies often look at C++-specific concerns: image must come from registry.acme.com, base image must be distroless, must run non-root, must specify CPU/memory limits.", + "expect": 1, + "sldIds": [ + 337 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What this catches in a C++ deployment: an unsigned image shipped from a developer laptop, a Pod that pulls from Docker Hub instead of the internal registry, a CI bypass.", + "repl": "What this catches in a C++ deployment: an unsigned image shipped from a developer workstation, a Pod that pulls from Docker Hub instead of the internal registry, a CI bypass.", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Run the same policy in CI via conftest BEFORE the merge — same Rego, evaluated against the proposed manifest.", + "repl": "Run the same policy in CI via conftest before the merge: same Rego, evaluated against the proposed manifest.", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Both matter; both are trivially implementable in C++.", + "repl": "Both matter, and both are straightforward to implement in C++.", + "expect": 1, + "sldIds": [ + 339 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three idioms worth calling out: (1) try_acquire_for with a timeout — fail FAST when saturated, don't queue requests indefinitely (the queue itself becomes the bottleneck).", + "repl": "Three idioms: (1) try_acquire_for with a timeout: fail fast when saturated and don't queue requests indefinitely (the queue itself becomes the bottleneck).", + "expect": 1, + "sldIds": [ + 340 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Consumer reads the ticket, fetches the payload, handles, commits offset AFTER success.", + "repl": "Consumer reads the ticket, fetches the payload, handles it, and commits the offset after success.", + "expect": 1, + "sldIds": [ + 342 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost of NOT having it (drift, inconsistency, forgotten reviews) is much higher than the cost of adopting OPA / Kyverno.", + "repl": "The cost of not having it (drift, inconsistency, forgotten reviews) is much higher than the cost of adopting OPA / Kyverno.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Seven patterns, seven concrete trigger conditions.", + "repl": "Each pattern has a concrete trigger condition.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ developers reach for in-process shared state more easily because it's cheap; that discipline of 'in-process is per-service, never cross-service' has to be enforced explicitly.", + "repl": "C++ developers reach for in-process shared state readily because it's cheap; the discipline of 'in-process is per-service, never cross-service' has to be enforced explicitly.", + "expect": 1, + "sldIds": [ + 346 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The universal ones come straight from the Python deck; the C++-specific one is worth calling out because C++ makes shared-memory tempting in a way Python or JVM don't. mmap, shared memory segments, even just a static library that wraps a 'singleton config' — all of these become coupling that the deployment topology hides until something breaks.", + "repl": "The universal ones come straight from the Python deck; the C++-specific one matters because C++ makes shared memory tempting in a way Python or JVM don't. mmap, shared memory segments, even a static library that wraps a 'singleton config': all of these become coupling that the deployment topology hides until something breaks.", + "expect": 1, + "sldIds": [ + 347 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "If they truly need to share, use a real data store or message bus; pay the deserialisation cost and get the operational benefits.", + "repl": "If they need to share, use a data store or message bus; pay the deserialisation cost and get the operational benefits.", + "expect": 1, + "sldIds": [ + 347 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log — the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live); LRU (Least Recently Used — cache eviction policy); SETEX (Redis 'SET with EXpiry'); ORM (Object-Relational Mapping); libpq (C library for PostgreSQL — preferred C++ driver in this deck); nlohmann::json (the canonical header-only C++ JSON library).", + "repl": "Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log: the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live); LRU (Least Recently Used: cache eviction policy); SETEX (Redis 'SET with EXpiry'); ORM (Object-Relational Mapping); libpq (C library for PostgreSQL: the C++ driver in this deck); nlohmann::json (the canonical header-only C++ JSON library).", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration — the request-driven metrics method); USE (Utilisation, Saturation, Errors — the resource metrics method); MDC (Mapped Diagnostic Context — per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency); spdlog (the canonical C++ structured logging library, used throughout this deck); DWARF (the C++ binary debug format that makes eBPF profilers actionable).", + "repl": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context: per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency); spdlog (the canonical C++ structured logging library, used throughout this deck); DWARF (the C++ binary debug format that makes eBPF profilers actionable).", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ build & runtime — Conan (the C++ package manager used in this deck); CMake (the build system generator); RAII (Resource Acquisition Is Initialization — the central C++ ownership idiom); PMR (Polymorphic Memory Resource — C++17 arena allocators); DI (Dependency Injection); DTO (Data Transfer Object); ABI (Application Binary Interface); LTO (Link-Time Optimization).", + "repl": "C++ build & runtime — Conan (the C++ package manager used in this deck); CMake (the build system generator); RAII (Resource Acquisition Is Initialization: the central C++ ownership idiom); PMR (Polymorphic Memory Resource: C++17 arena allocators); DI (Dependency Injection); DTO (Data Transfer Object); ABI (Application Binary Interface); LTO (Link-Time Optimization).", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three protocols, three mental models.", + "repl": "Each protocol has its own mental model.", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++-specific traps: parsing JSON repeatedly in a hot path instead of caching the parsed result; copying protobuf messages across boundaries instead of passing const-ref; using grpc::Status as a return value when an exception fits better — be deliberate about which boundary errors cross.", + "repl": "C++-specific traps: parsing JSON repeatedly in a hot path instead of caching the parsed result; copying protobuf messages across boundaries instead of passing const-ref; using grpc::Status as a return value when an exception fits better. Decide which boundary errors cross.", + "expect": 1, + "sldIds": [ + 358 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Easy to write — but clients_ is per-pod.", + "repl": "Short to write, but clients_ is per-pod.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The code is mechanical — registration, message handler, disconnect cleanup — but the clients_ map at the bottom is the foundation of the scaling problem.", + "repl": "The code is mechanical (registration, message handler, disconnect cleanup), but the clients_ map is the foundation of the scaling problem.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For C++ this is the same problem as for any language; the fact that C++ can handle tens of thousands of concurrent sockets per pod (vs Python's few thousand) just delays the problem, it doesn't solve it.", + "repl": "For C++ this is the same problem as for any language: C++ can handle tens of thousands of concurrent sockets per pod (vs Python's few thousand), which delays the problem but does not solve it.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The headline: C++'s low per-connection memory footprint changes the SHAPE of the problem (you need fewer pods) but not the KIND of the problem (you still need a backplane for cross-pod delivery, still need graceful drain on rollout, still need resume on reconnect).", + "repl": "The headline: C++'s low per-connection memory footprint changes the scale of the problem (you need fewer pods) but not its nature (you still need a backplane for cross-pod delivery, graceful drain on rollout, and resume on reconnect).", + "expect": 1, + "sldIds": [ + 370 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Operational reality: a C++ pod hitting 50k connections is approaching FD limits (ulimit -n = 65536 by default); raise the limit deliberately, monitor it, and don't pack pods past where graceful drain becomes painful.", + "repl": "Operational reality: a C++ pod at 50k connections is approaching FD limits (ulimit -n = 65536 by default); raise the limit explicitly, monitor it, and don't pack pods past where graceful drain becomes painful.", + "expect": 1, + "sldIds": [ + 370 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Six rules for WebSockets that actually scale on Kubernetes.", + "repl": "Six rules for WebSockets that scale on Kubernetes.", + "expect": 1, + "sldIds": [ + 374 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When a saga fails midway, the compensations for ONLY the steps that actually completed are run, in REVERSE order.", + "repl": "When a saga fails midway, compensations run only for the steps that completed, in reverse order.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Same five facts, four encodings.", + "repl": "The facts stay the same; only the encoding changes.", + "expect": 1, + "sldIds": [ + 383 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Discipline: handlers throw typed exceptions; they NEVER format error responses themselves.", + "repl": "Discipline: handlers throw typed exceptions; they never format error responses themselves.", + "expect": 1, + "sldIds": [ + 385 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The CRITICAL discipline: commit AFTER you've routed the failure (whether to retry topic or DLQ), never before.", + "repl": "The critical discipline: commit after you've routed the failure (to the retry topic or the DLQ), never before.", + "expect": 1, + "sldIds": [ + 389 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Subdomains are business: CORE (your competitive advantage, where most effort goes), SUPPORTING (necessary but not differentiating — payments, shipping), GENERIC (commoditised — auth, logging, billing platform).", + "repl": "Subdomains are business: CORE (your competitive advantage, where most effort goes), SUPPORTING (necessary but not differentiating: payments, shipping), GENERIC (commoditised: auth, logging, billing platform).", + "expect": 1, + "sldIds": [ + 396 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The API IS the context boundary, made explicit and contractual.", + "repl": "The API is the context boundary, made explicit and contractual.", + "expect": 1, + "sldIds": [ + 397 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The contract published to other contexts is the Published Language; the API documentation IS the language specification.", + "repl": "The contract published to other contexts is the Published Language; the API documentation is the language specification.", + "expect": 1, + "sldIds": [ + 397 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ structure: one CMake project per context; the public headers (or .proto / OpenAPI YAML) ARE the Published Language; internal headers stay private to the binary.", + "repl": "C++ structure: one CMake project per context; the wire contracts (.proto / OpenAPI YAML) are the Published Language; internal headers stay private to the binary.", + "expect": 1, + "sldIds": [ + 397 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ specific: the public headers exposed by your service ARE NOT the Published Language.", + "repl": "C++ specific: the public headers exposed by your service are not the Published Language.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The C++ specific bullet at the bottom is worth dwelling on: C++ ABI is a header-and-link concern that only matters between binaries that link together.", + "repl": "The C++-specific bullet: C++ ABI is a header-and-link concern that only matters between binaries that link together.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The shape of the diagram is incidental; the property is what matters: domain code depends ONLY on interfaces it declares (ports), never on concrete I/O implementations.", + "repl": "The shape of the diagram is incidental; the property is what matters: domain code depends only on interfaces it declares (ports), never on concrete I/O implementations.", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two services that NEVER interact are not coupled — they're also not cooperating.", + "repl": "Two services that never interact are not coupled, and they are not cooperating either.", + "expect": 1, + "sldIds": [ + 405 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The optimization rule: strength should match distance, and BOTH should match co-volatility.", + "repl": "The optimization rule: strength should match distance, and both should match co-volatility.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tightly coupled things at high distance changing at different rates — that's pain.", + "repl": "Tightly coupled things at high distance, changing at different rates, cause pain.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "STRENGTH — the kind of dependency: shared memory > shared schema > shared events > shared concepts > unrelated.", + "repl": "STRENGTH — the type of dependency: shared memory > shared schema > shared events > shared concepts > unrelated.", + "expect": 1, + "sldIds": [ + 407 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The C++-specific entries: shared C++ libraries (libstateless from the cpp-stateless deck, a libtelemetry wrapping OTel boilerplate) are a real form of coupling worth being deliberate about.", + "repl": "The C++-specific entries: shared C++ libraries (for example a libtelemetry wrapping OTel boilerplate) are a real form of coupling to manage explicitly.", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Above the diagonal (strong + far) is the pain zone; below the diagonal (weak + close) is wasted indirection that probably should be merged.", + "repl": "Off the diagonal on the strong-and-far side is the pain zone; on the weak-and-close side is wasted indirection that probably should be merged.", + "expect": 1, + "sldIds": [ + 410 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The stub generator (protoc) has its own version that must be consistent across consumers — this is invisible from the .proto contract itself.", + "repl": "The stub generator (protoc) has its own version that must be consistent across consumers, and the .proto contract does not show it.", + "expect": 1, + "sldIds": [ + 412 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "If a single service has two pieces that NEVER change together, split it.", + "repl": "If a single service has two pieces that never change together, split it.", + "expect": 1, + "sldIds": [ + 413 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Make them deliberately, not by accident.", + "repl": "Make them explicitly, not by accident.", + "expect": 1, + "sldIds": [ + 413 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The C++-specific final bullet is worth dwelling on: a well-designed C++ monolith with clean in-process aggregates can outperform a poorly-decomposed microservices system in every dimension — latency, reliability, operational cost, developer velocity.", + "repl": "The C++-specific final bullet: a well-designed C++ monolith with clean in-process aggregates can outperform a poorly decomposed microservices system on latency, reliability, operational cost and developer velocity.", + "expect": 1, + "sldIds": [ + 413 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "RAII, std::stop_token, SIGTERM — the C++ leverage.", + "repl": "RAII, std::stop_token, SIGTERM: the C++ toolkit.", + "expect": 2, + "sldIds": [ + 414 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "NOT 'just die when SIGTERM arrives'.", + "repl": "Not 'just die when SIGTERM arrives'.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Graceful shutdown is one of the C++-specific high-leverage topics in cloud-native architecture.", + "repl": "Graceful shutdown is where C++ has a clear advantage in cloud-native architecture.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The language gives you tools (RAII, std::stop_token, deterministic destruction) that managed runtimes don't have — but you have to use them deliberately.", + "repl": "The language gives you tools (RAII, std::stop_token, deterministic destruction) that managed runtimes don't have, but you have to apply them consistently.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The race condition in step 1: traffic can arrive AFTER SIGTERM for a few seconds.", + "repl": "The race condition in step 1: traffic can still arrive for a few seconds after SIGTERM.", + "expect": 1, + "sldIds": [ + 416 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The response is to fail the readiness probe FIRST (so the Service stops sending new traffic) BEFORE you stop accepting.", + "repl": "The response is to fail the readiness probe first, so the Service stops sending new traffic, before you stop accepting.", + "expect": 1, + "sldIds": [ + 416 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Software, not silicon — four layers, two directions, dozens of decisions.", + "repl": "Software, not silicon: routing decisions across four layers and both directions.", + "expect": 2, + "sldIds": [ + 423 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Four layers, two directions, dozens of decisions per request.", + "repl": "Routing spans four layers and both traffic directions, with many decisions per request.", + "expect": 1, + "sldIds": [ + 424 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each layer earns its place by making decisions the layers above can't.", + "repl": "Each layer is there because it makes decisions the layers in front of it can't.", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ — this IS a C++ project.", + "repl": "Written in C++.", + "expect": 1, + "sldIds": [ + 429 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Worth noting for the C++ audience: Envoy is itself a C++ project (CNCF graduated, ~600k LOC modern C++).", + "repl": "For the C++ audience: Envoy is itself a C++ project (CNCF graduated, ~600k LOC modern C++).", + "expect": 1, + "sldIds": [ + 429 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Worth noting for the C++ audience: HAProxy uses memory-arena allocation per request — the same pattern PMR enables for your own Drogon services.", + "repl": "For the C++ audience: HAProxy uses memory-arena allocation per request, the same pattern PMR enables for your own Drogon services.", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ specific: a Drogon service running behind Istio NEVER does TLS itself — the sidecar handles it. drogon::HttpAppFramework runs plaintext on the pod's localhost; mTLS is platform.", + "repl": "C++ specific: a Drogon service behind Istio never does TLS itself; the sidecar handles it. drogon::HttpAppFramework runs plaintext on the pod's localhost; mTLS is platform.", + "expect": 1, + "sldIds": [ + 433 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right fix is usually upstream: move session state to Redis (via sw/redis++) or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as truly disposable.", + "repl": "The right fix is usually upstream: move session state to Redis (via sw/redis++) or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable.", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The honest framing of business-rule routing in C++.", + "repl": "Business-rule routing in C++.", + "expect": 1, + "sldIds": [ + 441 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The Python deck uses durable-rules; for C++ services we recommend sol2 + Lua because: (a) sol2 is genuinely modern C++ (header-only, MIT licensed); (b) Lua is small, embedable, and sandboxable; (c) domain experts can edit .lua files without C++ build tooling.", + "repl": "The Python deck uses durable-rules; for C++ services we recommend sol2 + Lua because: (a) sol2 is modern C++ (header-only, MIT licensed); (b) Lua is small, embeddable, and sandboxable; (c) domain experts can edit .lua files without C++ build tooling.", + "expect": 1, + "sldIds": [ + 441 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Rules can be tested independently of the C++ service. sol2 makes C++ ↔ Lua glue trivially type-safe.", + "repl": "Rules can be tested independently of the C++ service. sol2 makes the C++ ↔ Lua glue type-safe.", + "expect": 1, + "sldIds": [ + 442 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A 'low' timeout at the top of the stack + retries below can amplify into a thundering herd.", + "repl": "A 'low' timeout at the top of the stack plus retries further down can amplify into a thundering herd.", + "expect": 1, + "sldIds": [ + 446 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "East/west traffic — service-to-service inside the cluster — is the majority of traffic in a microservices system.", + "repl": "East/west traffic, service-to-service inside the cluster, is the majority of traffic in a microservices system.", + "expect": 1, + "sldIds": [ + 447 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When the data is genuinely per-module, breaking the binary apart is mechanical: each module already has its own libpq connection pool, its own schema, its own migrations.", + "repl": "When the data is per-module, breaking the binary apart is mechanical: each module already has its own libpq connection pool, its own schema, its own migrations.", + "expect": 1, + "sldIds": [ + 456 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The single most important step in a strangler migration is putting the proxy in FRONT of the existing monolith BEFORE you extract anything.", + "repl": "The most important step in a strangler migration is putting the proxy in front of the existing monolith before you extract anything.", + "expect": 1, + "sldIds": [ + 458 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Critical observability: at the proxy, per-route success rates and latencies for BOTH old and new sides.", + "repl": "Critical observability: at the proxy, per-route success rates and latencies for both the old and the new side.", + "expect": 1, + "sldIds": [ + 459 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "One template, one prefix, one TTL, one loader — wire it up once; every caller gets the cache-aside discipline for free.", + "repl": "One template, one prefix, one TTL, one loader: wire it up once and every caller gets the cache-aside discipline by default.", + "expect": 1, + "sldIds": [ + 470 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A background task watches their TTLs and refreshes BEFORE expiry, so users never see a miss on the hot path.", + "repl": "A background task watches their TTLs and refreshes them before expiry, so users never see a miss on the hot path.", + "expect": 1, + "sldIds": [ + 477 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Unbounded caches grow until they evict at random — the worst kind of failure.", + "repl": "Unbounded caches grow until they evict at random, which is the worst way to fail.", + "expect": 1, + "sldIds": [ + 479 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Your handlers must reason about each leg independently — fail open, fail closed, degrade, retry — per dependency, not service-wide.", + "repl": "Your handlers must reason about each leg independently (fail open, fail closed, degrade, retry) per dependency, not service-wide.", + "expect": 1, + "sldIds": [ + 486 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The honest framing for C++: most uniform concerns (timeouts at the network, retries between services, outlier ejection, mesh bulkheads) belong in Istio.", + "repl": "For C++, most uniform concerns (timeouts at the network, retries between services, outlier ejection, mesh bulkheads) belong in Istio.", + "expect": 1, + "sldIds": [ + 490 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Isolate resources — connection pools, thread pools, std::counting_semaphore, queues — by tenant, by call type, or by priority.", + "repl": "Isolate resources (connection pools, thread pools, std::counting_semaphore, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 497 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For C++ services, Drogon middleware can implement adaptive concurrency limits (count in-flight requests, shed when above threshold).", + "repl": "For C++ services, Drogon middleware can implement adaptive concurrency limits (count in-flight requests, shed when the count passes a threshold).", + "expect": 1, + "sldIds": [ + 498 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The headline: failure is the default; resilience is what you build deliberately.", + "repl": "The headline: failure is the default; resilience is what you design for.", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "PMR (Polymorphic Memory Resource: C++17 arena allocators)", + "repl": "PMR (Polymorphic Memory Resource: the standard arena allocators, since C++17)", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "version" + }, + { + "op": "set_code", + "sldId": 519, + "shape_id": 8, + "lines": [ + "{", + " \"name\": \"Create order\",", + " \"request\": {", + " \"method\": \"POST\",", + " \"header\": [{ \"key\": \"Idempotency-Key\", \"value\": \"{{$guid}}\" }],", + " \"url\": \"{{base_url}}/orders\",", + " \"body\": { \"mode\": \"raw\", \"raw\": \"{\\\"sku\\\":\\\"A-1\\\",\\\"qty\\\":2}\" }", + " },", + " \"event\": [{", + " \"listen\": \"test\",", + " \"script\": { \"exec\": [", + " \"pm.test('201 Created', () => pm.response.to.have.status(201));\",", + " \"pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));\",", + " \"const body = pm.response.json();\",", + " \"pm.test('returns an id', () => pm.expect(body).to.have.property('id'));\",", + " \"pm.collectionVariables.set('order_id', body.id);\"", + " ] }", + " }]", + "}" + ], + "cat": "code", + "_why": "_docs/28 Anatomy of a collection (adds the response-time assertion)" + }, + { + "op": "replace_picture", + "sldId": 520, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-black-box.png", + "cat": "stack", + "_why": "the old figure shows five backends; the site figure shows all six" + }, + { + "op": "set_code", + "sldId": 521, + "shape_id": 8, + "lines": [ + "# C++ / Drogon — build and run, then the same collection", + "cmake --build build && ./build/order-service & # http://localhost:8080", + "newman run orders.postman_collection.json -e local.postman_environment.json" + ], + "cat": "code", + "_why": "_docs/28 codetab 'One collection, six backends', this deck's language" + }, + { + "op": "set_code", + "sldId": 522, + "shape_id": 8, + "lines": [ + "# install once (Node.js):", + "# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1", + "newman run orders.postman_collection.json \\", + " --environment local.postman_environment.json \\", + " --reporters cli,junit,htmlextra \\", + " --reporter-junit-export results/newman.xml \\", + " --timeout-request 5000 \\", + " --bail", + "# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure", + "", + "# data-driven: one iteration per CSV row; the columns become {{variables}}", + "newman run orders.postman_collection.json -e local.postman_environment.json \\", + " --iteration-data cases.csv" + ], + "cat": "code", + "_why": "_docs/28 Running Newman + Data-driven tests; pinned install; no comment after a line continuation" + }, + { + "op": "set_code", + "sldId": 524, + "shape_id": 8, + "lines": [ + "# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js)", + "jobs:", + " api-tests:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v7", + " - run: docker compose up -d --build --wait # order, payment, inventory, ...", + " - run: ./scripts/wait-for-ready.sh # poll /readyz before testing", + " - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1", + " - run: |", + " newman run orders.postman_collection.json \\", + " -e ci.postman_environment.json \\", + " --reporters cli,junit \\", + " --reporter-junit-export results/newman.xml", + " - uses: actions/upload-artifact@v7 # keep the report even on failure", + " if: always()", + " with: { name: newman-results, path: results/ }" + ], + "cat": "code", + "_why": "_docs/28 Newman in CI on Docker Engine; actions at v7 as in .github/workflows" + }, + { + "op": "replace_picture", + "sldId": 525, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-ci-gate.png", + "cat": "stack", + "_why": "the old figure shows the old container engine; site figure 27-ci-gate" + }, + { + "op": "replace_picture", + "sldId": 283, + "shape_id": 314, + "png": "lgtm-presentation/edits/png/04-cqrs.png", + "cat": "voice", + "_slide": 28, + "_why": "image text had a positional reference ('the next slide') and 'genuinely'" + }, + { + "op": "replace_picture", + "sldId": 285, + "shape_id": 338, + "png": "lgtm-presentation/edits/png/04-log-tailing.png", + "cat": "positional", + "_slide": 30, + "_why": "image text had '(next slide)'" + }, + { + "op": "replace_picture", + "sldId": 346, + "shape_id": 1011, + "png": "lgtm-presentation/edits/png/13-antipatterns-grid.png", + "cat": "voice", + "_slide": 91, + "_why": "box text broken mid-word (layout defect)" + }, + { + "op": "replace_picture", + "sldId": 427, + "shape_id": 1891, + "png": "lgtm-presentation/edits/png/22-l7-layers.png", + "cat": "stack", + "_slide": 172, + "_why": "in-app router named modern-cpp-kafka (C++), Spring Boot + Camel (in the .NET deck), FastAPI + durable-rules (in the Go deck)" + }, + { + "op": "replace_picture", + "sldId": 428, + "shape_id": 1902, + "png": "lgtm-presentation/edits/png/22-l7-journey.png", + "cat": "voice", + "_slide": 173, + "_why": "subtitle 'each layer earns its place' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 442, + "shape_id": 2063, + "png": "lgtm-presentation/edits/png/22-business-rule-routing-cpp.png", + "cat": "stack", + "_slide": 187, + "_why": "modern-cpp-kafka did the dispatch; now rdkafka++" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost-cpp.png", + "cat": "voice", + "_slide": 190, + "_why": "'every hop must earn its place' (ban tier); rule-engine row per language" + }, + { + "op": "replace_picture", + "sldId": 447, + "shape_id": 2121, + "png": "lgtm-presentation/edits/png/22-east-west.png", + "cat": "voice", + "_slide": 192, + "_why": "caption 'for free' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 460, + "shape_id": 2261, + "png": "lgtm-presentation/edits/png/24-shared-database.png", + "cat": "positional", + "_slide": 205, + "_why": "'(decomposed DB above)'" + }, + { + "op": "replace_picture", + "sldId": 483, + "shape_id": 2523, + "png": "lgtm-presentation/edits/png/25-failure-taxonomy.png", + "cat": "voice", + "_slide": 228, + "_why": "'the rest of this appendix' (ban tier)" + }, + { + "op": "set_text", + "sldId": 442, + "shape_id": 2064, + "paragraphs": [ + "Drogon is the HTTP front; embedded Lua is the rules engine; rdkafka++ (librdkafka) does the dispatch." + ], + "cat": "stack", + "_why": "caption repeated the replaced figure's stale library" + }, + { + "op": "set_code", + "sldId": 300, + "shape_id": 509, + "lines": [ + "apiVersion: keda.sh/v1alpha1", + "kind: ScaledObject", + "metadata: { name: payment-consumer }", + "spec:", + " scaleTargetRef: { name: payment-consumer }", + " minReplicaCount: 0", + " maxReplicaCount: 20", + " triggers:", + " - type: kafka", + " metadata: { bootstrapServers: \"cndp-kafka-bootstrap:9092\", topic: order.placed,", + " consumerGroup: payment, lagThreshold: \"100\" }", + "---", + "# HTTP: scale the Drogon service on concurrent requests (KEDA http-add-on)", + "apiVersion: http.keda.sh/v1alpha1", + "kind: HTTPScaledObject", + "metadata: { name: order-service }", + "spec:", + " hosts: [orders.example.com]", + " scaleTargetRef: { name: order-service, service: order-service, port: 8080 }", + " replicas: { min: 0, max: 30 }" + ], + "cat": "code", + "_why": "ScaledObject needs metadata.name and the kafka scaler needs bootstrapServers; HTTPScaledObject needs apiVersion, name, hosts and a full scaleTargetRef (KEDA v2.21 / http-add-on)" + }, + { + "op": "replace", + "scope": "slide", + "find": "ssl crt /etc/haproxy/certs/ ", + "repl": "ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 ", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "gRPC needs HTTP/2 on the TLS listener (ALPN h2)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# TLS termination here", + "repl": "# TLS here; h2 for gRPC", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "comment matches the ALPN change" + }, + { + "op": "replace", + "scope": "slide", + "find": "listen 443 ssl http2;", + "repl": "listen 443 ssl; http2 on;", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "code", + "_why": "nginx 1.25.1+ deprecates the http2 listen parameter; http2 is its own directive" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-request-id: \"%REQ(x-request-id)%\"", + "repl": "x-client-ip: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "setting x-request-id to itself is a no-op; Envoy generates it" + }, + { + "op": "replace", + "scope": "slide", + "find": "# propagate or create", + "repl": "# caller address", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "comment matches the new header" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-tenant-id: \"%DOWNSTREAM_PEER_CERT_V_END%\"", + "repl": "x-peer-identity: \"%DOWNSTREAM_PEER_URI_SAN%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "DOWNSTREAM_PEER_CERT_V_END is the certificate expiry, not an identity; the URI SAN carries the SPIFFE ID" + }, + { + "op": "set_notes", + "sldId": 440, + "paragraphs": [ + "Header injection lets the mesh add context and identity and strip secrets before they cross a trust boundary. %DOWNSTREAM_PEER_URI_SAN% is the URI SAN of the client's mTLS certificate, the SPIFFE ID inside the mesh; Envoy creates x-request-id itself when a request has none, and traceparent travels with the request.", + "For a C++ service downstream, these headers arrive in the request and the Drogon middleware reads them as plain values." + ], + "cat": "notes", + "_why": "notes described the old headers (tenant id from the cert expiry operator)" + }, + { + "op": "set_code", + "sldId": 509, + "shape_id": 8, + "lines": [ + "{", + " \"$schema\": \"https://flagd.dev/schema/v0/flags.json\",", + " \"flags\": {", + " \"new-checkout\": {", + " \"state\": \"ENABLED\",", + " \"variants\": { \"on\": true, \"off\": false },", + " \"defaultVariant\": \"off\",", + " \"targeting\": {", + " \"if\": [", + " { \"==\": [ {\"var\": \"plan\"}, \"enterprise\" ] }, \"on\",", + " { \"fractional\": [ {\"var\": \"targetingKey\"},", + " [\"on\", 25], [\"off\", 75] ] }", + " ]", + " }", + " }", + " }", + "}" + ], + "cat": "code", + "_why": "examples/27-feature-flags/flags/flags.json carries the $schema line" + }, + { + "op": "set_notes", + "sldId": 256, + "paragraphs": [ + "Title slide. Same Red Hat brand frame as the other decks in the series. Talk track: this is the C++ entry in the Designing Cloud-Native APIs series. Like its siblings (Python/FastAPI, Java/Quarkus, Spring Boot, .NET 10, Go), it carries the same five-service running example end to end, and every line of code is idiomatic C++23.", + "Toolchain: GCC 15 (gcc-toolset-15 on the ubi10/ubi:10.2 builder image), CMake 4.4.4 and Ninja 1.13.2 from the upstream release binaries, Conan 2.33.0 with a lockfile per project; libstdc++ is linked statically into a ubi10/ubi-minimal runtime image. Libraries: Drogon for REST and WebSocket, grpc++, librdkafka's C++ API (rdkafka++) for Kafka, libpq directly for Postgres, sw/redis++, cppgraphqlgen, OpenTelemetry C++ with spdlog. The examples run on Docker Engine with docker compose; Example 05 also runs on Kubernetes (Appendix R)." + ], + "cat": "stack", + "_slide": 1 + }, + { + "op": "replace", + "scope": "both", + "find": "Kafka (Strimzi) via modern-cpp-kafka", + "repl": "Kafka (Strimzi) via librdkafka (rdkafka++)", + "expect": 1, + "sldIds": [ + 268 + ], + "cat": "stack", + "_slide": 13 + }, + { + "op": "replace", + "scope": "both", + "find": "asynchronous code uses modern-cpp-kafka producers from a process-scoped producer instance (one per binary, not per request)", + "repl": "asynchronous code uses a process-scoped librdkafka producer (rdkafka++, one per binary, not per request)", + "expect": 1, + "sldIds": [ + 274 + ], + "cat": "stack", + "_slide": 19 + }, + { + "op": "replace", + "scope": "both", + "find": "a single modern-cpp-kafka Producer instance", + "repl": "a single RdKafka::Producer instance", + "expect": 1, + "sldIds": [ + 290 + ], + "cat": "stack", + "_slide": 35 + }, + { + "op": "replace", + "scope": "slide", + "find": "Producing and consuming with modern-cpp-kafka", + "repl": "Producing and consuming with librdkafka", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "stack", + "_slide": 36 + }, + { + "op": "set_text", + "sldId": 291, + "shape_id": 406, + "paragraphs": [ + "c++ · rdkafka++" + ], + "cat": "stack", + "_slide": 36 + }, + { + "op": "replace", + "scope": "both", + "find": "windowed aggregation over modern-cpp-kafka", + "repl": "windowed aggregation over librdkafka's C++ API", + "expect": 1, + "sldIds": [ + 297 + ], + "cat": "stack", + "_slide": 42 + }, + { + "op": "set_text", + "sldId": 298, + "shape_id": 484, + "paragraphs": [ + "c++ · rdkafka++" + ], + "cat": "stack", + "_slide": 43 + }, + { + "op": "replace", + "scope": "slide", + "find": "C++ subscribes to topics with modern-cpp-kafka; in-process saga via boost::sml", + "repl": "C++ subscribes to topics with rdkafka++; in-process saga via boost::sml", + "expect": 1, + "sldIds": [ + 303 + ], + "cat": "stack", + "_slide": 48 + }, + { + "op": "replace", + "scope": "slide", + "find": "for Drogon and modern-cpp-kafka the instrumentation is manual middleware", + "repl": "for Drogon and librdkafka the instrumentation is manual middleware", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "stack", + "_slide": 63 + }, + { + "op": "replace", + "scope": "notes", + "find": "Drogon, modern-cpp-kafka, libpq,", + "repl": "Drogon, librdkafka, libpq,", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "stack", + "_slide": 63 + }, + { + "op": "replace", + "scope": "notes", + "find": "modern-cpp-kafka for the broker", + "repl": "librdkafka (rdkafka++) for the broker", + "expect": 1, + "sldIds": [ + 342 + ], + "cat": "stack", + "_slide": 87 + }, + { + "op": "replace", + "scope": "slide", + "find": "modern-cpp-kafka for facts", + "repl": "librdkafka for facts", + "expect": 1, + "sldIds": [ + 348 + ], + "cat": "stack", + "_slide": 93 + }, + { + "op": "replace", + "scope": "notes", + "find": "(Drogon, gRPC, modern-cpp-kafka, OpenTelemetry C++, AWS SDK for C++)", + "repl": "(Drogon, gRPC, librdkafka, OpenTelemetry C++, AWS SDK for C++)", + "expect": 1, + "sldIds": [ + 348 + ], + "cat": "stack", + "_slide": 93 + }, + { + "op": "replace", + "scope": "slide", + "find": "C++ producers: modern-cpp-kafka.", + "repl": "C++ producers: librdkafka (rdkafka++).", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "stack", + "_slide": 102 + }, + { + "op": "replace", + "scope": "notes", + "find": "publishes events on Kafka (modern-cpp-kafka)", + "repl": "publishes events on Kafka (rdkafka++)", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "stack", + "_slide": 102 + }, + { + "op": "replace", + "scope": "notes", + "find": "modern-cpp-kafka if you're already running Kafka", + "repl": "librdkafka (rdkafka++) if you're already running Kafka", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "stack", + "_slide": 116 + }, + { + "op": "replace", + "scope": "slide", + "find": "Kafka via modern-cpp-kafka", + "repl": "Kafka via rdkafka++", + "expect": 1, + "sldIds": [ + 374 + ], + "cat": "stack", + "_slide": 119 + }, + { + "op": "replace", + "scope": "slide", + "find": "Kafka retry + DLQ with modern-cpp-kafka", + "repl": "Kafka retry + DLQ with librdkafka", + "expect": 1, + "sldIds": [ + 389 + ], + "cat": "stack", + "_slide": 134 + }, + { + "op": "set_text", + "sldId": 389, + "shape_id": 1489, + "paragraphs": [ + "c++ · rdkafka++" + ], + "cat": "stack", + "_slide": 134 + }, + { + "op": "replace", + "scope": "slide", + "find": "modern-cpp-kafka producers per service.", + "repl": "rdkafka++ producers per service.", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "stack", + "_slide": 148 + }, + { + "op": "replace", + "scope": "notes", + "find": "modern-cpp-kafka publishes to each", + "repl": "librdkafka publishes to each", + "expect": 1, + "sldIds": [ + 442 + ], + "cat": "stack", + "_slide": 187 + }, + { + "op": "replace", + "scope": "notes", + "find": "modern-cpp-kafka events around it", + "repl": "librdkafka events around it", + "expect": 1, + "sldIds": [ + 462 + ], + "cat": "stack", + "_slide": 207 + }, + { + "op": "set_text", + "sldId": 463, + "shape_id": 2299, + "paragraphs": [ + "Drogon wraps the legacy: caching with sw/redis++, events with librdkafka, no legacy code changes." + ], + "cat": "stack", + "_slide": 208 + }, + { + "op": "replace", + "scope": "notes", + "find": "legacy_ is a cpp-httplib client; cache_ is sw/redis++; producer_ is modern-cpp-kafka.", + "repl": "legacy_ wraps Drogon's HttpClient; cache_ is sw/redis++; producer_ is a librdkafka producer.", + "expect": 1, + "sldIds": [ + 463 + ], + "cat": "stack", + "_slide": 208 + }, + { + "op": "set_text", + "sldId": 350, + "shape_id": 1053, + "paragraphs": [ + "Designing Cloud-Native APIs · C++23 · Drogon · gRPC · Kafka · Kubernetes" + ], + "cat": "version", + "_slide": 95 + }, + { + "op": "set_text", + "sldId": 340, + "shape_id": 945, + "paragraphs": [ + "c++23 · semaphore" + ], + "cat": "version", + "_slide": 85 + }, + { + "op": "replace", + "scope": "slide", + "find": "// std::counting_semaphore (C++20) bounds concurrency per tenant.", + "repl": "// std::counting_semaphore bounds concurrency per tenant.", + "expect": 1, + "sldIds": [ + 340 + ], + "cat": "version", + "_slide": 85 + }, + { + "op": "replace", + "scope": "notes", + "find": "C++20's std::counting_semaphore makes this pattern much cleaner than pre-C++20 (where you'd build it from atomic + condition_variable).", + "repl": "std::counting_semaphore (standard since C++20) makes this pattern cleaner than the older construction from an atomic and a condition_variable.", + "expect": 1, + "sldIds": [ + 340 + ], + "cat": "version", + "_slide": 85 + }, + { + "op": "replace", + "scope": "slide", + "find": "std::stop_token (C++20) propagates shutdown cooperatively", + "repl": "std::stop_token (since C++20) propagates shutdown cooperatively", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "version", + "_slide": 160 + }, + { + "op": "replace", + "scope": "notes", + "find": "C++20 makes this cleaner — std::stop_source::request_stop() is async-signal-safe by specification and propagates to every std::stop_token in the program.", + "repl": "The main thread can then call std::stop_source::request_stop(), which propagates to every std::stop_token in the program; call it outside the handler, never inside it.", + "expect": 1, + "sldIds": [ + 417 + ], + "cat": "code", + "_slide": 162 + }, + { + "op": "replace", + "scope": "slide", + "find": "Install async-signal-safe SIGTERM handler that flips an atomic readiness flag and calls std::stop_source::request_stop(). No locks, no destructors, no printf.", + "repl": "Install an async-signal-safe SIGTERM handler that only flips an atomic readiness flag. No locks, no destructors, no printf; the main thread acts on the flag.", + "expect": 1, + "sldIds": [ + 422 + ], + "cat": "code", + "_slide": 167 + }, + { + "op": "replace", + "scope": "slide", + "find": "Use std::jthread (C++20) for managed worker threads", + "repl": "Use std::jthread (since C++20) for managed worker threads", + "expect": 1, + "sldIds": [ + 422 + ], + "cat": "version", + "_slide": 167 + }, + { + "op": "replace", + "scope": "notes", + "find": "Write-back in C++ leans hard on C++20: std::jthread for the flusher", + "repl": "Write-back in C++ leans on the standard library: std::jthread for the flusher", + "expect": 1, + "sldIds": [ + 476 + ], + "cat": "version", + "_slide": 221 + }, + { + "op": "replace", + "scope": "notes", + "find": "std::counting_semaphore (C++20) is the in-process primitive", + "repl": "std::counting_semaphore (since C++20) is the in-process primitive", + "expect": 1, + "sldIds": [ + 497 + ], + "cat": "version", + "_slide": 242 + }, + { + "op": "replace", + "scope": "notes", + "find": "We'll show both in the next two slides, in that order.", + "repl": "Both appear in this section, native C++ first.", + "expect": 1, + "sldIds": [ + 278 + ], + "cat": "positional", + "_slide": 23 + }, + { + "op": "replace", + "scope": "notes", + "find": "Seven security patterns we'll work through in the next slides.", + "repl": "Seven security patterns, each with a concept and an implementation.", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "positional", + "_slide": 74 + }, + { + "op": "replace", + "scope": "notes", + "find": "Either way, the scaling problem is the same and the solutions (next slides) are the same.", + "repl": "Either way, the scaling problem and its solutions are the same.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "positional", + "_slide": 113 + }, + { + "op": "replace", + "scope": "notes", + "find": "The next slides show the actual fixes.", + "repl": "The backplane, graceful drain and resume are the fixes.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "positional", + "_slide": 114 + }, + { + "op": "replace", + "scope": "notes", + "find": "The next slides show this pattern.", + "repl": "The Lua rules and the dispatcher show the pattern in code.", + "expect": 1, + "sldIds": [ + 441 + ], + "cat": "positional", + "_slide": 186 + }, + { + "op": "replace", + "scope": "notes", + "find": "the matrix on slide 247 maps which pattern defends which mode", + "repl": "the failure-mode matrix maps which pattern defends which mode", + "expect": 1, + "sldIds": [ + 483 + ], + "cat": "positional", + "_slide": 228 + }, + { + "op": "replace", + "scope": "notes", + "find": "we covered this in our turn 1 framing as", + "repl": "the Stream Processing section frames this as", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "voice", + "_slide": 39 + }, + { + "op": "replace", + "scope": "notes", + "find": "we picked it back in turn 1 as our JWT library, but", + "repl": "it is the deck's JWT library, but", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice", + "_slide": 76 + }, + { + "op": "set_code", + "sldId": 284, + "shape_id": 327, + "lines": [ + "// Write side — command handler (one txn, outbox row, no broker call)", + "Task<> Orders::place(HttpRequestPtr req, auto cb) {", + " auto in = co_await parse_validated(req);", + " PgTxn txn = co_await pg_.begin(); // one local txn", + " Order o = co_await txn.exec(insert_order_sql, in);", + " co_await txn.exec(insert_outbox_sql,", + " \"order.placed\", o.id, to_json(o)); // same txn", + " co_await txn.commit(); // CDC publishes", + " cb(HttpResponse::newHttpJsonResponse(to_json(o)));", + "}", + "// Read side — queries a DIFFERENT, denormalised store", + "Task<> Orders::summary(HttpRequestPtr req, auto cb, std::string oid) {", + " auto row = co_await read_pg_.fetch_opt(", + " \"SELECT * FROM order_summary WHERE order_id = $1\", oid);", + " if (!row) throw grpc::Status{NOT_FOUND, \"unknown order\"};", + " cb(HttpResponse::newHttpJsonResponse(to_json(*row)));", + "}", + "// Projection — consumes events, upserts the read model (idempotent)", + "std::unique_ptr msg(consumer->consume(100)); // rdkafka++", + "if (msg->err() == RdKafka::ERR_NO_ERROR) {", + " read_pg_.exec(upsert_order_summary_sql, deserialize(payload_of(*msg)));", + " consumer->commitSync(msg.get()); // after the upsert", + "}" + ], + "cat": "code", + "_slide": 29, + "_why": "projection loop on rdkafka++ (was modern-cpp-kafka); outbox topic order.placed (was order.created)" + }, + { + "op": "set_code", + "sldId": 287, + "shape_id": 362, + "lines": [ + "// order-service (excerpt): order row + outbox row in one libpq transaction", + "PGresult* res = PQexec(conn, \"BEGIN\"); // single local txn", + "if (PQresultStatus(res) != PGRES_COMMAND_OK) { /* 500, nothing written */ }", + "PQclear(res);", + "const char* params_order[4] = {id.c_str(), sku.c_str(), quantity_s.c_str(), status.c_str()};", + "res = PQexecParams(conn,", + " \"INSERT INTO orders (id, sku, quantity, status) VALUES ($1, $2, $3, $4)\",", + " 4, nullptr, params_order, nullptr, nullptr, 0); // the state change", + "// on any error: PQexec(conn, \"ROLLBACK\") and answer 500", + "const char* params_outbox[3] = {id.c_str(), \"order.placed\", payload_str.c_str()};", + "res = PQexecParams(conn,", + " \"INSERT INTO outbox (aggregate_id, event_type, payload) VALUES ($1, $2, $3::jsonb)\",", + " 3, nullptr, params_outbox, nullptr, nullptr, 0); // atomic with the state change", + "res = PQexec(conn, \"COMMIT\"); // Debezium publishes the row", + "// No Kafka call on the request path: if the broker is down the order", + "// still commits, and the event is delivered when CDC catches up." + ], + "cat": "code", + "_slide": 32, + "_why": "examples/04-data/cpp/order-service/src/main.cpp uses libpq directly (was a PgTxn coroutine wrapper)" + }, + { + "op": "set_notes", + "sldId": 287, + "paragraphs": [ + "The outbox write in the C++ example uses libpq directly. Three things to call out: (1) BEGIN, the orders insert, the outbox insert and COMMIT run on one connection; any failure rolls back and answers 500, so the order and its event are written together or not at all. (2) No Kafka call inside the request path: calling a producer from the handler would reintroduce the dual-write the outbox removes. (3) Debezium runs as Kafka Connect and publishes the committed outbox rows; the order-service has no Kafka-publish code. The cost is one extra process and a small outbox table; the benefit is an event for every committed order with no distributed-transaction machinery." + ], + "cat": "notes", + "_slide": 32 + }, + { + "op": "set_code", + "sldId": 291, + "shape_id": 407, + "lines": [ + "// Producer: process-scoped, constructed once in main() (librdkafka C++ API, rdkafka++)", + "std::string err;", + "std::unique_ptr conf(RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL));", + "conf->set(\"bootstrap.servers\", cfg.kafka_bootstrap, err);", + "conf->set(\"acks\", \"all\", err); // wait for ISR ack: durable", + "conf->set(\"enable.idempotence\", \"true\", err); // no dupes on producer retry", + "conf->set(\"dr_cb\", &delivery_report, err); // DeliveryReportCb logs failures", + "std::unique_ptr producer(RdKafka::Producer::create(conf.get(), err));", + "void publish_order_placed(const Order& o) {", + " auto value = serialize(o);", + " producer->produce(\"order.placed\", RdKafka::Topic::PARTITION_UA,", + " RdKafka::Producer::RK_MSG_COPY, value.data(), value.size(),", + " o.id.data(), o.id.size(), 0, nullptr); // key = order id", + " producer->poll(0); // serve delivery reports", + "}", + "// Consumer: notification-consumer (cconf: group.id, enable.auto.commit=false)", + "std::unique_ptr consumer(", + " RdKafka::KafkaConsumer::create(cconf.get(), err));", + "consumer->subscribe({\"order.placed\"});", + "while (running) {", + " std::unique_ptr msg(consumer->consume(1000));", + " if (msg->err() != RdKafka::ERR_NO_ERROR) continue; // timeout / partition EOF", + " send_email(deserialize(payload_of(*msg))); // idempotent handler", + " consumer->commitSync(msg.get()); // after the side-effect" + ], + "cat": "code", + "_slide": 36, + "_why": "modern-cpp-kafka (archived) -> librdkafka C++ API, as examples/05-event-driven/cpp and _docs/05", + "pt": 9.5, + "_fit": "slide 36: 24 code lines, 88 columns; 10.0 pt overflows the box" + }, + { + "op": "set_notes", + "sldId": 291, + "paragraphs": [ + "librdkafka's C++ API (rdkafka++) is the C++ binding that ships with librdkafka, the C client that most Kafka clients build on; the examples pin librdkafka 2.14.2 from ConanCenter. Three patterns: (1) the producer is process-scoped: one instance per binary, owned in main(), shared by reference, never one per request; poll() serves the delivery reports. (2) enable.idempotence=true with acks=all deduplicates producer retries, so a retry does not write the record twice.", + "(3) Commit after the side-effect succeeds: with enable.auto.commit=false and commitSync after the handler, the offset never advances past work that did not finish, and a restart re-delivers instead of losing events. Handlers are idempotent because at-least-once delivery means a record can arrive twice. The loop exits on a shutdown flag, which gives a graceful drain instead of an abrupt termination." + ], + "cat": "notes", + "_slide": 36 + }, + { + "op": "set_code", + "sldId": 298, + "shape_id": 485, + "lines": [ + "// stream-processor (excerpt, simplified): order.placed -> per-merchant tumbling", + "// windows -> revenue.by-merchant. Bounded state fits in-process; joins: Flink.", + "std::unordered_map merchants_; // open windows", + "", + "void aggregate(const std::string& merchant, int64_t ts, const cndp::Decimal& amount) {", + " auto it = merchants_.try_emplace(merchant,", + " cndp::MerchantWindows(window_seconds_)).first; // WINDOW_SECONDS", + " auto update = it->second.apply(ts, amount); // nullopt: late record, dropped", + " if (!update) return;", + " send(\"revenue.by-merchant\", merchant, // running total for the window", + " cndp::revenue_json(merchant, *update), 0);", + "}", + "", + "bool commit_batch() { // after each batch of up to 500", + " producer_->flush(30000); // outputs delivery-reported first", + " if (reports_.failed() > 0) return false; // else exit, replay from last commit", + " consumer_->commitSync(); // then commit the input offsets", + " return true;", + "}", + "// The example re-keys order.placed through a repartition topic first, so each", + "// merchant's records land on one partition and one instance." + ], + "cat": "code", + "_slide": 43, + "_why": "examples/06-stream-processing/cpp/stream-processor (rdkafka++, MerchantWindows, commit after delivery)" + }, + { + "op": "set_text", + "sldId": 298, + "shape_id": 486, + "paragraphs": [ + "In-process is fine for small, bounded windowed state; outputs are delivered before offsets commit. For joins, CEP or large state, deploy Flink." + ], + "cat": "code", + "_slide": 43 + }, + { + "op": "set_notes", + "sldId": 298, + "paragraphs": [ + "The in-process stream-processing pattern from the C++ example. Three things to call out: (1) state is bounded by design: one MerchantWindows per merchant holds only the open tumbling windows, and a window closes when stream time passes its end, so a late record is dropped. (2) Every update is a running total keyed by merchant and window start; consumers of revenue.by-merchant keep the latest record per window. (3) Outputs first, offsets second: after each batch the producer flushes, and the input offsets are committed only when every output was delivery-reported; a failed delivery stops the process so the batch replays.", + "The example also re-keys order.placed through a repartition topic so each merchant lands on one partition, and it sums money as exact fixed-point decimals. When this pattern stops scaling (joins across topics, long retention, exactly-once aggregation), deploy a Flink job and keep the C++ service as a producer and consumer." + ], + "cat": "notes", + "_slide": 43 + }, + { + "op": "replace", + "scope": "slide", + "find": "// Apicurio rejects (HTTP 409) any version that violates the artifact's rule", + "repl": "// Apicurio rejects (HTTP 400) any version that violates the artifact's rule", + "expect": 1, + "sldIds": [ + 310 + ], + "cat": "code", + "_slide": 55 + }, + { + "op": "replace", + "scope": "slide", + "find": "if (res->status == 409) {", + "repl": "if (res->status == 400) {", + "expect": 1, + "sldIds": [ + 310 + ], + "cat": "code", + "_slide": 55 + }, + { + "op": "replace", + "scope": "slide", + "find": "otlp_grpc_exporter_factory.h", + "repl": "otlp_http_exporter_factory.h", + "expect": 1, + "sldIds": [ + 319 + ], + "cat": "code", + "_slide": 64 + }, + { + "op": "replace", + "scope": "slide", + "find": "OtlpGrpcExporterFactory", + "repl": "OtlpHttpExporterFactory", + "expect": 1, + "sldIds": [ + 319 + ], + "cat": "code", + "_slide": 64 + }, + { + "op": "set_code", + "sldId": 377, + "shape_id": 1349, + "lines": [ + "// saga_machine.hpp (excerpt): the saga lifecycle as a boost::sml state machine", + "struct StepSucceeded {}; struct StepFailed {}; struct AllStepsDone {}; struct Compensated {};", + "struct Progress { int step_index = 0; int total_steps = 0; };", + "", + "struct Lifecycle {", + " auto operator()() const {", + " using namespace sml;", + " auto all_done = [](Progress& p) { return p.step_index >= p.total_steps; };", + " auto advance = [](Progress& p) { ++p.step_index; };", + " return make_transition_table(", + " *state + event / advance = state,", + " state + event[all_done] = state,", + " state + event = state,", + " state + event = state);", + " }", + "};", + "// main.cpp advance(): one step per transaction, under a row lock (libpq)", + "// BEGIN; SELECT status, step_index, context FROM sagas WHERE id = $1 FOR UPDATE;", + "// restore(sm, status); run g_steps[step_index]; context[step.name] = result;", + "// sm.process_event(StepSucceeded{}); UPDATE sagas ...; COMMIT" + ], + "cat": "code", + "_slide": 122, + "_why": "examples/17-sagas/cpp/saga-orchestrator/src/saga_machine.hpp" + }, + { + "op": "set_text", + "sldId": 377, + "shape_id": 1350, + "paragraphs": [ + "A typed lifecycle in four transitions; status, step_index and context persisted to Postgres on every step. Crash = reload the row and call advance() again." + ], + "cat": "code", + "_slide": 122 + }, + { + "op": "set_notes", + "sldId": 377, + "paragraphs": [ + "boost::sml (header-only, Boost Software License) is the natural C++ choice for typed state machines. The example models the saga lifecycle, not the individual steps: RUNNING advances step by step, AllStepsDone moves to COMPLETED when the guard holds, StepFailed moves to COMPENSATING, and Compensated ends in COMPENSATED. The machine owns no I/O: the orchestrator runs a step, reports the outcome as an event, and the machine decides the next status.", + "Postgres holds status, step_index and context. Each advance() is one transaction under SELECT ... FOR UPDATE, so one saga moves one step at a time and no two workers advance the same saga. restore() rebuilds the machine from the persisted status, which is how a restarted orchestrator resumes a RUNNING saga." + ], + "cat": "notes", + "_slide": 122 + }, + { + "op": "set_code", + "sldId": 379, + "shape_id": 1373, + "lines": [ + "// main.cpp (excerpt): COMPENSATING undoes the completed steps, newest first", + "if (status == \"COMPENSATING\") {", + " for (int i : cndp::saga::compensation_order(progress.step_index)) { // n-1 .. 0", + " auto& step = g_steps[i]; // charge_payment -> refund_payment, ...", + " if (!logStep(db.conn, sagaId, step.compensationName, \"compensate\",", + " step.compensate(context))) { abandon(); return; }", + " }", + " sm.process_event(cndp::saga::Compensated{});", + " setState(db.conn, sagaId, cndp::saga::status_of(sm), progress.step_index, nullptr);", + " exec(db.conn, \"COMMIT\"); // one transaction for the unwind", + "}", + "", + "// A forward step that throws is logged, then StepFailed moves the saga", + "// from RUNNING to COMPENSATING; the next advance() runs the block.", + "catch (const std::exception& e) {", + " logStep(db.conn, sagaId, step.name, \"failed\", failResult);", + " sm.process_event(cndp::saga::StepFailed{});", + " setState(db.conn, sagaId, cndp::saga::status_of(sm), progress.step_index, nullptr);", + "}" + ], + "cat": "code", + "_slide": 124, + "_why": "examples/17-sagas/cpp/saga-orchestrator/src/main.cpp (compensation_order)" + }, + { + "op": "set_notes", + "sldId": 379, + "paragraphs": [ + "Compensation in the example is a separate phase of the same lifecycle. When a step throws, the orchestrator logs the failure and sends StepFailed, which moves the saga to COMPENSATING. The next advance() undoes the completed steps in reverse order (compensation_order returns step_index-1 down to 0), logs each compensation (refund_payment, release_stock, cancel_shipping), then sends Compensated and commits.", + "The state machine guarantees that only steps that committed are compensated. In a production system each compensation call also carries an idempotency key such as the saga id plus the step name, because a restart between the compensation and the commit repeats it." + ], + "cat": "notes", + "_slide": 124 + }, + { + "op": "set_code", + "sldId": 381, + "shape_id": 1397, + "lines": [ + "// Every step's output lands in context[step.name], the sagas.context jsonb column", + "// (saga-orchestrator/src/main.cpp, jsoncpp Json::Value).", + "// after reserve_stock: context[\"reserve_stock\"] = {\"reservation_id\": ..., \"sku\": ...}", + "", + "// A later step reads A's output from the persisted context, never from A:", + "[](const Json::Value& ctx) { // book_shipping execute()", + " const auto& reserved = ctx[\"reserve_stock\"]; // B reads A's data", + " Json::Value r;", + " r[\"shipment_id\"] = generateId(\"shp-\");", + " r[\"reservation_id\"] = reserved[\"reservation_id\"].asString();", + " return r;", + "}", + "", + "// advance(), after the step succeeds, in the same transaction as the status:", + "Json::Value result = step.execute(context);", + "context[step.name] = result; // carried forward", + "setState(db.conn, sagaId, status_of(sm), progress.step_index, &context);", + "// Crash between A and B: the next advance() reloads the row; context", + "// already holds A's output, so B runs with it and A is never called again." + ], + "cat": "code", + "_slide": 126, + "_why": "context handling as examples/17 (jsoncpp, context[step.name]); B reading A's data is the pattern shown on that context" + }, + { + "op": "replace", + "scope": "notes", + "find": "fits the C++ + libpq + nlohmann::json toolchain naturally", + "repl": "fits the C++ + libpq + jsoncpp (Drogon's Json::Value) toolchain naturally", + "expect": 1, + "sldIds": [ + 381 + ], + "cat": "code", + "_slide": 126 + }, + { + "op": "set_code", + "sldId": 389, + "shape_id": 1490, + "lines": [ + "// Bounded in-place retries, then a delayed-retry topic, then a DLQ.", + "constexpr int MAX_RETRIES = 3;", + "while (!stop_token.stop_requested()) {", + " std::unique_ptr msg(consumer->consume(100));", + " if (msg->err() != RdKafka::ERR_NO_ERROR) continue;", + " try {", + " handle(*msg);", + " consumer->commitSync(msg.get()); // success", + " } catch (const PoisonError& e) {", + " int attempts = read_header(*msg, \"retries\", 0);", + " auto value = std::string(payload_of(*msg));", + " if (attempts < MAX_RETRIES) {", + " auto* h = RdKafka::Headers::create();", + " h->add(\"retries\", std::to_string(attempts + 1));", + " produce_with_headers(*producer, \"orders.retry.30s\", value, h); // delayed retry", + " } else {", + " auto* h = RdKafka::Headers::create();", + " h->add(\"error\", \"poison\");", + " h->add(\"traceId\", current_trace_id());", + " produce_with_headers(*producer, \"orders.DLQ\", value, h); // DLQ", + " }", + " consumer->commitSync(msg.get()); // free head-of-line", + " }", + "}" + ], + "cat": "code", + "_slide": 134, + "_why": "_docs/18-appendix-e-errors.md C++ tab (rdkafka++)", + "pt": 9.5, + "_fit": "slide 134: 24 code lines, 87 columns; 10.0 pt overflows the box" + }, + { + "op": "set_code", + "sldId": 401, + "shape_id": 1621, + "lines": [ + "// domain/ports.hpp — the domain declares the interfaces it needs (no framework)", + "struct OrderRepository { // outbound port", + " virtual ~OrderRepository() = default;", + " virtual void save(const Order& order) = 0;", + " virtual std::optional find_by_id(const std::string& id) = 0;", + "};", + "struct EventPublisher { // outbound port", + " virtual ~EventPublisher() = default;", + " virtual void publish(const OrderPlaced& event) = 0;", + "};", + "// domain/service.hpp — the use case: pure logic, depends only on ports", + "class PlaceOrderUseCase {", + " public:", + " PlaceOrderUseCase(OrderRepository& repo, EventPublisher& publisher)", + " : repo_(repo), publisher_(publisher) {}", + " Order execute(const PlaceOrderCmd& cmd) {", + " if (cmd.quantity <= 0) throw std::invalid_argument(\"quantity must be positive\");", + " Order order{generate_uuid(), cmd.sku, cmd.quantity, \"placed\"}; // aggregate", + " repo_.save(order);", + " publisher_.publish(OrderPlaced{order.id, order.sku, order.quantity});", + " return order;", + " }", + " private: OrderRepository& repo_; EventPublisher& publisher_;", + "}; // adapters/: rest_adapter.cpp (Drogon), pg_repository.hpp (libpq), log_publisher" + ], + "cat": "code", + "_slide": 146, + "_why": "examples/19-ddd-hexagonal/cpp/order-service/src/domain (synchronous ports, PlaceOrderUseCase)", + "pt": 9.5, + "_fit": "slide 146: 24 code lines, 85 columns; 10.0 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 401, + "shape_id": 1622, + "paragraphs": [ + "The domain depends only on ports it declares; REST, CLI, Postgres and log adapters are wired in at startup. Swap or add one without touching PlaceOrderUseCase." + ], + "cat": "code", + "_slide": 146 + }, + { + "op": "replace", + "scope": "notes", + "find": "PlaceOrder just uses the dependencies", + "repl": "PlaceOrderUseCase just uses the dependencies", + "expect": 1, + "sldIds": [ + 401 + ], + "cat": "code", + "_slide": 146 + }, + { + "op": "replace", + "scope": "notes", + "find": "PlaceOrder doesn't know", + "repl": "PlaceOrderUseCase doesn't know", + "expect": 1, + "sldIds": [ + 401 + ], + "cat": "code", + "_slide": 146 + }, + { + "op": "set_code", + "sldId": 419, + "shape_id": 1809, + "lines": [ + "// order-service (excerpt): SIGTERM flips readiness; Drogon keeps serving", + "std::atomic g_shutting_down{false};", + "std::atomic g_in_flight{0};", + "struct InFlightGuard { // RAII: one per request", + " InFlightGuard() { ++g_in_flight; }", + " ~InFlightGuard() { --g_in_flight; }", + "};", + "void sigterm_handler(int) {", + " g_shutting_down.store(true, std::memory_order_release); // fail readiness first", + "}", + "int main() {", + " // Drogon's run() installs a SIGTERM handler that quits; replace it after init", + " app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); });", + " app().registerHandler(\"/readyz\", [](const HttpRequestPtr&, auto&& cb) {", + " auto r = HttpResponse::newHttpResponse();", + " r->setStatusCode(g_shutting_down.load() ? k503ServiceUnavailable : k200OK);", + " cb(r);", + " });", + " app().addListener(\"0.0.0.0\", 8080).run(); // returns on quit / SIGINT", + " while (g_in_flight.load() > 0) // drain in-flight requests", + " std::this_thread::sleep_for(std::chrono::milliseconds(100));", + "} // RAII closes the libpq pool" + ], + "cat": "code", + "_slide": 164, + "_why": "examples/21-graceful-shutdown/cpp/src/main.cpp (atomic flag, InFlightGuard, registerBeginningAdvice)" + }, + { + "op": "set_text", + "sldId": 419, + "shape_id": 1806, + "paragraphs": [ + "Graceful shutdown in Drogon" + ], + "cat": "code", + "_slide": 164 + }, + { + "op": "set_text", + "sldId": 419, + "shape_id": 1808, + "paragraphs": [ + "c++23 · Drogon" + ], + "cat": "version", + "_slide": 164 + }, + { + "op": "set_text", + "sldId": 419, + "shape_id": 1810, + "paragraphs": [ + "SIGTERM → fail readiness → keep serving while endpoints update → drain in-flight → RAII closes the pool." + ], + "cat": "code", + "_slide": 164 + }, + { + "op": "set_notes", + "sldId": 419, + "paragraphs": [ + "The application side of graceful shutdown, from the C++ example. Three layers: (1) the SIGTERM handler only stores an atomic flag, which is async-signal-safe: no locks, no allocation, no destructors. Drogon installs its own SIGTERM handler that quits, so the example replaces it after initialisation with registerBeginningAdvice. (2) /readyz answers 503 once the flag is set, which removes the pod from the Service endpoints while the process keeps serving the requests already routed to it.", + "(3) An RAII InFlightGuard counts requests; after run() returns, main() waits for the count to reach zero and returns, and destructors close the libpq pool in reverse construction order. std::stop_token and std::jthread extend the same pattern to worker threads such as Kafka consumers: the main thread calls request_stop() after it sees the flag, never inside the handler. Compared with garbage-collected runtimes, deterministic destruction makes the teardown order predictable." + ], + "cat": "notes", + "_slide": 164 + }, + { + "op": "set_code", + "sldId": 444, + "shape_id": 2089, + "lines": [ + "// dispatcher.cpp — Drogon controller + sol2 + librdkafka", + "#include ", + "class Router { // process-scoped", + " sol::state lua_;", + " public:", + " Router() {", + " lua_.open_libraries(sol::lib::base, sol::lib::table);", + " lua_.script_file(\"/etc/orders/rules.lua\"); // load DSL", + " }", + " std::vector evaluate(const Order& o, const Ctx& c) {", + " return lua_[\"route\"](o, c); // call route()", + " }", + "} router;", + "Task<> Orders::route(HttpRequestPtr req, auto cb) {", + " auto order = parse(req);", + " auto ctx = co_await enrich(order); // DB / HTTP", + " auto dests = router.evaluate(order, ctx); // rules", + " for (const auto& dest : dests) { // fan-out", + " auto [proto, target] = split(dest, ':');", + " if (proto == \"kafka\") produce_to(*producer_, target, order.id, to_json(order));", + " else if (proto == \"http\") co_await http_.post(target, order);", + " }", + " cb(json_response({{\"routed_to\", dests}}));", + "}" + ], + "cat": "code", + "_slide": 189, + "_why": "_docs/22-appendix-i-l7-routing.md C++ tab (librdkafka produce_to)", + "pt": 9.5, + "_fit": "slide 189: 24 code lines, 83 columns; 10.0 pt overflows the box" + }, + { + "op": "set_code", + "sldId": 461, + "shape_id": 2274, + "lines": [ + "// router/src/main.cpp (excerpt): route POST /orders by tenant during cutover", + "g_service_urls[\"monolith\"] = env_m ? env_m : \"http://monolith:8080\";", + "g_service_urls[\"new-service\"] = env_n ? env_n : \"http://new-service:8080\";", + "g_tenant_routes[\"acme\"] = \"new-service\"; // cut Acme over first", + "g_default_service = \"monolith\"; // everyone else: legacy", + "", + "app().registerHandler(\"/orders\",", + " [](const HttpRequestPtr& req, std::function&& cb) {", + " auto body = req->getJsonObject(); // decide by content", + " std::string tenant;", + " if (body && body->isMember(\"tenant\")) tenant = (*body)[\"tenant\"].asString();", + " forward_request(resolve_upstream(tenant), Post, \"/orders\",", + " std::string(req->body()), std::move(cb));", + " },", + " {Post});", + "", + "// forward_request(): drogon::HttpClient to the chosen upstream, 502 on failure", + "auto client = HttpClient::newHttpClient(upstream);", + "auto fwd = HttpRequest::newHttpRequest();", + "fwd->setMethod(method); fwd->setPath(path); fwd->setBody(body);", + "client->sendRequest(fwd, [cb](ReqResult result, const HttpResponsePtr& resp) {", + " cb(result == ReqResult::Ok ? copy_of(resp) : bad_gateway()); // simplified", + "});" + ], + "cat": "code", + "_slide": 206, + "_why": "examples/24-monolith-to-microservices/cpp/router (Drogon HttpClient; was cpp_httplib::Client)" + }, + { + "op": "set_text", + "sldId": 461, + "shape_id": 2275, + "paragraphs": [ + "Read the body, decide by content (tenant here; region or a feature flag work the same way), forward to the right backend. Cutover by attribute, not by URL." + ], + "cat": "code", + "_slide": 206 + }, + { + "op": "replace", + "scope": "notes", + "find": "The Drogon middleware reads the request body, decides by content, forwards via cpp-httplib.", + "repl": "The Drogon handler reads the request body, decides by tenant, and forwards with Drogon's HttpClient.", + "expect": 1, + "sldIds": [ + 461 + ], + "cat": "code", + "_slide": 206 + }, + { + "op": "set_code", + "sldId": 463, + "shape_id": 2298, + "lines": [ + "// Drogon decorator wrapping a legacy /orders API: Redis caching of GET", + "// responses and Kafka event emission on writes. The legacy service is unchanged.", + "class OrdersDecorator : public drogon::HttpController {", + " public:", + " METHOD_LIST_BEGIN", + " METHOD_ADD(OrdersDecorator::getOrder, \"/orders/{oid}\", Get);", + " METHOD_ADD(OrdersDecorator::placeOrder, \"/orders\", Post);", + " METHOD_LIST_END", + " Task<> getOrder(HttpRequestPtr req, auto cb, std::string oid) {", + " auto key = \"order:\" + oid;", + " if (auto hit = cache_.get(key)) { // 1. hit (sw/redis++)", + " cb(json_response(*hit)); co_return;", + " }", + " auto body = co_await legacy_.get(\"/orders/\" + oid); // 2. miss → legacy", + " cache_.setex(key, 60, body); // 3. populate", + " cb(json_response(body));", + " }", + " Task<> placeOrder(HttpRequestPtr req, auto cb) {", + " auto body = co_await legacy_.post(\"/orders\", req->body()); // 1. legacy first", + " auto id = json::parse(body)[\"id\"].get();", + " produce_to(*producer_, \"order.placed\", id, body); // 2. emit (rdkafka++)", + " cache_.del(\"order:\" + id); // 3. invalidate", + " cb(json_response(body));", + " }" + ], + "cat": "code", + "_slide": 208, + "_why": "examples/24 decorator: sw/redis++ (synchronous) and an RdKafka::Producer", + "pt": 9.5, + "_fit": "slide 208: 24 code lines, 85 columns; 10.0 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "// Pair with a circuit breaker (next slide) so a down dep stops retries entirely.", + "repl": "// Pair with a circuit breaker so a down dependency stops retries entirely.", + "expect": 1, + "sldIds": [ + 494 + ], + "cat": "positional", + "_slide": 239 + }, + { + "op": "replace", + "scope": "slide", + "find": "cpp_httplib::Client cli{", + "repl": "httplib::Client cli{", + "expect": 1, + "sldIds": [ + 492 + ], + "cat": "code", + "_slide": 237 + }, + { + "op": "replace", + "scope": "slide", + "find": "cpp_httplib::Headers hdrs{", + "repl": "httplib::Headers hdrs{", + "expect": 1, + "sldIds": [ + 492 + ], + "cat": "code", + "_slide": 237 + }, + { + "op": "set_code", + "sldId": 500, + "shape_id": 2715, + "lines": [ + "// Recap of Appendix H in the failure-mode context: shutdown IS a failure mode.", + "// A pod that dies mid-request causes 5xx; a pod that drains cleanly does not.", + "std::atomic g_shutting_down{false};", + "std::atomic g_in_flight{0}; // InFlightGuard per request", + "", + "void sigterm_handler(int) {", + " g_shutting_down.store(true, std::memory_order_release); // /readyz answers 503", + "}", + "int main() {", + " app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); });", + " app().addListener(\"0.0.0.0\", 8080).run();", + " while (g_in_flight.load() > 0) // drain, then exit 0", + " std::this_thread::sleep_for(std::chrono::milliseconds(100));", + "}", + "// K8s pod spec: terminationGracePeriodSeconds: 30 (or more if needed)", + "// lifecycle.preStop: { exec: { command: [\"sleep\", \"5\"] } }" + ], + "cat": "code", + "_slide": 245, + "_why": "aligned with slide 164 and examples/21" + }, + { + "op": "set_text", + "sldId": 500, + "shape_id": 2714, + "paragraphs": [ + "c++23 · Drogon" + ], + "cat": "version", + "_slide": 245 + }, + { + "op": "set_text", + "sldId": 500, + "shape_id": 2716, + "paragraphs": [ + "Shutdown is a failure mode: readiness flip, drain in-flight requests, RAII cleanup." + ], + "cat": "code", + "_slide": 245 + }, + { + "op": "replace", + "scope": "notes", + "find": "(signal handler → readiness flip → stop_token propagation → RAII unwind)", + "repl": "(signal handler → readiness flip → drain in-flight requests → RAII cleanup)", + "expect": 1, + "sldIds": [ + 500 + ], + "cat": "code", + "_slide": 245 + }, + { + "op": "set_code", + "sldId": 508, + "shape_id": 8, + "lines": [ + "// flag-service (excerpt): flagd's gRPC ResolveBoolean, called with grpc++", + "bool FlagdClient::evaluateBoolean(const std::string& flagKey, bool defaultValue,", + " const std::string& targetingKey,", + " const std::string& plan) {", + " flagd::evaluation::v1::ResolveBooleanRequest request;", + " request.set_flag_key(flagKey);", + " auto* fields = request.mutable_context()->mutable_fields();", + " (*fields)[\"targetingKey\"].set_string_value(targetingKey); // sticky bucketing", + " if (!plan.empty()) (*fields)[\"plan\"].set_string_value(plan);", + " flagd::evaluation::v1::ResolveBooleanResponse response;", + " grpc::ClientContext ctx;", + " ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::milliseconds(800));", + " auto status = grpc::internal::BlockingUnaryCall(", + " channel_.get(), method_, &ctx, request, &response);", + " return status.ok() ? response.value() : defaultValue; // flagd down: default", + "}", + "// POST /checkout", + "bool useNew = flagdClient->evaluateBoolean(\"new-checkout\", false, user, plan);" + ], + "cat": "code", + "_slide": 253, + "_why": "examples/27-feature-flags/cpp/flag-service (flagd gRPC; no OpenFeature C++ SDK)" + }, + { + "op": "set_text", + "sldId": 508, + "shape_id": 6, + "paragraphs": [ + "c++ · flagd gRPC" + ], + "cat": "stack", + "_slide": 253 + }, + { + "op": "set_notes", + "sldId": 508, + "paragraphs": [ + "The C++ example evaluates flags through flagd's gRPC evaluation API, ResolveBoolean, with a small hand-written grpc++ stub: flagd's proto service is named Service, which collides with grpc::Service in generated C++. The request carries the flag key and an evaluation context: the targeting key, which makes a percentage rollout sticky, and attributes such as plan. A deadline bounds the call, and any failure returns the default, so a flagd outage leaves the new path dark instead of failing the request.", + "In the other languages the shape is the same through the OpenFeature SDK: register a flagd provider, build an evaluation context with a targeting key and attributes, and ask for a boolean with a safe default. The targeting rules live in flagd's configuration, not in the code, so the one call serves an on/off flag, a per-plan entitlement or a 25 percent rollout." + ], + "cat": "notes", + "_slide": 253 + }, + { + "op": "set_code", + "sldId": 511, + "shape_id": 8, + "lines": [ + "// ops kill switch: default true (fail open), on unless an operator turns it off", + "bool enabled = flagdClient->evaluateBoolean(\"recommendations-enabled\", true, user);", + "if (!enabled) {", + " response[\"recommendations\"] = Json::Value(Json::arrayValue); // page still renders", + " response[\"reason\"] = \"killed\";", + "} else {", + " response[\"recommendations\"] = live_recommendations(user); // with a circuit breaker", + " response[\"reason\"] = \"live\";", + "}" + ], + "cat": "code", + "_slide": 256, + "_why": "examples/27-feature-flags/cpp /recommendations handler" + }, + { + "op": "set_text", + "sldId": 511, + "shape_id": 6, + "paragraphs": [ + "c++ · flagd" + ], + "cat": "stack", + "_slide": 256 + }, + { + "op": "replace", + "scope": "both", + "find": "scaffold C++ stubs via openapi-generator-cli (cpp-pistache-server template is most mature).", + "repl": "scaffold C++ stubs with OpenAPI Generator (stable C++ server generators: cpp-pistache-server, cpp-httplib-server; none targets Drogon).", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless compendium's 'budget-aware' rule applies — never retry past the inbound deadline.", + "repl": "Cap the retry budget at the inbound deadline and never retry past it (cpp-statelessness Doc 07, patterncatalyst.github.io/cpp-statelessness/docs/07-state-externalization/).", + "expect": 1, + "sldIds": [ + 276 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Dependencies injected by reference (OrdersClient&, InventoryClient&) — same DI pattern as the cpp-stateless composition root.", + "repl": "Dependencies are injected by reference (OrdersClient&, InventoryClient&) from a composition root in main(), as in cpp-statelessness Doc 04 (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/).", + "expect": 1, + "sldIds": [ + 279 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless compendium's ScopedConnection pattern (RAII checkout, invalidate on broken) applies per service.", + "repl": "Each service checks connections out through an RAII guard that invalidates a broken connection instead of returning it to the pool (ScopedConn in cpp-statelessness Doc 07, patterncatalyst.github.io/cpp-statelessness/docs/07-state-externalization/).", + "expect": 1, + "sldIds": [ + 282 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Note the cpp-stateless idioms: PgTxn is an RAII transaction object (commit on success, rollback on destruction); the consumer is process-scoped;", + "repl": "PgTxn stands for a small RAII wrapper over a libpq transaction (BEGIN on construction, ROLLBACK in the destructor unless commit() ran); Example 04 writes the same BEGIN, COMMIT and ROLLBACK with libpq calls directly. The consumer is process-scoped;", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "cpp-httplib (header-only, MIT) is the lean HTTP client we picked back in §2; libcurl is the heavier alternative if you need richer auth or HTTP/2 multiplexing.", + "repl": "cpp-httplib (header-only, MIT, github.com/yhirose/cpp-httplib) is the lean HTTP client; libcurl (curl license, curl.se) is the heavier alternative when you need richer auth or HTTP/2 multiplexing.", + "expect": 1, + "sldIds": [ + 310 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "the OpenTelemetry C++ SDK (BSL/Apache 2.0, well-maintained)", + "repl": "the OpenTelemetry C++ SDK (opentelemetry-cpp, Apache 2.0, actively released)", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless deck has the configuration in detail.", + "repl": "cpp-statelessness Doc 04 shows the BatchSpanProcessor setup (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/).", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "the OpenTelemetry log appender bridges into the OTel pipeline. Never spdlog::basic_logger_mt — that's a file sink which dies on read-only rootfs (the cpp-stateless §11 'logs to stdout, not a file' rule).", + "repl": "the spdlog sink in opentelemetry-cpp-contrib bridges into the OTel pipeline. Never spdlog::basic_logger_mt: that's a file sink which dies on a read-only root filesystem (the 'logs to stdout, not a file' rule of cpp-statelessness Doc 08, patterncatalyst.github.io/cpp-statelessness/docs/08-ephemeral-filesystem/).", + "expect": 1, + "sldIds": [ + 317 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "For C++: opentelemetry-cpp-contrib has gRPC, libcurl, MySQL client instrumentations; for Drogon and librdkafka the instrumentation is manual middleware, not auto.", + "repl": "For C++: opentelemetry-cpp-contrib ships log bridges (spdlog, glog, Boost.Log, log4cxx) and nginx and Apache httpd modules; gRPC, Drogon, librdkafka and libpq spans are written by hand.", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "the C++ ecosystem covers gRPC, libcurl, MySQL well, but Drogon, librdkafka, libpq, and most C++ frameworks require you to write the boundary spans yourself.", + "repl": "for C++, opentelemetry-cpp-contrib covers logging bridges and the nginx and httpd modules, while gRPC, Drogon, librdkafka, libpq and most C++ frameworks need the boundary spans written by hand (opentelemetry-cpp's examples/grpc shows the context propagation).", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Outbound calls (gRPC ClientContext, Kafka producer headers) pick up the trace_id from the active span automatically via the OTel context propagation API — you don't manually propagate, but you do need to ensure your gRPC stubs are wrapped with the OTel interceptor (opentelemetry-cpp-contrib provides this for gRPC).", + "repl": "Outbound calls do not pick up the context on their own: inject it with the W3C trace-context propagator into a carrier (the gRPC ClientContext metadata, the Kafka message headers). opentelemetry-cpp's examples/grpc shows the carrier; opentelemetry-cpp-contrib has no gRPC interceptor.", + "expect": 1, + "sldIds": [ + 319 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless §6 covers PMR per-request arenas which bound memory per request; bulkheads bound CONCURRENCY per tenant.", + "repl": "PMR per-request arenas bound memory per request (cpp-statelessness Doc 03, patterncatalyst.github.io/cpp-statelessness/docs/03-pmr/); bulkheads bound concurrency per tenant.", + "expect": 1, + "sldIds": [ + 339 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless compendium covers the per-service in-process state patterns (PMR arenas, RAII connection pools);", + "repl": "cpp-statelessness covers the per-service in-process state patterns, PMR arenas and RAII connection pools (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/);", + "expect": 1, + "sldIds": [ + 346 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "and the cpp-stateless / cpp-container compendiums that accompany this deck.", + "repl": "and two Apache-2.0 companion projects: cpp-statelessness (patterncatalyst.github.io/cpp-statelessness/) on stateless C++ services in containers, and the C++ container optimization tutorial (patterncatalyst.github.io/cpp-container-optimization-tutorial/).", + "expect": 1, + "sldIds": [ + 349 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless compendium's port/adapter pattern lets you write the business logic once and adapt to all three boundaries — but in practice most C++ services pick one or two protocols and commit.", + "repl": "Ports and adapters (Appendix F, Example 19) let you write the business logic once and adapt it to all three boundaries, though in practice most C++ services pick one or two protocols and commit.", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless port/adapter pattern keeps the business logic clean of any protocol concerns;", + "repl": "The ports-and-adapters structure of Appendix F keeps the business logic clean of any protocol concerns;", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "When you upgrade protobuf itself (e.g., 3.21 → 4.0), every consumer rebuilds against the new stubs simultaneously. The cpp-stateless §3 Conan lockfile pinning is exactly the mechanism that keeps this manageable.", + "repl": "When you upgrade protobuf itself to a new major version, every consumer rebuilds against the new stubs at the same time. A committed Conan lockfile (conan.lock, as every C++ example here has) is the mechanism that keeps this manageable; cpp-statelessness Doc 11 shows the profiles around it (patterncatalyst.github.io/cpp-statelessness/docs/11-build-tooling/).", + "expect": 1, + "sldIds": [ + 366 + ], + "cat": "version", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "for hot paths you can use protobuf arena allocators (Arena::CreateMessage) to amortise that cost. The cpp-stateless §5 PMR pattern integrates cleanly with protobuf arenas.", + "repl": "for hot paths a protobuf arena (google::protobuf::Arena::Create(&arena); CreateMessage is no longer public in protobuf 6.33) amortises that cost. Keep the two allocators apart: protobuf messages on their arena or the default allocator, handler internals on the PMR request arena, with the boundary explicit in code review (cpp-statelessness Doc 03, patterncatalyst.github.io/cpp-statelessness/docs/03-pmr/).", + "expect": 1, + "sldIds": [ + 372 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Reuse the cpp-stateless §6 PMR arenas — per-connection allocations clear on disconnect.", + "repl": "Give each connection its own PMR arena (the per-request arena of cpp-statelessness Doc 03, applied to a connection's lifetime) so its allocations are released on disconnect.", + "expect": 1, + "sldIds": [ + 373 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Per-connection state in PMR arenas (cpp-stateless §6):", + "repl": "Per-connection state in a PMR arena (the cpp-statelessness Doc 03 pattern):", + "expect": 1, + "sldIds": [ + 374 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "If you need in-process retries (a sidecar isn't deployed), library options include hedge-cpp or just std::this_thread::sleep_for + backoff math.", + "repl": "If you need in-process retries (a sidecar isn't deployed), no widely adopted C++ retry library exists: write the loop with capped exponential backoff and jitter, as Example 26 does (retry_with_backoff in edge-service).", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ note: cpp-stateless §4 (composition root + adapter pattern) is exactly hexagonal.", + "repl": "C++ note: a composition root in main() wiring adapters into ports (Example 19) is hexagonal architecture.", + "expect": 1, + "sldIds": [ + 395 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the cpp-stateless §4 idiom.", + "repl": "cpp-statelessness Doc 04 describes the same composition-root idiom (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/).", + "expect": 1, + "sldIds": [ + 401 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless §6 PMR-arena pattern is what makes this affordable — you don't pay heap-allocation cost per aggregate, you pay arena cost per request.", + "repl": "A PMR request arena makes this affordable: you pay arena cost per request instead of a heap allocation per aggregate (cpp-statelessness Doc 03, patterncatalyst.github.io/cpp-statelessness/docs/03-pmr/).", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Hexagonal ports & adapters → cpp-stateless composition root + adapter pattern.", + "repl": "Hexagonal ports & adapters → a composition root in main() plus adapters (Example 19).", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Conan + lockfile (cpp-stateless §3) pins versions;", + "repl": "Conan + a committed conan.lock pins versions;", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless §9 covers this in detail. This appendix is the architectural framing; the cpp-stateless deck is the implementation reference. Together they cover the whole discipline.", + "repl": "This appendix is the architectural framing; Example 21 is the runnable C++ version, and cpp-statelessness Doc 09 covers the readiness and signal-handling side (patterncatalyst.github.io/cpp-statelessness/docs/09-health-checks/).", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless §9 covers a complete sequence: stop accepting → drain HTTP → stop Kafka poll → commit offsets → close DB pool → exit.", + "repl": "A complete sequence: stop accepting → drain HTTP → stop Kafka poll → commit offsets → close DB pool → exit (cpp-statelessness Doc 09 walks through the signal-handler side, patterncatalyst.github.io/cpp-statelessness/docs/09-health-checks/).", + "expect": 1, + "sldIds": [ + 418 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless §11 (and the cpp-statelessness compendium more broadly) addresses this directly:", + "repl": "cpp-statelessness addresses this directly (patterncatalyst.github.io/cpp-statelessness/docs/01-deployment-posture/):", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The script lives in a ConfigMap or a Git-versioned file; the C++ service watches for changes and re-loads.", + "repl": "The script lives in a ConfigMap or a Git-versioned file (ROUTING_RULES_FILE in Example 22); the service compiles it once at startup, so a bad script stops the rollout instead of reaching traffic.", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Hot-reload story: the cpp-stateless §7 watcher pattern picks up file changes and atomically swaps the loaded Lua state on a per-request boundary. No restart.", + "repl": "Example 22 changes the rule inputs (thresholds, topics) live through PUT /rules; a new script version ships as a rollout. A file watcher that swaps in a freshly compiled Lua state between requests is an extension, not part of the example.", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "libpq via PgConn from the cpp-stateless §5 pool.", + "repl": "libpq through a PgConn checked out from a process-scoped pool (ScopedConn in cpp-statelessness Doc 07, patterncatalyst.github.io/cpp-statelessness/docs/07-state-externalization/).", + "expect": 1, + "sldIds": [ + 468 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless §9 graceful-shutdown discipline matters more here than anywhere:", + "repl": "Graceful shutdown (Appendix H) matters more here than anywhere:", + "expect": 1, + "sldIds": [ + 475 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "The cpp-stateless idioms (RAII unwinds, std::stop_token cooperative shutdown) are what make recovery clean.", + "repl": "RAII unwinding and cooperative cancellation with std::stop_token make recovery clean (cpp-statelessness Docs 02 and 05, patterncatalyst.github.io/cpp-statelessness/).", + "expect": 1, + "sldIds": [ + 482 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "C++ ecosystem: Hystrix-CPP and breaker-cpp exist but neither dominates.", + "repl": "C++ has no widely adopted circuit-breaker library; Example 26 hand-rolls one (CircuitBreaker in edge-service).", + "expect": 1, + "sldIds": [ + 495 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Unleash is a capable open-source alternative backend behind the same OpenFeature SDK.", + "repl": "Unleash (AGPL-3.0) is an open-source alternative backend; OpenFeature's C++ contrib ships only a flagd provider, so a C++ service would call Unleash through its own API.", + "expect": 1, + "sldIds": [ + 515 + ], + "cat": "stack", + "_why": "C++ reference verification (Cpp manifest table)", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD)", + "repl": "validates JWTs from a modern IdP (Keycloak, Dex)", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Edge (IAP): Google IAP, Cloudflare Access, Pomerium, AWS Verified Access — ", + "repl": "Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — ", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Pomerium open source; Google IAP / Cloudflare Access / AWS Verified Access are managed.", + "repl": "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "an Identity-Aware Proxy (Google IAP, Cloudflare Access, Pomerium, AWS Verified Access)", + "repl": "an Identity-Aware Proxy (Pomerium or OAuth2 Proxy)", + "expect": 1, + "sldIds": [ + 335 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "; SAS (Shared Access Signature, Azure's scoped token)", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "AWS Builders' Library (timeouts, retries, backoff with jitter)", + "repl": "the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling)", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "the service hands the client a pre-signed PUT URL with a short expiry and a scoped key", + "repl": "the service hands the client a signed ticket with a short expiry, scoped to one key and one operation", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "the C++ service mints the URL in microseconds, the upload happens directly to S3 or MinIO,", + "repl": "the C++ service mints the ticket in microseconds, the upload goes directly to the object store,", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "uri: 's3://claims/abc-123'", + "repl": "uri: 'https://objects.internal/claims/abc-123'", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "where you can use bucket policies, KMS keys, audit logs", + "repl": "where you can use per-object access policies, KMS keys, audit logs", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "; AWS SDK for C++ enables valet-key and claim-check directly.", + "repl": "; Example 12 mints valet-key tickets with jwt-cpp, and a claim check carries the same ticket.", + "expect": 1, + "sldIds": [ + 344 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "(Drogon, gRPC, librdkafka, OpenTelemetry C++, AWS SDK for C++)", + "repl": "(Drogon, gRPC, librdkafka, OpenTelemetry C++, jwt-cpp)", + "expect": 1, + "sldIds": [ + 348 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 864, + "paragraphs": [ + "Valet key — scoped, time-bound, op-restricted" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 866, + "paragraphs": [ + "c++ · Drogon + jwt-cpp" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 333, + "shape_id": 867, + "lines": [ + "// valet.hpp (excerpt), examples/12-security/cpp: the valet key is an HS256 JWT (jwt-cpp)", + "inline Minted mint(const std::string& secret, const std::string& resource,", + " const std::string& operation,", + " std::chrono::seconds ttl = std::chrono::minutes(5)) { // time-bound", + " const auto now = std::chrono::system_clock::now();", + " const auto exp = std::chrono::time_point_cast(now + ttl);", + " auto token = jwt::create()", + " .set_type(\"JWT\")", + " .set_issuer(kIssuer) // \"order-service\"", + " .set_issued_at(now)", + " .set_expires_at(exp)", + " .set_payload_claim(\"res\", jwt::claim(resource)) // one object", + " .set_payload_claim(\"op\", jwt::claim(operation)) // one operation", + " .sign(jwt::algorithm::hs256{secret});", + " return {std::move(token), exp.time_since_epoch().count()};", + "}", + "// verify(): HS256 pinned (\"none\" and RS256 never reach the verifier), then iss, res, op", + "const auto decoded = jwt::decode(token);", + "if (decoded.get_algorithm() != \"HS256\") return false;", + "jwt::verify().allow_algorithm(jwt::algorithm::hs256{secret}).with_issuer(kIssuer)", + " .with_claim(\"res\", jwt::claim(resource)).with_claim(\"op\", jwt::claim(operation))", + " .verify(decoded); // signature + exp; throws on failure" + ], + "cat": "code", + "_src": "examples/12-security/cpp/order-service/src/valet.hpp (mint, verify)", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 333, + "paragraphs": [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for five minutes; after that it is worthless. In Example 12 the ticket is an HS256 JWT signed with jwt-cpp: the res and op claims carry the scope, exp the expiry, iss the issuer. POST /valet-key returns the resource, the operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) reads the header first and rejects anything but HS256, so alg=none or an RS256 token never reaches the verifier; then jwt::verify checks the signature, the expiry, the issuer and the res and op claims, and the exp must equal the expiry the client presents. Any mismatch is a 403.", + "The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth. What this saves: the Drogon worker thread is back in the pool before the upload starts." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 969, + "paragraphs": [ + "c++ · rdkafka++ + jwt-cpp" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 342, + "shape_id": 970, + "lines": [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "void publish_with_claim(const std::string& order_id, const std::string& body) {", + " const auto key = \"payloads/\" + order_id;", + " store_.put(key, body, /*encrypt=*/true); // 1. store: encrypted, ACL'd", + " auto ticket = cndp::valet::mint(VALET_SECRET, key, \"GET\", std::chrono::minutes(15));", + " auto claim = nlohmann::json{{\"order_id\", order_id},", + " {\"uri\", \"https://objects.internal/\" + key},", + " {\"token\", ticket.token}, {\"expires\", ticket.expires}}.dump();", + " producer_->produce(\"order.documents\", RdKafka::Topic::PARTITION_UA,", + " RdKafka::Producer::RK_MSG_COPY, claim.data(), claim.size(),", + " order_id.data(), order_id.size(), 0, nullptr); // 2. claim only", + "}", + "// Consumer: consume the claim, fetch the payload, then handle", + "std::unique_ptr msg(consumer_->consume(100));", + "if (msg->err() == RdKafka::ERR_NO_ERROR) {", + " auto claim = nlohmann::json::parse(payload_of(*msg));", + " auto body = store_.get(claim[\"uri\"].get(),", + " claim[\"token\"].get()); // the store checks the ticket", + " handle(body); // PII never touched the topic", + " consumer_->commitSync(msg.get()); // after handle", + "}" + ], + "cat": "code", + "_src": "_docs/12-security.md claim-check codetab (site-fixes.md); ticket from examples/12-security", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 342, + "paragraphs": [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's cndp::valet::mint (15 minutes), and publishes only a small claim (order id, URI and the ticket) to order.documents with rdkafka++. The PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 971, + "paragraphs": [ + "Payloads sit in encrypted object storage; Kafka carries only the claim and its ticket. PII never crosses the broker." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 332, + "shape_id": 854, + "png": "lgtm-presentation/edits/png/12-valet-key.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "labels named S3, Azure SAS, GCS, STS (tools/vendor_figures.py)" + }, + { + "op": "replace_picture", + "sldId": 335, + "shape_id": 888, + "png": "lgtm-presentation/edits/png/12-zero-trust.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "examples named Google IAP, Cloudflare Access, AWS Verified Access" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 30, + "paragraphs": [ + "07 · Workflows & Jobs", + "08 · API Management", + "09 · API Registry", + "10 · API Metadata", + "11 · Observability", + "12 · Security", + "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices K–L · Monolith → Microservices, Caching Patterns", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendix R · Example 05 on minikube", + "Glossary of acronyms" + ], + "cat": "new", + "_why": "agenda gains Appendices R-S (C++: R)" + }, + { + "op": "set_notes", + "sldId": 257, + "paragraphs": [ + "The agenda: the same scope as the other decks in the series. A short setup, thirteen sections, the deep-dive appendices A to O, Appendix R and a glossary. Every section is shown in idiomatic C++23; the architecture and the running example are the same as in the other languages.", + "Appendix R runs Example 05 on minikube with the same verify.sh as compose.", + "Tell the audience which sections you will cover given the session length." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 516, + "as": "r0", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 3, + "paragraphs": [ + "R" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 4, + "paragraphs": [ + "Example 05 on minikube" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 5, + "paragraphs": [ + "The same services and the same verify.sh on a single-node Kubernetes cluster" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r0", + "paragraphs": [ + "Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod.", + "The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r1", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r1", + "text": "244", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 5, + "paragraphs": [ + "From compose to a cluster" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 6, + "paragraphs": [ + "Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress.", + "Scope: Example 05 (event-driven fan-out), three services, all six languages, one node.", + "One Kafka broker, one Postgres instance, no service mesh: a local topology, not production.", + "The contract carries over: Service names, the Secret, environment variables, the health endpoint.", + "The same verify.sh runs against compose and the cluster: ten checks per language." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 7, + "paragraphs": [ + "Site: Appendix R · Code: examples/05-event-driven/k8s/" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r1", + "paragraphs": [ + "What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it.", + "What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged.", + "The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r2", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r2", + "text": "245", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 5, + "paragraphs": [ + "The minikube runtime" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 6, + "paragraphs": [ + "Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5.", + "8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services.", + "NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation.", + "No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop.", + "Every command passes --context cndp-k8s; setup-profile.sh restores the prior context." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 7, + "paragraphs": [ + "minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r2", + "paragraphs": [ + "minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version.", + "Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1.", + "The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r3", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r3", + "text": "246", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 5, + "paragraphs": [ + "One node, two namespaces" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r3", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-topology.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 7, + "paragraphs": [ + "The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r3", + "paragraphs": [ + "The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret.", + "From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r4", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r4", + "text": "247", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 5, + "paragraphs": [ + "Compose to Kubernetes" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 6, + "paragraphs": [ + "kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1).", + "postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw.", + "Credential → Secret cndp-postgres-app, read through secretKeyRef.", + "build: → docker build, then minikube image load; imagePullPolicy: Never.", + "healthcheck → startup and readiness probes on the order-service /healthz.", + "ports: 8080:8080 → NodePort 30080." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 7, + "paragraphs": [ + "The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r4", + "paragraphs": [ + "Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts.", + "The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r5", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r5", + "text": "248", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 5, + "paragraphs": [ + "One overlay per language" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 6, + "paragraphs": [ + "yaml · kustomize" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r5", + "shape_id": 8, + "lines": [ + "# k8s/overlays/cpp/kustomization.yaml (excerpt)", + "images:", + " - name: order-service", + " newName: localhost/cndp-05/cpp-order-service", + " newTag: v1", + " # shipping-consumer and notification-consumer map the same way", + "# appended to each Deployment's environment by a JSON patch", + "- name: PG_CONNINFO", + " value: \"postgresql://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb\"", + "- name: KAFKA_BOOTSTRAP", + " value: \"cndp-kafka-bootstrap:9092\"", + "# memory: requests 128Mi, limits 256Mi" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 9, + "paragraphs": [ + "base/ is language-neutral; each overlay maps the images and appends the variables its language reads." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r5", + "paragraphs": [ + "The C++ overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service.", + "Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r6", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r6", + "text": "249", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 5, + "paragraphs": [ + "Images built on the host" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r6", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-image-flow.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 7, + "paragraphs": [ + "docker build on the host, minikube image load into the node; pods never pull" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r6", + "paragraphs": [ + "The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load.", + "The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r7", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r7", + "text": "250", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 5, + "paragraphs": [ + "Run it" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 6, + "paragraphs": [ + "bash · minikube" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r7", + "shape_id": 8, + "lines": [ + "# from examples/05-event-driven/, with no other profile or container running", + "k8s/setup-profile.sh # create cndp-k8s; check the published ports", + "k8s/build-images.sh cpp # docker build, then minikube image load", + "k8s/deploy.sh cpp # Strimzi, Kafka, Postgres, LGTM, then the overlay", + "RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh", + "", + "# all six languages, a fresh namespace for each", + "OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh", + "", + "# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile", + "k8s/teardown.sh --operator", + "minikube stop -p cndp-k8s" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 9, + "paragraphs": [ + "With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r7", + "paragraphs": [ + "The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three C++ images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay.", + "verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0.", + "What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r8", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r8", + "text": "251", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 5, + "paragraphs": [ + "Recorded results" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 6, + "paragraphs": [ + "10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0).", + "Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s.", + "Tempo received traces from order-service, shipping-consumer and notification-consumer in every language.", + "shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset.", + "Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 7, + "paragraphs": [ + "Times cover the image build and load, the deploy, the checks and the namespace teardown." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r8", + "paragraphs": [ + "The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language.", + "The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster." + ], + "cat": "new" + }, + { + "op": "resize_shape", + "sldId": 279, + "shape_id": 270, + "cx": 1826230, + "keep": "right", + "cat": "fit", + "_fit": "slide 24: chip 'c++ · cppgraphqlgen' wraps; one line at 11.0 pt" + }, + { + "op": "fit_font", + "sldId": 279, + "shape_id": 271, + "pt": 9.5, + "cat": "fit", + "_fit": "slide 24: 24 code lines, 82 columns; 10.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 279, + "shape_id": 272, + "y": 5712714, + "cy": 492506, + "cat": "fit", + "_fit": "slide 24: text overflows its frame by overflow-top 3.6 pt, overflow-bottom 4.7 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 284, + "shape_id": 326, + "cx": 1826230, + "keep": "right", + "cat": "fit", + "_fit": "slide 29: chip 'c++ · libpq + kafka' wraps; one line at 11.0 pt" + }, + { + "op": "resize_shape", + "sldId": 319, + "shape_id": 714, + "cx": 1826230, + "keep": "right", + "cat": "fit", + "_fit": "slide 64: chip 'c++ · OpenTelemetry' wraps; one line at 11.0 pt" + }, + { + "op": "resize_shape", + "sldId": 321, + "shape_id": 738, + "cx": 1826230, + "keep": "right", + "cat": "fit", + "_fit": "slide 66: chip 'c++ · OpenTelemetry' wraps; one line at 11.0 pt" + }, + { + "op": "resize_shape", + "sldId": 333, + "shape_id": 866, + "cx": 2077689, + "keep": "right", + "cat": "fit", + "_fit": "slide 78: chip 'c++ · Drogon + jwt-cpp' wraps; one line at 11.0 pt" + }, + { + "op": "fit_font", + "sldId": 338, + "shape_id": 922, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 83: 25 code lines, 85 columns; 10.0 pt overflows the box" + }, + { + "op": "fit_font", + "sldId": 340, + "shape_id": 946, + "pt": 9.5, + "cat": "fit", + "_fit": "slide 85: 24 code lines, 65 columns; 10.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 342, + "shape_id": 969, + "cx": 2329150, + "keep": "right", + "cat": "fit", + "_fit": "slide 87: chip 'c++ · rdkafka++ + jwt-cpp' wraps; one line at 11.0 pt" + }, + { + "op": "fit_font", + "sldId": 365, + "shape_id": 1216, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 110: 25 code lines, 73 columns; 10.0 pt overflows the box" + }, + { + "op": "fit_font", + "sldId": 368, + "shape_id": 1249, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 113: 25 code lines, 79 columns; 10.0 pt overflows the box" + }, + { + "op": "fit_font", + "sldId": 385, + "shape_id": 1442, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 130: 25 code lines, 78 columns; 10.0 pt overflows the box" + }, + { + "op": "fit_font", + "sldId": 387, + "shape_id": 1466, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 132: 25 code lines, 80 columns; 10.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 391, + "shape_id": 1513, + "cx": 1826230, + "keep": "right", + "cat": "fit", + "_fit": "slide 136: chip 'c++ · cppgraphqlgen' wraps; one line at 11.0 pt" + }, + { + "op": "fit_font", + "sldId": 391, + "shape_id": 1514, + "pt": 9.5, + "cat": "fit", + "_fit": "slide 136: 24 code lines, 76 columns; 10.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 401, + "shape_id": 1622, + "y": 5712714, + "cy": 492506, + "cat": "fit", + "_fit": "slide 146: text overflows its frame by overflow-top 3.6 pt, overflow-bottom 4.7 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 444, + "shape_id": 2088, + "cx": 1826230, + "keep": "right", + "cat": "fit", + "_fit": "slide 189: chip 'c++ · Drogon + sol2' wraps; one line at 11.0 pt" + }, + { + "op": "resize_shape", + "sldId": 461, + "shape_id": 2275, + "y": 5712714, + "cy": 492506, + "cat": "fit", + "_fit": "slide 206: text overflows its frame by overflow-top 3.6 pt, overflow-bottom 4.7 pt; free space around the frame" + }, + { + "op": "fit_font", + "sldId": 472, + "shape_id": 2400, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 217: 25 code lines, 77 columns; 10.0 pt overflows the box" + }, + { + "op": "cut_code", + "sldId": 468, + "shape_id": 2352, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 18, + "to": 18, + "first": "" + } + ], + "pt": 9.5, + "cat": "fit", + "_fit": "slide 213: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 470, + "shape_id": 2376, + "cuts": [ + { + "from": 5, + "to": 8, + "first": " sw::redis::Redis& redis_;", + "last": " std::string prefix_;", + "ellipsis": " // ... members: redis_, loader_, ttl_, prefix_" + } + ], + "pt": 9.5, + "cat": "fit", + "_fit": "slide 215: 27 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 474, + "shape_id": 2424, + "cuts": [ + { + "from": 13, + "to": 18, + "first": "// Optional defensive: invalidate any stale entry (e.g. from a sibling read)", + "last": "}", + "ellipsis": "// ... optional variant: after the same INSERT, cache_.del(\"event:\" + id)" + } + ], + "pt": 10.0, + "cat": "fit", + "_fit": "slide 219: 28 code lines do not fit the box at 9 pt; excerpt of 23 lines at 10.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 476, + "shape_id": 2448, + "cuts": [ + { + "from": 18, + "to": 24, + "first": " PgTxn txn = pg_.begin();", + "last": " txn.commit();", + "ellipsis": " // ... one transaction: INSERT each metric:{id} hash into metrics, commit" + } + ], + "pt": 9.5, + "cat": "fit", + "_fit": "slide 221: 30 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 478, + "shape_id": 2472, + "cuts": [ + { + "from": 7, + "to": 12, + "first": " if (auto hit = cache_.get(key)) { cb(json_response(*hit)); co_return; }", + "last": " cb(json_response(body));", + "ellipsis": " // ... then cache-aside on key with TTL_S" + } + ], + "pt": 9.5, + "cat": "fit", + "_fit": "slide 223: 29 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 492, + "shape_id": 2623, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// Every C++ client has an EXPLICIT timeout; deadlines propagate downstream.", + "last": "using namespace std::chrono;", + "ellipsis": "// ... #include ; using namespace std::chrono;" + }, + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 9, + "to": 9, + "first": " // -------- gRPC: deadline propagation is built-in -------------" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 237: 29 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 494, + "shape_id": 2647, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "// Prefer Istio retry policy when possible; in-process retry is for when you can't." + }, + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 27, + "to": 27, + "first": "" + } + ], + "pt": 9.5, + "cat": "fit", + "_fit": "slide 239: 27 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 496, + "shape_id": 2671, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "// Production C++ shops typically let Istio do this; included for reference." + }, + { + "from": 4, + "to": 9, + "first": " enum State { Closed, Open, HalfOpen };", + "last": " static constexpr seconds RESET_AFTER = 30s;", + "ellipsis": " // ... state_ (Closed, Open, HalfOpen), failures_, opened_at_; THRESHOLD 5, RESET_AFTER 30s" + } + ], + "pt": 9.5, + "cat": "fit", + "_fit": "slide 241: 30 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.5 pt, was 10.0 pt" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "fit", + "_fit": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 429, + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 2, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 440 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox-cpp.png", + "cat": "voice", + "_why": "figure heading 'Dual-write is a lie' -> 'Dual-write is unsafe' (src/04-dual-write-outbox.svg)", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 336 + ], + "find": "validate identity (OIDC against your IdP), check device posture, issue a JWT", + "repl": "validate identity (OIDC against your IdP), apply the route's access policy, issue a JWT", + "expect": 1, + "cat": "code", + "_why": "OAuth2 Proxy has no device-posture check (C++ slide wording)", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 335 + ], + "find": "validates identity against your IdP (OIDC), checks device posture, and issues a JWT", + "repl": "validates identity against your IdP (OIDC), applies the route's access policy, and issues a JWT", + "expect": 1, + "cat": "code", + "_why": "OAuth2 Proxy has no device-posture check", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "std::make_unique(50)); // 50 / tenant", + "repl": "std::make_unique(5)); // 5 / tenant", + "expect": 1, + "cat": "code", + "_why": "examples/12-security/cpp CAPACITY = 5", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "50 concurrent requests per tenant", + "repl": "5 concurrent requests per tenant", + "expect": 1, + "cat": "code", + "_why": "bulkhead 5", + "_src": "content", + "_fixA": true + }, + { + "op": "set_geometry", + "sldId": 531, + "shape": "Image 1", + "x": 2.16, + "y": 1.95, + "w": 9.01, + "h": 4.3, + "cat": "fit", + "_why": "Appendix R topology figure enlarged to the free area, as Q.fix2 does in Quarkus", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: brand-new field", + "repl": "safe: brand-new field", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: new field; old clients", + "repl": "safe: new field; old clients", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "BREAKING (don't)", + "repl": "breaking (don't)", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 284 + ], + "find": "a DIFFERENT, denormalised", + "repl": "a different, denormalised", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 474 + ], + "find": "deliberately NOT writing to cache", + "repl": "no cache write here", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "Do NOT retry a non-idempotent POST", + "repl": "Do not retry a non-idempotent POST", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 264 + ], + "find": "NO in-process", + "repl": "no in-process", + "expect": 4, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 268 + ], + "find": "parsed ONCE in main()", + "repl": "parsed once in main()", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "configured ONCE per artifact", + "repl": "configured once per artifact", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 321 + ], + "find": "Set ONCE at the edge", + "repl": "Set once at the edge", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 280 + ], + "find": "Push the aggregation OUT to the edge", + "repl": "Push the aggregation out to the edge", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 435 + ], + "find": "things to design OUT of a C++ service", + "repl": "things to design out of a C++ service", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 284 + ], + "find": "writes the row AND an outbox row", + "repl": "writes the row and an outbox row", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "write state AND an outbox row", + "repl": "write state and an outbox row", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "Event STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "are TRANSPORTED", + "repl": "are transported", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "use event STREAMING (with", + "repl": "use event streaming (with", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 302 + ], + "find": "There is NO first-class Temporal C++ SDK", + "repl": "There is no first-class Temporal C++ SDK", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 302 + ], + "find": "as ACTIVITIES, invoked", + "repl": "as activities, invoked", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "for THIS tenant?", + "repl": "for this tenant?", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 319 + ], + "find": "// BATCH, not Simple", + "repl": "// batch, not simple", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 321 + ], + "find": "To FILTER traces", + "repl": "To filter traces", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 322 + ], + "find": "ONE trace_id binds", + "repl": "One trace_id binds", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL: stops", + "repl": "Essential: stops", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 330 + ], + "find": "audit logging UNIFORMLY across", + "repl": "audit logging uniformly across", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 332 + ], + "find": "uploading a file THROUGH the C++", + "repl": "uploading a file through the C++", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 336 + ], + "find": "lives in YOUR logs", + "repl": "lives in your logs", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 336 + ], + "find": "record WHO made each request", + "repl": "record who made each request", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 336 + ], + "find": "and WHAT they were", + "repl": "and what they were", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "are a SEPARATE bulkhead", + "repl": "are a separate bulkhead", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 362 + ], + "find": "use the field NAMES for accessor", + "repl": "use the field names for accessor", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 363 + ], + "find": "for any tag number AND name", + "repl": "for any tag number and name", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 366 + ], + "find": "stubs are BUILD output", + "repl": "stubs are build output", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 368 + ], + "find": "// THIS pod now owns this user", + "repl": "// this pod now owns the user", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 371 + ], + "find": "when ANY pod wants", + "repl": "when any pod wants", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "produce IDENTICAL output", + "repl": "produce identical output", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "a STABLE machine code", + "repl": "a stable machine code", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 389 + ], + "find": "you've LOST the record", + "repl": "you've lost the record", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 390 + ], + "find": "Other fields STILL RETURN", + "repl": "Other fields still return", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "clients ALL retry now", + "repl": "clients all retry now", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 395 + ], + "find": "the LANGUAGE to talk about WHERE service", + "repl": "the language to talk about where service", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 396 + ], + "find": "business: CORE (your", + "repl": "business: core (your", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 396 + ], + "find": "goes), SUPPORTING (necessary", + "repl": "goes), supporting (necessary", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 396 + ], + "find": "shipping), GENERIC (commoditised", + "repl": "shipping), generic (commoditised", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 397 + ], + "find": "Inside the context, ONE model.", + "repl": "Inside the context, one model.", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 397 + ], + "find": "boundaries SHOULD match", + "repl": "boundaries should match", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 399 + ], + "find": "published language for ALL consumers", + "repl": "published language for all consumers", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 399 + ], + "find": "published TO?", + "repl": "published to?", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 401 + ], + "find": "injection by REFERENCE, not", + "repl": "injection by reference, not", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 402 + ], + "find": "An AGGREGATE is a consistency", + "repl": "An aggregate is a consistency", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 402 + ], + "find": "stay within ONE aggregate", + "repl": "stay within one aggregate", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 402 + ], + "find": "through DOMAIN EVENTS (asynchronously", + "repl": "through domain events (asynchronously", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 402 + ], + "find": "name what BUSINESS-LEVEL happened", + "repl": "name what happened at the business level", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 405 + ], + "find": "framing is BALANCED coupling", + "repl": "framing is balanced coupling", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 407 + ], + "find": "change TOGETHER need", + "repl": "change together need", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 409 + ], + "find": "shared database: AVOID.", + "repl": "shared database: avoid.", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 411 + ], + "find": "services have INDEPENDENT volatility", + "repl": "services have independent volatility", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 413 + ], + "find": "by REAL DDD-bounded contexts and INDEPENDENT volatility", + "repl": "by real DDD-bounded contexts and independent volatility", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 411 + ], + "find": "Two things that DO change together", + "repl": "Two things that do change together", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 412 + ], + "find": "are MODERATE strength", + "repl": "are moderate strength", + "expect": 2, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 412 + ], + "find": "are LOW strength on read", + "repl": "are low strength on read", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 412 + ], + "find": "but MODERATE on write paths", + "repl": "but moderate on write paths", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 412 + ], + "find": "are LOWEST strength", + "repl": "are lowest strength", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 412 + ], + "find": "stubs are GENERATED C++", + "repl": "stubs are generated C++", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 413 + ], + "find": "in one service is FREE", + "repl": "in one service is free", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "between modules is FREE", + "repl": "between modules is free", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 415 + ], + "find": "Kubernetes ASSUMES this", + "repl": "Kubernetes assumes this", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "Fail readiness FIRST.", + "repl": "Fail readiness first.", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "start the countdown EARLY", + "repl": "start the countdown early", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 418 + ], + "find": "continue on OTHER pods", + "repl": "continue on other pods", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 418 + ], + "find": "should have ONE shutdown coordinator", + "repl": "should have one shutdown coordinator", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 420 + ], + "find": "Failing it RESTARTS the pod", + "repl": "Failing it restarts the pod", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 422 + ], + "find": "and TEST it under load", + "repl": "and test it under load", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 448 + ], + "find": "happens WITHOUT modifying your binary", + "repl": "happens without modifying your binary", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "a unit of DEPLOYMENT, not", + "repl": "a unit of deployment, not", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 456 + ], + "find": "splitting the data FIRST while", + "repl": "splitting the data first while", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "(1) IDENTIFY the seam", + "repl": "(1) Identify the seam", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "(2) MOVE — implement", + "repl": "(2) Move — implement", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 460 + ], + "find": "a USEFUL INTERMEDIATE state", + "repl": "a useful intermediate state", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is often FASTER and more", + "repl": "is often faster and more", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 487 + ], + "find": "health to OBSERVED traffic", + "repl": "health to observed traffic", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 500 + ], + "find": "shutdown IS a failure mode", + "repl": "shutdown is a failure mode", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 501 + ], + "find": "How to SEE the failure modes", + "repl": "How to see the failure modes", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "reads the payload AND business context", + "repl": "reads the payload and business context", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "recolour_code", + "sldId": 387, + "shape_id": 1466, + "allow_noop": true, + "cat": "code", + "_src": "colour", + "_fixA": true + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix1.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix1.json new file mode 100644 index 00000000..61a770c9 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix1.json @@ -0,0 +1,4 @@ +{"ops":[ + {"op":"set_text","sldId":377,"shape_id":1345,"paragraphs":["Two Blazor hosting models with different scaling profiles; both run on plain ASP.NET Core Pods and need no managed SignalR service."],"_cat":"fit","_why":"one line again, clear of the figure's footnote (the figure already says both are open source)"}, + {"op":"resize_shape","sldId":377,"shape_id":1345,"y":5943600,"cy":310800,"_cat":"fit","_why":"restore the committed caption frame below the figure"} +]} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix2.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix2.json new file mode 100644 index 00000000..b78dd46d --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix2.json @@ -0,0 +1,2030 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 525 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 295 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 319 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 319 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 319 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 320 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 320 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 326 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 326 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 326 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 330 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 418 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 425 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 449 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 449 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 459 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 472 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 478 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 496 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 512 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 515 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 517 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 287 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 288 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 319 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 322 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 322 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 325 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 339 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 343 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 390 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 391 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 400 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 424 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 444 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 455 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 455 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 468 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 468 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 479 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 481 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 503 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 505 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 513 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 513 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 516 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 516 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 435 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 435 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 507 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "cat": "code", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 448 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code", + "_src": "capsfix" + }, + { + "op": "replace_picture", + "sldId": 288, + "shape": "Google Shape;370;p35", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure heading 'Dual-write is a lie' -> 'Dual-write is unsafe' (src/04-dual-write-outbox.svg)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 338 + ], + "find": "validate identity (OAuth/OIDC against your IdP), check device posture, then issue a JWT", + "repl": "validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT", + "expect": 1, + "cat": "code", + "_why": "OAuth2 Proxy has no device-posture check; the proxies enforce per-route identity policy", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 346 + ], + "find": "; Microsoft Cloud Adoption Framework: security patterns.", + "repl": "; OWASP Microservices Security Cheat Sheet.", + "expect": 1, + "cat": "stack", + "_why": "vendor-neutral reference (decision 7)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 320 + ], + "find": "tracer.start_span(...), span.set_attribute(...), span.add_event(...)", + "repl": "activitySource.StartActivity(...), activity.SetTag(...), activity.AddEvent(...)", + "expect": 1, + "cat": "code", + "_why": "System.Diagnostics Activity API (OpenTelemetry .NET), not Python's", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 342 + ], + "find": "new SemaphoreSlim(20)); // 20 / tenant", + "repl": "new SemaphoreSlim(5)); // 5 / tenant", + "expect": 1, + "cat": "code", + "_why": "examples/12-security/dotnet bulkheadLimit = 5", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 342 + ], + "find": "its own twenty slots", + "repl": "its own five slots", + "expect": 1, + "cat": "code", + "_why": "bulkhead 5", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 270 + ], + "find": "app.MapHealthChecks(\"/healthz/live\", new()", + "repl": "app.MapHealthChecks(\"/healthz\", new()", + "expect": 1, + "cat": "code", + "_why": "examples/01-cloud-native-principles/dotnet maps /healthz and /readyz", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 270 + ], + "find": "app.MapHealthChecks(\"/healthz/ready\", new()", + "repl": "app.MapHealthChecks(\"/readyz\", new()", + "expect": 1, + "cat": "code", + "_why": "examples/01-cloud-native-principles/dotnet maps /healthz and /readyz", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 427 + ], + "find": "// /healthz/ready → 503", + "repl": "// /readyz → 503", + "expect": 1, + "cat": "code", + "_why": "examples/21-graceful-shutdown/dotnet maps /readyz", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 466 + ], + "find": "Map health checks at /healthz/live and /healthz/ready;", + "repl": "Map health checks at /healthz and /readyz;", + "expect": 1, + "cat": "code", + "_why": "the examples' probe paths", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 383 + ], + "find": "s.Status = \"FAILED\";", + "repl": "s.Status = \"COMPENSATED\";", + "expect": 1, + "cat": "code", + "_why": "examples/17-sagas/dotnet ends a compensated saga in COMPENSATED (the notes say so after capsfix)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 296 + ], + "find": "Event-driven tools landscape — Kafka", + "repl": "Event-driven tools landscape — Kafka", + "expect": 1, + "cat": "voice", + "_why": "title spacing (one space around the dash, as the other titles)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 335 + ], + "find": "Valet key — scoped", + "repl": "Valet key — scoped", + "expect": 1, + "cat": "voice", + "_why": "title spacing (Q.fix2 in Quarkus)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 353 + ], + "find": "Glossary of acronyms (1 of 2) — protocols", + "repl": "Glossary of acronyms (1 of 2) — protocols", + "expect": 1, + "cat": "voice", + "_why": "title spacing, as the (2 of 2) title", + "_src": "content" + }, + { + "op": "set_geometry", + "sldId": 562, + "shape": "Image 1", + "x": 2.16, + "y": 1.95, + "w": 9.01, + "h": 4.3, + "cat": "fit", + "_why": "Appendix R topology figure enlarged to the free area, as Q.fix2 does in Quarkus", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 364 + ], + "find": "a field with a NEW number", + "repl": "a field with a new number", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 365 + ], + "find": "SAFE: brand-new field", + "repl": "safe: brand-new field", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 365 + ], + "find": "SAFE: new field; old clients", + "repl": "safe: new field; old clients", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 365 + ], + "find": "BREAKING (don't)", + "repl": "breaking (don't)", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 471 + ], + "find": "IDENTIFY: pick", + "repl": "Identify: pick", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 471 + ], + "find": "MOVE: build", + "repl": "Move: build", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "is the constraint AND eventual durability is acceptable.", + "repl": "is the constraint and eventual durability is acceptable.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 490 + ], + "find": "only ONE flusher Pod", + "repl": "only one flusher Pod", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 492 + ], + "find": "only ONE Pod refreshing", + "repl": "only one Pod refreshing", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 472 + ], + "find": "Pods — OR ", + "repl": "Pods — or ", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 282 + ], + "find": "SCALING THE BLAZOR SERVER POOL", + "repl": "scaling the Blazor Server pool", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 289 + ], + "find": "this REPLACES the MSDTC", + "repl": "this replaces the MSDTC", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "this REPLACES the old MSDTC", + "repl": "this replaces the old MSDTC", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 289 + ], + "find": "'save the order AND queue the event'", + "repl": "'save the order and queue the event'", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 289 + ], + "find": "this is THE most important pattern", + "repl": "this is the most important pattern", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 289 + ], + "find": "You DON'T want one", + "repl": "You don't want one", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 289 + ], + "find": "write the state change AND an outbox row in ONE local", + "repl": "write the state change and an outbox row in one local", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 312 + ], + "find": "server-side WITHOUT persisting it", + "repl": "server-side without persisting it", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 463 + ], + "find": "your .NET Pods talk TO", + "repl": "your .NET Pods talk to", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 486 + ], + "find": "SET, not REMOVE", + "repl": "set, not remove", + "expect": 2, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 486 + ], + "find": "SET, not REMOVE", + "repl": "set, not remove", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 486 + ], + "find": "on all OTHER Pods", + "repl": "on all other Pods", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 492 + ], + "find": "read STILL returns", + "repl": "read still returns", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 506 + ], + "find": "when EITHER the caller", + "repl": "when either the caller", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 508 + ], + "find": "backoff WITH jitter", + "repl": "backoff with jitter", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 486 + ], + "find": "IDistributedCache OR + FusionCache", + "repl": "IDistributedCache or FusionCache", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 492 + ], + "find": "sorted set OR FusionCache", + "repl": "sorted set or FusionCache", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 437 + ], + "find": " and give you the mesh CRDs we'll see next.", + "repl": " and give you the mesh CRDs (VirtualService, DestinationRule).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 415 + ], + "find": "Strength is a ladder we'll see next (intrusive to contract).", + "repl": "Strength is a ladder (intrusive to contract).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 370 + ], + "find": "the scaling problem the following slides solve with a Redis backplane", + "repl": "the scaling problem a Redis backplane solves", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 504 + ], + "find": "The next slides cover each with code; the final matrix maps patterns to modes.", + "repl": "Each one gets code, and a matrix maps patterns to modes.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 471 + ], + "find": "The next slides cover three ways to do the redirect:", + "repl": "There are three ways to do the redirect:", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 504 + ], + "find": "The next slides give each its own diagram and per-language code", + "repl": "Each gets its own diagram and per-language code", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 271 + ], + "find": "The next slides give you a decision rule and then show each protocol in code", + "repl": "The section gives a decision rule and then shows each protocol in code", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 439 + ], + "find": "payload modification that we'll see more of — the L7 router", + "repl": "payload modification — the L7 router", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 297 + ], + "find": "state, which is the next section.", + "repl": "state, the subject of stream processing.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 331 + ], + "find": "that the next slides alternate between pattern-and-implementation pairs", + "repl": "that the section alternates between pattern-and-implementation pairs", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "recolour_code", + "sldId": 270, + "shape_id": 167, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 273, + "shape_id": 201, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 274, + "shape_id": 214, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 280, + "shape_id": 283, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 286, + "shape_id": 349, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 289, + "shape_id": 383, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 293, + "shape_id": 428, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 300, + "shape_id": 506, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 312, + "shape_id": 642, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 321, + "shape_id": 738, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 323, + "shape_id": 762, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 335, + "shape_id": 890, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 342, + "shape_id": 967, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 344, + "shape_id": 990, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 363, + "shape_id": 1186, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 365, + "shape_id": 1210, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 367, + "shape_id": 1234, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 370, + "shape_id": 1267, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 374, + "shape_id": 1312, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 383, + "shape_id": 1412, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 387, + "shape_id": 1457, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 389, + "shape_id": 1481, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 393, + "shape_id": 1526, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 395, + "shape_id": 1550, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 397, + "shape_id": 1574, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 399, + "shape_id": 1598, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 409, + "shape_id": 1705, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 427, + "shape_id": 1893, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 451, + "shape_id": 2163, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 452, + "shape_id": 2176, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 457, + "shape_id": 2231, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 475, + "shape_id": 2424, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 477, + "shape_id": 2448, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 482, + "shape_id": 2502, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 484, + "shape_id": 2526, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 486, + "shape_id": 2550, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 488, + "shape_id": 2574, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 490, + "shape_id": 2598, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 492, + "shape_id": 2622, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 506, + "shape_id": 2773, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 508, + "shape_id": 2796, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 514, + "shape_id": 2862, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 522, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 535, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 536, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 538, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 564, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 566, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix3.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix3.json new file mode 100644 index 00000000..bd6c996b --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix3.json @@ -0,0 +1,12 @@ +{ + "ops": [ + { + "op": "replace_picture", + "sldId": 288, + "shape": "Google Shape;370;p35", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure footer: the absolute 'can never be lost' claim softened (src/04-dual-write-outbox.svg)" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix4.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix4.json new file mode 100644 index 00000000..b98d71c4 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix4.json @@ -0,0 +1,567 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "Kafka is the substrate — a partitioned", + "repl": "Kafka is the substrate: a partitioned", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup (6 per paragraph)", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "Pulsar is also substrate — same broad category", + "repl": "Pulsar is also substrate, in the same broad category", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "Flink is compute — not a transport layer at all, but", + "repl": "Flink is compute: not a transport layer, but", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "NiFi is movement — a visual", + "repl": "NiFi is movement: a visual", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 296 + ], + "find": "They complement — pick by", + "repl": "They complement each other; pick by", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "This is the slide the user specifically asked for: distinguish auto-injection from auto-instrumentation from manual instrumentation — three things people conflate.", + "repl": "Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request; em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "wrap known frameworks — your HTTP handler", + "repl": "wrap known frameworks: your HTTP handler", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "a Kubernetes-level mechanism — the OpenTelemetry Operator", + "repl": "a Kubernetes-level mechanism; the OpenTelemetry Operator", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "Python — ", + "repl": "Python: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "Java/Quarkus — ", + "repl": "Java/Quarkus: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "The cost: eventual consistency — there is a small lag", + "repl": "The cost: eventual consistency, a small lag", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "happily handles both — CQRS adds", + "repl": "handles both; CQRS adds", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "for projections — we cover both distinctions in section 5.", + "repl": "for projections; the Event-Driven section draws both distinctions.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup; cross-reference by concept", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 326 + ], + "find": "The sub-bullet is the reminder the user asked for: ", + "repl": "The sub-bullet is the reminder: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 384 + ], + "find": "This is the specific case you asked about, and a common source of bad designs.", + "repl": "This case is a common source of bad designs.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 566 + ], + "find": "Fallback: the recorded results that follow.", + "repl": "Fallback: the recorded results.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 263 + ], + "find": "The six pillars that follow are the rubric", + "repl": "These six pillars are the rubric", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "in-memory state is a non-starter — a Pod restart", + "repl": "in-memory state is a non-starter: a Pod restart", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "Stateless is a lighter in-process alternative we'll show side by side.", + "repl": "Stateless is the lighter in-process alternative, compared with Temporal in this appendix.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "with EF Core — simplest, and great for most cases.", + "repl": "with EF Core; the simplest option, and enough for most cases.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 381 + ], + "find": "The managed alternative is Temporal, covered in this appendix.NET and Stateless — same problem, different point", + "repl": "Temporal .NET and Stateless, both in this appendix, solve the same problem at a different point", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): garbled text from an earlier replace", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 490 + ], + "find": "a 'dirty set' — the dirty set tells", + "repl": "a 'dirty set'; the dirty set tells", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 490 + ], + "find": "inside the loop iteration — BackgroundService is a singleton.", + "repl": "inside the loop iteration, because BackgroundService is a singleton.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 490 + ], + "find": "go back on the dirty set for retry — idempotent.", + "repl": "go back on the dirty set for retry; the flush is idempotent.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 381 + ], + "find": "the one I'd reach for first when the saga is bounded and I want to own the transitions.", + "repl": "the default choice when the saga is bounded and the team wants to own the transitions.", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 381 + ], + "find": "Recovery is beautifully boring:", + "repl": "Recovery needs no special path:", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "In the running example the loop runs inside a BackgroundService.", + "repl": "In the running example the loop runs in a plain console worker that PosixSignalRegistration cancels on SIGTERM.", + "expect": 1, + "cat": "notes", + "_slide": 38, + "_why": "examples/05-event-driven/dotnet/NotificationConsumer/Program.cs has no BackgroundService (no example does); the loop is top-level with a SIGTERM-cancelled token", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 381 + ], + "find": "s.Status = \"DONE\";", + "repl": "s.Status = \"COMPLETED\";", + "expect": 1, + "cat": "code", + "_slide": 126, + "_why": "examples/17-sagas/dotnet (and every language, verify.sh, _docs/17 codetab) end a saga in COMPLETED, not DONE", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 465 + ], + "find": "run Camunda or Kogito DMN as a separate service", + "repl": "run a DMN engine such as Apache KIE (Kogito) as a separate service", + "expect": 1, + "cat": "stack", + "_slide": 210, + "_why": "maintained open source only: Camunda 7 Community Edition reached end of life in October 2025 and Camunda 8 is under the source-available Camunda License; Apache KIE (Kogito/Drools DMN) is Apache 2.0", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 465 + ], + "find": " you may still want a Camel sidecar in a JVM Pod, or commercial alternatives like Boomi / MuleSoft if open source isn't a hard constraint.", + "repl": " you may still want a Camel sidecar in a JVM Pod.", + "expect": 1, + "cat": "stack", + "_slide": 210, + "_why": "no commercial vendor products as recommendations (open-source-only policy)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 465 + ], + "find": "it moved to a commercial license in late 2024.", + "repl": "it moved to a commercial license in 2025.", + "expect": 1, + "cat": "stack", + "_slide": 210, + "_why": "MediatR's commercial licensing was announced in April 2025 and shipped with v13 in July 2025, not late 2024", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 465 + ], + "find": "either a workable .NET path, a polyglot path, or a commercial path.", + "repl": "either a workable .NET path or a polyglot path.", + "expect": 1, + "cat": "stack", + "_slide": 210, + "_why": "the commercial path is gone with the Boomi / MuleSoft mention", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 465 + ], + "find": "run Camunda or Kogito as a separate service.", + "repl": "run Apache KIE (Kogito) as a separate service.", + "expect": 1, + "cat": "stack", + "_slide": 210, + "_why": "Camunda is no longer a maintained open-source option (7 CE end of life, 8 source-available)", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 452 + ], + "find": "run Camunda or Kogito DMN as a separate service", + "repl": "run Apache KIE (Kogito) DMN as a separate service", + "expect": 1, + "cat": "stack", + "_slide": 197, + "_why": "Camunda is no longer a maintained open-source option", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 460 + ], + "find": "For DMN specifically, Camunda DMN engine docs", + "repl": "For DMN specifically, the Apache KIE (Kogito) DMN docs", + "expect": 1, + "cat": "stack", + "_slide": 205, + "_why": "Camunda is no longer a maintained open-source option", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "and the consumer extracts them, as the Appendix S code shows.", + "repl": "and the consumer extracts them, as examples/05-event-driven/dotnet does (OrderService injects, ShippingConsumer and NotificationConsumer extract).", + "expect": 1, + "cat": "notes", + "_why": "the decks carry no Appendix S; point at the example that shows both sides", + "_src": "mine" + }, + { + "op": "replace", + "scope": "notes", + "find": "StackExchangeRedis package (Apache 2.0)", + "repl": "StackExchangeRedis package (MIT)", + "expect": 2, + "cat": "notes", + "_why": "Microsoft.AspNetCore.SignalR.StackExchangeRedis ships from dotnet/aspnetcore under MIT", + "_src": "mine" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 462 + ], + "find": "All MIT or Apache 2.0; no cloud-vendor lock-in.", + "repl": "All MIT, Apache 2.0 or BSD-3; no cloud-vendor lock-in.", + "expect": 1, + "cat": "notes", + "_why": "Polly is BSD-3; matches the figure footer", + "_src": "mine" + }, + { + "op": "replace_picture", + "sldId": 501, + "shape": "Google Shape;2717;p248", + "png": "lgtm-presentation/edits/png/25-gray-failure.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (PERCEIVED DIFFERENTLY, NOT fully down, OBSERVE FROM THE CLIENT, GREEN, OUTLIER DETECTION); replaced by the site figure assets/diagrams/25-gray-failure.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 295, + "shape": "Google Shape;450;p42", + "png": "lgtm-presentation/edits/png/05-sourcing-vs-streaming.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (SOURCING/STREAMING, ARE, IS, TRANSPORT, ABOUT, NOT, EVENT, PROPAGATED); replaced by the site figure assets/diagrams/05-sourcing-vs-streaming.svg (caps lowered there too)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 296, + "shape": "Google Shape;461;p43", + "png": "lgtm-presentation/edits/png/05-tools-landscape.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (COMPLEMENT); replaced by the site figure assets/diagrams/05-tools-landscape.svg (SUBSTRATE/COMPUTE/MOVEMENT are design labels)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 418, + "shape": "Google Shape;1798;p165", + "png": "lgtm-presentation/edits/png/20-coupling-quadrant.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure ('strong OR distant'); replaced by the site figure assets/diagrams/20-coupling-quadrant.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 548, + "shape_id": 4, + "png": "lgtm-presentation/edits/png/29-container-layers.png", + "cat": "fit", + "_why": "figure re-rendered: the 'COPY --from=build' edge label overlapped the publish box (assets/diagrams/29-container-layers.svg, scripts/gen_29_diagrams.py)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 462, + "shape_id": 2282, + "png": "lgtm-presentation/edits/png/70-appJ-pinned-libraries-dn.png", + "cat": "stack", + "_why": "Temporalio.Temporal -> Temporalio (the NuGet id), MIT; ASP.NET Core SignalR rows MIT; Asp.Versioning MIT; AspNetCore.HealthChecks Apache 2.0 (src/70-appJ-pinned-libraries-dn.svg)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 453, + "shape_id": 2187, + "png": "lgtm-presentation/edits/png/22-hop-cost-nrules.png", + "cat": "voice", + "_why": "figure footer: 'where p99 goes to die' rewritten without the drama", + "_src": "hop" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix5.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix5.json new file mode 100644 index 00000000..1ae085dd --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.fix5.json @@ -0,0 +1,256 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 270 + ], + "find": "csharp · .NET 10 + ASP.NET Core + AspNetCore.HealthChecks", + "repl": "csharp · .NET 10 + ASP.NET Core + Npgsql", + "expect": 1, + "cat": "code", + "_slide": 15, + "_why": "examples/01 dotnet maps /healthz and /readyz by hand", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 270, + "lines": [ + "// Program.cs (excerpt), examples/01-cloud-native-principles/dotnet", + "var builder = WebApplication.CreateBuilder(args);", + "// ... ConnectionStrings__Default from the environment (factor III) -> NpgsqlDataSource", + "var app = builder.Build();", + "app.MapGet(\"/healthz\", () => new { status = \"ok\" }); // liveness: the process is up", + "app.MapGet(\"/readyz\", async (NpgsqlDataSource db) => // readiness: SELECT 1", + "{", + " try", + " {", + " await using var conn = await db.OpenConnectionAsync();", + " await using var cmd = conn.CreateCommand();", + " cmd.CommandText = \"SELECT 1\";", + " await cmd.ExecuteScalarAsync();", + " return Results.Ok(new { status = \"ready\", checks = new { database = \"ok\" } });", + " }", + " catch // down: 503, out of rotation", + " {", + " return Results.Json(new { status = \"down\", checks = new { database = \"unreachable\" } },", + " statusCode: StatusCodes.Status503ServiceUnavailable);", + " }", + "});" + ], + "cat": "code", + "_slide": 15, + "_why": "examples/01: hand-written /healthz and /readyz; readiness answers 503 when the database is down", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 270, + "cat": "notes", + "_slide": 15, + "_why": "notes follow the code", + "_src": "align", + "text": "Walk the code: ASP.NET Core 10 uses minimal hosting (Program.cs as a top-level script). Config flows from appsettings.json overridden by environment variables (factor III): ConnectionStrings__Default reaches GetConnectionString(\"Default\"), the cloud-native equivalent of web.config transforms, except that the cluster injects the values through ConfigMaps and Secrets instead of an MSDeploy package. The two probes are mapped by hand in Example 01: /healthz is liveness (am I alive? if not, restart me, and Kubernetes kills the Pod) and never checks a dependency; /readyz is readiness (can I serve? if not, take me out of rotation but don't kill me): it opens a pooled Npgsql connection and runs SELECT 1, and when that fails it answers 503 with status down, which removes the Pod from the Service endpoints. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. Getting these wrong is a top cause of cascading restarts, the same way wrong IIS app-pool ping settings used to crash apps under load. AddHealthChecks with MapHealthChecks and the AspNetCore.HealthChecks.* community packages (MIT) are the framework route to the same pair; the example writes the handlers by hand so every language serves the same paths and bodies. The .NET runtime side: the service ships as a Linux container image built from a UBI Containerfile, not an MSI deployed to IIS." + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 427 + ], + "find": "HostOptions.ShutdownTimeout bounds the drain; each BackgroundService's StopAsync is your hook. Flip readiness DOWN first so Kubernetes stops routing.", + "repl": "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, StopApplication() lets Kestrel drain in-flight requests within ShutdownTimeout (20 s).", + "expect": 1, + "cat": "code", + "_slide": 172, + "_why": "examples/21 dotnet: PosixSignalRegistration, drain delay, StopApplication (9676f32)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 427, + "lines": [ + "// Program.cs (excerpt), examples/21-graceful-shutdown/dotnet: one SIGTERM drives the drain", + "builder.Services.AddSingleton(); // not ConsoleLifetime", + "builder.Services.Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20));", + "var app = builder.Build();", + "var shuttingDown = false; // GET /readyz answers 503 once it is set", + "var drainDelay = int.TryParse(Environment.GetEnvironmentVariable(\"DRAIN_DELAY_SECONDS\"),", + " out var d) && d >= 0 ? d : 5;", + "PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>", + "{", + " ctx.Cancel = true; // the host does not stop at once", + " if (shuttingDown) return; // already draining", + " shuttingDown = true; // 1. readiness fails first", + " _ = Task.Run(async () =>", + " {", + " await Task.Delay(TimeSpan.FromSeconds(drainDelay)); // 2. serve on: endpoints update", + " app.Lifetime.StopApplication(); // 3. Kestrel stops accepting, finishes in-flight", + " });", + "});", + "app.Lifetime.ApplicationStopped.Register(() => dataSource.Dispose()); // 4. close the pool", + "// ... MapGet /healthz (always 200), /readyz, /orders; app.Run();", + "// ManualLifetime : IHostLifetime: WaitForStartAsync and StopAsync return Task.CompletedTask" + ], + "cat": "code", + "_slide": 172, + "_why": "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight drain within 20 s -> cleanup -> exit 0", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 427, + "cat": "notes", + "_slide": 172, + "_why": "notes follow the code", + "_src": "align", + "text": "ASP.NET Core's default ConsoleLifetime stops the host as soon as SIGTERM arrives, which leaves no time to fail readiness first. Example 21 replaces it with a ManualLifetime and takes the signal itself through PosixSignalRegistration; one SIGTERM, the only signal Kubernetes sends before SIGKILL, drives everything. The handler cancels the default action and flips a flag, so the hand-written /readyz answers 503 and the Service stops routing, then a task keeps the service serving for DRAIN_DELAY_SECONDS (5 s; it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls StopApplication(): the host signals ApplicationStopping, Kestrel stops accepting and finishes in-flight requests, and every IHostedService gets StopAsync, all bounded by HostOptions.ShutdownTimeout (20 s here; the default is 30 s). ApplicationStopped disposes the data source and the process exits 0, inside the 30-second grace period. The pattern for other work: one BackgroundService per long-running concern (a Kafka consumer, a scheduler, the Temporal worker from Appendix D, the Streamiz processor from Section 6), each draining in its own StopAsync: consumer.Close() commits offsets and leaves the group, a SignalR hub sends close frames, a Temporal worker shuts down. For the WCF refugees: this is the in-box equivalent of ServiceHost.Close, except that SIGTERM triggers it and the budget is HostOptions.ShutdownTimeout rather than a per-binding timeout. The Kubernetes settings (probes, preStop, grace period) are the platform half of the contract." + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "A BackgroundService drains the cache to the DB", + "repl": "A background flusher drains the cache to the DB", + "expect": 1, + "cat": "notes", + "_slide": 234, + "_why": "examples/25 dotnet: the flusher is a Task.Run loop", + "_src": "align" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 490 + ], + "find": "Write-back with BackgroundService in ASP.NET Core", + "repl": "Write-back with a background flusher in ASP.NET Core", + "expect": 1, + "cat": "code", + "_slide": 235, + "_why": "examples/25 dotnet: the flusher is a Task.Run loop, not a BackgroundService", + "_src": "align" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 490 + ], + "find": "csharp · ASP.NET Core 10 + BackgroundService + StackExchange.Redis + EF Core", + "repl": "csharp · ASP.NET Core 10 + StackExchange.Redis + Npgsql", + "expect": 1, + "cat": "code", + "_slide": 235, + "_why": "examples/25 dotnet: Npgsql upserts, no EF Core", + "_src": "align" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 490 + ], + "find": "The dirty set is the key idea — it tells the flusher what to drain. Failed batches go back on the set for retry.", + "repl": "A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed.", + "expect": 1, + "cat": "code", + "_slide": 235, + "_why": "examples/25: 202 and bounded retry (ebca295)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 490, + "lines": [ + "// Program.cs (excerpt), examples/25-caching/dotnet: write-back", + "app.MapPut(\"/write-back/metrics/{mid}\", async (string mid, HttpContext ctx) =>", + "{", + " // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down)", + " return Results.Json(new { ok = true, pattern = \"write-back\", action = \"cached-for-flush\" },", + " statusCode: StatusCodes.Status202Accepted); // 202: queued, not yet durable", + "});", + "_ = Task.Run(async () => // the flusher, started at boot", + "{", + " var failures = 0;", + " while (!cts.Token.IsCancellationRequested)", + " {", + " await Task.Delay(Math.Min(FlushPeriodMs << failures, MaxFlushBackoffMs), cts.Token);", + " var ids = await db.SetPopAsync(\"metric:dirty\", 100); // atomic batch dequeue", + " if (ids.Length == 0) continue;", + " var failed = new List(); // Upsert: HGETALL, INSERT ... ON CONFLICT (id)", + " foreach (var mid in ids)", + " try { await Upsert(mid); await db.HashDeleteAsync(\"metric:attempts\", mid); }", + " catch (Exception ex) { logger.LogError(ex, \"flush failed\"); failed.Add(mid); }", + " if (failed.Count == 0) { failures = 0; continue; }", + " failures = Math.Min(failures + 1, 3); // back off: 2 s, 4 s, 8 s", + " await Requeue(db, failed); // HINCRBY metric:attempts: dirty again, failed after 5", + " }", + "});" + ], + "cat": "code", + "_slide": 235, + "_why": "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, metric:failed, backoff)", + "_src": "align", + "shape_id": 2598 + }, + { + "op": "set_notes", + "sldId": 490, + "cat": "notes", + "_slide": 235, + "_why": "notes follow the code", + "_src": "align", + "text": "Write-back in .NET, as Example 25 runs it. PUT /write-back/metrics/{mid} writes the metric fields to a Redis hash, adds the id to a 'dirty set' that tells the flusher what to drain, and answers 202 Accepted in microseconds: the value is queued, not yet durable. The flusher is a background loop started with the app (a BackgroundService works the same way) that SPOPs up to 100 dirty ids at once. SPOP is the right primitive: it removes and returns elements in one call, so two flusher instances would each get a distinct subset of ids and horizontal scale works. Each id is upserted with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice stays one row. An id whose upsert fails is re-queued: HashIncrementAsync counts its attempts in metric:attempts and it goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in the cache and the failure in the log; the loop then waits two, four and at most eight seconds between runs. Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale." + }, + { + "op": "set_code", + "sldId": 514, + "lines": [ + "// OrderService/Program.cs (excerpt, example 21): one SIGTERM drives the drain", + "var shuttingDown = false;", + "PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>", + "{", + " ctx.Cancel = true; // the app drains; the host does not stop at once", + " if (shuttingDown) return; // already draining", + " shuttingDown = true; // 1. readiness → 503: the Service stops routing", + " _ = Task.Run(async () =>", + " {", + " await Task.Delay(TimeSpan.FromSeconds(drainDelay)); // 2. endpoints update (5 s)", + " app.Lifetime.StopApplication(); // 3. Kestrel drains in-flight (ShutdownTimeout 20 s)", + " });", + "});", + "app.MapGet(\"/healthz\", () => new { status = \"ok\" }); // liveness stays 200", + "app.MapGet(\"/readyz\", () => shuttingDown // (the example also pings", + " ? Results.Json(new { ready = false, reason = \"shutting down\" }, statusCode: 503)", + " : Results.Ok(new { ready = true })); // Postgres here)", + "app.MapPost(\"/orders\", async (HttpContext ctx, NpgsqlDataSource db) =>", + "{", + " if (shuttingDown) return Results.Json(new { error = \"shutting down\" }, statusCode: 503);", + " // ... in-flight requests finish before the host stops; 4. the pool is disposed, exit 0", + "});" + ], + "cat": "code", + "_slide": 259, + "_why": "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 514, + "cat": "notes", + "_slide": 259, + "_why": "notes follow the code", + "_src": "align", + "paragraphs": [ + "The application half of graceful shutdown, from the running example's .NET order-service. PosixSignalRegistration catches the single SIGTERM and cancels the default immediate shutdown; the handler flips a flag so /readyz answers 503 and Kubernetes takes the Pod out of the Service, while /healthz stays 200 so the kubelet does not restart a Pod that is winding down.", + "New requests get 503. After DRAIN_DELAY_SECONDS the handler calls StopApplication(): Kestrel stops accepting and finishes in-flight requests within HostOptions.ShutdownTimeout (20 s), the data source is disposed and the process exits 0, inside terminationGracePeriodSeconds. Appendix H covers the hosted-service drain hooks, the preStop hook and the grace period." + ] + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.json new file mode 100644 index 00000000..90726ccc --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-DotNet.json @@ -0,0 +1,7648 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "both", + "find": "Only this step is language-specific; the Newman command is identical for all five.", + "repl": "Only this step is language-specific; the Newman command is identical for all six.", + "expect": 1, + "sldIds": [ + 535 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The order is deliberate: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "repl": "The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "AI coding tools — Claude, Claude Code, Cursor, Copilot, every frontier model — are now in production engineering workflows.", + "repl": "AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They produce code very fluently.", + "repl": "They produce code fluently.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side — plausible enough to compile, deploy and run — from a one-sentence prompt.", + "repl": "From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns — the focus of this deck — span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "repl": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘Least-worst decision’: every architecture choice is a set of trades — latency for throughput, consistency for availability, simplicity for flexibility — and the job is not to find the optionless option but to make the trades visible and deliberate.", + "repl": "‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions easy and cheap.", + "repl": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The visual companion to the previous slide.", + "repl": "The visual companion to the three layers of patterns literacy.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Three stacked boxes: design patterns at the bottom (Gang of Four — factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns — the scope of this deck — sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch.", + "repl": "Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch).", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The four maxims at the bottom in red are the philosophical anchors that follow you through the rest of the deck — every concrete choice you'll see is a deliberate exercise in those four principles.", + "repl": "The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pillars are the rubric for the rest of the talk — every design decision later either earns or spends one of these properties.", + "repl": "The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties.", + "expect": 1, + "sldIds": [ + 264 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The contract — OpenAPI, .proto, AsyncAPI — is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "repl": "The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Make the point that this is what actually delivers independent deployability: the promise the contract encodes is the service boundary.", + "repl": "Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Treat it as a pre-flight check — “will this survive the platform?” — the next slides list the twelve factors and map each to where it lives in our architecture.", + "repl": "Treat it as a pre-flight check: “will this survive the platform?” The twelve factors are listed and mapped to where each lives in our architecture.", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The key reframing here is ownership — don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides for free (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "repl": "The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that in a microservices/event-driven world several factors deepen — backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "repl": "In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two takeaways to say: first, this 2011 list essentially predicted cloud-native a decade early; second, notice how many are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "repl": "Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "expect": 1, + "sldIds": [ + 268 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That sets up the next slide, which maps each factor onto our architecture.", + "repl": "That sets up the map of each factor onto our architecture.", + "expect": 1, + "sldIds": [ + 268 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the payoff — not the traditional twelve-factor table, but a map of where each factor actually lives in the system we're building.", + "repl": "This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building.", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows the actual config.", + "repl": "The configuration itself is an Istio VirtualService.", + "expect": 1, + "sldIds": [ + 276 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, the services stay unaware of each other — the gateway owns the composition.", + "repl": "The services stay unaware of each other; the gateway owns the composition.", + "expect": 1, + "sldIds": [ + 279 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the resolver code.", + "repl": "The resolver code shows how the gateway composes the calls.", + "expect": 1, + "sldIds": [ + 279 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "That quietly rebuilds the monolith with network latency between the pieces.", + "repl": "That rebuilds the monolith with network latency between the pieces.", + "expect": 1, + "sldIds": [ + 281 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Section four, and arguably the hardest discipline to hold.", + "repl": "Section four, and the hardest discipline to hold.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "CQRS — Command Query Responsibility Segregation — is the pattern of using DIFFERENT models for writes and reads.", + "repl": "CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The two are linked by a stream of events — either domain events emitted by the write side, or the database commit log itself via CDC (the next slide).", + "repl": "The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Use this pattern when reads and writes have GENUINELY different shapes and access patterns.", + "repl": "Use this pattern when reads and writes have different shapes and access patterns.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The mechanism that makes CQRS projections AND the outbox pattern (next slide) work: Change Data Capture, or CDC.", + "repl": "The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC.", + "expect": 1, + "sldIds": [ + 287 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Debezium — an Apache 2.0 open-source project running as a set of Kafka Connect connectors — tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "repl": "Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "expect": 1, + "sldIds": [ + 287 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is why log tailing matters before we get to the outbox slides: the outbox is just a deliberate use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "repl": "This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "expect": 1, + "sldIds": [ + 287 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide is the actual transaction.", + "repl": "The outbox write itself is one database transaction, shown in code.", + "expect": 1, + "sldIds": [ + 288 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "When a step fails — payment fails — you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "repl": "When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "expect": 1, + "sldIds": [ + 290 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Flag that Appendix D goes deep on saga state, compensation and a genuinely tricky data-dependency case, for anyone who wants the mechanics.", + "repl": "Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics.", + "expect": 1, + "sldIds": [ + 290 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This inverts the dependency direction and is what truly decouples services in time and ownership.", + "repl": "This inverts the dependency direction, and it is what decouples services in time and ownership.", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Events need contracts just as much as REST and gRPC do — arguably more, because consumers are anonymous and you can't just call them to coordinate.", + "repl": "Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate.", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what makes adding-a-consumer-safely actually safe.", + "repl": "This is what makes adding a consumer safe.", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "These are NOT mutually exclusive.", + "repl": "These are not mutually exclusive.", + "expect": 1, + "sldIds": [ + 295 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In most enterprise architectures, streaming is far more common than sourcing — sourcing is a deliberate choice for domains where audit and replay are core.", + "repl": "In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core.", + "expect": 1, + "sldIds": [ + 295 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Four tools, four different jobs.", + "repl": "Each of the four tools does a different job.", + "expect": 1, + "sldIds": [ + 296 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Messages aren't deleted when read — they're retained, and each consumer group tracks its own offset.", + "repl": "Messages are retained after they are read, and each consumer group tracks its own offset.", + "expect": 1, + "sldIds": [ + 297 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Once events are a durable, replayable stream, you can compute over them continuously — filtering, joining, windowing, aggregating — and emit new derived streams.", + "repl": "Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams.", + "expect": 1, + "sldIds": [ + 298 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right scaling signal for an event consumer is consumer lag — how far behind the log it is — not CPU.", + "repl": "The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "KEDA reads that lag and scales workers up, and crucially down to zero when there's no lag.", + "repl": "KEDA reads that lag and scales workers up, and down to zero when there's no lag.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows both configs.", + "repl": "The KEDA configuration covers both: lag for events and RPS for HTTP.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "A lot of real work isn't a synchronous request at all — it's a multi-step business process, or batch and scheduled work.", + "repl": "A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work.", + "expect": 1, + "sldIds": [ + 303 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This section covers how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "repl": "Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "expect": 1, + "sldIds": [ + 303 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Orchestration: a central coordinator explicitly commands each service in turn — easy to follow and observe, but the coordinator can become a god-service.", + "repl": "Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service.", + "expect": 1, + "sldIds": [ + 304 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Neither is universally right; the next slide gives the selection criteria.", + "repl": "Neither is universally right; choose by the selection criteria in the comparison.", + "expect": 1, + "sldIds": [ + 304 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Management isn't a product you buy — it's four cooperating concerns: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "repl": "Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "expect": 1, + "sldIds": [ + 307 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Signal plane: observability (section 11) tells you what's actually happening.", + "repl": "Signal plane: observability (section 11) tells you what is happening.", + "expect": 1, + "sldIds": [ + 308 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The core discipline: evolve additively — add optional fields and new endpoints, never repurpose or silently remove.", + "repl": "The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period.", + "expect": 1, + "sldIds": [ + 309 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the CI gate that turns this into an automatic guardrail.", + "repl": "A CI gate turns this into an automatic guardrail.", + "expect": 1, + "sldIds": [ + 311 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "It ingests from many sources — OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas — and builds one searchable catalog.", + "repl": "It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog.", + "expect": 1, + "sldIds": [ + 314 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide lists what consumers actually get from it.", + "repl": "What consumers get from the catalog is the point of running it.", + "expect": 1, + "sldIds": [ + 314 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The key word is correlate: traces, metrics and logs only become powerful when they share a trace_id so you can pivot between them.", + "repl": "The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them.", + "expect": 1, + "sldIds": [ + 316 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The LGTM-style stack stores them — Tempo for traces, Mimir/Prometheus for metrics, Loki for logs — and Grafana joins them.", + "repl": "The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them.", + "expect": 1, + "sldIds": [ + 317 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the instrumentation, then the trace.", + "repl": "The instrumentation comes first, then the trace.", + "expect": 1, + "sldIds": [ + 317 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Expand the 'three pillars' framing into the five signals modern observability actually uses.", + "repl": "Expand the 'three pillars' framing into the five signals modern observability uses.", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) for free along the same mechanism — and then filter traces and logs by tenant.", + "repl": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant.", + "expect": 1, + "sldIds": [ + 319 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Spring — Micrometer Tracing + the OTel bridge, or the Java agent. .NET — OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "repl": "Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "expect": 1, + "sldIds": [ + 320 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The common production setup is BOTH: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "repl": "The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "expect": 1, + "sldIds": [ + 325 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that metrics and logs are usually NOT sampled the same way — metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "repl": "Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "expect": 1, + "sldIds": [ + 325 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tail-based is the most powerful: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which is exactly the traces you actually want during an incident.", + "repl": "Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident.", + "expect": 1, + "sldIds": [ + 326 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Keep testing pragmatic — you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and literally see the KEDA http-add-on add replicas. ghz is the gRPC load tester — confirm your Istio timeouts and retries behave under pressure.", + "repl": "Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each pattern here exists because the layer below it is not enough on its own.", + "repl": "Each pattern here exists because no single layer is enough on its own.", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Cloud is the outermost — IAM, VPC, KMS, audit logs, region selection — owned by the platform team or cloud admin.", + "repl": "Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin.", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cluster is the Kubernetes layer — RBAC, NetworkPolicy, Pod Security Standards, admission control — owned by platform/SRE.", + "repl": "Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE.", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Container is the workload — minimal base images, non-root users, signed images, vulnerability scanning, secret mounting — owned jointly by app teams and platform.", + "repl": "Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform.", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code is the innermost — secure defaults, input validation, dependency scanning, secrets handling — owned by app teams.", + "repl": "Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams.", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide covers what each layer's controls actually are.", + "repl": "The 4 C's table lists each layer's controls and its owner.", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide opens the pattern catalog — seven patterns that show up across these four layers.", + "repl": "The pattern catalog holds seven patterns that show up across these four layers.", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that these patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "repl": "These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Why this is powerful: ONE security policy applied uniformly across every workload regardless of language.", + "repl": "Why this matters: one security policy applied uniformly across every workload regardless of language.", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set ONLY by the sidecar; the app must reject requests that don't have it.", + "repl": "Set only by the sidecar; the app must reject requests that don't have it.", + "expect": 1, + "sldIds": [ + 333 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The audience point: the sidecar pattern only works if the app actually USES the headers the sidecar attaches.", + "repl": "The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches.", + "expect": 1, + "sldIds": [ + 333 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows the code.", + "repl": "The code shows the pattern in the service's language.", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted' — once an attacker is in, they move laterally trivially.", + "repl": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance.", + "expect": 1, + "sldIds": [ + 337 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Equivalent Kyverno expression is much more YAML-native if your team prefers it; Rego is more powerful for complex logic.", + "repl": "An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic.", + "expect": 1, + "sldIds": [ + 340 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In software, you isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pattern also has performance benefits — you don't push 50MB messages through the message bus — but in this section we frame it for SECURITY.", + "repl": "The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost of NOT having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "repl": "The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "expect": 1, + "sldIds": [ + 345 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost lines are deliberately included — every pattern is a trade.", + "repl": "The cost lines are there because every pattern is a trade.", + "expect": 1, + "sldIds": [ + 345 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the take-home: real production systems use all seven, in deliberate combinations per concern — not as a one-size-fits-all checklist.", + "repl": "The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist.", + "expect": 1, + "sldIds": [ + 345 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide turns these into positive heuristics.", + "repl": "The anti-pattern heuristics turn these into positive rules.", + "expect": 1, + "sldIds": [ + 348 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "(1) If two services must deploy together, they're really one — merge them or fix the contract.", + "repl": "(1) If two services must deploy together, they are one service: merge them or fix the contract.", + "expect": 1, + "sldIds": [ + 349 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the slides and these references are enough to actually build the system, not just talk about it.", + "repl": "The slides and these references are enough to build the system, not only to discuss it.", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They cover the questions that always come up: how the three protocols really differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "repl": "They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "REST, gRPC and GraphQL — how they actually differ.", + "repl": "REST, gRPC and GraphQL: how they differ.", + "expect": 1, + "sldIds": [ + 355 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS — both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "repl": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The ACL ambiguity is worth flagging out loud: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "repl": "Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration — the request-driven metrics method); USE (Utilisation, Saturation, Errors — the resource metrics method); MDC (Mapped Diagnostic Context — per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "repl": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what actually travels on the wire.", + "repl": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire.", + "expect": 1, + "sldIds": [ + 355 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most arguments about 'which is best' are really arguments about which mental model fits the problem — and often the answer is different per interaction.", + "repl": "Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction.", + "expect": 1, + "sldIds": [ + 356 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape — no field selection, but extremely fast.", + "repl": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three.", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What actually travels, and why it constrains your choice.", + "repl": "What travels on the wire, and why it constrains your choice.", + "expect": 1, + "sldIds": [ + 358 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The decision guide, said plainly.", + "repl": "The decision guide.", + "expect": 1, + "sldIds": [ + 359 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold closing point ties back to the whole talk: forcing one protocol everywhere — 'we're a gRPC shop' or 'everything is GraphQL' — is itself an anti-pattern, because you'll be paying gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "repl": "The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "expect": 1, + "sldIds": [ + 359 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and arguably breaks REST's idea that a URL identifies a resource, not a representation.", + "repl": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation.", + "expect": 1, + "sldIds": [ + 362 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Query param: easy to default but muddies caches.", + "repl": "Query param: simple to default but muddies caches.", + "expect": 1, + "sldIds": [ + 362 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Custom header: keeps URLs clean but is invisible and easy to forget.", + "repl": "Custom header: keeps URLs clean but is invisible and often forgotten.", + "expect": 1, + "sldIds": [ + 362 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation, the URL stays stable and identifies the resource, the representation is what's versioned — this is the genuinely RESTful approach.", + "repl": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach.", + "expect": 1, + "sldIds": [ + 362 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The honest take: URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "repl": "In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "expect": 1, + "sldIds": [ + 362 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code on the next slide.", + "repl": "The media-type versioning code shows how.", + "expect": 1, + "sldIds": [ + 362 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "We removed an old 'notes' field — so we reserve both its tag number (4) AND its name, permanently, so nobody can ever reuse them and silently corrupt old data on the wire.", + "repl": "We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We add currency and state as brand-new fields with new tag numbers; old clients simply ignore unknown fields, new clients read them — that's forward and backward compatibility for free.", + "repl": "We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The lifecycle on screen: ADD new fields and types (additive is always safe — existing queries are unaffected because they didn't ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; WATCH field-level usage metrics to see who still queries the old field and drive their migration; and only RETIRE — the single breaking step — once usage hits zero.", + "repl": "The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero.", + "expect": 1, + "sldIds": [ + 366 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three protocols, three genuinely different answers to the same problem.", + "repl": "Each protocol answers the same problem differently.", + "expect": 1, + "sldIds": [ + 367 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Never break silently — deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "repl": "Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Appendix C tackles the protocol we deliberately left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "repl": "Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections — only new ones — so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "repl": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fix isn't to avoid scaling — it's to stop making any single pod the home of a conversation, which is the backplane.", + "repl": "The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the resource shape is different — the bold point — you scale on open-connection count and file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "repl": "And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "expect": 1, + "sldIds": [ + 372 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Every one of these is solved or eased by the backplane on the next slide.", + "repl": "The pub/sub backplane solves or eases every one of these.", + "expect": 1, + "sldIds": [ + 372 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "And the security note in bold that's easy to forget: authenticate on connect AND re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "repl": "And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "expect": 1, + "sldIds": [ + 376 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three hard parts that the happy-path picture glosses over: where the orchestrator actually keeps its state so it survives a crash, how compensation really works when a step fails midway, and a specific gnarly case people hit — when step B needs data that only step A produced, and how to resume after a crash between them.", + "repl": "Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them.", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each forward step that committed — payment.charge, inventory.reserve — has a defined semantic inverse: payment.refund, inventory.release.", + "repl": "Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When book_shipping fails, you don't undo a database transaction (the payment really happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "repl": "When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the specific case you asked about, and it's a genuinely common source of bad designs.", + "repl": "This is the specific case you asked about, and a common source of bad designs.", + "expect": 1, + "sldIds": [ + 384 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In the middle, the Temporal Server: a cluster of Frontend, Matching and History services backed by Postgres (or MySQL / Cassandra) that persists every workflow event — every activity invocation, every result, every timer firing — so the workflow can be reconstructed from scratch by replay.", + "repl": "In the middle, the Temporal Server: a cluster of Frontend, Matching and History services backed by Postgres (or MySQL / Cassandra) that persists every workflow event (every activity invocation, every result, every timer firing), so the workflow can be reconstructed from scratch by replay.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Error handling is where cloud-native systems quietly fall apart — not in the happy path.", + "repl": "Error handling is where cloud-native systems fall apart, more often than the happy path.", + "expect": 1, + "sldIds": [ + 390 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We finish on the failure mode that turns a blip into an outage — the retry storm — and the handful of controls that prevent it.", + "repl": "We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it.", + "expect": 1, + "sldIds": [ + 390 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope — type, title, status, detail, instance — and you extend it with your stable code and the traceId.", + "repl": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId.", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC gives you a small, fixed set of canonical status codes — and choosing the right one IS the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "repl": "gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "expect": 1, + "sldIds": [ + 394 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Beyond the code, attach a google.rpc.Status in the trailers with rich details — ErrorInfo (machine reason + domain) and RetryInfo (how long to wait) — so the client gets the same five facts as everywhere else.", + "repl": "Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else.", + "expect": 1, + "sldIds": [ + 394 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, you commit the offset either way, so the main consumer keeps making progress.", + "repl": "You commit the offset either way, so the main consumer keeps making progress.", + "expect": 1, + "sldIds": [ + 396 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When one field fails — say stock can't reach inventory — that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "repl": "When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "expect": 1, + "sldIds": [ + 398 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the failure mode that turns a brief blip into a full outage, and it's worth dwelling on.", + "repl": "This is the failure mode that turns a brief blip into a full outage.", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The loop: a service slows, calls start timing out, clients retry — often 3x — which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "repl": "The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Bound every retry — @Timeout, @Retry + jitter, @CircuitBreaker, retry budget — and make handlers idempotent.", + "repl": "Bound every retry (@Timeout, @Retry + jitter, @CircuitBreaker, retry budget) and make handlers idempotent.", + "expect": 1, + "sldIds": [ + 401 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Where the boundaries — and the contracts — come from.", + "repl": "Where the boundaries and the contracts come from.", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions — where to split, what the contract should contain, what stays private — are really boundary questions, and DDD is the discipline for boundaries.", + "repl": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries.", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge — complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them simply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "repl": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "expect": 1, + "sldIds": [ + 404 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The vivid example is the word 'order' meaning different things in fulfillment versus billing — that's why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "repl": "The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "expect": 1, + "sldIds": [ + 405 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The relationship patterns between contexts — and which ones your API actually implements.", + "repl": "The relationship patterns between contexts, and which ones your API implements.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The starred three are the ones to design deliberately.", + "repl": "The starred three are the ones to design explicitly.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Published Language: the shared, versioned schema that API speaks — OpenAPI, .proto, Avro — which is exactly what lives in the Apicurio registry.", + "repl": "Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry.", + "expect": 1, + "sldIds": [ + 407 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "So the registry-backed contract from section 9 IS a Published Language, and edge/gateway translation IS an Anticorruption Layer.", + "repl": "So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer.", + "expect": 1, + "sldIds": [ + 407 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the payoff: students have already built these things in sections 9 and 8 without the names — now they have the names and the theory.", + "repl": "The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory.", + "expect": 1, + "sldIds": [ + 407 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driving adapters on the left — REST, gRPC, GraphQL — translate an incoming protocol into a call on an inbound port.", + "repl": "Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port.", + "expect": 1, + "sldIds": [ + 408 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driven adapters on the right — a Postgres repository, a Kafka publisher — implement the outbound ports.", + "repl": "Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports.", + "expect": 1, + "sldIds": [ + 408 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The aggregate is the consistency boundary — invariants hold within it and one transaction commits it — and it typically maps to a single API resource.", + "repl": "The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource.", + "expect": 1, + "sldIds": [ + 410 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the small-aggregate rule — one per transaction, reference others by id, connect via events — is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "repl": "And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "expect": 1, + "sldIds": [ + 410 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The message: DDD isn't a separate methodology bolted on — it's the naming and reasoning behind the boundaries the whole architecture already relies on.", + "repl": "The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on.", + "expect": 1, + "sldIds": [ + 411 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The reframe: coupling is necessary — a system with zero coupling does nothing — so the goal is to balance it, not eliminate it.", + "repl": "The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it.", + "expect": 1, + "sldIds": [ + 412 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Coupling has three dimensions — integration strength, distance and volatility — and a coupling only hurts when all three are high together.", + "repl": "Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together.", + "expect": 1, + "sldIds": [ + 412 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Khononov's model evaluates the connection between two components — not the components themselves — along three dimensions: integration strength, distance and volatility.", + "repl": "Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility.", + "expect": 1, + "sldIds": [ + 413 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Distance is how far apart they sit — same method, class, module, service, or system — and it sets the cost of a change that has to cross the boundary.", + "repl": "Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary.", + "expect": 1, + "sldIds": [ + 414 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is how often the coupled thing actually changes.", + "repl": "Volatility is how often the coupled thing changes.", + "expect": 1, + "sldIds": [ + 414 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost box at the bottom is the key relationship: strong plus distant is global complexity — the distributed monolith — but, crucially, it only bites when the code is volatile.", + "repl": "The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile.", + "expect": 1, + "sldIds": [ + 414 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is Khononov's modularity rule rendered as a slogan — strength XOR distance — you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "repl": "The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Contract coupling — APIs and event schemas in the registry — is the only strength level appropriate across service distance.", + "repl": "Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance.", + "expect": 1, + "sldIds": [ + 417 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is what decides whether a less-than-ideal coupling actually matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling that's actually on hot, ever-changing core code can still hurt.", + "repl": "Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you deliberately route your strongest couplings away from core and toward stable, generic areas.", + "repl": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Close by pointing to both Khononov books — Learning Domain-Driven Design for the boundary tools in Appendix F, and Balancing Coupling in Software Design for this model — as the two references that underpin both of these appendices.", + "repl": "Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices.", + "expect": 1, + "sldIds": [ + 421 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done — the 5xx blips users actually notice during a rollout.", + "repl": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout.", + "expect": 1, + "sldIds": [ + 423 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "It's a protocol between your app and the platform — not a single setting — and the platform gives you a fixed budget to do it in.", + "repl": "It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in.", + "expect": 1, + "sldIds": [ + 423 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The rest of the appendix walks that sequence, then shows the code on both sides of it.", + "repl": "This appendix walks that sequence, then shows the code on both sides of it.", + "expect": 1, + "sldIds": [ + 423 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Liveness must stay UP during the drain, or the kubelet restarts a pod that's deliberately winding down.", + "repl": "Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down.", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The distinction that trips people up is readiness vs liveness during shutdown: you WANT readiness to fail (it stops new traffic) but you must NOT let liveness fail (the kubelet would restart a pod that's intentionally draining).", + "repl": "The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining).", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes actually stops routing after you report DOWN — here roughly ten seconds, which your preStop sleep should cover.", + "repl": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after you report DOWN: here roughly ten seconds, which your preStop sleep should cover.", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Only HTTP drains for free — everything else needs explicit drain code in your shutdown hook.", + "repl": "Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook.", + "expect": 1, + "sldIds": [ + 429 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The closing bold point is the philosophical anchor of the whole appendix: you cannot guarantee a graceful exit — the kubelet will sometimes SIGKILL you — so idempotency is what actually makes the system safe, and graceful shutdown just makes the unsafe window rare.", + "repl": "The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare.", + "expect": 1, + "sldIds": [ + 429 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fourth point is the one that turns shutdown from fragile to robust — idempotency means the occasional hard kill is a non-event.", + "repl": "The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event.", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Open the appendix by reframing what L7 routing IS in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "repl": "Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "expect": 1, + "sldIds": [ + 432 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The TLS SNI line in both columns is worth highlighting — L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "repl": "The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "expect": 1, + "sldIds": [ + 433 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you actually see what's happening across the four layers — ties straight back to the observability section.", + "repl": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section.", + "expect": 1, + "sldIds": [ + 434 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Worth calling out: the resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms — the mesh implements many of them for free, but you still need bounded retries and budgets to prevent amplification.", + "repl": "The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification.", + "expect": 1, + "sldIds": [ + 434 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each layer is the right place for a different KIND of decision — we'll see that throughout the rest of the appendix.", + "repl": "Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them.", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each box can refuse, transform or branch the request, and each emits a trace span so the full journey is observable end-to-end.", + "repl": "Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end.", + "expect": 1, + "sldIds": [ + 436 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point teases the rule-based section: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place isn't a YAML file in the cluster — it's a rule engine inside the app.", + "repl": "The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster.", + "expect": 1, + "sldIds": [ + 437 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three match rules — by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie — each sending to a different DestinationRule subset.", + "repl": "Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie.", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use it as a deliberate choice for a known constraint, never as a default.", + "repl": "Use it only for a known constraint, never as a default.", + "expect": 1, + "sldIds": [ + 442 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as truly disposable.", + "repl": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable.", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie deliberately and NAT doesn't break it.", + "repl": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it.", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Shadow / mirror: copy a percentage of real traffic to a new version silently, observe its behaviour, but don't return its responses to the client — great for testing under realistic load.", + "repl": "Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load.", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Header inject/strip and URL rewrites are essentially free — the router is already parsing the HTTP line and headers anyway.", + "repl": "Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers.", + "expect": 1, + "sldIds": [ + 447 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two examples worth mentioning: trace context propagation (the W3C traceparent header is how the spans from earlier in the journey stitch together with the spans downstream — that's why the request-id injection in the Nginx slide mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "repl": "Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "expect": 1, + "sldIds": [ + 447 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert — a really clean pattern, identity at the network layer.", + "repl": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert: a clean pattern that puts identity at the network layer.", + "expect": 1, + "sldIds": [ + 448 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set expectations honestly.", + "repl": "Set expectations with the numbers.", + "expect": 1, + "sldIds": [ + 453 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out) that means every layer must be lean and earn its place.", + "repl": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency.", + "expect": 1, + "sldIds": [ + 453 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the modern answer to 'how do you build a microservices system that actually works in production.'", + "repl": "This is the modern answer to 'how do you build a microservices system that works in production.'", + "expect": 1, + "sldIds": [ + 455 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what the 'mesh' actually IS: the same routing CRDs the edge uses, but everywhere, between every pair of services, configured centrally and applied by the sidecars.", + "repl": "This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars.", + "expect": 1, + "sldIds": [ + 457 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC has a few sharp edges that are worth calling out separately.", + "repl": "gRPC has a few sharp edges of its own.", + "expect": 1, + "sldIds": [ + 458 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most powerful for vendor / 3rd-party / legacy-mainframe wrappers.", + "repl": "Most useful for vendor / 3rd-party / legacy-mainframe wrappers.", + "expect": 1, + "sldIds": [ + 478 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The defining property: change anywhere, redeploy everything; a bug in Reporting can take down Payments; one scaling unit for very different workloads.", + "repl": "The defining property: change anywhere, redeploy everything; a bug in Reporting can take down Payments; one scaling unit for workloads that scale differently.", + "expect": 1, + "sldIds": [ + 468 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Splitting the database is the genuinely hard part of decomposition.", + "repl": "Splitting the database is the hard part of decomposition.", + "expect": 1, + "sldIds": [ + 470 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold final line is the architectural take-home: reversibility — every step should be a config change you can undo without coordinating a release — is the property to design for.", + "repl": "The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release.", + "expect": 1, + "sldIds": [ + 478 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pick the pattern that matches the consistency you actually need.", + "repl": "Pick the pattern that matches the consistency you need.", + "expect": 1, + "sldIds": [ + 480 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The sub-bullet is what trips teams up: every cache adds complexity; pick the pattern that matches the consistency you actually need.", + "repl": "The sub-bullet is what trips teams up: every cache adds complexity; pick the pattern that matches the consistency you need.", + "expect": 1, + "sldIds": [ + 480 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When data is written far more often than it's read — audit logs, event records, telemetry — caching every write is wasteful: most cached entries will expire before being read again.", + "repl": "When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful: most cached entries expire before being read again.", + "expect": 1, + "sldIds": [ + 487 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: extra DB load for keys that may not actually be requested again.", + "repl": "Cons: extra DB load for keys that may not be requested again.", + "expect": 1, + "sldIds": [ + 491 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Unbounded caches grow until they evict at random, which gives you the worst kind of failure — unpredictable.", + "repl": "Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable.", + "expect": 1, + "sldIds": [ + 493 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The mistake that causes most production incidents is treating a remote call like a local one — no timeout, no fallback, no circuit breaker — so a single down dependency hangs the caller.", + "repl": "The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller.", + "expect": 1, + "sldIds": [ + 500 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on actual traffic, SLO burn-rate alerts, and — critically — Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "repl": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "expect": 1, + "sldIds": [ + 501 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Production resilience is the deliberate composition of several of these, tuned together.", + "repl": "Production resilience is the composition of several of these, tuned together.", + "expect": 1, + "sldIds": [ + 504 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The code on the next slide shows the language-specific mechanics.", + "repl": "The code example shows the language-specific mechanics.", + "expect": 1, + "sldIds": [ + 505 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The code on the next slide shows the idiomatic library for each language.", + "repl": "The code example shows the idiomatic library for each language.", + "expect": 1, + "sldIds": [ + 507 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Libraries differ per language; the next slide shows each.", + "repl": "Libraries differ per language; the code example shows each.", + "expect": 1, + "sldIds": [ + 509 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 511 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Load shedding is counter-intuitive to engineers — deliberately rejecting requests feels like giving up — but it is the difference between a system that degrades and one that collapses.", + "repl": "Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses.", + "expect": 1, + "sldIds": [ + 512 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each failure mode has characteristic signals, and the point is that you must instrument for them deliberately — a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "repl": "Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "expect": 1, + "sldIds": [ + 515 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what actually failed' (the logs on that trace) — the correlation story from section 11 applied to incident response.", + "repl": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response.", + "expect": 1, + "sldIds": [ + 515 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two observations worth stating aloud: first, quorum/consensus is the ONLY real answer to split-brain — no amount of retrying or circuit-breaking helps when the problem is two leaders; you need a majority rule.", + "repl": "Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule.", + "expect": 1, + "sldIds": [ + 516 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The platform around the code — Strimzi, Apicurio, OpenMetadata, the LGTM stack — is unchanged from any language choice; .NET 10 is just how we write the services.", + "repl": "The platform around the code (Strimzi, Apicurio, OpenMetadata, the LGTM stack) is the same for every language; .NET 10 is how we write the services.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Almost every pattern you knew — IIS, WCF, COM+, MSMQ — maps onto a Kubernetes-native equivalent.", + "repl": "Almost every pattern you knew, from IIS and WCF to COM+ and MSMQ, maps onto a Kubernetes-native equivalent.", + "expect": 1, + "sldIds": [ + 263 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cloud-native isn't a rejection of those patterns — it's a re-platforming where Kubernetes owns the host and the messaging substrate, and your code owns less infrastructure plumbing.", + "repl": "Cloud-native is a re-platforming of those patterns, not a rejection: Kubernetes owns the host and the messaging substrate, and your code owns less infrastructure plumbing.", + "expect": 1, + "sldIds": [ + 263 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bottom line below the table is the architectural shift to make explicit: you stop owning the host and start owning the code, because the platform now provides the host as a primitive.", + "repl": "The line under the table is the architectural shift to make explicit: you stop owning the host and start owning the code, because the platform now provides the host as a primitive.", + "expect": 1, + "sldIds": [ + 263 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The pillars on the next slide are the rubric Kubernetes is enforcing on you when you make that switch.", + "repl": "The six pillars that follow are the rubric Kubernetes enforces on you when you make that switch.", + "expect": 1, + "sldIds": [ + 263 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Tie back to the previous slide: IIS gave you some of these (process recycle, health monitoring) but on a single Windows host; Kubernetes gives you all six, across many hosts, declaratively.", + "repl": "Tie back to the Windows-to-Kubernetes bridge: IIS gave you some of these (process recycle, health monitoring) but on a single Windows host; Kubernetes gives you all six, across many hosts, declaratively.", + "expect": 1, + "sldIds": [ + 264 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs) — not /GetOrder?id=123 like an .asmx service; (2) request contracts validated declaratively — records with [Required], [Range], or FluentValidation rules, so bad input never reaches your logic; (3) correct status codes — Results.Created (201) on create, Results.ValidationProblem (RFC 9457) for validation failures, not 200 with success=false in the body; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth.", + "repl": "Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs), not /GetOrder?id=123 like an .asmx service; (2) request contracts validated declaratively, with records carrying [Required] and [Range] or FluentValidation rules, so bad input never reaches your logic; (3) correct status codes: Results.Created (201) on create, Results.ValidationProblem (RFC 9457) for validation failures, not 200 with success=false in the body; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth.", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For WCF refugees this is the most familiar protocol in cloud-native: the .proto file IS the service contract, exactly like a WCF [ServiceContract] interface, and the wire format is binary like NetTcpBinding.", + "repl": "For WCF refugees this is the most familiar protocol in cloud-native: the .proto file is the service contract, exactly like a WCF [ServiceContract] interface, and the wire format is binary like NetTcpBinding.", + "expect": 1, + "sldIds": [ + 274 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When NOT to choose it: SEO-critical public sites (use static rendering or SSR), or anything that must work without a persistent connection — every Blazor Server interaction is a SignalR round-trip and inherits all the WebSocket scaling concerns in Appendix C.", + "repl": "When not to choose it: SEO-critical public sites (use static rendering or SSR), or anything that must work without a persistent connection. Every Blazor Server interaction is a SignalR round-trip and inherits the WebSocket scaling concerns in Appendix C.", + "expect": 1, + "sldIds": [ + 282 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Blazor Server is more than a UI framework — architecturally it IS a backend-for-frontend: the server-side process maintains the user's session-scoped component tree and pushes UI updates (Render Tree Diffs) to a thin JavaScript client over SignalR.", + "repl": "Blazor Server is more than a UI framework: architecturally it is a backend-for-frontend. The server-side process maintains the user's session-scoped component tree and pushes UI updates (Render Tree Diffs) to a thin JavaScript client over SignalR.", + "expect": 1, + "sldIds": [ + 282 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log — the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live — how long a cached key lives before expiring); LRU (Least Recently Used — a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command — sets a key and its TTL atomically); ORM (Object-Relational Mapping); EF Core (Entity Framework Core — .NET's ORM); LINQ (Language-Integrated Query).", + "repl": "Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log, the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used, a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command, which sets a key and its TTL atomically); ORM (Object-Relational Mapping); EF Core (Entity Framework Core, .NET's ORM); LINQ (Language-Integrated Query).", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Runtime & build — CLR (Common Language Runtime — .NET's execution engine); BCL (Base Class Library); JIT (Just-In-Time compilation); AOT (Ahead-Of-Time / Native AOT compilation); GC (Garbage Collection); DI (Dependency Injection); DTO (Data Transfer Object); LINQ (Language-Integrated Query).", + "repl": "Runtime & build — CLR (Common Language Runtime, .NET's execution engine); BCL (Base Class Library); JIT (Just-In-Time compilation); AOT (Ahead-Of-Time / Native AOT compilation); GC (Garbage Collection); DI (Dependency Injection); DTO (Data Transfer Object); LINQ (Language-Integrated Query).", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The field keeps working — nothing breaks — but the deprecation shows up in introspection, in any IDE plugin that reads the schema, and in HotChocolate's bundled GraphQL IDE (called Banana Cake Pop / Nitro) at /graphql.", + "repl": "The field keeps working and nothing breaks, but the deprecation shows up in introspection, in any IDE plugin that reads the schema, and in HotChocolate's bundled GraphQL IDE (Nitro, formerly Banana Cake Pop) at /graphql.", + "expect": 1, + "sldIds": [ + 367 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each Pod still owns only its own sockets — that's fine — but every Pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "repl": "Each Pod still owns only its own sockets, but every Pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "expect": 1, + "sldIds": [ + 373 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When a publisher calls Clients.User(\"X\").SendAsync(…), the message goes to the backplane without knowing which Pod holds X; the backplane fans it to all Pods, and the one Pod that actually holds X's connection delivers it.", + "repl": "When a publisher calls Clients.User(\"X\").SendAsync(…), the message goes to the backplane without knowing which Pod holds X; the backplane fans it to all Pods, and the one Pod that holds X's connection delivers it.", + "expect": 1, + "sldIds": [ + 373 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Notably: we do NOT use Azure SignalR Service — that's the cloud-coupled option; the OSS Redis backplane is what runs on your own Kubernetes.", + "repl": "Azure SignalR Service is not used: it is the cloud-coupled option, and the open-source Redis backplane is what runs on your own Kubernetes.", + "expect": 1, + "sldIds": [ + 373 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two Blazor hosting models, two very different scaling profiles — both run on plain ASP.NET Core Pods, both open source, no Azure SignalR Service.", + "repl": "Two Blazor hosting models with different scaling profiles; both run on plain ASP.NET Core Pods, both are open source, and neither needs Azure SignalR Service.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Blazor deserves its own slide in this appendix because it sits on BOTH sides of the WebSocket scaling discussion.", + "repl": "Blazor deserves its own slide in this appendix because it sits on both sides of the WebSocket scaling discussion.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Every UI interaction — every button click — is a round-trip.", + "repl": "Every UI interaction, every button click, is a round-trip.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Once you scale to N Pods, you need a Redis backplane (one line: AddStackExchangeRedis), and every pinning / connection-count / drain concern from this appendix applies in full — it IS a WebSocket workload, just one that drives UI updates.", + "repl": "Once you scale to N Pods, you need a Redis backplane (one line: AddStackExchangeRedis), and every pinning, connection-count and drain concern from this appendix applies in full: it is a WebSocket workload that happens to drive UI updates.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most production .NET shops run BOTH — Blazor Server for the admin console, Blazor WebAssembly for the customer-facing app.", + "repl": "Most production .NET shops run both: Blazor Server for the admin console, Blazor WebAssembly for the customer-facing app.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For the WCF/IIS-era developer, Blazor Server feels familiar — server-side state, cookie auth, classic web app shape — which is both its appeal and its scaling problem.", + "repl": "For the WCF/IIS-era developer, Blazor Server feels familiar (server-side state, cookie auth, classic web app shape), which is both its appeal and its scaling problem.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each successful step accumulates its result into the Context dictionary (remember that — it's the whole point of the data-dependency slide) and bumps the step index; the EF Core SaveChangesAsync makes the row durable after every step.", + "repl": "Each successful step accumulates its result into the Context dictionary, which is what the data-dependency slide relies on, and bumps the step index; the EF Core SaveChangesAsync makes the row durable after every step.", + "expect": 1, + "sldIds": [ + 381 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "On the left, your client — a REST handler in an ASP.NET Core app — injects ITemporalClient and calls StartWorkflowAsync(…) to kick off a workflow.", + "repl": "On the left, your client, a REST handler in an ASP.NET Core app, injects ITemporalClient and calls StartWorkflowAsync(…) to kick off a workflow.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The top row IS the saga as a state machine: Created → Paid → Reserved → Shipped → Done, with each transition triggered by an event (PayOk, ReserveOk, ShipOk, Delivered).", + "repl": "The top row is the saga as a state machine: Created → Paid → Reserved → Shipped → Done, with each transition triggered by an event (PayOk, ReserveOk, ShipOk, Delivered).", + "expect": 1, + "sldIds": [ + 388 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Below: three pieces.", + "repl": "Under it, three pieces.", + "expect": 1, + "sldIds": [ + 388 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "What's deliberately absent: any stack trace, exception class name, or SQL — those leak internals and are an attack surface (and the WCF-era habit of returning SoapFault detail with full stack traces is exactly what this replaces).", + "repl": "Nothing internal leaks: no stack trace, exception class name, or SQL. Those are an attack surface, and the WCF-era habit of returning SoapFault detail with full stack traces is exactly what this replaces.", + "expect": 1, + "sldIds": [ + 393 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Ports: the domain declares plain C# interfaces — a repository and an event publisher — and depends on nothing else (no ASP.NET Core, no EF Core, no MassTransit imports anywhere in the domain project).", + "repl": "Ports: the domain declares plain C# interfaces, a repository and an event publisher, and depends on nothing else (no ASP.NET Core, no EF Core, no Confluent.Kafka imports anywhere in the domain project).", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The K8s YAML on the next slide is the platform half of the contract.", + "repl": "The Kubernetes YAML that follows is the platform half of the contract.", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "And liveness deliberately points at /healthz/live (which stays UP during the drain, returning the static 'self' health check) so the kubelet doesn't restart a Pod that's cleanly winding down.", + "repl": "And liveness points at /healthz/live, which stays UP during the drain by returning the static 'self' health check, so the kubelet doesn't restart a Pod that's cleanly winding down.", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with MassTransit RoutingSlip + NRules, then circle back to east/west where the most decisions actually happen.", + "repl": "We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with a Confluent.Kafka consumer and NRules, then circle back to east/west, where most decisions happen.", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "North/south traffic (clients into the system) enters through the EDGE/INGRESS layer (HAProxy, Nginx, Envoy, Traefik) — where TLS terminates and coarse host/path routing happens — then through the API GATEWAY (Kong, Apigee, Istio gateway, YARP) which handles product-level concerns like auth, rate limiting and transformation.", + "repl": "North/south traffic (clients into the system) enters through the edge/ingress layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API gateway (Kong, Apigee, Istio gateway, YARP), which handles product-level concerns like auth, rate limiting and transformation.", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The point isn't the syntax; it's that these rules are first-class C# classes — type-safe, refactor-friendly, unit-testable through NRules.Testing — and live in their own .csproj.", + "repl": "The point isn't the syntax: these rules are first-class C# classes (type-safe, refactor-friendly, unit-testable through NRules.Testing) and live in their own .csproj.", + "expect": 1, + "sldIds": [ + 452 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Decision tables: if your rules genuinely fit a spreadsheet shape (the DMN sweet spot), the .NET ecosystem doesn't ship a strong open-source DMN runtime — the pragmatic answer is to run Camunda or Kogito DMN as a separate service and call it via REST from the same enrichment step, or implement the table convention in NRules.", + "repl": "Decision tables: if your rules fit a spreadsheet shape (the DMN sweet spot), the .NET ecosystem doesn't ship a strong open-source DMN runtime; the pragmatic answer is to run Camunda or Kogito DMN as a separate service and call it via REST from the same enrichment step, or implement the table convention in NRules.", + "expect": 1, + "sldIds": [ + 452 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We'll cover that honestly in the toolkit appendix.", + "repl": "The toolkit appendix (Appendix J) lists that gap.", + "expect": 1, + "sldIds": [ + 452 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For DMN specifically, Camunda DMN engine docs — run as a separate service called over REST when you genuinely need decision tables.", + "repl": "For DMN specifically, Camunda DMN engine docs — run as a separate service called over REST when you need decision tables.", + "expect": 1, + "sldIds": [ + 460 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pinned stack, expanded migration table, honest gaps.", + "repl": "Pinned stack, expanded migration table, known gaps.", + "expect": 1, + "sldIds": [ + 461 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Then a candid look at what .NET genuinely doesn't have, and a short closing recipe to start a new cloud-native service.", + "repl": "Then the gaps in the .NET cloud-native story, and a short closing recipe to start a new cloud-native service.", + "expect": 1, + "sldIds": [ + 461 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That's the point: the .NET library choices on the previous slide are entirely separable from the platform choices on this one.", + "repl": "That's the point: the .NET library choices in the toolkit table are entirely separable from the platform choices on this one.", + "expect": 1, + "sldIds": [ + 463 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "If you're already running this platform with a Java or Python application, you can drop .NET services in alongside without changing anything below the application layer.", + "repl": "If you're already running this platform with a Java or Python application, you can drop .NET services in alongside without changing anything under the application layer.", + "expect": 1, + "sldIds": [ + 463 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "A few callouts: Strimzi is the Kubernetes-native Kafka operator (open-source, Apache 2.0 — not Confluent Cloud); Apicurio is the open-source schema registry that holds your OpenAPI / .proto / Avro contracts; OpenMetadata is the asset-catalog/lineage tool; LGTM (Tempo + Mimir + Loki + Grafana) is Grafana Labs' open-source observability stack — note the AGPL-3.0 licensing if that's a concern for you; PostgreSQL is the primary OLTP store; Redis (or its BSD-licensed Valkey fork) is the SignalR backplane and distributed-lock substrate; Temporal Server pairs with the Temporal .NET SDK from the application stack.", + "repl": "A few callouts: Strimzi is the Kubernetes-native Kafka operator (open-source, Apache 2.0, not Confluent Cloud); Apicurio is the open-source schema registry that holds your OpenAPI / .proto / Avro contracts; OpenMetadata is the asset-catalog/lineage tool; LGTM (Tempo + Mimir + Loki + Grafana) is Grafana Labs' open-source observability stack (note the AGPL-3.0 licensing if that's a concern for you); PostgreSQL is the primary OLTP store; Redis (or its BSD-licensed Valkey fork) is the SignalR backplane and distributed-lock substrate; Temporal Server pairs with the Temporal .NET SDK from the application stack.", + "expect": 1, + "sldIds": [ + 463 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "APPENDIX J · HONEST GAPS", + "repl": "APPENDIX J · GAPS", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What the .NET cloud-native story genuinely lacks", + "repl": "What the .NET cloud-native story lacks", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "MassTransit's RoutingSlip covers EIP patterns well, but for integration with truly long-tail systems (HL7, AS2, EDI, mainframe connectors) you may still want a Camel sidecar in a JVM Pod — or commercial alternatives like Boomi / MuleSoft if open source isn't a hard constraint.", + "repl": "For integration with long-tail systems (HL7, AS2, EDI, mainframe connectors) you may still want a Camel sidecar in a JVM Pod, or commercial alternatives like Boomi / MuleSoft if open source isn't a hard constraint.", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "DI complexity — Microsoft.Extensions.DependencyInjection is intentionally simple.", + "repl": "DI complexity — Microsoft.Extensions.DependencyInjection is simple by design.", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Wolverine (MIT) is the modern OSS equivalent and arguably better; consider it for new projects.", + "repl": "Wolverine (MIT) is the modern OSS equivalent; consider it for new projects.", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "These are real gaps, not hidden traps — in each case there is either a workable .NET path, a polyglot path, or a commercial path.", + "repl": "These are real gaps with known workarounds: in each case there is either a workable .NET path, a polyglot path, or a commercial path.", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pick exactly the protocols you need: ASP.NET Core minimal APIs for REST at the edge, Grpc.AspNetCore for internal service-to-service, HotChocolate if (and only if) you genuinely need GraphQL aggregation.", + "repl": "Pick exactly the protocols you need: ASP.NET Core minimal APIs for REST at the edge, Grpc.AspNetCore for internal service-to-service, HotChocolate only if you need GraphQL aggregation.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Add MassTransit only when you actually need bus abstraction (or use Confluent.Kafka directly for tighter control).", + "repl": "Produce and consume with Confluent.Kafka directly; it keeps the Kafka semantics (keys, partitions, offsets) visible.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Persist EVERY transition; never trust in-memory state to survive a Pod restart.", + "repl": "Persist every transition; never trust in-memory state to survive a Pod restart.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Importantly: monoliths are not always wrong.", + "repl": "Monoliths are not always wrong.", + "expect": 1, + "sldIds": [ + 468 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The proxy earns its keep: by path, by header (X-Tenant: acme), by percentage (1% canary), or by feature flag (Microsoft.FeatureManagement).", + "repl": "The proxy is what makes this work: by path, by header (X-Tenant: acme), by percentage (1% canary), or by feature flag (Microsoft.FeatureManagement).", + "expect": 1, + "sldIds": [ + 473 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "NOT a destination — every EF Core migration now needs both apps to agree, and the coordination tax grows.", + "repl": "It is an intermediate state, not a destination: every EF Core migration now needs both apps to agree, and the coordination tax grows.", + "expect": 1, + "sldIds": [ + 474 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The data split is the genuinely hard part; doing it with multiple AddDbContext registrations is much cheaper than coordinating across services.", + "repl": "The data split is the hard part; doing it with multiple AddDbContext registrations is much cheaper than coordinating across services.", + "expect": 1, + "sldIds": [ + 478 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note: IDistributedCache doesn't have a typed get/set API — it's intentionally low-level.", + "repl": "Note: IDistributedCache has no typed get/set API; it is low-level by design.", + "expect": 1, + "sldIds": [ + 482 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For typed caching with retry / hedging / cache stampede protection out of the box, FusionCache (covered on the next slide) is the modern idiomatic alternative.", + "repl": "For typed caching with retry, hedging and cache-stampede protection out of the box, FusionCache (the read-through slide) is the modern idiomatic alternative.", + "expect": 1, + "sldIds": [ + 482 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The controller uses GetOrSetAsync with a factory — the factory runs ONLY on cache miss, and FusionCache's built-in concurrency control ensures that if 100 concurrent requests hit the same missing key, the factory runs ONCE and the other 99 await the result — no thundering herd.", + "repl": "The controller uses GetOrSetAsync with a factory. The factory runs only on a cache miss, and FusionCache's built-in concurrency control ensures that if 100 concurrent requests hit the same missing key, the factory runs once and the other 99 await the result: no thundering herd.", + "expect": 1, + "sldIds": [ + 484 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've thought about what 'lost the last 5 seconds of writes' actually means for your business.", + "repl": "Use only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've thought about what 'lost the last 5 seconds of writes' means for your business.", + "expect": 1, + "sldIds": [ + 489 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Refresh-ahead pre-empts this: a BackgroundService watches the cache (or maintains a Redis sorted set of 'hot' keys) and refreshes their values from the DB BEFORE the TTL fires.", + "repl": "Refresh-ahead pre-empts this: a BackgroundService watches the cache (or maintains a Redis sorted set of 'hot' keys) and refreshes their values from the DB before the TTL fires.", + "expect": 1, + "sldIds": [ + 491 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The first point is the most important: caching is a complexity tax, only worth paying when reads genuinely hurt.", + "repl": "The first point is the most important: caching is a complexity tax, only worth paying when reads hurt.", + "expect": 1, + "sldIds": [ + 493 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "CIL: what dotnet build actually produces", + "repl": "CIL: what dotnet build produces", + "expect": 1, + "sldIds": [ + 544 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "One line of configuration — AddSignalR().AddStackExchangeRedis(redisConn) — and every hub invocation is published to Redis Pub/Sub.", + "repl": "One line of configuration, AddSignalR().AddStackExchangeRedis(redisConn), and every hub invocation is published to Redis Pub/Sub.", + "expect": 1, + "sldIds": [ + 555 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The example runs on P\u006fdman Compose: Blazor app + Postgres + Redis + LGTM stack.", + "repl": "The example runs on docker compose: two Blazor app instances (ports 8080 and 8081) on one Redis backplane, plus Postgres and the LGTM stack.", + "expect": 1, + "sldIds": [ + 556 + ], + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people there are four deep-dive appendices at the end (protocols, versioning, WebSockets, sagas) they can jump to.", + "repl": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The four appendices that follow are deep-dive backup material — pull them up on demand during Q&A, or work through them directly if you're running the full-length session.", + "repl": "The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session.", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "That's the entire menu of inputs every later slide will use to make routing decisions.", + "repl": "That's the entire menu of inputs the routing patterns in this appendix use to make routing decisions.", + "expect": 1, + "sldIds": [ + 433 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The sticky-session one ties back to the earlier slide.", + "repl": "The sticky-session one ties back to the sticky-sessions comparison.", + "expect": 1, + "sldIds": [ + 454 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "If you want the managed alternative, the next few slides show Temporal .", + "repl": "The managed alternative is Temporal, covered in this appendix.", + "expect": 1, + "sldIds": [ + 381 + ], + "cat": "positional" + }, + { + "op": "set_code", + "sldId": 533, + "shape_id": 8, + "lines": [ + "{", + " \"name\": \"Create order\",", + " \"request\": {", + " \"method\": \"POST\",", + " \"header\": [{ \"key\": \"Idempotency-Key\", \"value\": \"{{$guid}}\" }],", + " \"url\": \"{{base_url}}/orders\",", + " \"body\": { \"mode\": \"raw\", \"raw\": \"{\\\"sku\\\":\\\"A-1\\\",\\\"qty\\\":2}\" }", + " },", + " \"event\": [{", + " \"listen\": \"test\",", + " \"script\": { \"exec\": [", + " \"pm.test('201 Created', () => pm.response.to.have.status(201));\",", + " \"pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));\",", + " \"const body = pm.response.json();\",", + " \"pm.test('returns an id', () => pm.expect(body).to.have.property('id'));\",", + " \"pm.collectionVariables.set('order_id', body.id);\"", + " ] }", + " }]", + "}" + ], + "cat": "code", + "_why": "_docs/28 Anatomy of a collection (adds the response-time assertion)" + }, + { + "op": "replace_picture", + "sldId": 534, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-black-box.png", + "cat": "stack", + "_why": "the old figure shows five backends; the site figure shows all six" + }, + { + "op": "set_code", + "sldId": 535, + "shape_id": 8, + "lines": [ + "# .NET — run the service, then the same collection", + "dotnet run & # http://localhost:8080", + "newman run orders.postman_collection.json -e local.postman_environment.json" + ], + "cat": "code", + "_why": "_docs/28 codetab 'One collection, six backends', this deck's language" + }, + { + "op": "set_code", + "sldId": 536, + "shape_id": 8, + "lines": [ + "# install once (Node.js):", + "# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1", + "newman run orders.postman_collection.json \\", + " --environment local.postman_environment.json \\", + " --reporters cli,junit,htmlextra \\", + " --reporter-junit-export results/newman.xml \\", + " --timeout-request 5000 \\", + " --bail", + "# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure", + "", + "# data-driven: one iteration per CSV row; the columns become {{variables}}", + "newman run orders.postman_collection.json -e local.postman_environment.json \\", + " --iteration-data cases.csv" + ], + "cat": "code", + "_why": "_docs/28 Running Newman + Data-driven tests; pinned install; no comment after a line continuation" + }, + { + "op": "set_code", + "sldId": 538, + "shape_id": 8, + "lines": [ + "# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js)", + "jobs:", + " api-tests:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v7", + " - run: docker compose up -d --build --wait # order, payment, inventory, ...", + " - run: ./scripts/wait-for-ready.sh # poll /readyz before testing", + " - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1", + " - run: |", + " newman run orders.postman_collection.json \\", + " -e ci.postman_environment.json \\", + " --reporters cli,junit \\", + " --reporter-junit-export results/newman.xml", + " - uses: actions/upload-artifact@v7 # keep the report even on failure", + " if: always()", + " with: { name: newman-results, path: results/ }" + ], + "cat": "code", + "_why": "_docs/28 Newman in CI on Docker Engine; actions at v7 as in .github/workflows" + }, + { + "op": "replace_picture", + "sldId": 539, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-ci-gate.png", + "cat": "stack", + "_why": "the old figure shows the old container engine; site figure 27-ci-gate" + }, + { + "op": "replace_picture", + "sldId": 263, + "shape_id": 91, + "png": "lgtm-presentation/edits/png/01b-windows-to-k8s-bridge-dn.png", + "cat": "stack", + "_slide": 8, + "_why": "'Temporal workflows / MassTransit sagas'; Serilog row" + }, + { + "op": "replace_picture", + "sldId": 272, + "shape_id": 188, + "png": "lgtm-presentation/edits/png/02-pick-protocol-dotnet.png", + "cat": "stack", + "_slide": 17, + "_why": "framework labels of another language (Python in Go, Quarkus in .NET and Spring)" + }, + { + "op": "replace_picture", + "sldId": 276, + "shape_id": 236, + "png": "lgtm-presentation/edits/png/02-mesh-handles-wire-dotnet.png", + "cat": "stack", + "_slide": 21, + "_why": "app box named another language's framework" + }, + { + "op": "replace_picture", + "sldId": 279, + "shape_id": 270, + "png": "lgtm-presentation/edits/png/03-composition.png", + "cat": "stack", + "_slide": 24, + "_why": "gateway named Strawberry (Go) or SmallRye GraphQL (.NET, Spring)" + }, + { + "op": "replace_picture", + "sldId": 285, + "shape_id": 336, + "png": "lgtm-presentation/edits/png/04-cqrs.png", + "cat": "voice", + "_slide": 30, + "_why": "image text had a positional reference ('the next slide') and 'genuinely'" + }, + { + "op": "replace_picture", + "sldId": 287, + "shape_id": 359, + "png": "lgtm-presentation/edits/png/04-log-tailing.png", + "cat": "positional", + "_slide": 32, + "_why": "image text had '(next slide)'" + }, + { + "op": "replace_picture", + "sldId": 301, + "shape_id": 517, + "png": "lgtm-presentation/edits/png/06-scale-on-lag.png", + "cat": "stack", + "_slide": 46, + "_why": "HTTP pods named FastAPI (Go) or Quarkus (.NET, Spring)" + }, + { + "op": "replace_picture", + "sldId": 348, + "shape_id": 1030, + "png": "lgtm-presentation/edits/png/13-antipatterns-grid.png", + "cat": "voice", + "_slide": 93, + "_why": "box text broken mid-word (layout defect)" + }, + { + "op": "replace_picture", + "sldId": 388, + "shape_id": 1468, + "png": "lgtm-presentation/edits/png/63-appD-stateless-dn.png", + "cat": "stack", + "_slide": 133, + "_why": "'or: MassTransit Sagas'; '@Transactional' (Java)" + }, + { + "op": "replace_picture", + "sldId": 435, + "shape_id": 1978, + "png": "lgtm-presentation/edits/png/22-l7-layers.png", + "cat": "stack", + "_slide": 180, + "_why": "in-app router named modern-cpp-kafka (C++), Spring Boot + Camel (in the .NET deck), FastAPI + durable-rules (in the Go deck)" + }, + { + "op": "replace_picture", + "sldId": 436, + "shape_id": 1989, + "png": "lgtm-presentation/edits/png/22-l7-journey.png", + "cat": "voice", + "_slide": 181, + "_why": "subtitle 'each layer earns its place' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 450, + "shape_id": 2150, + "png": "lgtm-presentation/edits/png/22-business-rule-routing-dotnet.png", + "cat": "stack", + "_slide": 195, + "_why": "MassTransit RoutingSlip removed; NRules + Confluent.Kafka (site Appendix I .NET codetab)" + }, + { + "op": "replace_picture", + "sldId": 453, + "shape_id": 2187, + "png": "lgtm-presentation/edits/png/22-hop-cost-nrules.png", + "cat": "voice", + "_slide": 198, + "_why": "'every hop must earn its place' (ban tier); rule-engine row per language" + }, + { + "op": "replace_picture", + "sldId": 455, + "shape_id": 2208, + "png": "lgtm-presentation/edits/png/22-east-west.png", + "cat": "voice", + "_slide": 200, + "_why": "caption 'for free' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 462, + "shape_id": 2282, + "png": "lgtm-presentation/edits/png/70-appJ-pinned-libraries-dn.png", + "cat": "stack", + "_slide": 207, + "_why": "MassTransit row; 'Every entry above' (positional); rows now from site Appendix J" + }, + { + "op": "replace_picture", + "sldId": 463, + "shape_id": 2293, + "png": "lgtm-presentation/edits/png/71-appJ-pinned-platform-dn.png", + "cat": "stack", + "_slide": 208, + "_why": "Redis license BSD (Redis 8 is AGPLv3 option); rows from site Appendix J" + }, + { + "op": "replace_picture", + "sldId": 464, + "shape_id": 2304, + "png": "lgtm-presentation/edits/png/72-appJ-migration-reference-dn.png", + "cat": "stack", + "_slide": 209, + "_why": "MassTransit in three rows; rows from site Appendix J" + }, + { + "op": "replace_picture", + "sldId": 474, + "shape_id": 2411, + "png": "lgtm-presentation/edits/png/24-shared-database.png", + "cat": "positional", + "_slide": 219, + "_why": "'(decomposed DB above)'" + }, + { + "op": "replace_picture", + "sldId": 497, + "shape_id": 2673, + "png": "lgtm-presentation/edits/png/25-failure-taxonomy.png", + "cat": "voice", + "_slide": 242, + "_why": "'the rest of this appendix' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 544, + "shape_id": 4, + "png": "lgtm-presentation/edits/png/29-dotnet-compilation.png", + "cat": "stack", + "_slide": 289, + "_why": "missing-glyph boxes in the old render; site figure" + }, + { + "op": "replace_picture", + "sldId": 546, + "shape_id": 4, + "png": "lgtm-presentation/edits/png/29-kestrel-pipeline.png", + "cat": "stack", + "_slide": 291, + "_why": "refreshed from the site figure (same content, consistent render)" + }, + { + "op": "replace_picture", + "sldId": 548, + "shape_id": 4, + "png": "lgtm-presentation/edits/png/29-container-layers.png", + "cat": "engine", + "_slide": 293, + "_why": "old container engine named in a box; separate restore step (S9 changed to one RUN)" + }, + { + "op": "set_text", + "sldId": 450, + "shape_id": 2151, + "paragraphs": [ + "NRules owns the decision; Confluent.Kafka producers and HttpClient dispatch, in-process behind the Istio mesh." + ], + "cat": "stack", + "_why": "caption repeated the replaced figure's stale library" + }, + { + "op": "replace", + "scope": "slide", + "find": "# Istio: retries, timeout and outlier detection for inventory-service", + "repl": "# Istio: bounded retries and timeout for inventory-service", + "expect": 1, + "sldIds": [ + 277 + ], + "cat": "code", + "_why": "the manifest has no outlier detection; matches _docs/02 comment" + }, + { + "op": "set_code", + "sldId": 302, + "shape_id": 530, + "lines": [ + "apiVersion: keda.sh/v1alpha1", + "kind: ScaledObject", + "metadata: { name: payment-consumer }", + "spec:", + " scaleTargetRef: { name: payment-consumer }", + " minReplicaCount: 0", + " maxReplicaCount: 20", + " triggers:", + " - type: kafka", + " metadata: { bootstrapServers: \"cndp-kafka-bootstrap:9092\", topic: order.placed,", + " consumerGroup: payment, lagThreshold: \"100\" }", + "---", + "# HTTP: scale the ASP.NET Core service on concurrent requests (KEDA http-add-on)", + "apiVersion: http.keda.sh/v1alpha1", + "kind: HTTPScaledObject", + "metadata: { name: order-service }", + "spec:", + " hosts: [orders.example.com]", + " scaleTargetRef: { name: order-service, service: order-service, port: 8080 }", + " replicas: { min: 0, max: 30 }" + ], + "cat": "code", + "_why": "ScaledObject needs metadata.name and the kafka scaler needs bootstrapServers; HTTPScaledObject needs apiVersion, name, hosts and a full scaleTargetRef (KEDA v2.21 / http-add-on)", + "pt": 11.0, + "_fit": "slide 47: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "/healthz/ready", + "repl": "/readyz", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "code", + "_why": "examples/21-graceful-shutdown/dotnet maps /readyz" + }, + { + "op": "replace", + "scope": "slide", + "find": "/healthz/live", + "repl": "/healthz", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "code", + "_why": "examples/21-graceful-shutdown/dotnet maps /healthz" + }, + { + "op": "replace", + "scope": "slide", + "find": "# must NOT fail during the drain", + "repl": "# keeps answering during the drain", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "code", + "_why": "comment, no ALL-CAPS emphasis" + }, + { + "op": "set_notes", + "sldId": 428, + "paragraphs": [ + "The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover.", + "Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages.", + "The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number." + ], + "cat": "notes", + "_why": "notes named /livez (/healthz/live on .NET); examples use /readyz and /healthz" + }, + { + "op": "replace", + "scope": "slide", + "find": "ssl crt /etc/haproxy/certs/ ", + "repl": "ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 ", + "expect": 1, + "sldIds": [ + 438 + ], + "cat": "code", + "_why": "gRPC needs HTTP/2 on the TLS listener (ALPN h2)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# TLS termination here", + "repl": "# TLS here; h2 for gRPC", + "expect": 1, + "sldIds": [ + 438 + ], + "cat": "code", + "_why": "comment matches the ALPN change" + }, + { + "op": "replace", + "scope": "slide", + "find": "listen 443 ssl http2;", + "repl": "listen 443 ssl; http2 on;", + "expect": 1, + "sldIds": [ + 439 + ], + "cat": "code", + "_why": "nginx 1.25.1+ deprecates the http2 listen parameter; http2 is its own directive" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-request-id: \"%REQ(x-request-id)%\"", + "repl": "x-client-ip: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"", + "expect": 1, + "sldIds": [ + 448 + ], + "cat": "code", + "_why": "setting x-request-id to itself is a no-op; Envoy generates it" + }, + { + "op": "replace", + "scope": "slide", + "find": "# propagate or create", + "repl": "# caller address", + "expect": 1, + "sldIds": [ + 448 + ], + "cat": "code", + "_why": "comment matches the new header" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-tenant-id: \"%DOWNSTREAM_PEER_CERT_V_END%\"", + "repl": "x-peer-identity: \"%DOWNSTREAM_PEER_URI_SAN%\"", + "expect": 1, + "sldIds": [ + 448 + ], + "cat": "code", + "_why": "DOWNSTREAM_PEER_CERT_V_END is the certificate expiry, not an identity; the URI SAN carries the SPIFFE ID" + }, + { + "op": "set_notes", + "sldId": 448, + "paragraphs": [ + "Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders.", + "The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none.", + "The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved." + ], + "cat": "notes", + "_why": "notes described the old headers (tenant id from the cert expiry operator)" + }, + { + "op": "set_code", + "sldId": 523, + "shape_id": 8, + "lines": [ + "{", + " \"$schema\": \"https://flagd.dev/schema/v0/flags.json\",", + " \"flags\": {", + " \"new-checkout\": {", + " \"state\": \"ENABLED\",", + " \"variants\": { \"on\": true, \"off\": false },", + " \"defaultVariant\": \"off\",", + " \"targeting\": {", + " \"if\": [", + " { \"==\": [ {\"var\": \"plan\"}, \"enterprise\" ] }, \"on\",", + " { \"fractional\": [ {\"var\": \"targetingKey\"},", + " [\"on\", 25], [\"off\", 75] ] }", + " ]", + " }", + " }", + " }", + "}" + ], + "cat": "code", + "_why": "examples/27-feature-flags/flags/flags.json carries the $schema line" + }, + { + "op": "set_code", + "sldId": 312, + "shape_id": 642, + "lines": [ + "# No Maven plugin for .NET: gate breaking changes through the Apicurio v3 REST API.", + "# dryRun=true runs the COMPATIBILITY rule server-side and stores nothing.", + "# .github/workflows/contracts.yml (runs on every PR; no app build needed)", + "jobs:", + " schema-compat:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v7", + " - name: Gate the new Avro schema against the registry rule", + " env:", + " REGISTRY: http://apicurio.registry.svc/apis/registry/v3", + " run: |", + " body=$(jq -n --rawfile s src/Schemas/order-placed.avsc \\", + " '{content: {content: $s, contentType: \"application/json\"}}')", + " code=$(curl -s -o resp.json -w '%{http_code}' -X POST \\", + " \"$REGISTRY/groups/orders/artifacts/order-placed/versions?dryRun=true\" \\", + " -H 'Content-Type: application/json' -d \"$body\")", + " # 400 with RuleViolationException: the COMPATIBILITY rule rejected it", + " [ \"$code\" = \"200\" ] || { echo 'Breaking schema change'; cat resp.json; exit 1; }" + ], + "cat": "code", + "_why": "checkout@v7 as in .github/workflows; Apicurio v3 create-version takes a JSON body with content and contentType (examples/09 verify.sh); artifact order-placed; incompatible = 400 RuleViolationException (Apicurio 3.3)" + }, + { + "op": "replace", + "scope": "notes", + "find": "MassTransit + Confluent.Kafka for events, Streamiz.Kafka.Net for stream processing", + "repl": "Confluent.Kafka 2.16.0 for events, Streamiz.Kafka.Net 1.8.2 for stream processing", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "stack", + "_slide": 1, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 273, + "shape_id": 201, + "lines": [ + "// Program.cs — minimal API style; FluentValidation for input contracts", + "var orders = app.MapGroup(\"/orders\"); // Microsoft.AspNetCore.OpenApi documents it", + "orders.MapPost(\"/\", async (", + " [FromBody] OrderIn body,", + " IValidator validator, // FluentValidation", + " IOrderService svc,", + " IEventPublisher events) =>", + "{", + " var result = await validator.ValidateAsync(body);", + " if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary());", + "", + " var order = await svc.CreateAsync(body);", + " await events.PublishAsync(\"order.placed\", order); // made safe by the outbox", + " return Results.Created($\"/orders/{order.Id}\", order); // 201, not 200", + "});", + "orders.MapGet(\"/\", (string? after, int limit, IOrderService svc) =>", + " svc.Page(after, limit)); // cursor pagination, not offset", + "", + "// request record: immutable, validated (types follow the top-level statements)", + "public record OrderIn(", + " [Required, StringLength(64)] string Sku,", + " [Range(1, 1_000)] int Quantity);" + ], + "cat": "code", + "_slide": 18, + "_why": "site 02 codetab; WithOpenApi is obsolete in .NET 10, record moved after top-level statements, FluentValidation is a library (not built in)", + "pt": 10.0, + "_fit": "slide 18: 22 code lines, 86 columns; 10.5 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "gRPC client, MassTransit consumer)", + "repl": "gRPC client, Confluent.Kafka consumer)", + "expect": 1, + "sldIds": [ + 282 + ], + "cat": "stack", + "_slide": 27, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 286, + "shape_id": 348, + "paragraphs": [ + "csharp · ASP.NET Core 10 + EF Core + Confluent.Kafka" + ], + "cat": "stack", + "_slide": 31, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 286, + "shape_id": 349, + "lines": [ + "// Write side — the command handler commits, then emits the fact", + "[ApiController, Route(\"orders\")]", + "public class OrderWriteController(WriteDb writes, IProducer producer)", + " : ControllerBase", + "{", + " [HttpPost]", + " public async Task> Create(OrderIn body, CancellationToken ct)", + " {", + " var order = new Order(body);", + " writes.Orders.Add(order);", + " await writes.SaveChangesAsync(ct); // write model", + " await producer.ProduceAsync(\"order.placed\", new Message", + " { Key = order.Id, Value = JsonSerializer.Serialize(order) }, ct);", + " return Ok(order); // the outbox makes this crash-safe", + " }", + "}", + "// Read side — a different, denormalised store (OrderReadController(ReadDb reads))", + "[HttpGet(\"{id}/summary\")]", + "public async Task> Summary(string id, CancellationToken ct) =>", + " await reads.OrderSummaries.FindAsync([id], ct) is { } s ? Ok(s) : NotFound();", + "", + "// Projection — a Confluent.Kafka consumer, idempotent upsert into the read model", + "var cr = consumer.Consume(ct);", + "var summary = OrderSummary.From(JsonSerializer.Deserialize(cr.Message.Value)!);", + "await reads.UpsertAsync(summary, ct); // keyed by order id", + "consumer.Commit(cr); // ack after the write" + ], + "cat": "code", + "_slide": 31, + "_why": "projection on a Confluent.Kafka consumer; no site codetab (illustrative, CQRS)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 31: 26 code lines, 89 columns; 8.0 pt overflows the box" + }, + { + "op": "set_notes", + "sldId": 286, + "paragraphs": [ + "The .NET shape with primary constructors. OrderWriteController handles writes through WriteDb, an EF Core context on the write database, then produces order.placed with Confluent.Kafka, keyed by the order id. The read side queries ReadDb, a separate context on a denormalised store: another Postgres, or a search index.", + "The projection is a Confluent.Kafka consumer loop. It deserializes each event, upserts the summary row keyed by the order id, and commits the offset only after the write, so a crash means redelivery and the upsert keeps the replay idempotent.", + "Producing right after SaveChangesAsync is a dual write; in production the outbox from the Data section closes that gap. Each side scales on its own load: the write side on commands, the read side on queries." + ], + "cat": "notes", + "_slide": 31, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "// MassTransit has a built-in transactional outbox if you prefer the framework path.", + "repl": "// Wolverine (MIT) ships an EF Core transactional outbox if you prefer a framework.", + "expect": 1, + "sldIds": [ + 289 + ], + "cat": "code", + "_slide": 34, + "_why": "site 04 codetab" + }, + { + "op": "replace", + "scope": "notes", + "find": "OR use MassTransit's built-in transactional outbox, which gives you the same guarantees with framework-managed plumbing — we'll see MassTransit for events on the next section.", + "repl": "or use a framework outbox such as Wolverine's (MIT) EF Core integration, which gives the same guarantees with framework-managed plumbing.", + "expect": 1, + "sldIds": [ + 289 + ], + "cat": "stack", + "_slide": 34, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 293, + "shape_id": 425, + "paragraphs": [ + "Producing and consuming with Confluent.Kafka" + ], + "cat": "stack", + "_slide": 38, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 293, + "shape_id": 427, + "paragraphs": [ + "csharp · .NET 10 + Confluent.Kafka 2.16.0" + ], + "cat": "stack", + "_slide": 38, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 293, + "shape_id": 428, + "lines": [ + "// Confluent.Kafka 2.16.0 — the low-level Kafka client (Apache 2.0).", + "// Auto-commit off, commit after the work: at-least-once processing.", + "// producer — order-service emits the fact", + "var producer = new ProducerBuilder(", + " new ProducerConfig { BootstrapServers = kafkaBootstrap }).Build();", + "var json = JsonSerializer.Serialize(new { id, sku, quantity, status });", + "await producer.ProduceAsync(\"order.placed\",", + " new Message { Value = json }); // awaits the broker ack", + "// consumer — notification-service", + "var consumer = new ConsumerBuilder(new ConsumerConfig", + "{", + " BootstrapServers = kafkaBootstrap,", + " GroupId = \"notification-group\",", + " AutoOffsetReset = AutoOffsetReset.Earliest,", + " EnableAutoCommit = false,", + "}).Build();", + "consumer.Subscribe(\"order.placed\");", + "while (!ct.IsCancellationRequested)", + "{", + " var cr = consumer.Consume(TimeSpan.FromSeconds(1));", + " if (cr is null) continue;", + " await ProcessNotification(cr.Message.Value); // do the work", + " consumer.Commit(cr); // ack after — at-least-once", + "}" + ], + "cat": "code", + "_slide": 38, + "_why": "site 05 codetab, examples/05-event-driven/dotnet (topic order.placed, group notification-group, manual commit)", + "pt": 9.0, + "_fit": "slide 38: 24 code lines, 81 columns; 9.5 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 293, + "shape_id": 429, + "paragraphs": [ + "Confluent.Kafka keeps the Kafka semantics visible: keys, partitions, offsets. Commit after the work; handlers are idempotent." + ], + "cat": "stack", + "_slide": 38, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 293, + "paragraphs": [ + "Confluent.Kafka is the .NET Kafka client for every Kafka path in this deck, pinned at 2.16.0. MassTransit was dropped: its abstraction hides the Kafka semantics this section teaches (keys, partitions, consumer groups, offsets), and version 9 moved to a commercial license.", + "The producer emits order.placed and awaits the broker's delivery report. The consumer joins notification-group, starts from the earliest offset, and runs with auto-commit off.", + "Two points to drill home. First, the offset is committed only after the work succeeds; a crash before the commit means redelivery, so delivery is at-least-once and handlers must be idempotent (dedupe by order id or an idempotency key). Exactly-once is mostly at-least-once plus idempotency. Second, the loop is yours: retries, dead-letter topics and shutdown are explicit code, shown in Appendix E and Appendix H. In the running example the loop runs inside a BackgroundService. Apicurio holds the schemas." + ], + "cat": "notes", + "_slide": 38, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 300, + "shape_id": 505, + "paragraphs": [ + "csharp · Streamiz.Kafka.Net 1.8.2" + ], + "cat": "version", + "_slide": 45, + "_why": "Streamiz 1.8.2 pin" + }, + { + "op": "set_code", + "sldId": 300, + "shape_id": 506, + "lines": [ + "// StreamProcessor/Program.cs (excerpt) — Streamiz.Kafka.Net 1.8.2", + "var config = new StreamConfig", + "{", + " ApplicationId = \"stream-processor\", // also the consumer group", + " BootstrapServers = kafkaBootstrap,", + " StateDir = \"/tmp/kafka-streams\", // local RocksDB, changelog-backed", + "};", + "var builder = new StreamBuilder();", + "builder.Stream(\"order.placed\")", + " .Map((_, json, _) => Rekey(json)) // key by merchant_id", + " .Filter((merchant, _, _) => merchant.Length > 0)", + " .GroupByKey()", + " .WindowedBy(TumblingWindowOptions.Of(TimeSpan.FromSeconds(windowSeconds), TimeSpan.Zero))", + " .Aggregate(() => new Acc(),", + " (merchant, amount, acc) => Add(merchant, acc, amount), // exact decimal total", + " RocksDbWindows.As(\"revenue-store\")", + " .WithKeySerdes(new StringSerDes())", + " .WithValueSerdes(new JsonSerDes()))", + " .ToStream()", + " // .Map(...): window_start, merchant_id, order_count, revenue rounded half-up", + " .To(\"revenue.by-merchant\");", + "await new KafkaStream(builder.Build(), config).StartAsync(cts.Token);" + ], + "cat": "code", + "_slide": 45, + "_why": "excerpt of examples/06-stream-processing/dotnet/StreamProcessor/Program.cs", + "pt": 9.0, + "_fit": "slide 45: 22 code lines, 93 columns; 9.5 pt overflows the box" + }, + { + "op": "set_notes", + "sldId": 300, + "paragraphs": [ + "Streamiz.Kafka.Net 1.8.2 (MIT) is a .NET implementation of the Kafka Streams model: the same topology, state-store and changelog design, in native C#, with no JVM in the container.", + "The processor in the running example keys each order by merchant, groups by key, and aggregates into tumbling windows (WINDOW_SECONDS, 10 s in the example) held in a RocksDB window store backed by a Kafka changelog topic. If the Pod dies, the state rebuilds by replaying the changelog. The aggregate keeps an exact decimal total and skips records whose amount does not parse; the output rounds half-up to two places, the same contract as the Spring Boot and Quarkus Kafka Streams processors.", + "ApplicationId is also the consumer group, so KEDA can scale the processor on its lag. For this audience it replaces SQL Server Service Broker activation procedures or a hand-rolled Windows Service draining MSMQ. Alternatives if Streamiz doesn't fit: Apache Flink as a separate service fed by Kafka, Akka.NET Streams for in-process reactive streams, or Orleans Streams on the virtual-actor model." + ], + "cat": "version", + "_slide": 45, + "_why": "Streamiz 1.8.2; matches the example's contract" + }, + { + "op": "set_code", + "sldId": 321, + "shape_id": 738, + "lines": [ + "// Program.cs — OpenTelemetry .NET replaces Windows performance counters,", + "// ETW and the Event Log with one model.", + "builder.Services.AddOpenTelemetry()", + " .ConfigureResource(r => r.AddService(\"order-service\"))", + " .WithTracing(t => t", + " .AddAspNetCoreInstrumentation() // every HTTP request → span", + " .AddSource(\"order-service\") // your own ActivitySource", + " .AddOtlpExporter()) // endpoint and protocol from the env", + " .WithMetrics(m => m", + " .AddMeter(\"order-service\")", + " .AddOtlpExporter());", + "// compose: OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4318", + "// OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf", + "", + "// custom business spans use the BCL ActivitySource (no extra dependency)", + "var activitySource = new ActivitySource(\"order-service\");", + "app.MapPost(\"/orders\", async (HttpContext ctx) =>", + "{", + " // ...parse sku and quantity from the body", + " using var reserveSpan = activitySource.StartActivity(\"reserve-stock\");", + " reserveSpan?.SetTag(\"sku\", sku);", + " var reply = await inventoryClient.ReserveStockAsync( // gRPC; HttpClient", + " new ReserveRequest { Sku = sku, Quantity = quantity }); // carries traceparent", + " // ...insert the order, produce order.placed: trace_id rides every hop", + "});" + ], + "cat": "code", + "_slide": 66, + "_why": "examples/11-observability/dotnet/OrderService/Program.cs (env-configured OTLP to lgtm:4318)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 66: 25 code lines, 88 columns; 9.5 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 321, + "shape_id": 739, + "paragraphs": [ + "ASP.NET Core requests become spans with no code; ActivitySource is the built-in API for your own spans. OTLP ships traces to Tempo, metrics to Mimir, logs to Loki." + ], + "cat": "code", + "_slide": 66 + }, + { + "op": "set_notes", + "sldId": 321, + "paragraphs": [ + "OpenTelemetry .NET implements the OTel spec on top of types the runtime already has: System.Diagnostics.ActivitySource is the span API, built into the BCL since .NET 6, so starting a span needs no extra dependency.", + "This is the running example's setup. AddAspNetCoreInstrumentation turns every HTTP request into a span; AddSource registers the service's own ActivitySource; AddOtlpExporter reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL from the environment, which compose sets to http://lgtm:4318 and http/protobuf. Add OpenTelemetry.Instrumentation.Http, GrpcNetClient or Runtime when you want outbound HTTP, gRPC client and GC or thread-pool signals as well.", + "For business operations, declare one ActivitySource and wrap the work in using var span = source.StartActivity(...); the using stops the span when the scope exits. The gRPC call to inventory carries the W3C traceparent through HttpClient, so the inventory span lands in the same trace. For this audience this replaces Windows performance counters, ETW providers and the Event Log with one model that works the same in a container, in CI and on any cluster." + ], + "cat": "notes", + "_slide": 66, + "_why": "describes the example's setup" + }, + { + "op": "set_text", + "sldId": 323, + "shape_id": 761, + "paragraphs": [ + "csharp · OpenTelemetry Baggage + Confluent.Kafka" + ], + "cat": "stack", + "_slide": 68, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 323, + "shape_id": 762, + "lines": [ + "using OpenTelemetry; // Baggage: the W3C baggage API", + "using OpenTelemetry.Context.Propagation;", + "", + "// set at the edge in middleware; the propagator writes the baggage header", + "app.Use(async (ctx, next) =>", + "{", + " Baggage.SetBaggage(\"tenant_id\",", + " ctx.Request.Headers[\"X-Tenant\"].FirstOrDefault() ?? \"unknown\");", + " await next();", + "});", + "", + "// Confluent.Kafka has no built-in propagation: the producer injects the context", + "// into the record headers and the consumer extracts it", + "var parent = Propagators.DefaultTextMapPropagator.Extract(default,", + " cr.Message.Headers, ReadHeaders); // ReadHeaders: UTF-8 values for a key", + "Baggage.Current = parent.Baggage;", + "using var span = source.StartActivity(\"process_shipment\", ActivityKind.Consumer,", + " parent.ActivityContext);", + "var tenant = Baggage.GetBaggage(\"tenant_id\");", + "span?.SetTag(\"tenant_id\", tenant); // filterable in the trace UI" + ], + "cat": "code", + "_slide": 68, + "_why": "OpenTelemetry Baggage API (site 11 codetab) + header extraction as in Appendix S / examples/05-event-driven/dotnet", + "pt": 10.0, + "_fit": "slide 68: 20 code lines, 81 columns; 10.5 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 323, + "shape_id": 763, + "paragraphs": [ + "Baggage.SetBaggage at the edge; extract from the record headers and GetBaggage downstream. SetTag to filter traces." + ], + "cat": "code", + "_slide": 68 + }, + { + "op": "set_notes", + "sldId": 323, + "paragraphs": [ + "Baggage in .NET goes through OpenTelemetry's Baggage API: Baggage.SetBaggage at the edge, and the default propagator writes the W3C baggage header on every outbound HTTP and gRPC call.", + "Kafka is different: Confluent.Kafka has no built-in tracing, so the producer injects the trace context and baggage into the record headers and the consumer extracts them, as the Appendix S code shows. After the extract, Baggage.Current carries the values and the consumer span is a child of the producer span.", + "Baggage propagates on the wire; span attributes do not, but they are recorded and searchable. The idiom is to read the baggage value and set it as a tag on the local span so traces filter by tenant. Baggage travels to every downstream hop, so never put secrets or PII in it." + ], + "cat": "notes", + "_slide": 68, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 964, + "paragraphs": [ + "Per-tenant bulkheads in ASP.NET Core" + ], + "cat": "code", + "_slide": 87, + "_why": "site 12 codetab" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 966, + "paragraphs": [ + "csharp · ASP.NET Core 10 + SemaphoreSlim" + ], + "cat": "code", + "_slide": 87 + }, + { + "op": "set_code", + "sldId": 342, + "shape_id": 967, + "lines": [ + "// Per-tenant bulkhead with a SemaphoreSlim; A's storm can't drain B's capacity", + "public class TenantBulkhead", + "{", + " private readonly ConcurrentDictionary _sems = new();", + "", + " public async Task HandleAsync(string tenant, Func> work)", + " {", + " var sem = _sems.GetOrAdd(tenant, _ => new SemaphoreSlim(20)); // 20 / tenant", + " await sem.WaitAsync(); // blocks only this tenant when full", + " try { return await work(); }", + " finally { sem.Release(); }", + " }", + "}", + "// Polly 8 alternative: ResiliencePipelineBuilder().AddConcurrencyLimiter(permitLimit,", + "// queueLimit) per tenant, from the Polly.RateLimiting package." + ], + "cat": "code", + "_slide": 87, + "_why": "site 12 codetab (the Polly version returned the wrong type: PostAsJsonAsync yields HttpResponseMessage)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 87: 15 code lines, 86 columns; 9.0 pt overflows the box" + }, + { + "op": "set_notes", + "sldId": 342, + "paragraphs": [ + "The bulkhead in its simplest .NET form: one SemaphoreSlim per tenant, created on first use. A tenant that floods the service fills only its own twenty slots and waits; other tenants keep their capacity.", + "Polly 8 offers the same isolation as a pipeline strategy: AddConcurrencyLimiter (Polly.RateLimiting) with a permit limit and a queue limit, one pipeline per tenant, combined with a timeout so a slow downstream fails fast. Microsoft.Extensions.Http.Resilience can attach such pipelines to named HttpClients.", + "At the platform layer, Istio connection-pool limits per destination complement these in-process limits." + ], + "cat": "notes", + "_slide": 87, + "_why": "matches the new code" + }, + { + "op": "replace", + "scope": "slide", + "find": "Temporal .NET SDK (docs.temporal.io/dev-guide/dotnet), MassTransit, HotChocolate", + "repl": "Temporal .NET SDK (docs.temporal.io/dev-guide/dotnet), Confluent.Kafka, HotChocolate", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "stack", + "_slide": 96, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 363, + "shape_id": 1186, + "lines": [ + "// Asp.Versioning.Http (MIT, .NET Foundation): media-type, URI segment,", + "// query string or header versioning, picked centrally.", + "builder.Services.AddApiVersioning(o =>", + "{", + " o.ApiVersionReader = new MediaTypeApiVersionReaderBuilder()", + " .Template(\"application/vnd.acme.order.v{version}+json\") // Accept header", + " .Build();", + " o.DefaultApiVersion = new ApiVersion(1, 0);", + " o.AssumeDefaultVersionWhenUnspecified = true;", + "});", + "", + "// one resource URL, /orders/{id}; the representation is versioned", + "var orders = app.NewVersionedApi(\"Orders\").MapGroup(\"/orders\")", + " .HasApiVersion(1, 0).HasApiVersion(2, 0);", + "orders.MapGet(\"/{id}\", (string id, IOrderService svc) => OrderV2.From(svc.Get(id)))", + " .MapToApiVersion(2, 0);", + "orders.MapGet(\"/{id}\", (string id, IOrderService svc) => OrderV1.From(svc.Get(id)))", + " .MapToApiVersion(1, 0); // the default for old clients", + "", + "public record OrderV1(string Id, double Total);", + "public record OrderV2(string Id, double Total, string Currency);" + ], + "cat": "code", + "_slide": 108, + "_why": "Asp.Versioning: a media-type template reader and NewVersionedApi/MapToApiVersion (the old code used a parameter reader that cannot read vnd.*.v2+json and an argument-less WithApiVersionSet); no site codetab" + }, + { + "op": "set_notes", + "sldId": 363, + "paragraphs": [ + "ASP.NET Core versioning lives in Asp.Versioning.Http (.NET Foundation). The reader is pluggable: MediaTypeApiVersionReaderBuilder with a template reads the version from the Accept media type (application/vnd.acme.order.v2+json), the most RESTful choice; UrlSegment, QueryString and Header readers swap in without changing handlers.", + "There is one URL for the order resource, and it never changes. NewVersionedApi declares the versions the group supports, and MapToApiVersion binds each handler to one. New clients ask for the v2 media type and get the currency field; old clients send the default and get v1, unaffected. Contrast with /v2/orders, which gives one resource two URLs.", + "OrderV1 and OrderV2 are explicit representation DTOs: the wire contract stays decoupled from the domain entity, a discipline WCF DataContracts taught, applied to HTTP." + ], + "cat": "notes", + "_slide": 108, + "_why": "matches the new code" + }, + { + "op": "set_code", + "sldId": 367, + "shape_id": 1234, + "lines": [ + "// HotChocolate — [GraphQLDeprecated] maps to the SDL @deprecated directive", + "public class Order", + "{", + " public string Id { get; init; }", + "", + " [GraphQLDeprecated(\"Use totalMinor (integer cents) instead.\")]", + " public double Total { get; init; } // still served; shows in introspection", + "", + " public int TotalMinor => (int)(Total * 100); // the replacement, added additively", + "}", + "// Schema-first alternative, if your team prefers .graphql files:", + "// type Order {", + "// id: ID!", + "// total: Float @deprecated(reason: \"Use totalMinor (integer cents) instead.\")", + "// totalMinor: Int!", + "// }", + "// Per-field usage counters (a diagnostic event listener exporting to OpenTelemetry)", + "// show when 'total' has zero callers. Only then remove it. No /v2 endpoint." + ], + "cat": "code", + "_slide": 112, + "_why": "site 15 codetab (class with [GraphQLDeprecated]; the record form needed a property: target; UseXmlDocumentation has nothing to do with usage metrics)", + "pt": 10.5, + "_fit": "slide 112: 18 code lines, 88 columns; 11.0 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 369, + "shape_id": 1256, + "paragraphs": [ + "Raw WebSockets, pod pinning, the backplane, binary framing, reconnection — and where Blazor fits." + ], + "cat": "code", + "_slide": 114, + "_why": "examples/16-websockets/dotnet uses raw ASP.NET Core WebSockets" + }, + { + "op": "set_notes", + "sldId": 369, + "paragraphs": [ + "Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes. WebSockets are long-lived, stateful and pinned to a Pod: the opposite of the stateless request model the platform assumes.", + "We build an endpoint with raw ASP.NET Core WebSockets, as the running example does, see why it fights scaling, fix it with a Redis backplane, make it fast and versioned with protobuf frames, and make it survivable with reconnection. Then Blazor: Blazor Server sits on the SignalR pinning side of this discussion and Blazor WebAssembly on the other; both run on Kubernetes as plain ASP.NET Core Pods. SignalR itself is the subject of Appendix Q." + ], + "cat": "notes", + "_slide": 114, + "_why": "Appendix C now matches the example (raw WebSockets); SignalR is Appendix Q" + }, + { + "op": "set_text", + "sldId": 370, + "shape_id": 1264, + "paragraphs": [ + "A WebSocket endpoint in ASP.NET Core" + ], + "cat": "code", + "_slide": 115 + }, + { + "op": "set_text", + "sldId": 370, + "shape_id": 1266, + "paragraphs": [ + "csharp · ASP.NET Core 10 WebSockets" + ], + "cat": "code", + "_slide": 115 + }, + { + "op": "set_code", + "sldId": 370, + "shape_id": 1267, + "lines": [ + "// raw ASP.NET Core WebSockets, as in the running example (no SignalR)", + "app.UseWebSockets();", + "app.Map(\"/ws/{clientId}\", async (HttpContext ctx, string clientId) =>", + "{", + " if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }", + " var ws = await ctx.WebSockets.AcceptWebSocketAsync();", + " clients[clientId] = ws; // per-pod state", + " var buf = new byte[4096];", + " while (ws.State == WebSocketState.Open)", + " {", + " var result = await ws.ReceiveAsync(buf, CancellationToken.None);", + " if (result.MessageType == WebSocketMessageType.Close) break;", + " var msg = Encoding.UTF8.GetString(buf, 0, result.Count);", + " await Broadcast(msg); // fan-out to this pod's clients", + " }", + " clients.TryRemove(clientId, out _);", + "});" + ], + "cat": "code", + "_slide": 115, + "_why": "site 16 codetab and examples/16-websockets/dotnet/WsServer/Program.cs (route /ws/{clientId})" + }, + { + "op": "set_text", + "sldId": 370, + "shape_id": 1268, + "paragraphs": [ + "Raw ASP.NET Core WebSockets, as in the running example. The clients map lives in this Pod: that single fact is the scaling problem." + ], + "cat": "code", + "_slide": 115 + }, + { + "op": "set_notes", + "sldId": 370, + "paragraphs": [ + "The running example's endpoint uses raw ASP.NET Core WebSockets: UseWebSockets, a mapped route, AcceptWebSocketAsync, then a receive loop. No SignalR, so any client can connect and verify.sh can test it with a plain WebSocket client.", + "Point at the clients dictionary: it lives in this Pod's memory. The connection, the client id and the fan-out all stay on one Pod, and that single fact is the scaling problem the following slides solve with a Redis backplane, which the example also runs.", + "SignalR (Appendix Q) wraps the same transport with hubs, groups, reconnection and serialization; it is the choice when server and clients are .NET. The per-Pod state problem is the same either way." + ], + "cat": "notes", + "_slide": 115, + "_why": "matches the new code" + }, + { + "op": "set_text", + "sldId": 374, + "shape_id": 1309, + "paragraphs": [ + "Binary framing with protobuf — fast and versioned" + ], + "cat": "code", + "_slide": 119 + }, + { + "op": "set_text", + "sldId": 374, + "shape_id": 1311, + "paragraphs": [ + "csharp · Google.Protobuf + WebSockets" + ], + "cat": "code", + "_slide": 119 + }, + { + "op": "set_code", + "sldId": 374, + "shape_id": 1312, + "lines": [ + "// raw System.Net.WebSockets: a protobuf binary frame, not JSON text", + "async Task Push(WebSocket socket, ulong seq, OrderPlaced ev)", + "{", + " var env = new Envelope {", + " Version = 2, Seq = seq, Type = \"OrderPlaced\",", + " Payload = ev.ToByteString() }; // nested protobuf", + " await socket.SendAsync(env.ToByteArray(),", + " WebSocketMessageType.Binary, endOfMessage: true, ct);", + "}", + "", + "// receive: parse the Envelope, dispatch on Type", + "var result = await ws.ReceiveAsync(buf, ct);", + "if (result.MessageType == WebSocketMessageType.Binary)", + "{", + " var env = Envelope.Parser.ParseFrom(buf, 0, result.Count);", + " switch (env.Type)", + " {", + " case \"OrderPlaced\":", + " HandleOrderPlaced(env.Seq, OrderPlaced.Parser.ParseFrom(env.Payload));", + " break;", + " }", + "}" + ], + "cat": "code", + "_slide": 119, + "_why": "site 16 codetabs (push and receive/dispatch)", + "pt": 9.5, + "_fit": "slide 119: 22 code lines, 82 columns; 10.0 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 374, + "shape_id": 1313, + "paragraphs": [ + "Protobuf frames are smaller and faster than JSON; the versioned envelope and seq number enable evolution and resume." + ], + "cat": "code", + "_slide": 119 + }, + { + "op": "set_notes", + "sldId": 374, + "paragraphs": [ + "Two wins in one slide. Performance: protobuf binary frames are smaller and cheaper to parse than JSON text, which matters when pushing high-frequency updates to many connections.", + "Versioning: a WebSocket message has no URL to hang a version on, so you version the envelope. The Version field lets the client detect an incompatible payload format and renegotiate; the Type field dispatches to the right typed payload; and a monotonic Seq number per connection is what makes reconnection and replay possible, the subject of the failure-handling slide.", + "The envelope is a protobuf message compiled with Grpc.Tools, so the same .proto evolution rules from Appendix B apply. With SignalR (Appendix Q) the equivalent is the MessagePack hub protocol plus a versioned envelope record." + ], + "cat": "notes", + "_slide": 119, + "_why": "matches the new code" + }, + { + "op": "replace", + "scope": "notes", + "find": "If you use Temporal .NET instead, its Saga utility (Saga.AddCompensation / Saga.CompensateAsync via the Workflow runtime) drives the unwind for you with the same guarantees; the next few slides show it.", + "repl": "With Temporal .NET the workflow keeps a stack of compensations and runs them in reverse on failure, with the same guarantees and durable history.", + "expect": 1, + "sldIds": [ + 383 + ], + "cat": "code", + "_slide": 128, + "_why": "the Temporal .NET SDK has no Saga helper" + }, + { + "op": "replace", + "scope": "slide", + "find": " MassTransit Sagas is a closely-related alternative if you're already on MassTransit for events.", + "repl": "", + "expect": 1, + "sldIds": [ + 385 + ], + "cat": "stack", + "_slide": 130, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "on NuGet as Temporalio.Temporal)", + "repl": "on NuGet as Temporalio)", + "expect": 1, + "sldIds": [ + 385 + ], + "cat": "stack", + "_slide": 130, + "_why": "the NuGet package is Temporalio" + }, + { + "op": "set_notes", + "sldId": 385, + "paragraphs": [ + "Frame the choice as managed durability against lightweight in-process state, both open source and production-grade.", + "Temporal: an MIT-licensed Server and an open-source .NET SDK, on NuGet as Temporalio, actively maintained by the team that built Cadence. You get durable execution, deterministic replay, retries with backoff, timers, child workflows, signals and queries, and a CLI and UI to inspect runs. The cost is operating a Temporal cluster on your own Kubernetes, or choosing Temporal Cloud.", + "Stateless: a small in-process .NET state-machine library (a community project). No extra cluster; you persist the CurrentState property to your own database, one column, and restore it before firing the next trigger. Better when the saga is small, the team already works in EF Core, and another runtime is overkill.", + "Dapr Workflows is a sidebar: a third option when your environment already uses the sidecar pattern, not the focus of this deck. The decision is how much engine this saga needs." + ], + "cat": "notes", + "_slide": 130, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 387, + "shape_id": 1456, + "paragraphs": [ + "csharp · Temporalio + ASP.NET Core" + ], + "cat": "stack", + "_slide": 132, + "_why": "the NuGet package is Temporalio" + }, + { + "op": "set_code", + "sldId": 387, + "shape_id": 1457, + "lines": [ + "// PaymentActivities: [Activity] ChargeAsync / RefundAsync, plain C# methods", + "[Workflow]", + "public class FulfillmentWorkflow // deterministic C#, replayed by a Worker after a crash", + "{", + " private static readonly ActivityOptions Opts =", + " new() { StartToCloseTimeout = TimeSpan.FromSeconds(10) };", + " [WorkflowRun]", + " public async Task RunAsync(SagaInput input)", + " {", + " var undo = new Stack>(); // compensations, newest on top", + " try", + " {", + " var paymentId = await Workflow.ExecuteActivityAsync(", + " (PaymentActivities a) => a.ChargeAsync(input), Opts);", + " undo.Push(() => Workflow.ExecuteActivityAsync(", + " (PaymentActivities a) => a.RefundAsync(paymentId), Opts));", + " var reservationId = await Workflow.ExecuteActivityAsync(", + " (InventoryActivities a) => a.ReserveAsync(input, paymentId), Opts);", + " undo.Push(() => Workflow.ExecuteActivityAsync(", + " (InventoryActivities a) => a.ReleaseAsync(reservationId), Opts));", + " return await Workflow.ExecuteActivityAsync(", + " (ShippingActivities a) => a.BookAsync(input, reservationId), Opts);", + " }", + " catch (ActivityFailureException) { while (undo.TryPop(out var u)) await u(); throw; }", + " }", + "}" + ], + "cat": "code", + "_slide": 132, + "_why": "Temporal .NET SDK: no Saga helper class; StartToCloseTimeout required on every activity call; no site codetab (illustrative)" + }, + { + "op": "set_text", + "sldId": 387, + "shape_id": 1458, + "paragraphs": [ + "Workflow and activities are plain C# classes; the workflow keeps its own compensation stack. State is durable on the Server." + ], + "cat": "code", + "_slide": 132 + }, + { + "op": "set_notes", + "sldId": 387, + "paragraphs": [ + "The whole story on one slide. Activity classes are plain C#; methods marked [Activity] are registered with the worker at startup. The workflow class is marked [Workflow] with one [WorkflowRun] entry point. The body reads like synchronous code, but every activity call goes through Temporal's task queue and is durably scheduled, retried and timed out on the Server. StartToCloseTimeout is required on every activity call, so the options object is shared.", + "The output of each activity (paymentId, reservationId) is captured in a local variable and in Temporal's event history. If the worker dies between charge and reserve, a replacement worker replays the history, recovers paymentId, and continues at reserve without re-running charge.", + "Compensation is plain C# as well: each completed step pushes its undo onto a stack, and on an activity failure the workflow pops and runs them in reverse, the same discipline as the hand-rolled version, now durable because each undo is itself an activity. Hosting in ASP.NET Core: Temporalio.Extensions.Hosting registers the client and a hosted worker that lists the workflow and activity types." + ], + "cat": "notes", + "_slide": 132, + "_why": "matches the new code" + }, + { + "op": "replace", + "scope": "notes", + "find": " The MassTransit Sagas callout is for shops already using MassTransit: it gives you the same state-machine + persister + bus integration but with one more framework on the dependency list.", + "repl": "", + "expect": 1, + "sldIds": [ + 388 + ], + "cat": "stack", + "_slide": 133, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": ".OnEntry(() =>", + "repl": ".OnEntryAsync(() =>", + "expect": 4, + "sldIds": [ + 389 + ], + "cat": "code", + "_slide": 134, + "_why": "Stateless: async entry actions need OnEntryAsync" + }, + { + "op": "set_notes", + "sldId": 389, + "paragraphs": [ + "FulfillmentSagaMachine wraps a Stateless StateMachine with enums for both type parameters: type-safe states and triggers. Configure() declares each state's permitted transitions and its entry action; OnEntryAsync runs the business call when the state is entered, and FireAsync is required once entry actions are asynchronous. The Permit chain reads like the state diagram.", + "FulfillmentService.OnAsync is the entry point, called from an HTTP handler or a Kafka consumer. It restores the saga's state by order id from EF Core, rebuilds the machine in that state, fires the trigger, and saves the new CurrentState.", + "The transactional discipline is yours: wrap the action's effects and the state save in one DbContext transaction, the same commit-together rule as the hand-rolled version. Compared with Temporal there is no separate server, but also no automatic retries, timers or replay; the BackgroundService sweeper from the persisted-state slide still applies." + ], + "cat": "notes", + "_slide": 134, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "notes", + "find": "With MassTransit this is declarative configuration (UseMessageRetry + UseInMemoryOutbox + routing to a faulted-message endpoint), not hand-rolled plumbing.", + "repl": "With Confluent.Kafka the loop is yours, so the invariant is explicit: bounded retries, then a delayed-retry topic, then a DLQ, with the offset committed on every branch.", + "expect": 1, + "sldIds": [ + 396 + ], + "cat": "stack", + "_slide": 141, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 397, + "shape_id": 1571, + "paragraphs": [ + "Kafka retry + DLQ with Confluent.Kafka" + ], + "cat": "stack", + "_slide": 142, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 397, + "shape_id": 1573, + "paragraphs": [ + "csharp · Confluent.Kafka + Polly 8" + ], + "cat": "stack", + "_slide": 142, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 397, + "shape_id": 1574, + "lines": [ + "// Confluent.Kafka + Polly 8: bounded in-place retries, a delayed-retry topic, a DLQ.", + "var pipeline = new ResiliencePipelineBuilder()", + " .AddRetry(new RetryStrategyOptions {", + " MaxRetryAttempts = 3, Delay = TimeSpan.FromMilliseconds(200), // in-place", + " BackoffType = DelayBackoffType.Exponential, UseJitter = true,", + " ShouldHandle = new PredicateBuilder()", + " .Handle(e => e is not ValidationException) })", + " .Build();", + "while (!ct.IsCancellationRequested)", + "{", + " var cr = consumer.Consume(ct);", + " try { await pipeline.ExecuteAsync(async t => await HandleAsync(cr.Message.Value, t), ct); }", + " catch (Exception ex)", + " {", + " var attempts = cr.Message.Headers.TryGetLastBytes(\"retries\", out var raw)", + " ? int.Parse(Encoding.UTF8.GetString(raw)) : 0;", + " var dlq = ex is ValidationException || attempts >= MaxRetries; // 4xx or exhausted", + " var headers = new Headers();", + " if (dlq) headers.Add(\"error\", Encoding.UTF8.GetBytes(ex.GetType().Name));", + " else headers.Add(\"retries\", Encoding.UTF8.GetBytes((attempts + 1).ToString()));", + " await producer.ProduceAsync(dlq ? \"orders.DLQ\" : \"orders.retry.30s\",", + " new Message { Value = cr.Message.Value, Headers = headers });", + " }", + " consumer.Commit(cr); // every branch commits: no head-of-line block", + "}" + ], + "cat": "code", + "_slide": 142, + "_why": "site 18 codetab (Kafka), condensed", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 142: 25 code lines, 95 columns; 9.0 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 397, + "shape_id": 1575, + "paragraphs": [ + "Bounded in-place retries, a delayed-retry topic, then a DLQ; the offset is committed on every branch, so a poison record can't stall the partition." + ], + "cat": "stack", + "_slide": 142, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 397, + "paragraphs": [ + "This is the Appendix E pattern over Confluent.Kafka. The loop is yours, so the invariant is explicit and readable: a Polly 8 retry pipeline gives bounded in-place retries with exponential backoff and jitter, and treats ValidationException as non-retryable, since retrying a 4xx gains nothing.", + "When the in-place retries are exhausted, the record is re-published to a delayed-retry topic with a retries header; after MaxRetries attempts, or immediately for a validation failure, it goes to the dead-letter topic with the error type attached. Either way the offset is committed, so the consumer keeps making progress and a poison record never blocks the partition.", + "Make the handler idempotent: at-least-once delivery means a retried record may have partly applied. For the MSMQ audience this is the MaxRetryCount, ReceiveErrorHandling and poison-queue model, written as code you can test." + ], + "cat": "notes", + "_slide": 142, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "MassTransitEventPublisher : IEventPublisher.", + "repl": "KafkaEventPublisher : IEventPublisher.", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "code", + "_slide": 154, + "_why": "site 19 codetab" + }, + { + "op": "replace", + "scope": "slide", + "find": "(EF Core repo, MassTransit publisher)", + "repl": "(EF Core repo, Kafka publisher)", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "stack", + "_slide": 154, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "notes", + "find": "(EfCoreOrderRepository for persistence, MassTransitEventPublisher for events)", + "repl": "(EfCoreOrderRepository for persistence, KafkaEventPublisher on Confluent.Kafka for events)", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "stack", + "_slide": 154, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "MassTransit consumer Stop semantics", + "repl": "Confluent.Kafka consumer Close semantics", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "stack", + "_slide": 175, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "Traefik, YARP, MassTransit, NRules", + "repl": "Traefik, YARP, NRules", + "expect": 1, + "sldIds": [ + 432 + ], + "cat": "stack", + "_slide": 177, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "notes", + "find": "(.NET + MassTransit RoutingSlip + NRules)", + "repl": "(.NET: a Confluent.Kafka consumer + NRules)", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "stack", + "_slide": 180, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "MassTransit RoutingSlip + NRules: when the routing logic is a business rule, push it into the app — EIP-shaped pipeline + a rule engine.", + "repl": "Kafka consumer + NRules: when the routing logic is a business rule, push it into the app — a content-based router + a rule engine.", + "expect": 1, + "sldIds": [ + 437 + ], + "cat": "stack", + "_slide": 182, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "(MassTransit RoutingSlip, Wolverine routing, a custom dispatcher)", + "repl": "(a Kafka consumer, Wolverine routing, a custom dispatcher)", + "expect": 1, + "sldIds": [ + 449 + ], + "cat": "stack", + "_slide": 194, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 449, + "paragraphs": [ + "Pivot from network-layer routing to in-app routing. The threshold: if the decision fits in a few YAML lines and depends only on the envelope, do it at the mesh. If it depends on the payload, needs a decision table that domain experts edit, or involves many intersecting conditions, push it into the app.", + "The classic example is HL7 healthcare messages, routed by patient class, encounter type and message subtype: dozens of conditions a non-developer maintains. That belongs in a rule engine, not a VirtualService. In-app routing sits behind the mesh and complements it.", + "In .NET the router is a plain Confluent.Kafka consumer (or Wolverine's routing), and NRules (MIT, Rete-based, the same algorithm as Drools) is the open-source decision engine. The rule-based-routing slide shows them together." + ], + "cat": "notes", + "_slide": 194, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 450, + "shape_id": 2149, + "paragraphs": [ + ".NET + Confluent.Kafka + NRules: rule-based routing" + ], + "cat": "stack", + "_slide": 195, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 450, + "shape_id": 2151, + "paragraphs": [ + "A Kafka consumer owns the plumbing; NRules owns the decision logic; the whole thing runs in-process behind the Istio mesh." + ], + "cat": "stack", + "_slide": 195, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 450, + "paragraphs": [ + "The .NET take on rule-based routing. Inputs arrive over REST, gRPC or Kafka and land in a Confluent.Kafka consumer, which does two things: it enriches the message with context from an HTTP lookup (customer, region, tier), then evaluates the enriched fact in an NRules session.", + "NRules is an MIT-licensed Rete rules engine, the algorithm Drools uses. Rules are C# classes with a fluent Match/Do API, so the IDE catches typos and the test runner runs them, or external NRules.Language files when domain experts edit the rules on their own. The rules set a WhereTo property on the fact, and the consumer dispatches to each destination: a Kafka topic or an HTTP endpoint.", + "Everything runs in the ASP.NET Core Pod, with no separate rule server, behind Istio, which still owns mTLS, retries and locality. The running example keeps the rules in a runtime-editable table instead of NRules; the shape is the same." + ], + "cat": "notes", + "_slide": 195, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 451, + "shape_id": 2160, + "paragraphs": [ + "A Kafka consumer in .NET, calling NRules" + ], + "cat": "stack", + "_slide": 196, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 451, + "shape_id": 2162, + "paragraphs": [ + "csharp · .NET 10 + Confluent.Kafka + NRules" + ], + "cat": "stack", + "_slide": 196, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 451, + "shape_id": 2163, + "lines": [ + "// NRules + Confluent.Kafka — the consumer enriches, evaluates, dispatches.", + "// ... record OrderFact(OrderId, AmountCents, Customer) with a settable WhereTo", + "public class OrderRouter(HttpClient http, ISessionFactory rules,", + " IProducer producer)", + "{", + " public async Task Route(OrderInbound msg)", + " {", + " var customer = await http.GetFromJsonAsync( // 1) enrich", + " $\"http://customer-svc/lookup/{msg.OrderId}\") ?? throw new NotFoundException();", + " var fact = new OrderFact(msg.OrderId, msg.AmountCents, customer);", + " var session = rules.CreateSession(); // 2) evaluate; one session per message", + " session.Insert(fact); session.Fire(); // matching rules set fact.WhereTo", + " var body = JsonSerializer.Serialize(msg); // 3) dispatch: kafka:, http:", + " foreach (var dest in (fact.WhereTo ?? \"kafka:orders.default\").Split(','))", + " {", + " var parts = dest.Split(':', 2);", + " if (parts[0] == \"kafka\")", + " await producer.ProduceAsync(parts[1],", + " new Message { Value = body });", + " else if (parts[0] == \"http\")", + " await http.PostAsync(parts[1],", + " new StringContent(body, Encoding.UTF8, \"application/json\"));", + " }", + " }", + "}" + ], + "cat": "code", + "_slide": 196, + "_why": "site 22 codetab (NRules + Confluent.Kafka router)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 196: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "op": "set_text", + "sldId": 451, + "shape_id": 2164, + "paragraphs": [ + "enrich → NRules evaluate → dispatch to Kafka or HTTP: the content-based router, with the decision externalised to NRules." + ], + "cat": "stack", + "_slide": 196, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 451, + "paragraphs": [ + "The .NET reading of the Camel and Drools pattern, on Confluent.Kafka. OrderRouter takes an HttpClient for enrichment and destinations, an NRules ISessionFactory, and a producer.", + "The flow: enrich the message by calling customer-svc (attach Polly pipelines through Microsoft.Extensions.Http.Resilience, as in Appendix E); create a session per message, because sessions are not thread-safe; insert the fact and call Fire(), which runs every rule whose pattern matches and sets WhereTo; then dispatch to each destination, producing to a Kafka topic or posting to an HTTP endpoint.", + "NRules.Language, also MIT, parses an external rule file into the same model, so domain experts can change routing without rebuilding the consumer; the NRules rule slide shows the C# class form. The EIP semantics match Apache Camel; the API is C#-native." + ], + "cat": "notes", + "_slide": 196, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "notes", + "find": "setting the WhereTo property the RoutingSlip will consume", + "repl": "setting the WhereTo property the router will consume", + "expect": 1, + "sldIds": [ + 452 + ], + "cat": "stack", + "_slide": 197, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "In-app router (MassTransit RoutingSlip + NRules)", + "repl": "In-app router (a Kafka consumer + NRules)", + "expect": 1, + "sldIds": [ + 459 + ], + "cat": "stack", + "_slide": 204, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "MassTransit documentation (RoutingSlip activities + receive endpoints)", + "repl": "Confluent.Kafka .NET client documentation", + "expect": 1, + "sldIds": [ + 460 + ], + "cat": "stack", + "_slide": 205, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "notes", + "find": "plus the MassTransit RoutingSlip / NRules samples (the canonical .NET demos for the content-based-router EIP)", + "repl": "plus the NRules samples", + "expect": 1, + "sldIds": [ + 460 + ], + "cat": "stack", + "_slide": 205, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 462, + "paragraphs": [ + "The application-side bill of materials: every NuGet library used in the deck, grouped by concern, each MIT, Apache 2.0 or BSD-3, with no commercial-only dependency and no Azure-only service.", + "Highlights: Microsoft.AspNetCore.OpenApi and SignalR are part of ASP.NET Core 10; Confluent.Kafka 2.16.0 is the Kafka client for every producer and consumer, chosen because it keeps the Kafka semantics visible (MassTransit was dropped: it hides those semantics and moved to a commercial license in version 9); Streamiz.Kafka.Net 1.8.2 is the Kafka Streams model in .NET, so windowed aggregations need no Flink cluster; Temporalio pairs with the Temporal Server for durable workflows; Stateless covers the lightweight state-machine slot; NRules covers rules; YARP and Wolverine appear where they fit.", + "The running examples pin every package exactly on .NET 10.0.12: OpenTelemetry 1.19.1, Npgsql 10.0.3, Grpc 2.84.0, StackExchange.Redis 3.4.0, HotChocolate 16.6.8, OpenFeature 2.14.1. Everything runs on plain Kubernetes; nothing requires Azure or any other cloud-specific service." + ], + "cat": "notes", + "_slide": 207, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client); pinned versions" + }, + { + "op": "set_notes", + "sldId": 464, + "paragraphs": [ + "The expanded version of the bridge diagram from Cloud-Native Principles, a 22-row table with four columns: the concern, what you came from in the Windows world, what it becomes in the cloud-native world, and what changes most. Read the rightmost column first; that is where the architectural shift lives.", + "Almost every row keeps the shape of the pattern (request handlers, background workers, scheduled jobs, distributed locks, health checks, configuration, telemetry) and changes who owns the substrate. IIS owned the host; Kubernetes does now. MSDTC owned distributed transactions; the saga and outbox do. MSMQ owned messaging; Kafka with Confluent.Kafka does. Active Directory owned service identity; SPIFFE and mTLS through Istio do. Performance counters and the Event Log owned telemetry; OpenTelemetry with Loki, Tempo and Mimir does.", + "Two rows are conceptual rewrites, not substrate swaps. COM+ and MSDTC distributed transactions become sagas, with compensations written explicitly because there is no two-phase commit across services. BizTalk orchestration becomes a Temporal workflow or a Kafka consumer with NRules routing: orchestration is C# in your repository, not an artifact in a separate runtime." + ], + "cat": "notes", + "_slide": 209, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 465, + "paragraphs": [ + "Every ecosystem has gaps; naming them keeps trust. (1) DMN: the closest open-source option in .NET is a decision-table convention on top of NRules; for spreadsheet authoring, decision-table semantics and the FEEL expression language, run Camunda or Kogito as a separate service.", + "(2) Camel breadth: Apache Camel has about 300 components, from HL7 over MLLP to FTP and mainframe gateways. An integration shop with heavy mainframe, EDI or HL7 needs may run Camel in a JVM Pod and call it from .NET. (3) Streamiz scale: it works well, but its maintainer count and ecosystem are smaller than Kafka Streams'; if streams are central, plan for that.", + "(4) DI: Microsoft.Extensions.DependencyInjection is simple enough that complex patterns sometimes want Autofac. (5) Licensing moves: MediatR and MassTransit both moved to commercial licenses; many teams pick Wolverine (MIT) to replace MediatR, and this deck uses Confluent.Kafka directly instead of MassTransit. For LOB and SaaS workloads these gaps rarely bite; for integration platforms or heavy stream processing, plan for a polyglot runtime." + ], + "cat": "notes", + "_slide": 210, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "slide", + "find": "EF Core, MassTransit, Temporalio.Temporal, OpenTelemetry", + "repl": "EF Core, Confluent.Kafka, Temporalio, OpenTelemetry", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "stack", + "_slide": 211, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 466, + "paragraphs": [ + "Close the appendix with an opinionated recipe. The order of the five steps is the point: instrument first (factor XI and observability), then choose the protocol surface (avoid the one-of-each trap), then settle data ownership (outbox plus DbContext), then choose the workflow tier (Temporal or Stateless), then deploy with the cluster doing the heavy lifting.", + "The six-library sub-bullet is the one to leave up: a new .NET cloud-native service that starts with those packages, on the platform stack from the platform table, has most of the patterns in this deck working from day one. SignalR, Streamiz, NRules and HotChocolate are opt-in per workload. The audience leaves with a recipe and the reference tables." + ], + "cat": "notes", + "_slide": 211, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_notes", + "sldId": 476, + "paragraphs": [ + "Different from the proxy patterns: the decorator owns behaviour, not only routing. The new ASP.NET Core service sits in front of the legacy API and adds cross-cutting capability: a Redis cache, Kafka events with Confluent.Kafka, audit logging, validation, enrichment, rate limiting, OpenTelemetry metrics. The legacy service is unaware.", + "Clients talk to the decorator's API, which delegates the underlying work and contributes everything else. It is most useful when the legacy system is a third-party product, a vendor system, or fragile code you may not touch. The decorator is a full .NET microservice with its own deploy, scaling and data; the running example builds it on StackExchange.Redis and Confluent.Kafka." + ], + "cat": "notes", + "_slide": 221, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 477, + "shape_id": 2447, + "paragraphs": [ + "csharp · ASP.NET Core 10 + StackExchange.Redis + Confluent.Kafka" + ], + "cat": "stack", + "_slide": 222, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_code", + "sldId": 477, + "shape_id": 2448, + "lines": [ + "// Wraps the legacy /orders API: Redis cache, Kafka events.", + "app.MapGet(\"/orders/{id}\", async (string id, HttpContext ctx) =>", + "{", + " var cached = await redis.GetDatabase().StringGetAsync($\"order:{id}\");", + " if (cached.HasValue) // 1. cache hit", + " return Results.Content(cached!, \"application/json\");", + " var resp = await legacyClient.GetAsync($\"/orders/{id}\"); // 2. legacy fallback", + " var json = await resp.Content.ReadAsStringAsync();", + " await redis.GetDatabase().StringSetAsync(", + " $\"order:{id}\", json, TimeSpan.FromSeconds(60)); // 3. populate", + " return Results.Content(json, \"application/json\");", + "});", + "app.MapPost(\"/orders\", async (HttpContext ctx) =>", + "{", + " using var body = await JsonDocument.ParseAsync(ctx.Request.Body);", + " var resp = await legacyClient.PostAsync(\"/orders\",", + " new StringContent(body.RootElement.GetRawText(), Encoding.UTF8, \"application/json\"));", + " var json = await resp.Content.ReadAsStringAsync();", + " using var order = JsonDocument.Parse(json);", + " var orderId = order.RootElement.GetProperty(\"id\").GetString()!;", + " await producer.ProduceAsync(\"order.placed\", // Confluent.Kafka", + " new Message { Value = json });", + " await redis.GetDatabase().KeyDeleteAsync($\"order:{orderId}\"); // invalidate cache", + " return Results.Content(json, \"application/json\", statusCode: 201);", + "});" + ], + "cat": "code", + "_slide": 222, + "_why": "site 24 codetab (decorator); examples/24-monolith-to-microservices/dotnet uses StackExchange.Redis + Confluent.Kafka", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 222: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "op": "set_notes", + "sldId": 477, + "paragraphs": [ + "The running example's decorator: an ASP.NET Core minimal API in front of the legacy /orders API, with StackExchange.Redis for the cache and Confluent.Kafka for events.", + "The GET path is cache-aside: try Redis, fall back to the legacy service on a miss, populate the key with a 60-second TTL. The POST path delegates to the legacy service, which stays the source of truth, then produces order.placed and deletes the cached entry so the next read is fresh.", + "The legacy service has no idea it is wrapped. Over time you move more behaviour into the decorator (validation, idempotency keys, deduplication, multi-source reads); when the legacy service retires, the decorator owns the storage and the API clients see does not change." + ], + "cat": "notes", + "_slide": 222, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "notes", + "find": "IHttpClientFactory + IDistributedCache + MassTransit + FusionCache", + "repl": "IHttpClientFactory + IDistributedCache + StackExchange.Redis + Confluent.Kafka + FusionCache", + "expect": 1, + "sldIds": [ + 467 + ], + "cat": "stack", + "_slide": 212, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "replace", + "scope": "notes", + "find": "(typically via MediatR INotificationHandler for in-process events, or MassTransit IPublishEndpoint for cross-service)", + "repl": "(an in-process event dispatcher such as Wolverine for module events, Confluent.Kafka for cross-service events)", + "expect": 1, + "sldIds": [ + 470 + ], + "cat": "stack", + "_slide": 215, + "_why": "MassTransit removed (Confluent.Kafka is the Kafka client)" + }, + { + "op": "set_text", + "sldId": 484, + "shape_id": 2527, + "paragraphs": [ + "GetOrSetAsync is read-through. Factory runs only on miss. Built-in stampede protection." + ], + "cat": "voice", + "_slide": 229, + "_why": "ALL-CAPS emphasis (unit 474)" + }, + { + "op": "set_code", + "sldId": 482, + "shape_id": 2502, + "lines": [ + "[ApiController, Route(\"products\")]", + "public class ProductsController(ShopDb db, IDistributedCache cache) : ControllerBase", + "{", + " private static readonly DistributedCacheEntryOptions TTL =", + " new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) };", + " [HttpGet(\"{id}\")]", + " public async Task> Get(string id, CancellationToken ct)", + " {", + " var key = $\"product:{id}\";", + " var cached = await cache.GetStringAsync(key, ct); // 1. cache lookup", + " if (cached is not null) return Ok(JsonSerializer.Deserialize(cached));", + " if (await db.Products.FindAsync([id], ct) is not { } p) return NotFound(); // 2. DB", + " await cache.SetStringAsync(key, JsonSerializer.Serialize(p), TTL, ct); // 3. populate", + " return Ok(p);", + " }", + " [HttpPut(\"{id}\")]", + " public async Task> Update(", + " string id, Product body, CancellationToken ct)", + " {", + " // ... FindAsync (404 when missing), p.Update(body), SaveChangesAsync", + " await cache.RemoveAsync($\"product:{id}\", ct); // 4. invalidate", + " return Ok(p);", + " }", + "}" + ], + "cat": "code", + "_slide": 227, + "_why": "site 25 codetab (the old code had `?? return NotFound()`, which does not compile)", + "pt": 9.0, + "_fit": "slide 227: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "set_code", + "sldId": 484, + "shape_id": 2526, + "lines": [ + "// FusionCache (MIT): GetOrSetAsync is read-through, one factory call per missing key.", + "// Program.cs: AddFusionCache() with Redis as L2, a 60 s duration, 5-minute fail-safe.", + "[ApiController, Route(\"products\")]", + "public class ProductsController(ShopDb db, IFusionCache cache) : ControllerBase", + "{", + " [HttpGet(\"{id}\")]", + " public async Task> Get(string id, CancellationToken ct)", + " {", + " var product = await cache.GetOrSetAsync(", + " $\"product:{id}\",", + " async token => await db.Products.FindAsync([id], token), // loader", + " token: ct);", + " return product is null ? NotFound() : Ok(product);", + " }", + " [HttpPut(\"{id}\")]", + " public async Task> Update(", + " string id, Product body, CancellationToken ct)", + " {", + " if (await db.Products.FindAsync([id], ct) is not { } p) return NotFound();", + " p.Update(body);", + " await db.SaveChangesAsync(ct);", + " await cache.RemoveAsync($\"product:{id}\", token: ct); // invalidate", + " return Ok(p);", + " }", + "}" + ], + "cat": "code", + "_slide": 229, + "_why": "site 25 codetab (same `?? return` fix)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 229: 25 code lines, 86 columns; 9.5 pt overflows the box" + }, + { + "op": "set_code", + "sldId": 488, + "shape_id": 2574, + "lines": [ + "// Append-heavy data: writes hit the DB only; reads use cache-aside.", + "[ApiController, Route(\"events\")]", + "public class EventsController(ShopDb db, IDistributedCache cache) : ControllerBase", + "{", + " [HttpPost]", + " public async Task> Record(Event body, CancellationToken ct)", + " {", + " db.Events.Add(body);", + " await db.SaveChangesAsync(ct); // DB only, cache untouched", + " return CreatedAtAction(nameof(Get), new { id = body.Id }, body);", + " }", + " [HttpGet(\"{id}\")]", + " public async Task> Get(string id, CancellationToken ct)", + " {", + " var key = $\"event:{id}\";", + " var cached = await cache.GetStringAsync(key, ct);", + " if (cached is not null) return Ok(JsonSerializer.Deserialize(cached));", + " var e = await db.Events.FindAsync([id], ct);", + " if (e is null) return NotFound();", + " await cache.SetStringAsync(key, JsonSerializer.Serialize(e),", + " new DistributedCacheEntryOptions {", + " AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) }, ct);", + " return Ok(e);", + " }", + "}" + ], + "cat": "code", + "_slide": 233, + "_why": "site 25 codetab", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 233: 26 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "op": "set_notes", + "sldId": 488, + "paragraphs": [ + "Write-around: the POST writes to the database only and leaves the cache alone; the GET is plain cache-aside, so the first read after a write populates the cache.", + "The pattern fits workloads with a high write-to-re-read ratio. A security event log might take 10,000 writes a minute and 100 reads a day for active alerts; caching every write would only churn entries. Compare a product catalog, where every read is a likely hit and write-through makes more sense because readers should see the writer's update at once." + ], + "cat": "notes", + "_slide": 233, + "_why": "matches the new code" + }, + { + "op": "set_code", + "sldId": 514, + "shape_id": 2862, + "lines": [ + "// OrderService/Program.cs (excerpt, example 21) — SIGTERM flips readiness first", + "var shuttingDown = false;", + "PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>", + "{", + " ctx.Cancel = true; // the app drains; the host does not stop at once", + " shuttingDown = true; // 1. readiness → 503: the Service stops routing", + "});", + "app.MapGet(\"/healthz\", () => new { status = \"ok\" }); // liveness stays 200", + "app.MapGet(\"/readyz\", () => shuttingDown // (the example also pings", + " ? Results.Json(new { ready = false, reason = \"shutting down\" }, statusCode: 503)", + " : Results.Ok(new { ready = true })); // Postgres here)", + "app.MapPost(\"/orders\", async (HttpContext ctx, NpgsqlDataSource db) =>", + "{", + " if (shuttingDown) // 2. refuse new work", + " return Results.Json(new { error = \"shutting down\" }, statusCode: 503);", + " // ... 3. in-flight requests finish inside the grace period", + "});" + ], + "cat": "code", + "_slide": 259, + "_why": "excerpt of examples/21-graceful-shutdown/dotnet/OrderService/Program.cs (no Thread.Sleep in ApplicationStopping)" + }, + { + "op": "set_notes", + "sldId": 514, + "paragraphs": [ + "The application half of graceful shutdown, from the running example's .NET order-service. PosixSignalRegistration catches SIGTERM and cancels the default immediate shutdown; the handler flips a flag so /readyz answers 503 and Kubernetes takes the Pod out of the Service, while /healthz stays 200 so the kubelet does not restart a Pod that is winding down.", + "New requests get 503 while in-flight ones finish inside terminationGracePeriodSeconds. Appendix H covers the hosted-service drain hooks, the preStop hook and the grace period." + ], + "cat": "notes", + "_slide": 259, + "_why": "matches the new code" + }, + { + "op": "set_code", + "sldId": 522, + "shape_id": 8, + "lines": [ + "// Program.cs — set the provider once at startup (OpenFeature + flagd provider)", + "await Api.Instance.SetProviderAsync(new FlagdProvider(new Uri(\"http://flagd:8013\")));", + "", + "app.MapPost(\"/checkout\", async (Cart cart, [FromHeader(Name = \"X-User\")] string user) =>", + "{", + " var flags = Api.Instance.GetClient();", + " var ctx = EvaluationContext.Builder()", + " .SetTargetingKey(user) // sticky key", + " .Set(\"plan\", cart.Plan).Set(\"region\", cart.Region).Build();", + " var useNew = await flags.GetBooleanValueAsync(\"new-checkout\", false, ctx);", + " return useNew ? NewCheckout(cart) : LegacyCheckout(cart);", + "});" + ], + "cat": "code", + "_slide": 267, + "_why": "site 27 codetab API, as a minimal API (examples/27-feature-flags/dotnet: SetProviderAsync, GetClient, GetBooleanValueAsync)" + }, + { + "op": "set_code", + "sldId": 393, + "shape_id": 1526, + "lines": [ + "// ProblemDetails (RFC 9457) in the box since .NET 6; IExceptionHandler since .NET 8.", + "builder.Services.AddProblemDetails(); // standard problem+json output", + "builder.Services.AddExceptionHandler();", + "app.UseExceptionHandler(); // wires up the chain", + "public class DomainExceptionHandler : IExceptionHandler // one place: domain → wire", + "{", + " public async ValueTask TryHandleAsync(HttpContext ctx, Exception ex,", + " CancellationToken ct)", + " {", + " var (status, problem) = ex switch", + " {", + " StockException s => (StatusCodes.Status409Conflict, new ProblemDetails {", + " Type = \"https://errors.acme.io/stock\", Title = \"Insufficient stock\",", + " Status = StatusCodes.Status409Conflict, Detail = s.Message,", + " Extensions = {", + " [\"code\"] = \"STOCK_UNAVAILABLE\", // stable", + " [\"traceId\"] = Activity.Current?.TraceId.ToString() } }),", + " _ => (StatusCodes.Status500InternalServerError, new ProblemDetails())", + " };", + " ctx.Response.StatusCode = status;", + " await ctx.Response.WriteAsJsonAsync(problem, (JsonSerializerOptions?)null,", + " \"application/problem+json\", ct); // RFC 9457 media type", + " return true;", + " }", + "}" + ], + "cat": "code", + "_slide": 138, + "_why": "site 18 codetab; WriteAsJsonAsync sets the content type, so the problem+json media type is passed to it", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 138: 26 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "op": "replace", + "scope": "slide", + "find": "UBI images are freely redistributable, based on RHEL 9, and supported by Red Hat on OpenShift.", + "repl": "UBI images are freely redistributable, based on RHEL 9, and supported by Red Hat on OpenShift. Red Hat publishes no UBI 10 .NET image, so .NET stays on UBI 9 while the other languages use UBI 10.", + "expect": 1, + "sldIds": [ + 549 + ], + "cat": "version", + "_slide": 294, + "_why": "UBI9 reason" + }, + { + "op": "set_text", + "sldId": 549, + "shape_id": 5, + "paragraphs": [ + "ubi9/dotnet-100:9.8-1791421718 (SDK, build only) → ubi9/dotnet-100-aspnet:9.8-1791363520 (runtime, ships), both from registry.access.redhat.com." + ], + "cat": "version", + "_slide": 294, + "_why": "S4 image pins" + }, + { + "op": "replace", + "scope": "slide", + "find": "Multi-stage Dockerfiles separate", + "repl": "Multi-stage Containerfiles separate", + "expect": 1, + "sldIds": [ + 551 + ], + "cat": "stack", + "_slide": 296 + }, + { + "op": "replace", + "scope": "slide", + "find": "Red Hat UBI images are the same .NET runtime on a RHEL 9 base", + "repl": "Red Hat UBI 9 images (no UBI 10 .NET image exists) are the same .NET runtime on a RHEL 9 base", + "expect": 1, + "sldIds": [ + 551 + ], + "cat": "version", + "_slide": 296, + "_why": "UBI9 reason" + }, + { + "op": "set_notes", + "sldId": 542, + "paragraphs": [ + "Appendix P is for the audience moving from .NET Framework on Windows: what the runtime does on Linux, what replaces IIS, and what ships in the container. Four parts: the platform and the language, compilation and the runtime, Kestrel, and the container image.", + "It is reference material; pull it up when someone asks what changes when Windows goes away." + ], + "cat": "notes", + "_slide": 287, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 543, + "paragraphs": [ + "C# is the language, .NET is the platform. C#, F# and VB.NET all compile to the same intermediate language; the platform is the CoreCLR runtime, the Base Class Library, the Roslyn compiler, NuGet and the dotnet CLI, cross-platform since .NET 5.", + "A .csproj targets a platform version, net10.0, not a language version, because the platform version decides which APIs exist. The examples target .NET 10.0.12 with SDK 10.0.112, and the C# version follows from the SDK. Keeping the two words apart avoids design confusion: the runtime is language-agnostic." + ], + "cat": "notes", + "_slide": 288, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 544, + "paragraphs": [ + "The compilation pipeline. Roslyn compiles C# to CIL bytecode in an assembly; dotnet build and dotnet publish produce .dll files, not native executables. At run time RyuJIT compiles each method to native x86-64 or ARM64 code on its first call, and tiered compilation recompiles hot methods with more optimization.", + "The practical consequence: start-up pays a JIT cost, and steady-state code runs as native code. NativeAOT trades that start-up cost for restrictions on dynamic code; the CoreCLR slide covers it." + ], + "cat": "notes", + "_slide": 289, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 545, + "paragraphs": [ + "On Linux the runtime is libcoreclr.so, a native shared library. It owns JIT compilation, garbage collection (generational and concurrent; ASP.NET Core defaults to Server GC with one heap per core), the type system, threading (managed threads map to pthreads, and async work runs on the thread pool) and P/Invoke into native libraries such as libpq, librdkafka (which Confluent.Kafka wraps) and OpenSSL.", + "NativeAOT compiles ahead of time to one native binary: faster start-up and less memory, but no runtime code generation, so some reflection-heavy libraries do not work. JIT stays the default for full library compatibility. After the JIT, a hot .NET method is native code like any other; the runtime remains for GC and exceptions." + ], + "cat": "notes", + "_slide": 290, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 546, + "paragraphs": [ + "Kestrel is the web server built into ASP.NET Core. It listens on a TCP socket, handles TLS through OpenSSL on Linux, parses HTTP/1.1, HTTP/2 and HTTP/3, and hands each request to the middleware pipeline. There is no IIS and no w3wp.exe worker process.", + "Walk the figure from the socket to your endpoint: connection, protocol parsing, middleware (routing, authentication, health checks, OpenTelemetry), then the handler. Every example in the deck runs on Kestrel inside the container." + ], + "cat": "notes", + "_slide": 291, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 547, + "paragraphs": [ + "Teams moving from IIS often put nginx or Apache in front of Kestrel out of habit. On Kubernetes the service mesh or the ingress already terminates TLS, balances load and retries, so a reverse proxy sidecar adds latency, memory and another component to operate for features Kestrel and the platform already provide.", + "The examples bind with builder.WebHost.UseUrls(\"http://+:8080\"), and gRPC services listen on an HTTP/2-only endpoint configured through ConfigureKestrel. Port 8080 because ports under 1024 need root, and the containers run as a non-root user. REST, gRPC and WebSockets share one Kestrel host." + ], + "cat": "notes", + "_slide": 292, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 548, + "paragraphs": [ + "The multi-stage Containerfile. The build stage uses the SDK image, ubi9/dotnet-100 (about 780 MB), to restore and publish; the runtime stage uses ubi9/dotnet-100-aspnet (about 170 MB) and copies only the published output, owned by UID 1001 with group-0 permissions. The final image is about 200 MB, and the SDK, the NuGet cache and the source never reach production.", + "Restore and publish run in one RUN step with a BuildKit cache mount for NuGet packages, so rebuilds are fast and a stale layer cannot point at packages that are gone. What to show: docker image inspect on an example image and the number of layers." + ], + "cat": "notes", + "_slide": 293, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 549, + "paragraphs": [ + "What ships in the runtime image, layer by layer: the UBI 9 base (glibc, OpenSSL, CA certificates, tzdata, libicu), the ASP.NET Core runtime (libcoreclr.so, the JIT, the BCL and the ASP.NET shared framework), and your application's assemblies and configuration. The SDK, MSBuild, Roslyn, sources and test projects are not in it.", + "The examples pin ubi9/dotnet-100:9.8-1791421718 for the build and ubi9/dotnet-100-aspnet:9.8-1791363520 for the runtime. They stay on UBI 9 because Red Hat publishes no UBI 10 .NET image; every other language in the deck builds on UBI 10. UBI images are freely redistributable, supported by Red Hat on OpenShift, and carry the same .NET runtime as Microsoft's images; the difference is the base OS layer." + ], + "cat": "notes", + "_slide": 294, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 550, + "paragraphs": [ + "The life of a .NET container, in order: CoreCLR loads and starts the GC and the thread pool; the host builder registers services, logging and OpenTelemetry; Kestrel binds port 8080; Kubernetes starts its probes. On SIGTERM the host stops accepting requests, drains in-flight work, flushes telemetry and disposes services, within HostOptions.ShutdownTimeout.", + "It is an ordinary Linux process: no application-pool recycling, no w3wp.exe, no application domains. The container is the process boundary, SIGTERM is the shutdown signal, and the grace period is the budget, which Appendix H covers in detail." + ], + "cat": "notes", + "_slide": 295, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 551, + "paragraphs": [ + "The four points to keep. C# compiles to CIL and the JIT produces native code method by method, so steady-state performance is native. Kestrel is the web server, with TLS through OpenSSL and HTTP/1.1, HTTP/2, HTTP/3, gRPC and WebSockets, and needs no reverse proxy inside the Pod.", + "Multi-stage builds keep the SDK out of the shipped image. The Red Hat UBI 9 images carry the same .NET runtime on a RHEL 9 base, freely redistributable and supported on OpenShift; .NET stays on UBI 9 because no UBI 10 .NET image exists yet." + ], + "cat": "notes", + "_slide": 296, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 552, + "paragraphs": [ + "Appendix Q covers real-time .NET: Blazor Server for server-rendered UI, SignalR for push, and the Redis backplane that lets several Pods serve one audience. It builds on the WebSocket scaling problem from Appendix C and is backed by Example 30, an order dashboard that updates live when an order is placed." + ], + "cat": "notes", + "_slide": 297, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 553, + "paragraphs": [ + "Blazor Server runs all component logic on the server. The browser loads a thin JavaScript runtime that sends UI events over a persistent connection and applies the DOM diffs the server sends back.", + "Each browser tab is one SignalR circuit, and the server keeps that tab's component tree in memory, re-renders on state change and sends only the diff. There is no WebAssembly download and full access to server-side resources; the cost is a round trip per interaction and memory per connected tab. Because every tab holds a persistent connection, the Pod-pinning problem from Appendix C applies directly." + ], + "cat": "notes", + "_slide": 298, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 554, + "paragraphs": [ + "SignalR is ASP.NET Core's real-time library. It negotiates WebSockets first, with Server-Sent Events and long polling as fallbacks, behind one Hub API. Clients call server methods by name, the server invokes client methods by name, and groups broadcast to subsets of connections. Serialization (JSON or MessagePack), reconnection and the connection lifecycle are handled for you.", + "Blazor Server uses SignalR for its circuit, but SignalR stands on its own: push notifications, live feeds and collaborative editing in any ASP.NET Core app. Example 30 uses it both ways." + ], + "cat": "notes", + "_slide": 299, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 555, + "paragraphs": [ + "The Pod-pinning problem again: a SignalR connection stays on the Pod that accepted it, so a broadcast from Pod A reaches only Pod A's clients. The Redis backplane fixes it: AddSignalR().AddStackExchangeRedis(...) publishes every hub invocation to Redis pub/sub, every Pod subscribes, and each delivers to its own connections.", + "Example 30 runs two dashboard instances, on ports 8080 and 8081, against one Redis, and verify.sh checks that an order pushed through one instance reaches a client connected to the other, including after one instance restarts. Redis 8 is used under its AGPLv3 open-source option." + ], + "cat": "notes", + "_slide": 300, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 556, + "paragraphs": [ + "How Example 30 fits together. OrderHub has one method, JoinDashboard, which adds the caller to the dashboard group; the hub holds no push logic. POST /orders stores the order in Postgres and calls Clients.Group(\"dashboard\").SendAsync(\"OrderPlaced\", order) through IHubContext. Dashboard.razor connects a HubConnection to /hubs/orders, joins the group, and on OrderPlaced prepends the order and re-renders.", + "What to show: open the dashboard on port 8080 and on port 8081, place an order with curl, and watch both update with no polling. Fallback: run verify.sh, which checks the health endpoints, the orders API, the SignalR negotiate endpoint and cross-instance delivery." + ], + "cat": "notes", + "_slide": 301, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 557, + "paragraphs": [ + "Choose the lowest abstraction that meets the requirement. Raw WebSockets, as in Appendix C: full control, any client language, custom binary framing such as protobuf. SignalR: managed reconnection, groups, hub RPC and MessagePack when the server is ASP.NET Core and you want push without owning the WebSocket lifecycle.", + "Blazor Server: server-rendered UI with no client framework, for internal tools, dashboards and admin consoles where user counts are modest and server-side access matters more than offline use. Blazor WebAssembly runs .NET in the browser, holds no persistent circuit and scales like static content; it is not covered here." + ], + "cat": "notes", + "_slide": 302, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "set_notes", + "sldId": 558, + "paragraphs": [ + "The take-aways. Blazor Server runs the UI on the server and the browser is a thin terminal on a SignalR circuit: no download, full server access, a round trip per interaction. SignalR is a general real-time library, not only Blazor's transport.", + "The Redis backplane lets several Pods broadcast to every connected client, the same answer to Pod pinning as in Appendix C, built into SignalR. For internal dashboards with modest user counts, Blazor Server with SignalR is a pragmatic choice; for public, high-scale or cross-language clients, use raw WebSockets." + ], + "cat": "notes", + "_slide": 303, + "_why": "slide had no speaker notes (KNOWN_MISSING_NOTES)" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "repl": "Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'I need to get data from SFTP to Kafka to S3 with these transforms in the middle'", + "repl": "'I need to get data from SFTP to Kafka to an object store with these transforms in the middle'", + "expect": 1, + "sldIds": [ + 296 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "KMS for keys, CloudTrail / audit logging, region", + "repl": "KMS for keys, audit logging, region", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'someone left an S3 bucket open'", + "repl": "'someone left a storage bucket open to the internet'", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: requires the storage service to validate tokens (S3, Azure Blob, GCS all do); ", + "repl": "Cons: requires the storage side to validate tokens; ", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "The canonical implementations: S3 pre-signed URLs (15-minute upload tokens for a specific bucket+key+method), Azure SAS tokens (Shared Access Signatures with per-resource scoping), GCS signed URLs, AWS STS AssumeRole with a tight scoping policy.", + "repl": "Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape.", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD)", + "repl": "validates JWTs from a modern IdP (Keycloak, Dex)", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Edge (IAP): Google IAP, Cloudflare Access, Pomerium, AWS Verified Access — ", + "repl": "Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — ", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Pomerium is open source; Google IAP / Cloudflare Access / AWS Verified Access are managed services.", + "repl": "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "they authenticate against your IdP (Keycloak, Auth0, Azure AD, Google Workspace)", + "repl": "they authenticate against your IdP (Keycloak, or Dex federating an existing directory)", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "into a secure object store (S3, Azure Blob, GCS) and publishes", + "repl": "into a secure object store and publishes", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Valet key requires storage-side validation — S3, Azure Blob, GCS all support it natively, so the cost is usually just understanding the SDK.", + "repl": "Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry.", + "expect": 1, + "sldIds": [ + 345 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "; SAS (Shared Access Signature, Azure's scoped token)", + "repl": "", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "SAS is Azure's valet-key token; ", + "repl": "", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "(Kong, Apigee, Istio gateway, ", + "repl": "(Kong, Istio gateway, ", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "AWS Builders' Library (timeouts, retries, backoff with jitter)", + "repl": "the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling)", + "expect": 1, + "sldIds": [ + 517 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "the AWS Builders' Library has excellent practical articles on timeouts and backoff-with-jitter;", + "repl": "the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol;", + "expect": 1, + "sldIds": [ + 517 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Runs on Kubernetes as a regular ASP.NET Core Pod — no Azure App Service, no Azure SignalR Service.", + "repl": "Runs on Kubernetes as a regular ASP.NET Core Pod, with no managed hosting and no managed SignalR service.", + "expect": 1, + "sldIds": [ + 282 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "What we explicitly avoid: Azure SignalR Service (cloud-coupled, paid), Azure App Service hosting (Kubernetes is the deployment target), Azure Static Web Apps (Blazor WASM is hosted as static files inside an nginx Pod or the ASP.NET Core process itself).", + "repl": "What it avoids: a managed SignalR service and managed app hosting (Kubernetes is the deployment target); Blazor WASM is hosted as static files inside an nginx Pod or the ASP.NET Core process itself.", + "expect": 1, + "sldIds": [ + 282 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Azure SignalR Service is not used: it is the cloud-coupled option, and the open-source Redis backplane is what runs on your own Kubernetes.", + "repl": "A managed, cloud-hosted SignalR service is not used: the open-source Redis backplane is what runs on your own Kubernetes.", + "expect": 1, + "sldIds": [ + 373 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "and neither needs Azure SignalR Service.", + "repl": "and neither needs a managed SignalR service.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "both work without ANY Azure service — no Azure SignalR Service, no Azure App Service, no Azure Static Web Apps. Open Redis for the backplane is all you need.", + "repl": "both work without any managed cloud service. Open-source Redis for the backplane is all you need.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold final line is the one to leave up: nothing here requires Azure.", + "repl": "The bold final line is the one to leave up: nothing here requires a managed cloud service.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "If your team is moving off Azure App Service to Kubernetes, you don't lose Blazor — you just stop paying for Azure SignalR Service and run Redis yourself, with the same throughput characteristics.", + "repl": "A team moving from managed app hosting to Kubernetes keeps Blazor: it runs Redis itself as the backplane instead of a managed SignalR service.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "with no commercial-only dependency and no Azure-only service.", + "repl": "with no commercial-only dependency and no cloud-only service.", + "expect": 1, + "sldIds": [ + 462 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "nothing requires Azure or any other cloud-specific service.", + "repl": "nothing requires a cloud-specific service.", + "expect": 1, + "sldIds": [ + 462 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "(open-source, Apache 2.0, not Confluent Cloud)", + "repl": "(open-source, Apache 2.0)", + "expect": 1, + "sldIds": [ + 463 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "UBI images are freely redistributable, based on RHEL 9, and supported by Red Hat on OpenShift.", + "repl": "UBI images are freely redistributable, based on RHEL 9, and run on any Kubernetes cluster.", + "expect": 1, + "sldIds": [ + 549 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "UBI images are freely redistributable, supported by Red Hat on OpenShift, and carry", + "repl": "UBI images are freely redistributable, run on any Kubernetes cluster, and carry", + "expect": 1, + "sldIds": [ + 549 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "freely redistributable, commercially supported on OpenShift.", + "repl": "freely redistributable, and run on any Kubernetes cluster.", + "expect": 1, + "sldIds": [ + 551 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "freely redistributable and supported on OpenShift;", + "repl": "freely redistributable and run on any Kubernetes cluster;", + "expect": 1, + "sldIds": [ + 551 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 335, + "shape_id": 887, + "paragraphs": [ + "Valet key — scoped, time-bound, op-restricted" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 335, + "shape_id": 889, + "paragraphs": [ + "csharp · ASP.NET Core 10 + HMACSHA256" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 335, + "shape_id": 890, + "lines": [ + "// Program.cs (excerpt), examples/12-security/dotnet: an HMAC-SHA256 valet key", + "var valetSecret = Environment.GetEnvironmentVariable(\"VALET_SECRET\")", + " ?? \"demo-secret-do-not-use-in-prod\";", + "app.MapPost(\"/valet-key\", (HttpContext ctx) =>", + "{", + " var resource = ctx.Request.Query[\"resource\"].ToString();", + " var operation = ctx.Request.Query[\"operation\"].FirstOrDefault() ?? \"GET\";", + " var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 300; // time-bound", + " var payload = $\"{resource}:{operation}:{expires}\"; // one object, one op", + " var sig = ComputeHmac(valetSecret, payload);", + " return Results.Ok(new { resource, operation, expires, token = sig });", + "});", + "// GET /verify-valet: 403 once expired, then a constant-time compare", + " if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires)", + " return Results.Json(new { detail = \"expired valet key\" }, statusCode: 403);", + " var expected = ComputeHmac(valetSecret, $\"{resource}:{operation}:{expires}\");", + " if (!CryptographicOperations.FixedTimeEquals(", + " Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(expected)))", + " return Results.Json(new { detail = \"invalid or expired valet key\" }, statusCode: 403);", + "// ComputeHmac: HMACSHA256 keyed with the secret, Convert.ToHexStringLower of the hash" + ], + "cat": "code", + "_src": "examples/12-security/dotnet/OrderService/Program.cs (/valet-key, /verify-valet, ComputeHmac)", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 335, + "paragraphs": [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (HMACSHA256). POST /valet-key returns the resource, the operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with CryptographicOperations.FixedTimeEquals, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403.", + "The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 344, + "shape_id": 989, + "paragraphs": [ + "csharp · ASP.NET Core 10 + Confluent.Kafka" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 344, + "shape_id": 990, + "lines": [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "var key = $\"payloads/{orderId}\";", + "await store.PutAsync(key, largePayload, encrypt: true); // encrypted at rest, ACL'd", + "var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 900;", + "var token = ComputeHmac(valetSecret, $\"{key}:GET:{expires}\"); // GET only, 15 minutes", + "await producer.ProduceAsync(\"order.documents\",", + " new Message { Value = JsonSerializer.Serialize(new {", + " order_id = orderId,", + " uri = $\"https://objects.internal/{key}\",", + " resource = key, operation = \"GET\", expires, token,", + " }) }); // claim only: small, routable", + "// consumer: read the claim, fetch the body out of band", + "var claim = JsonSerializer.Deserialize(cr.Message.Value)!;", + "var body = await store.GetAsync(claim.Uri, claim.Token); // PII never touched the topic", + "consumer.Commit(cr);" + ], + "cat": "code", + "_src": "_docs/12-security.md claim-check codetab (site-fixes.md); ticket from examples/12-security", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 344, + "paragraphs": [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's ComputeHmac (900 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with Confluent.Kafka's ProduceAsync. The PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 334, + "shape_id": 877, + "png": "lgtm-presentation/edits/png/12-valet-key.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "labels named S3, Azure SAS, GCS, STS (tools/vendor_figures.py)" + }, + { + "op": "replace_picture", + "sldId": 337, + "shape_id": 910, + "png": "lgtm-presentation/edits/png/12-zero-trust.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "examples named Google IAP, Cloudflare Access, AWS Verified Access" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 30, + "paragraphs": [ + "07 · Workflows & Jobs", + "08 · API Management", + "09 · API Registry", + "10 · API Metadata", + "11 · Observability", + "12 · Security", + "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices J–L · .NET 10 Toolkit, Monolith → Microservices, Caching", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendices P–R · .NET on Linux, Blazor, minikube", + "Glossary of acronyms" + ], + "cat": "new", + "_why": "agenda gains Appendices R-S (C++: R)" + }, + { + "op": "set_notes", + "sldId": 257, + "paragraphs": [ + "The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns.", + "The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing, plus the .NET toolkit, .NET on Linux and Blazor Server. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck.", + "Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 530, + "as": "r0", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 3, + "paragraphs": [ + "R" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 4, + "paragraphs": [ + "Example 05 on minikube" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 5, + "paragraphs": [ + "The same services and the same verify.sh on a single-node Kubernetes cluster" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r0", + "paragraphs": [ + "Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod.", + "The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 541, + "as": "r1", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r1", + "text": "272", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 5, + "paragraphs": [ + "From compose to a cluster" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 6, + "paragraphs": [ + "Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress.", + "Scope: Example 05 (event-driven fan-out), three services, all six languages, one node.", + "One Kafka broker, one Postgres instance, no service mesh: a local topology, not production.", + "The contract carries over: Service names, the Secret, environment variables, the health endpoint.", + "The same verify.sh runs against compose and the cluster: ten checks per language." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 7, + "paragraphs": [ + "Site: Appendix R · Code: examples/05-event-driven/k8s/" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r1", + "paragraphs": [ + "What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it.", + "What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged.", + "The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 541, + "as": "r2", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r2", + "text": "273", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 5, + "paragraphs": [ + "The minikube runtime" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 6, + "paragraphs": [ + "Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5.", + "8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services.", + "NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation.", + "No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop.", + "Every command passes --context cndp-k8s; setup-profile.sh restores the prior context." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 7, + "paragraphs": [ + "minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r2", + "paragraphs": [ + "minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version.", + "Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1.", + "The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 539, + "as": "r3", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r3", + "text": "274", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 5, + "paragraphs": [ + "One node, two namespaces" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r3", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-topology.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 7, + "paragraphs": [ + "The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r3", + "paragraphs": [ + "The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret.", + "From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 541, + "as": "r4", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r4", + "text": "275", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 5, + "paragraphs": [ + "Compose to Kubernetes" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 6, + "paragraphs": [ + "kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1).", + "postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw.", + "Credential → Secret cndp-postgres-app, read through secretKeyRef.", + "build: → docker build, then minikube image load; imagePullPolicy: Never.", + "healthcheck → startup and readiness probes on the order-service /healthz.", + "ports: 8080:8080 → NodePort 30080." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 7, + "paragraphs": [ + "The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r4", + "paragraphs": [ + "Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts.", + "The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 535, + "as": "r5", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r5", + "text": "276", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 5, + "paragraphs": [ + "One overlay per language" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 6, + "paragraphs": [ + "yaml · kustomize" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r5", + "shape_id": 8, + "lines": [ + "# k8s/overlays/dotnet/kustomization.yaml (excerpt)", + "images:", + " - name: order-service", + " newName: localhost/cndp-05/dotnet-order-service", + " newTag: v1", + " # shipping-consumer and notification-consumer map the same way", + "# appended to each Deployment's environment by a JSON patch", + "- name: DATABASE_URL", + " value: \"Host=cndp-postgres-rw;Port=5432;Database=appdb;\\", + " Username=$(PG_USER);Password=$(PG_PASSWORD)\"", + "- name: KAFKA_BOOTSTRAP", + " value: \"cndp-kafka-bootstrap:9092\"", + "# order-service only", + "- name: SERVICE_VERSION", + " value: \"1.0.0\"" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 9, + "paragraphs": [ + "base/ is language-neutral; each overlay maps the images and appends the variables its language reads." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r5", + "paragraphs": [ + "The .NET overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service.", + "Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 539, + "as": "r6", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r6", + "text": "277", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 5, + "paragraphs": [ + "Images built on the host" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r6", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-image-flow.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 7, + "paragraphs": [ + "docker build on the host, minikube image load into the node; pods never pull" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r6", + "paragraphs": [ + "The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load.", + "The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 535, + "as": "r7", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r7", + "text": "278", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 5, + "paragraphs": [ + "Run it" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 6, + "paragraphs": [ + "bash · minikube" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r7", + "shape_id": 8, + "lines": [ + "# from examples/05-event-driven/, with no other profile or container running", + "k8s/setup-profile.sh # create cndp-k8s; check the published ports", + "k8s/build-images.sh dotnet # docker build, then minikube image load", + "k8s/deploy.sh dotnet # Strimzi, Kafka, Postgres, LGTM, then the overlay", + "RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh", + "", + "# all six languages, a fresh namespace for each", + "OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh", + "", + "# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile", + "k8s/teardown.sh --operator", + "minikube stop -p cndp-k8s" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 9, + "paragraphs": [ + "With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r7", + "paragraphs": [ + "The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three .NET images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay.", + "verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0.", + "What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 541, + "as": "r8", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r8", + "text": "279", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 5, + "paragraphs": [ + "Recorded results" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 6, + "paragraphs": [ + "10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0).", + "Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s.", + "Tempo received traces from order-service, shipping-consumer and notification-consumer in every language.", + "shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset.", + "Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 7, + "paragraphs": [ + "Times cover the image build and load, the deploy, the checks and the namespace teardown." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r8", + "paragraphs": [ + "The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language.", + "The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster." + ], + "cat": "new" + }, + { + "op": "resize_shape", + "sldId": 257, + "shape_id": 30, + "y": 1589592, + "cy": 4688780, + "cat": "fit", + "_fit": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 270, + "shape_id": 166, + "cx": 4577049, + "keep": "right", + "cat": "fit", + "_fit": "slide 15: chip 'csharp · .NET 10 + ASP.NET Core + AspNetCore.HealthChecks' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 270, + "shape_id": 167, + "pt": 10.5, + "cat": "fit", + "_fit": "slide 15: 19 code lines, 91 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 270, + "shape_id": 168, + "cy": 436820, + "cat": "fit", + "_fit": "slide 15: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 273, + "shape_id": 200, + "cx": 3586450, + "keep": "right", + "cat": "fit", + "_fit": "slide 18: chip 'csharp · .NET 10 + ASP.NET Core minimal APIs' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 274, + "shape_id": 213, + "cx": 3662650, + "keep": "right", + "cat": "fit", + "_fit": "slide 19: chip 'protobuf + csharp · .NET 10 + Grpc.AspNetCore' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 280, + "shape_id": 282, + "cx": 2595849, + "keep": "right", + "cat": "fit", + "_fit": "slide 25: chip 'csharp · .NET 10 + HotChocolate' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 286, + "shape_id": 348, + "cx": 4196050, + "keep": "right", + "cat": "fit", + "_fit": "slide 31: chip 'csharp · ASP.NET Core 10 + EF Core + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 289, + "shape_id": 382, + "cx": 2595849, + "keep": "right", + "cat": "fit", + "_fit": "slide 34: chip 'csharp · EF Core + Debezium CDC' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 293, + "shape_id": 427, + "cx": 3357849, + "keep": "right", + "cat": "fit", + "_fit": "slide 38: chip 'csharp · .NET 10 + Confluent.Kafka 2.16.0' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 300, + "shape_id": 505, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 45: chip 'csharp · Streamiz.Kafka.Net 1.8.2' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 312, + "shape_id": 641, + "cx": 2367250, + "keep": "right", + "cat": "fit", + "_fit": "slide 57: chip 'yaml · CI + Apicurio v3 REST' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 321, + "shape_id": 737, + "cx": 2976849, + "keep": "right", + "cat": "fit", + "_fit": "slide 66: chip 'csharp · .NET 10 + OpenTelemetry.NET' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 321, + "shape_id": 739, + "cy": 436820, + "cat": "fit", + "_fit": "slide 66: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 323, + "shape_id": 761, + "cx": 3891250, + "keep": "right", + "cat": "fit", + "_fit": "slide 68: chip 'csharp · OpenTelemetry Baggage + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 326, + "shape_id": 795, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 71: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 330, + "shape_id": 836, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 75: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 331, + "shape_id": 846, + "cy": 4597340, + "cat": "fit", + "_fit": "slide 76: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 333, + "shape_id": 867, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 78: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 335, + "shape_id": 889, + "cx": 3053050, + "keep": "right", + "cat": "fit", + "_fit": "slide 80: chip 'csharp · ASP.NET Core 10 + HMACSHA256' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 340, + "shape_id": 943, + "cx": 3357849, + "keep": "right", + "cat": "fit", + "_fit": "slide 85: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 342, + "shape_id": 966, + "cx": 3281650, + "keep": "right", + "cat": "fit", + "_fit": "slide 87: chip 'csharp · ASP.NET Core 10 + SemaphoreSlim' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 344, + "shape_id": 989, + "cx": 3434050, + "keep": "right", + "cat": "fit", + "_fit": "slide 89: chip 'csharp · ASP.NET Core 10 + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 363, + "shape_id": 1185, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 108: chip 'csharp · .NET 10 + Asp.Versioning' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 367, + "shape_id": 1233, + "cx": 3357849, + "keep": "right", + "cat": "fit", + "_fit": "slide 112: chip 'csharp + graphql · .NET 10 + HotChocolate' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 370, + "shape_id": 1266, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 115: chip 'csharp · ASP.NET Core 10 WebSockets' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 374, + "shape_id": 1311, + "cx": 3053050, + "keep": "right", + "cat": "fit", + "_fit": "slide 119: chip 'csharp · Google.Protobuf + WebSockets' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 377, + "shape_id": 1345, + "y": 5817712, + "cy": 460660, + "cat": "fit", + "_fit": "slide 122: text overflows its frame by overflow-top 2.4 pt, overflow-bottom 3.4 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 381, + "shape_id": 1387, + "cx": 2214850, + "keep": "right", + "cat": "fit", + "_fit": "slide 126: chip 'csharp · .NET 10 + EF Core' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 383, + "shape_id": 1411, + "cx": 2214850, + "keep": "right", + "cat": "fit", + "_fit": "slide 128: chip 'csharp · .NET 10 + EF Core' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 383, + "shape_id": 1412, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 128: 24 code lines, 85 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 387, + "shape_id": 1456, + "cx": 2824450, + "keep": "right", + "cat": "fit", + "_fit": "slide 132: chip 'csharp · Temporalio + ASP.NET Core' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 389, + "shape_id": 1480, + "cx": 2976849, + "keep": "right", + "cat": "fit", + "_fit": "slide 134: chip 'csharp · Stateless package + EF Core' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 393, + "shape_id": 1525, + "cx": 2976849, + "keep": "right", + "cat": "fit", + "_fit": "slide 138: chip 'csharp · .NET 10 + IExceptionHandler' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 395, + "shape_id": 1549, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 140: chip 'csharp · grpc-dotnet + Google.Rpc' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 395, + "shape_id": 1550, + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 140: 25 code lines, 83 columns; 10.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 397, + "shape_id": 1573, + "cx": 2824450, + "keep": "right", + "cat": "fit", + "_fit": "slide 142: chip 'csharp · Confluent.Kafka + Polly 8' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 399, + "shape_id": 1597, + "cx": 2595849, + "keep": "right", + "cat": "fit", + "_fit": "slide 144: chip 'csharp · .NET 10 + HotChocolate' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 409, + "shape_id": 1704, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 154: chip 'csharp · ports & adapters in .NET' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 409, + "shape_id": 1706, + "cy": 436820, + "cat": "fit", + "_fit": "slide 154: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 427, + "shape_id": 1892, + "cx": 2595849, + "keep": "right", + "cat": "fit", + "_fit": "slide 172: chip 'csharp · .NET 10 + ASP.NET Core' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 427, + "shape_id": 1894, + "cy": 436820, + "cat": "fit", + "_fit": "slide 172: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 428, + "shape_id": 1907, + "cy": 436820, + "cat": "fit", + "_fit": "slide 173: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "fit_font", + "sldId": 439, + "shape_id": 2025, + "pt": 11.0, + "cat": "fit", + "_fit": "slide 184: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 440, + "shape_id": 2037, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 185: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 445, + "shape_id": 2093, + "pt": 10.0, + "cat": "fit", + "_fit": "slide 190: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 446, + "shape_id": 2105, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 191: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 451, + "shape_id": 2162, + "cx": 3510250, + "keep": "right", + "cat": "fit", + "_fit": "slide 196: chip 'csharp · .NET 10 + Confluent.Kafka + NRules' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 452, + "shape_id": 2175, + "cx": 4577049, + "keep": "right", + "cat": "fit", + "_fit": "slide 197: chip 'csharp · NRules.Fluent (or external NRules.Language DSL)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 452, + "shape_id": 2177, + "cy": 436820, + "cat": "fit", + "_fit": "slide 197: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 457, + "shape_id": 2230, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 202: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 475, + "shape_id": 2423, + "cx": 4577049, + "keep": "right", + "cat": "fit", + "_fit": "slide 220: chip 'csharp · ASP.NET Core 10 minimal API + IHttpClientFactory' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 477, + "shape_id": 2447, + "cx": 5110450, + "keep": "right", + "cat": "fit", + "_fit": "slide 222: chip 'csharp · ASP.NET Core 10 + StackExchange.Redis + Confluent.Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 482, + "shape_id": 2501, + "cx": 4348450, + "keep": "right", + "cat": "fit", + "_fit": "slide 227: chip 'csharp · ASP.NET Core 10 + EF Core + IDistributedCache' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 484, + "shape_id": 2525, + "cx": 4577049, + "keep": "right", + "cat": "fit", + "_fit": "slide 229: chip 'csharp · ASP.NET Core 10 + FusionCache (MIT, open-source)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 486, + "shape_id": 2549, + "cx": 5034250, + "keep": "right", + "cat": "fit", + "_fit": "slide 231: chip 'csharp · ASP.NET Core 10 + IDistributedCache OR + FusionCache' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 488, + "shape_id": 2573, + "cx": 4348450, + "keep": "right", + "cat": "fit", + "_fit": "slide 233: chip 'csharp · ASP.NET Core 10 + EF Core + IDistributedCache' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 490, + "shape_id": 2597, + "cx": 6024850, + "keep": "right", + "cat": "fit", + "_fit": "slide 235: chip 'csharp · ASP.NET Core 10 + BackgroundService + StackExchange.Redis + EF Core' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 494, + "shape_id": 2643, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 239: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 506, + "shape_id": 2772, + "cx": 3053050, + "keep": "right", + "cat": "fit", + "_fit": "slide 251: chip 'csharp · ASP.NET Core 10 + HttpClient' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 508, + "shape_id": 2795, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 253: chip 'csharp · ASP.NET Core 10 + Polly v8' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 510, + "shape_id": 2818, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 255: chip 'csharp · ASP.NET Core 10 + Polly v8' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 514, + "shape_id": 2861, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 259: chip 'csharp · ASP.NET Core 10' wraps; one line at 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 274, + "shape_id": 214, + "cuts": [ + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 15, + "to": 15, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 19: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 11.0 pt" + }, + { + "op": "cut_code", + "sldId": 280, + "shape_id": 283, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 20, + "to": 20, + "first": "" + }, + { + "from": 27, + "to": 27, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 25: 28 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.5 pt" + }, + { + "op": "cut_code", + "sldId": 289, + "shape_id": 383, + "cuts": [ + { + "from": 9, + "to": 9, + "first": "" + }, + { + "from": 18, + "to": 18, + "first": "" + }, + { + "from": 24, + "to": 24, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 34: 27 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.5 pt" + }, + { + "op": "cut_code", + "sldId": 340, + "shape_id": 944, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 85: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 381, + "shape_id": 1388, + "cuts": [ + { + "from": 1, + "to": 2, + "first": "// EF Core 10 + pessimistic row lock — the in-process saga state store.", + "last": "// The PostgreSQL/SQL Server commit replaces what MSDTC used to make atomic." + }, + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 12, + "to": 13, + "first": "", + "last": " // pessimistic row lock so two workers can't advance the same saga" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 126: 31 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 389, + "shape_id": 1481, + "cuts": [ + { + "from": 3, + "to": 4, + "first": "", + "last": "// 1) Configure the machine — states, triggers, actions" + }, + { + "from": 8, + "to": 10, + "first": " public State CurrentState => _sm.State;", + "last": "", + "ellipsis": " // ... CurrentState => _sm.State; OrderId { get; }" + }, + { + "from": 18, + "to": 18, + "first": "" + }, + { + "from": 26, + "to": 31, + "first": " _sm.Configure(State.Reserved)", + "last": " .OnEntryAsync(() => CompensateAsync(orderId));", + "ellipsis": " // ... Reserved -> Shipped and Compensating -> Failed are configured the same way" + }, + { + "from": 35, + "to": 47, + "first": "", + "last": "}", + "ellipsis": "// ... drive it: FulfillmentService.OnAsync loads the saga row, builds the machine, fires, saves" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 134: 47 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.0 pt" + }, + { + "op": "cut_code", + "sldId": 399, + "shape_id": 1598, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// HotChocolate: implement IErrorFilter to map exceptions to GraphQL errors[]", + "last": "" + }, + { + "from": 18, + "to": 23, + "first": "", + "last": "", + "ellipsis": "// ... Program.cs: AddGraphQLServer().AddErrorFilter()" + }, + { + "from": 24, + "to": 24, + "first": "// Field resolver — throws normally; the filter turns it into a GraphQL error" + }, + { + "from": 31, + "to": 35, + "first": " try { return await inventory.RemainingAsync(order.Sku); }", + "last": " }", + "ellipsis": " // ... RemainingAsync; on UpstreamException throws InventoryUnavailable (nulls the field)" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 144: 37 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 409, + "shape_id": 1705, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 16, + "to": 16, + "first": "" + }, + { + "from": 25, + "to": 25, + "first": "" + }, + { + "from": 28, + "to": 28, + "first": "// Wire them up in Program.cs: builder.Services.AddScoped();" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 154: 28 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 427, + "shape_id": 1893, + "cuts": [ + { + "from": 5, + "to": 6, + "first": " o.BackgroundServiceExceptionBehavior = // tolerate handler errors", + "last": " BackgroundServiceExceptionBehavior.Ignore;", + "ellipsis": " // ... BackgroundServiceExceptionBehavior = Ignore (tolerate handler errors)" + }, + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 12, + "to": 16, + "first": " private volatile bool _ready = true;", + "last": " Task.FromResult(_ready ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy());", + "ellipsis": " // ... a volatile ready flag, MarkUnready(), CheckHealthAsync: Healthy or Unhealthy" + }, + { + "from": 18, + "to": 18, + "first": "" + }, + { + "from": 24, + "to": 32, + "first": " protected override async Task ExecuteAsync(CancellationToken ct)", + "last": " }", + "ellipsis": " // ... ExecuteAsync: Consume, Handle, Commit until the token is cancelled" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 172: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "cut_code", + "sldId": 452, + "shape_id": 2176, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// Rules live in their own NuGet/csproj — owned by domain experts via NRules.Language,", + "last": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 19, + "to": 19, + "first": "" + }, + { + "from": 20, + "to": 30, + "first": "[Name(\"European orders go to the EU region\")]", + "last": "", + "ellipsis": "// ... EuropeanOrderRule: Customer.Region == \"EU\" sets WhereTo = \"kafka:orders.eu\"" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 197: 40 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "op": "cut_code", + "sldId": 457, + "shape_id": 2231, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 202: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 475, + "shape_id": 2424, + "cuts": [ + { + "from": 3, + "to": 5, + "first": "// In production this usually lives in a separate sidecar / YARP, but the", + "last": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 15, + "to": 15, + "first": "" + }, + { + "from": 25, + "to": 25, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 220: 30 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 486, + "shape_id": 2550, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 23, + "to": 23, + "first": "" + }, + { + "from": 26, + "to": 41, + "first": "[ApiController, Route(\"products\")]", + "last": "}", + "ellipsis": "// ... same controller on IFusionCache: await cache.SetAsync($\"product:{id}\", p, token: ct);" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 231: 41 code lines do not fit the box at 9 pt; excerpt of 23 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "cut_code", + "sldId": 490, + "shape_id": 2598, + "cuts": [ + { + "from": 1, + "to": 17, + "first": "// BackgroundService is the canonical ASP.NET Core long-running task host.", + "last": "", + "ellipsis": "// ... MetricsController.Write: HSET, SADD metric:dirty, 202" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 33, + "to": 33, + "first": "" + }, + { + "from": 34, + "to": 44, + "first": " try", + "last": " }", + "ellipsis": " // ... try: add Metric.FromHash for every id in a new scope, SaveChangesAsync (batch)" + }, + { + "from": 54, + "to": 54, + "first": "// Program.cs: builder.Services.AddHostedService();" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 235: 54 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 7.5 pt" + }, + { + "op": "cut_code", + "sldId": 492, + "shape_id": 2622, + "cuts": [ + { + "from": 3, + "to": 4, + "first": "", + "last": "// Approach 1: explicit sorted-set tracking" + }, + { + "from": 5, + "to": 23, + "first": "public class ProductService(", + "last": "}", + "ellipsis": "// ... ProductService.FindAsync: ZADD product:hot , then cache-aside on product:{id}" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 39, + "to": 51, + "first": " using var scope = scopes.CreateScope();", + "last": " }", + "ellipsis": " // ... for each hot id with a TTL under 10 s: reload from ShopDb, SET with a fresh TTL" + }, + { + "from": 55, + "to": 55, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 237: 59 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 7.5 pt" + }, + { + "op": "fit_font", + "sldId": 345, + "shape_id": 1000, + "pt": 15, + "cat": "fit", + "_fit": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 351, + "shape_id": 1061, + "pt": 14, + "space_before": 6, + "cat": "fit", + "_fit": "references: the reference lines run past the frame into the page number; the frame cannot grow" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "fit", + "_fit": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "op": "resize_shape", + "sldId": 492, + "shape_id": 2621, + "cx": 4191000, + "cy": 457200, + "keep": "right", + "cat": "fit", + "_fit": "chip on two lines: one line would reach the first code line" + }, + { + "op": "fit_font", + "sldId": 493, + "shape_id": 2633, + "pt": 16, + "cat": "fit", + "_fit": "caching pitfalls: six long bullets run 15 pt past the frame; the frame cannot grow (page number below)" + }, + { + "op": "replace", + "scope": "slide", + "find": "Temporal .NET: your saga is plain C#, the Server makes it crash-proof", + "repl": "Temporal .NET: a plain-C# saga the Server makes durable", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "fit", + "_fit": "title wraps to two lines" + }, + { + "op": "replace", + "scope": "slide", + "find": "Appendix J · .NET 10 Toolkit & Migration Reference", + "repl": "Appendix J · .NET 10 Toolkit & Migration", + "expect": 1, + "sldIds": [ + 461 + ], + "cat": "fit", + "_fit": "divider title wraps to three lines and collides with the J letter" + }, + { + "op": "fit_font", + "sldId": 465, + "shape_id": 2315, + "pt": 15, + "cat": "fit", + "_fit": "known gaps: six long bullets run 36 pt past the frame into the page number" + }, + { + "op": "fit_font", + "sldId": 466, + "shape_id": 2325, + "scale": 0.85, + "cat": "fit", + "_fit": "recipe: five steps and a summary run 28 pt past the frame into the page number; scaled to keep the 17/14 pt hierarchy" + }, + { + "op": "fit_font", + "sldId": 478, + "shape_id": 2459, + "pt": 15.5, + "cat": "fit", + "_fit": "migration take-aways: seven bullets run 25 pt past the frame into the page number" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 486 + ], + "find": "var p = await db.Products.FindAsync([id], ct) ?? return NotFound();", + "repl": "if (await db.Products.FindAsync([id], ct) is not { } p) return NotFound();", + "expect": 1, + "_cat": "code", + "_why": "`?? return NotFound()` does not compile; a null-pattern guard is valid C# and keeps one line (a layout-fit excerpt already removed the second occurrence)" + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix1.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix1.json new file mode 100644 index 00000000..a7d66691 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix1.json @@ -0,0 +1,12 @@ +{ + "ops": [ + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure footer: the absolute 'can never be lost' claim softened (src/04-dual-write-outbox.svg)" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix2.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix2.json new file mode 100644 index 00000000..cbe1f933 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix2.json @@ -0,0 +1,362 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Kafka is the substrate — a partitioned", + "repl": "Kafka is the substrate: a partitioned", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup (6 per paragraph)", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Pulsar is also substrate — same broad category", + "repl": "Pulsar is also substrate, in the same broad category", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Flink is compute — not a transport layer at all, but", + "repl": "Flink is compute: not a transport layer, but", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NiFi is movement — a visual", + "repl": "NiFi is movement: a visual", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "They complement — pick by", + "repl": "They complement each other; pick by", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "This is the slide the user specifically asked for: distinguish auto-injection from auto-instrumentation from manual instrumentation — three things people conflate.", + "repl": "Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request; em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "wrap known frameworks — your HTTP handler", + "repl": "wrap known frameworks: your HTTP handler", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "a Kubernetes-level mechanism — the OpenTelemetry Operator", + "repl": "a Kubernetes-level mechanism; the OpenTelemetry Operator", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Python — ", + "repl": "Python: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Java/Quarkus — ", + "repl": "Java/Quarkus: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Go — no language agent", + "repl": "Go: no language agent", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "The cost: eventual consistency — there is a small lag", + "repl": "The cost: eventual consistency, a small lag", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "happily handles both — CQRS adds", + "repl": "handles both; CQRS adds", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "for projections — we cover both distinctions in section 5.", + "repl": "for projections; the Event-Driven section draws both distinctions.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup; cross-reference by concept", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 324 + ], + "find": "The sub-bullet is the reminder the user asked for: ", + "repl": "The sub-bullet is the reminder: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "This is the specific case you asked about, and a common source of bad designs.", + "repl": "This case is a common source of bad designs.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 535 + ], + "find": "Fallback: the recorded results that follow.", + "repl": "Fallback: the recorded results.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "This is the DB-backed option made concrete, and it's the one I'd reach for first.", + "repl": "This is the DB-backed option made concrete, and the default choice for a bounded saga.", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "Recovery is beautifully boring: after a crash, a sweeper just loads", + "repl": "Recovery needs no special path: after a crash, a sweeper loads", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "if _, err := db.CopyFrom(ctx, metricsTbl, cols, rows); err != nil {", + "repl": "if err := upsert(ctx, rows); err != nil { // INSERT ... ON CONFLICT (id) DO UPDATE", + "expect": 1, + "cat": "code", + "_slide": 221, + "_why": "metrics.id is the primary key and a metric is flushed many times: CopyFrom cannot upsert and fails on the second flush; examples/25-caching/go flushes with INSERT ... ON CONFLICT (id) DO UPDATE", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 476 + ], + "find": "to the DB in one CopyFrom call.", + "repl": "to the DB in one batched upsert (ON CONFLICT (id) DO UPDATE), so a key flushed twice stays one row.", + "expect": 1, + "cat": "notes", + "_slide": 221, + "_why": "notes follow the corrected code", + "_src": "content" + }, + { + "op": "set_code", + "sldId": 310, + "lines": [ + "// registry_client.go — register a new version; the registry enforces the rule", + "const registry = \"http://apicurio.registry.svc/apis/registry/v3\"", + "func publish(group, artifactID, schema string) error {", + " body, _ := json.Marshal(map[string]any{\"content\": map[string]string{ // v3 CreateVersion", + " \"content\": schema, \"contentType\": \"application/json\"}})", + " url := fmt.Sprintf(\"%s/groups/%s/artifacts/%s/versions\", registry, group, artifactID)", + " resp, err := http.Post(url, \"application/json\", bytes.NewReader(body))", + " if err != nil { return err }", + " defer resp.Body.Close()", + " if resp.StatusCode == http.StatusBadRequest { // 400 = compatibility rule violated", + " return errors.New(\"incompatible schema change: blocking the merge\")", + " }", + " if resp.StatusCode >= 300 { return fmt.Errorf(\"registry: %s\", resp.Status) }", + " return nil // 200 = compatible, new version created", + "}", + "// rule set once per artifact, e.g. BACKWARD: new consumers can read old data" + ], + "cat": "code", + "_why": "Apicurio Registry 3.3.3 REST v3: POST .../versions takes a CreateVersion JSON body (common/src/main/resources/META-INF/openapi.json); a raw schema body is the v2 shape. examples/09-api-registry/verify.sh sends the same body and expects 400 RuleViolationException", + "_src": "registry" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "Apicurio 3.3 answers 400", + "repl": "Apicurio 3.3.3 answers 400", + "expect": 1, + "cat": "version", + "_why": "pin with patch", + "_src": "registry" + }, + { + "op": "replace_picture", + "sldId": 487, + "shape": "Google Shape;2567;p234", + "png": "lgtm-presentation/edits/png/25-gray-failure.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (PERCEIVED DIFFERENTLY, NOT fully down, OBSERVE FROM THE CLIENT, GREEN, OUTLIER DETECTION); replaced by the site figure assets/diagrams/25-gray-failure.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 293, + "shape": "Google Shape;429;p40", + "png": "lgtm-presentation/edits/png/05-sourcing-vs-streaming.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (SOURCING/STREAMING, ARE, IS, TRANSPORT, ABOUT, NOT, EVENT, PROPAGATED); replaced by the site figure assets/diagrams/05-sourcing-vs-streaming.svg (caps lowered there too)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 294, + "shape": "Google Shape;440;p41", + "png": "lgtm-presentation/edits/png/05-tools-landscape.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (COMPLEMENT); replaced by the site figure assets/diagrams/05-tools-landscape.svg (SUBSTRATE/COMPUTE/MOVEMENT are design labels)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 410, + "shape": "Google Shape;1711;p157", + "png": "lgtm-presentation/edits/png/20-coupling-quadrant.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure ('strong OR distant'); replaced by the site figure assets/diagrams/20-coupling-quadrant.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost-go.png", + "cat": "voice", + "_why": "figure footer: 'where p99 goes to die' rewritten without the drama", + "_src": "hop" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix3.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix3.json new file mode 100644 index 00000000..4fe2bac9 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Go.fix3.json @@ -0,0 +1,191 @@ +{ + "ops": [ + { + "op": "set_code", + "sldId": 269, + "lines": [ + "// main.go (excerpt), examples/01-cloud-native-principles/go: config from env, two probes", + "func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {", + " w.Header().Set(\"Content-Type\", \"application/json\")", + " json.NewEncoder(w).Encode(HealthResponse{Status: \"ok\"}) // liveness: the process is up", + "}", + "func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {", + " ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)", + " defer cancel()", + " w.Header().Set(\"Content-Type\", \"application/json\")", + " if err := s.pool.Ping(ctx); err != nil { // readiness: the database answers", + " w.WriteHeader(http.StatusServiceUnavailable) // 503: out of rotation, not restarted", + " json.NewEncoder(w).Encode(ReadinessResponse{Status: \"down\",", + " Checks: map[string]string{\"database\": \"unreachable\"}})", + " return", + " }", + " json.NewEncoder(w).Encode(ReadinessResponse{Status: \"ready\",", + " Checks: map[string]string{\"database\": \"ok\"}})", + "}", + "// main(): SERVICE_VERSION and DATABASE_URL come from the environment (factor III); the mux", + "// serves /, /healthz, /readyz and /orders on :8080 (factor VII, port binding)" + ], + "cat": "code", + "_slide": 14, + "_why": "examples/01: hand-written /healthz and /readyz; readiness answers 503 when the database is down", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 269, + "cat": "notes", + "_slide": 14, + "_why": "notes follow the code", + "_src": "align", + "paragraphs": [ + "Walk the code from Example 01: configuration comes from the environment with os.Getenv (factor III), never from a baked-in file: SERVICE_VERSION names the version the image reports, and DATABASE_URL points the pgx pool at Postgres. The router is the standard library's ServeMux, so there is no router dependency.", + "The two probes are the contract with Kubernetes: /healthz is liveness (is the process up? if not, restart it) and never checks a dependency; /readyz is readiness (can it serve?): it pings the database with a two-second timeout and, when that fails, answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. Getting liveness and readiness wrong is a common cause of cascading restarts, so stress the distinction." + ] + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "signal.NotifyContext catches SIGTERM; readiness fails first so the Service stops routing, then Server.Shutdown drains within a 15-second budget.", + "repl": "One SIGTERM: readiness fails first, the service keeps serving for DRAIN_DELAY_SECONDS, then Server.Shutdown drains within 20 seconds.", + "expect": 1, + "cat": "code", + "_slide": 164, + "_why": "examples/21 go: drain delay and a 20 s shutdown budget (9676f32)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 419, + "lines": [ + "// main.go (excerpt), examples/21-graceful-shutdown/go: one SIGTERM drives the drain", + "const shutdownTimeout = 20 * time.Second // drain delay + this stays under the 30s grace", + "func main() {", + " // ... pgx pool, s := &server{pool: pool}; GET /readyz answers 503 once s.shuttingDown", + " httpServer := &http.Server{Addr: \":8080\", Handler: mux}", + " ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)", + " defer stop()", + " go httpServer.ListenAndServe()", + " <-ctx.Done() // 1. the single SIGTERM", + " stop() // a later signal terminates at once", + " s.shuttingDown.Store(true) // readiness fails first, the server keeps serving", + " time.Sleep(drainDelay()) // 2. DRAIN_DELAY_SECONDS (5s): endpoints update", + " shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)", + " defer cancel() // 3. stop accepting, finish in-flight requests", + " if err := httpServer.Shutdown(shutdownCtx); err != nil {", + " slog.Error(\"shutdown incomplete\", \"err\", err, \"in_flight\", s.inFlight.Load())", + " }", + "} // 4. deferred pool.Close(), exit 0" + ], + "cat": "code", + "_slide": 164, + "_why": "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight drain within 20 s -> cleanup -> exit 0", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 419, + "cat": "notes", + "_slide": 164, + "_why": "notes follow the code", + "_src": "align", + "paragraphs": [ + "Four steps, all started by one SIGTERM, the only signal Kubernetes sends before SIGKILL. (1) signal.NotifyContext turns SIGTERM into a cancelled context; main calls stop() so that a later signal terminates at once, and flips the atomic.Bool behind /readyz, so readiness answers 503 and the Service stops sending new traffic. (2) The service keeps serving for DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) while the endpoint removal propagates.", + "(3) http.Server.Shutdown stops accepting connections and waits for in-flight requests, bounded by a 20-second context so a stuck request cannot hang the rollout; delay plus drain stays inside the 30-second grace period. (4) The deferred pool.Close() runs and the process exits 0; a service with a franz-go client closes it here, which commits offsets and leaves the consumer group. Pair it with the preStop hook and the grace period on the Kubernetes side." + ] + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "The dirty set is the key idea — it tells the flusher what to drain. Failed batches go back on the set for retry.", + "repl": "A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed.", + "expect": 1, + "cat": "code", + "_slide": 221, + "_why": "examples/25: 202 and bounded retry (ebca295)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 476, + "lines": [ + "// main.go (excerpt), examples/25-caching/go: write-back", + "func handleWriteBackPut(w http.ResponseWriter, r *http.Request) {", + " key := \"metric:\" + r.PathValue(\"id\") // body decoded into parsed, id added: data", + " safeSet(r.Context(), key, string(data), 0) // 1. cache only, no TTL", + " rdb.SAdd(r.Context(), \"metric:dirty\", key) // mark for flush", + " writeJSON(w, 202, parsed) // 202: queued, not yet durable", + "}", + "func flusher(ctx context.Context) { // 2. drain the dirty set to the DB", + " failures := 0", + " for {", + " wait := flushPeriod // 1s, doubling to 8s after failures", + " if failures > 0 { wait = min(flushPeriod< pm.response.to.have.status(201));\",", + " \"pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));\",", + " \"const body = pm.response.json();\",", + " \"pm.test('returns an id', () => pm.expect(body).to.have.property('id'));\",", + " \"pm.collectionVariables.set('order_id', body.id);\"", + " ] }", + " }]", + "}" + ], + "cat": "code", + "_why": "_docs/28 Anatomy of a collection (adds the response-time assertion)" + }, + { + "op": "replace_picture", + "sldId": 520, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-black-box.png", + "cat": "stack", + "_why": "the old figure shows five backends; the site figure shows all six" + }, + { + "op": "set_code", + "sldId": 521, + "shape_id": 8, + "lines": [ + "# Go — run the service, then the same collection", + "go run ./cmd/order-service & # http://localhost:8080", + "newman run orders.postman_collection.json -e local.postman_environment.json" + ], + "cat": "code", + "_why": "_docs/28 codetab 'One collection, six backends', this deck's language" + }, + { + "op": "set_code", + "sldId": 522, + "shape_id": 8, + "lines": [ + "# install once (Node.js):", + "# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1", + "newman run orders.postman_collection.json \\", + " --environment local.postman_environment.json \\", + " --reporters cli,junit,htmlextra \\", + " --reporter-junit-export results/newman.xml \\", + " --timeout-request 5000 \\", + " --bail", + "# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure", + "", + "# data-driven: one iteration per CSV row; the columns become {{variables}}", + "newman run orders.postman_collection.json -e local.postman_environment.json \\", + " --iteration-data cases.csv" + ], + "cat": "code", + "_why": "_docs/28 Running Newman + Data-driven tests; pinned install; no comment after a line continuation" + }, + { + "op": "set_code", + "sldId": 524, + "shape_id": 8, + "lines": [ + "# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js)", + "jobs:", + " api-tests:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v7", + " - run: docker compose up -d --build --wait # order, payment, inventory, ...", + " - run: ./scripts/wait-for-ready.sh # poll /readyz before testing", + " - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1", + " - run: |", + " newman run orders.postman_collection.json \\", + " -e ci.postman_environment.json \\", + " --reporters cli,junit \\", + " --reporter-junit-export results/newman.xml", + " - uses: actions/upload-artifact@v7 # keep the report even on failure", + " if: always()", + " with: { name: newman-results, path: results/ }" + ], + "cat": "code", + "_why": "_docs/28 Newman in CI on Docker Engine; actions at v7 as in .github/workflows" + }, + { + "op": "replace_picture", + "sldId": 525, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-ci-gate.png", + "cat": "stack", + "_why": "the old figure shows the old container engine; site figure 27-ci-gate" + }, + { + "op": "replace_picture", + "sldId": 268, + "shape_id": 143, + "png": "lgtm-presentation/edits/png/01-factors-mapped-go.png", + "cat": "stack", + "_slide": 13, + "_why": "Poetry (examples use pip + requirements.txt); Go deck showed Python tooling" + }, + { + "op": "replace_picture", + "sldId": 271, + "shape_id": 177, + "png": "lgtm-presentation/edits/png/02-pick-protocol-go.png", + "cat": "stack", + "_slide": 16, + "_why": "framework labels of another language (Python in Go, Quarkus in .NET and Spring)" + }, + { + "op": "replace_picture", + "sldId": 275, + "shape_id": 225, + "png": "lgtm-presentation/edits/png/02-mesh-handles-wire-go.png", + "cat": "stack", + "_slide": 20, + "_why": "app box named another language's framework" + }, + { + "op": "replace_picture", + "sldId": 278, + "shape_id": 259, + "png": "lgtm-presentation/edits/png/03-composition.png", + "cat": "stack", + "_slide": 23, + "_why": "gateway named Strawberry (Go) or SmallRye GraphQL (.NET, Spring)" + }, + { + "op": "replace_picture", + "sldId": 283, + "shape_id": 315, + "png": "lgtm-presentation/edits/png/04-cqrs.png", + "cat": "voice", + "_slide": 28, + "_why": "image text had a positional reference ('the next slide') and 'genuinely'" + }, + { + "op": "replace_picture", + "sldId": 285, + "shape_id": 338, + "png": "lgtm-presentation/edits/png/04-log-tailing.png", + "cat": "positional", + "_slide": 30, + "_why": "image text had '(next slide)'" + }, + { + "op": "replace_picture", + "sldId": 297, + "shape_id": 472, + "png": "lgtm-presentation/edits/png/06-derive-streams.png", + "cat": "stack", + "_slide": 42, + "_why": "caption 'Faust/Kafka Streams style' (faust-streaming; goka for Go)" + }, + { + "op": "replace_picture", + "sldId": 299, + "shape_id": 496, + "png": "lgtm-presentation/edits/png/06-scale-on-lag.png", + "cat": "stack", + "_slide": 44, + "_why": "HTTP pods named FastAPI (Go) or Quarkus (.NET, Spring)" + }, + { + "op": "replace_picture", + "sldId": 346, + "shape_id": 1009, + "png": "lgtm-presentation/edits/png/13-antipatterns-grid.png", + "cat": "voice", + "_slide": 91, + "_why": "box text broken mid-word (layout defect)" + }, + { + "op": "replace_picture", + "sldId": 376, + "shape_id": 1333, + "png": "lgtm-presentation/edits/png/17-saga-state.png", + "cat": "stack", + "_slide": 121, + "_why": "title 'Where a Python saga keeps its state' in the Go deck" + }, + { + "op": "replace_picture", + "sldId": 427, + "shape_id": 1891, + "png": "lgtm-presentation/edits/png/22-l7-layers.png", + "cat": "stack", + "_slide": 172, + "_why": "in-app router named modern-cpp-kafka (C++), Spring Boot + Camel (in the .NET deck), FastAPI + durable-rules (in the Go deck)" + }, + { + "op": "replace_picture", + "sldId": 428, + "shape_id": 1902, + "png": "lgtm-presentation/edits/png/22-l7-journey.png", + "cat": "voice", + "_slide": 173, + "_why": "subtitle 'each layer earns its place' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 442, + "shape_id": 2063, + "png": "lgtm-presentation/edits/png/22-business-rule-routing-go.png", + "cat": "stack", + "_slide": 187, + "_why": "Go deck showed FastAPI + durable-rules; now net/http + gopher-lua + franz-go" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost-go.png", + "cat": "voice", + "_slide": 190, + "_why": "'every hop must earn its place' (ban tier); rule-engine row per language" + }, + { + "op": "replace_picture", + "sldId": 447, + "shape_id": 2121, + "png": "lgtm-presentation/edits/png/22-east-west.png", + "cat": "voice", + "_slide": 192, + "_why": "caption 'for free' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 460, + "shape_id": 2261, + "png": "lgtm-presentation/edits/png/24-shared-database.png", + "cat": "positional", + "_slide": 205, + "_why": "'(decomposed DB above)'" + }, + { + "op": "replace_picture", + "sldId": 483, + "shape_id": 2523, + "png": "lgtm-presentation/edits/png/25-failure-taxonomy.png", + "cat": "voice", + "_slide": 228, + "_why": "'the rest of this appendix' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 495, + "shape_id": 2656, + "png": "lgtm-presentation/edits/png/25-circuit-breaker.png", + "cat": "stack", + "_slide": 240, + "_why": "library list without the locked Go library (sony/gobreaker)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# Istio: retries, timeout and outlier detection for inventory-service", + "repl": "# Istio: bounded retries and timeout for inventory-service", + "expect": 1, + "sldIds": [ + 276 + ], + "cat": "code", + "_why": "the manifest has no outlier detection; matches _docs/02 comment" + }, + { + "op": "set_code", + "sldId": 300, + "shape_id": 509, + "lines": [ + "apiVersion: keda.sh/v1alpha1", + "kind: ScaledObject", + "metadata: { name: payment-consumer }", + "spec:", + " scaleTargetRef: { name: payment-consumer }", + " minReplicaCount: 0", + " maxReplicaCount: 20", + " triggers:", + " - type: kafka", + " metadata: { bootstrapServers: \"cndp-kafka-bootstrap:9092\", topic: order.placed,", + " consumerGroup: payment, lagThreshold: \"100\" }", + "---", + "# HTTP: scale the Go service on concurrent requests (KEDA http-add-on)", + "apiVersion: http.keda.sh/v1alpha1", + "kind: HTTPScaledObject", + "metadata: { name: order-service }", + "spec:", + " hosts: [orders.example.com]", + " scaleTargetRef: { name: order-service, service: order-service, port: 8080 }", + " replicas: { min: 0, max: 30 }" + ], + "cat": "code", + "_why": "ScaledObject needs metadata.name and the kafka scaler needs bootstrapServers; HTTPScaledObject needs apiVersion, name, hosts and a full scaleTargetRef (KEDA v2.21 / http-add-on)", + "pt": 11.0, + "_fit": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "/livez", + "repl": "/healthz", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "code", + "_why": "examples/21-graceful-shutdown: liveness endpoint is /healthz in all six languages" + }, + { + "op": "replace", + "scope": "slide", + "find": "# must NOT fail during the drain", + "repl": "# keeps answering during the drain", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "code", + "_why": "comment, no ALL-CAPS emphasis" + }, + { + "op": "set_notes", + "sldId": 420, + "paragraphs": [ + "The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover.", + "Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages.", + "The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number." + ], + "cat": "notes", + "_why": "notes named /livez (/healthz/live on .NET); examples use /readyz and /healthz" + }, + { + "op": "replace", + "scope": "slide", + "find": "ssl crt /etc/haproxy/certs/ ", + "repl": "ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 ", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "gRPC needs HTTP/2 on the TLS listener (ALPN h2)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# TLS termination here", + "repl": "# TLS here; h2 for gRPC", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "comment matches the ALPN change" + }, + { + "op": "replace", + "scope": "slide", + "find": "listen 443 ssl http2;", + "repl": "listen 443 ssl; http2 on;", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "code", + "_why": "nginx 1.25.1+ deprecates the http2 listen parameter; http2 is its own directive" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-request-id: \"%REQ(x-request-id)%\"", + "repl": "x-client-ip: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "setting x-request-id to itself is a no-op; Envoy generates it" + }, + { + "op": "replace", + "scope": "slide", + "find": "# propagate or create", + "repl": "# caller address", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "comment matches the new header" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-tenant-id: \"%DOWNSTREAM_PEER_CERT_V_END%\"", + "repl": "x-peer-identity: \"%DOWNSTREAM_PEER_URI_SAN%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "DOWNSTREAM_PEER_CERT_V_END is the certificate expiry, not an identity; the URI SAN carries the SPIFFE ID" + }, + { + "op": "set_notes", + "sldId": 440, + "paragraphs": [ + "Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders.", + "The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none.", + "The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved." + ], + "cat": "notes", + "_why": "notes described the old headers (tenant id from the cert expiry operator)" + }, + { + "op": "set_code", + "sldId": 509, + "shape_id": 8, + "lines": [ + "{", + " \"$schema\": \"https://flagd.dev/schema/v0/flags.json\",", + " \"flags\": {", + " \"new-checkout\": {", + " \"state\": \"ENABLED\",", + " \"variants\": { \"on\": true, \"off\": false },", + " \"defaultVariant\": \"off\",", + " \"targeting\": {", + " \"if\": [", + " { \"==\": [ {\"var\": \"plan\"}, \"enterprise\" ] }, \"on\",", + " { \"fractional\": [ {\"var\": \"targetingKey\"},", + " [\"on\", 25], [\"off\", 75] ] }", + " ]", + " }", + " }", + " }", + "}" + ], + "cat": "code", + "_why": "examples/27-feature-flags/flags/flags.json carries the $schema line" + }, + { + "op": "set_code", + "sldId": 269, + "shape_id": 156, + "lines": [ + "// main.go — config from the environment (factor III); two probes, not one", + "type Settings struct{ DatabaseURL, KafkaBootstrap, Version string }", + "func main() {", + " cfg := Settings{os.Getenv(\"DATABASE_URL\"), os.Getenv(\"KAFKA_BOOTSTRAP\"),", + " os.Getenv(\"SERVICE_VERSION\")} // never a baked-in config file", + " mux := http.NewServeMux() // Go 1.22+ method patterns, no router dependency", + " mux.HandleFunc(\"GET /healthz\", func(w http.ResponseWriter, _ *http.Request) {", + " writeJSON(w, http.StatusOK, map[string]string{\"status\": \"ok\"}) // liveness", + " })", + " mux.HandleFunc(\"GET /readyz\", func(w http.ResponseWriter, r *http.Request) {", + " if err := errors.Join(checkDB(r.Context()), checkKafka(r.Context())); err != nil {", + " writeJSON(w, http.StatusServiceUnavailable, map[string]string{\"status\": \"down\"})", + " return // readiness drains traffic; it never restarts the pod", + " }", + " writeJSON(w, http.StatusOK, map[string]string{\"status\": \"ready\"})", + " })", + " slog.Info(\"starting\", \"version\", cfg.Version, \"kafka\", cfg.KafkaBootstrap)", + " _ = http.ListenAndServe(\":8080\", mux) // factor VII · port binding", + "}" + ], + "cat": "code", + "_slide": 14, + "_why": "site 01 Go codetab; version from SERVICE_VERSION as in examples/01-cloud-native-principles/go" + }, + { + "op": "set_notes", + "sldId": 269, + "paragraphs": [ + "Walk the code: configuration comes from the environment with os.Getenv (factor III), never from a baked-in file, and the service version comes from SERVICE_VERSION so the same image carries its version into logs, OpenAPI and the registry. The router is the standard library's ServeMux, which has matched on method and path since Go 1.22, so there is no router dependency.", + "The two probes are the contract with Kubernetes: /healthz is liveness (is the process up? if not, restart it) and /readyz is readiness (can it serve? it checks the database and Kafka, and on failure takes the pod out of rotation without killing it). errors.Join reports both dependency failures at once. Getting liveness and readiness wrong is a common cause of cascading restarts, so stress the distinction." + ], + "cat": "notes", + "_slide": 14, + "_why": "notes describe the new code" + }, + { + "op": "set_code", + "sldId": 272, + "shape_id": 190, + "lines": [ + "// orders.go — net/http (Go 1.22+ ServeMux); validated input, 201 on create", + "func (s *Server) place(w http.ResponseWriter, r *http.Request) {", + " var in OrderIn // request contract, validated before logic", + " if err := json.NewDecoder(r.Body).Decode(&in); err != nil ||", + " in.SKU == \"\" || in.Quantity < 1 {", + " http.Error(w, \"invalid order\", http.StatusBadRequest)", + " return", + " }", + " o, err := s.orders.Create(r.Context(), in)", + " if err != nil {", + " http.Error(w, err.Error(), http.StatusInternalServerError)", + " return", + " }", + " s.events.Publish(r.Context(), \"order.placed\", o) // made safe by the outbox", + " w.Header().Set(\"Location\", \"/orders/\"+o.ID)", + " writeJSON(w, http.StatusCreated, o) // 201, not 200", + "}", + "// routes: s.mux.HandleFunc(\"POST /orders\", s.place); \"GET /orders\" pages by cursor" + ], + "cat": "code", + "_slide": 17, + "_why": "site 02 Go codetab (REST at the edge), excerpt" + }, + { + "op": "set_text", + "sldId": 272, + "shape_id": 191, + "paragraphs": [ + "Typed request contract, validation before logic, 201 with a Location header: the edge surface other teams depend on." + ], + "cat": "code", + "_slide": 17, + "_why": "caption follows the excerpt (listing handler not shown)" + }, + { + "op": "set_notes", + "sldId": 272, + "paragraphs": [ + "This is the edge surface. Point out four things teams routinely get wrong: (1) resource-shaped URLs, nouns not verbs, registered with Go 1.22 method-aware patterns such as \"POST /orders\"; (2) the request decoded into a typed struct and validated, so bad input never reaches the logic; (3) correct status codes: 201 on create with a Location header, not 200; (4) cursor pagination on the listing, not offset, because offset breaks under concurrent writes and slows down at depth.", + "The handler publishes order.placed after the insert; the Data section makes that publish safe with the outbox. The OpenAPI document for this surface is what we register and gate. Test it with curl and Postman." + ], + "cat": "notes", + "_slide": 17, + "_why": "notes describe the new code" + }, + { + "op": "set_code", + "sldId": 273, + "shape_id": 203, + "lines": [ + "// inventory.proto — the contract, registered in Apicurio", + "syntax = \"proto3\";", + "service Inventory {", + " rpc ReserveStock (ReserveRequest) returns (ReserveReply);", + "}", + "message ReserveRequest { string sku = 1; int32 quantity = 2; }", + "message ReserveReply { bool reserved = 1; int32 remaining = 2; }", + "// inventory_server.go — embed the generated UnimplementedInventoryServer base", + "type inventoryServer struct { pb.UnimplementedInventoryServer; stock *Stock }", + "func (s *inventoryServer) ReserveStock(", + " ctx context.Context, req *pb.ReserveRequest) (*pb.ReserveReply, error) {", + " remaining := s.stock.Reserve(req.GetSku(), req.GetQuantity()) // generated getters", + " return &pb.ReserveReply{Reserved: remaining >= 0, Remaining: max(remaining, 0)}, nil", + "}" + ], + "cat": "code", + "_slide": 18, + "_why": "site 02 Go codetab (gRPC for internal calls); proto = examples/02-communications/go/proto/inventory.proto" + }, + { + "op": "set_notes", + "sldId": 273, + "paragraphs": [ + "gRPC for the internal paths. The .proto is the contract, and it lives in the registry just like OpenAPI. The field numbers (=1, =2) are the wire contract; Appendix B returns to why. protoc-gen-go and protoc-gen-go-grpc generate the message types and the server interface, so both sides share types and nobody parses JSON by hand.", + "The server embeds the generated UnimplementedInventoryServer, which keeps it compiling when the service gains methods, and reads fields through the generated getters. gRPC is binary and multiplexed over HTTP/2, so it is fast and supports streaming: the default for chatty service-to-service calls where you control both ends. Load-test it with ghz." + ], + "cat": "notes", + "_slide": 18, + "_why": "notes describe the new code (Python leftovers gone)" + }, + { + "op": "set_code", + "sldId": 279, + "shape_id": 272, + "lines": [ + "func (r *queryResolver) Orders(ctx context.Context, limit *int) ([]*model.Order, error) {", + " var orders []*model.Order // REST call to order-api", + " if err := r.getJSON(ctx, r.OrderAPIURL+\"/orders\", &orders); err != nil { return nil, err }", + " r.attachStock(ctx, orders) // stock only when the query selects it", + " return orders, nil", + "}", + "func (r *Resolver) attachStock(ctx context.Context, orders []*model.Order) {", + " if !slices.Contains(graphql.CollectAllFields(ctx), \"stock\") { return }", + " stock, _ := r.stockFor(ctx, skusOf(orders)) // one batched gRPC call, no N+1", + " for _, o := range orders { s := int(stock[o.Sku]); o.Stock = &s }", + "}" + ], + "cat": "code", + "_slide": 24, + "_why": "site 03 Go codetab; examples/03-composition/go/gateway/graph/{schema.resolvers.go,resolver.go}" + }, + { + "op": "set_text", + "sldId": 279, + "shape_id": 273, + "paragraphs": [ + "The gateway owns aggregation: orders come from order-api over REST; stock is fetched only when selected, in one batched gRPC call." + ], + "cat": "code", + "_slide": 24, + "_why": "graphql-go mention removed; caption follows the new code" + }, + { + "op": "set_notes", + "sldId": 279, + "paragraphs": [ + "The resolver makes federation concrete. gqlgen is schema-first: it generates the resolver interfaces from graph/schema.graphqls, and we implement them. The orders query calls order-api over REST. The Order.stock field is filled only when the query selects it, and one GetStockBatch gRPC call to inventory-service covers every order in the response.", + "That batching is the answer to the N+1 problem: naive per-field resolution would make one inventory call per order. The services stay unaware of each other; the gateway owns the composition, and the schema's SDL is what we export to the registry." + ], + "cat": "notes", + "_slide": 24, + "_why": "graphql-go leftover removed; notes describe the new code" + }, + { + "op": "replace", + "scope": "slide", + "find": "\"order.created\"", + "repl": "\"order.placed\"", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "code", + "_slide": 29, + "_why": "topic name order.placed (examples)" + }, + { + "op": "replace", + "scope": "slide", + "find": "subscribes: order.created", + "repl": "subscribes: order.placed", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "code", + "_slide": 29, + "_why": "topic name" + }, + { + "op": "replace", + "scope": "slide", + "find": "// Read model is a SEPARATE denormalised store", + "repl": "// Read model is a separate denormalised store", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "voice", + "_slide": 29, + "_why": "ALL-CAPS emphasis" + }, + { + "op": "set_code", + "sldId": 287, + "shape_id": 362, + "lines": [ + "// orders.go — pgx; the state change and the outbox row commit in one local txn", + "func (s *Orders) Create(ctx context.Context, in OrderIn) (Order, error) {", + " tx, err := s.pool.Begin(ctx)", + " if err != nil { return Order{}, fmt.Errorf(\"begin: %w\", err) }", + " defer tx.Rollback(ctx) // no-op after Commit; rolls back on any early return", + " o, err := insertOrder(ctx, tx, in)", + " if err != nil { return Order{}, err }", + " if err := insertOutbox(ctx, tx, \"order.placed\", o.ID, toJSON(o)); err != nil {", + " return Order{}, err // same txn: atomic with the state change", + " }", + " if err := tx.Commit(ctx); err != nil { return Order{}, fmt.Errorf(\"commit: %w\", err) }", + " return o, nil // Debezium tails the WAL and publishes; no broker on the request path", + "}" + ], + "cat": "code", + "_slide": 32, + "_why": "site 04 Go codetab" + }, + { + "op": "set_notes", + "sldId": 287, + "paragraphs": [ + "The whole mechanism is the single transaction: pool.Begin, then tx.Commit, with defer tx.Rollback for every early return. The order insert and the outbox insert commit together or not at all.", + "There is no broker call inside the request path. If the broker is down, the order still commits and the event ships when CDC catches up, so request latency and availability no longer depend on Kafka. Debezium tails the write-ahead log and publishes outbox rows in commit order, which gives a guaranteed, ordered event stream. The saga appendix uses the same idea: the state transition and the outbound command commit together." + ], + "cat": "notes", + "_slide": 32, + "_why": "notes describe the new code; voice unit 501 superseded" + }, + { + "op": "set_code", + "sldId": 291, + "shape_id": 407, + "lines": [ + "// producer — order-service: one client per service, keyed by order ID", + "func publish(ctx context.Context, cl *kgo.Client, o Order) error {", + " rec := &kgo.Record{Topic: \"order.placed\", Key: []byte(o.ID), Value: serialize(o)}", + " return cl.ProduceSync(ctx, rec).FirstErr() // acks from all in-sync replicas", + "}", + "// consumer — notification-consumer, Kafka-only by design", + "cl, err := kgo.NewClient(", + " kgo.SeedBrokers(os.Getenv(\"KAFKA_BOOTSTRAP\")),", + " kgo.ConsumerGroup(\"notification-group\"), // offset tracked per group", + " kgo.ConsumeTopics(\"order.placed\"),", + " kgo.DisableAutoCommit(), // commit after the side effect", + ")", + "if err != nil { return err }", + "for {", + " fs := cl.PollFetches(ctx)", + " fs.EachRecord(func(r *kgo.Record) { notify(ctx, r) }) // idempotent insert", + " if err := cl.CommitUncommittedOffsets(ctx); err != nil { // at-least-once", + " slog.Error(\"commit failed\", \"err\", err)", + " }", + "}" + ], + "cat": "code", + "_slide": 36, + "_why": "site 05 Go codetab (Producing and consuming); group name from examples/05-event-driven/go", + "pt": 11.0, + "_fit": "slide 36: 20 code lines, 84 columns; 11.5 pt overflows the box" + }, + { + "op": "set_notes", + "sldId": 291, + "paragraphs": [ + "Two things to drill home. First, auto-commit is disabled (kgo.DisableAutoCommit) and the offsets are committed after the side effect, here the notification insert. Commit first and crash, and the work is lost.", + "Second, because the commit comes after the work, a crash mid-batch means the records are delivered again, so delivery is at-least-once and handlers must be idempotent. The example's consumer inserts with ON CONFLICT (order_id) DO NOTHING, so a duplicate is a no-op. Exactly-once in practice means at-least-once plus idempotency. The record key is the order ID, which keeps every event for one order on one partition, in order." + ], + "cat": "notes", + "_slide": 36, + "_why": "notes describe the new code; voice unit 502 superseded" + }, + { + "op": "set_code", + "sldId": 298, + "shape_id": 485, + "lines": [ + "graph := goka.DefineGroup(\"stream-processor\",", + " goka.Input(\"order.placed\", new(codec.Bytes), rekey), // stage 1: re-key", + " goka.Output(\"stream-processor-by-merchant\", rekeyedCodec{}),", + " goka.Input(\"stream-processor-by-merchant\", rekeyedCodec{}, aggregate),", + " goka.Output(\"revenue.by-merchant\", new(codec.Bytes)),", + " goka.Persist(stateCodec{}), // the group table, backed by a changelog topic", + ")", + "func aggregate(ctx goka.Context, msg any) {", + " in := msg.(*rekeyed) // {ts, total} for one order", + " state := loadState(ctx) // this merchant's windows", + " start := (in.TS / 1000 / windowSeconds) * windowSeconds // tumbling-window floor", + " cur := state.add(start, in.Total) // count + running total", + " ctx.SetValue(state) // goka writes the changelog", + " ctx.Emit(revenue, ctx.Key(), revenueJSON(ctx.Key(), start, cur)) // revenue.by-merchant", + "}" + ], + "cat": "code", + "_slide": 43, + "_why": "site 06 Go codetab; examples/06-stream-processing/go/stream-processor/cmd/processor/main.go" + }, + { + "op": "set_text", + "sldId": 298, + "shape_id": 486, + "paragraphs": [ + "Window state lives in a changelog-backed group table, so a restart replays the changelog. The output is itself a stream." + ], + "cat": "code", + "_slide": 43, + "_why": "caption follows the new code" + }, + { + "op": "set_notes", + "sldId": 298, + "paragraphs": [ + "Stream processing in Go with goka. A goka group is a Kafka consumer group with a table: local state per key, backed by a changelog topic, so a pod that dies rebuilds its state by replaying the changelog. That is fault-tolerant local state without an external database in the hot path.", + "Stage one re-keys each order.placed record by merchant; stage two folds the order into that merchant's tumbling windows (the floor of the event time to the window size) and emits every update to revenue.by-merchant. goka has no windowing DSL, so the window is explicit code. The output topic is itself a stream other services consume. Kafka Streams and Flink are the JVM equivalents if asked." + ], + "cat": "notes", + "_slide": 43, + "_why": "Python/Faust leftovers removed; notes describe the new code" + }, + { + "op": "set_code", + "sldId": 310, + "shape_id": 621, + "lines": [ + "// registry_client.go — register a new version; the registry enforces the rule", + "const registry = \"http://apicurio.registry.svc/apis/registry/v3\"", + "func publish(group, artifactID string, schema []byte) error {", + " url := fmt.Sprintf(\"%s/groups/%s/artifacts/%s/versions\", registry, group, artifactID)", + " resp, err := http.Post(url, \"application/json\", bytes.NewReader(schema))", + " if err != nil { return err }", + " defer resp.Body.Close()", + " if resp.StatusCode == http.StatusBadRequest { // 400 = compatibility rule violated", + " return errors.New(\"incompatible schema change: blocking the merge\")", + " }", + " if resp.StatusCode >= 300 { return fmt.Errorf(\"registry: %s\", resp.Status) }", + " return nil // 200 = compatible, new version created", + "}", + "// rule set once per artifact, e.g. BACKWARD: new consumers can read old data" + ], + "cat": "code", + "_slide": 55, + "_why": "site 09 Go codetab; Apicurio 3.3 answers 400 (was 409), modernization S8" + }, + { + "op": "set_notes", + "sldId": 310, + "paragraphs": [ + "This is the guardrail that makes the contract story enforceable. The CI job posts the new schema version to Apicurio; if it violates the artifact's compatibility rule, Apicurio 3.3 answers 400 with a RuleViolationException and the job fails the build, blocking the merge. A compatible schema gets 200 and a new version.", + "The contract is enforced before code ships, not discovered broken in production by a downstream team. The rule (for example BACKWARD: new consumers can read old data) is set once per artifact and then applies to every change. This is design-first from section 1, mechanized." + ], + "cat": "notes", + "_slide": 55, + "_why": "409 -> 400 (Apicurio 3.3)" + }, + { + "op": "set_code", + "sldId": 319, + "shape_id": 717, + "lines": [ + "// otel.go — init once in main(); otelhttp wraps the mux, add custom spans", + "func main() {", + " shutdown := initTracer(context.Background()) // OTLP exporter + batch processor", + " defer shutdown()", + " mux := http.NewServeMux()", + " mux.HandleFunc(\"POST /orders\", placeOrder)", + " _ = http.ListenAndServe(\":8080\", otelhttp.NewHandler(mux, \"order-service\")) // span/request", + "}", + "var tracer = otel.Tracer(\"order-service\")", + "func placeOrder(w http.ResponseWriter, r *http.Request) {", + " ctx, span := tracer.Start(r.Context(), \"reserve-stock\") // custom span", + " defer span.End()", + " inventory.ReserveStock(ctx, body.SKU, body.Quantity)", + " // trace_id flows: REST -> gRPC -> Kafka -> consumer", + "}" + ], + "cat": "code", + "_slide": 64, + "_why": "site 11 Go codetab; span name reserve-stock as in examples/11-observability/go/order-service" + }, + { + "op": "set_notes", + "sldId": 319, + "paragraphs": [ + "How little code this takes. Go has no agent that attaches at runtime, so instrumentation is explicit: otelhttp.NewHandler wraps the mux and turns every request into a server span, otelgrpc does the same for gRPC, and initTracer sets up the OTLP exporter and the W3C propagator once in main.", + "You hand-write spans only for business operations worth measuring, such as reserve-stock. The trace_id flows from REST to gRPC to Kafka and into the consumer: the producer injects the trace context into the record headers with the propagator, and the consumer extracts it and starts its span as a child. The Collector ships everything to Tempo, Mimir and Loki, keyed by that trace_id." + ], + "cat": "notes", + "_slide": 64, + "_why": "franz-go instrumentor claim corrected (examples inject headers with the propagator)" + }, + { + "op": "set_code", + "sldId": 321, + "shape_id": 741, + "lines": [ + "// set baggage once at the edge; it rides every downstream hop on the context", + "func withTenant(next http.Handler) http.Handler {", + " return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {", + " m, _ := baggage.NewMember(\"tenant.id\", r.Header.Get(\"X-Tenant\"))", + " b, _ := baggage.New(m)", + " next.ServeHTTP(w, r.WithContext(baggage.ContextWithBaggage(r.Context(), b)))", + " })", + "}", + "// read it anywhere downstream, even in a Kafka consumer three hops away", + "func handleEvent(ctx context.Context, rec *kgo.Record) {", + " tenant := baggage.FromContext(ctx).Member(\"tenant.id\").Value()", + " trace.SpanFromContext(ctx).SetAttributes(", + " attribute.String(\"tenant.id\", tenant)) // pin to the span: filter traces by tenant", + "}" + ], + "cat": "code", + "_slide": 66, + "_why": "site 11 Go codetab (How correlation works): key tenant.id" + }, + { + "op": "set_text", + "sldId": 321, + "shape_id": 742, + "paragraphs": [ + "Set once at the edge; read anywhere downstream. Pin it to the span to filter traces by tenant.id." + ], + "cat": "code", + "_slide": 66, + "_why": "key name tenant.id as on the site" + }, + { + "op": "set_notes", + "sldId": 321, + "paragraphs": [ + "Baggage is the W3C-standard way to carry request-scoped key-values along the whole trace. The middleware sets tenant.id at the edge; with the baggage propagator registered next to the trace-context propagator, the OTel SDK serialises it into the baggage header and every downstream HTTP, gRPC or Kafka call made with that context carries it.", + "Three hops later a Kafka consumer reads it with baggage.FromContext without anyone passing it explicitly. The common idiom is to also set it as a span attribute so it is queryable in the trace UI: show all slow traces for tenant acme. Caution: baggage travels on the wire to every downstream service, including third parties, so never put secrets or PII in it." + ], + "cat": "notes", + "_slide": 66, + "_why": "Python API names removed; key tenant.id" + }, + { + "op": "replace", + "scope": "slide", + "find": "tracer.start_span(...), span.set_attribute(...), span.add_event(...)", + "repl": "tracer.Start(...), span.SetAttributes(...), span.AddEvent(...)", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "stack", + "_slide": 63, + "_why": "Go OTel API names" + }, + { + "op": "replace", + "scope": "notes", + "find": "x/sync/semaphore (Python)", + "repl": "buffered channels per tenant (Go)", + "expect": 1, + "sldIds": [ + 339 + ], + "cat": "stack", + "_slide": 84, + "_why": "Go implementation label" + }, + { + "op": "set_code", + "sldId": 340, + "shape_id": 946, + "lines": [ + "// per-tenant bulkhead: a buffered channel per tenant; A's storm can't drain B's capacity", + "var bulkheads sync.Map // tenant -> chan struct{}", + "func tenantSem(tenant string) chan struct{} {", + " v, _ := bulkheads.LoadOrStore(tenant, make(chan struct{}, 20)) // 20 per tenant", + " return v.(chan struct{})", + "}", + "func handle[T any](tenant string, work func() (T, error)) (T, error) {", + " sem := tenantSem(tenant)", + " sem <- struct{}{} // blocks only this tenant when full", + " defer func() { <-sem }()", + " return work()", + "}", + "// fail fast instead of queueing: select on sem <- struct{}{} and a timer, answer 503" + ], + "cat": "code", + "_slide": 85, + "_why": "site 12 Go codetab (Bulkhead); examples/12-security/go uses the same buffered-channel bulkhead" + }, + { + "op": "set_text", + "sldId": 340, + "shape_id": 945, + "paragraphs": [ + "go · buffered channel" + ], + "cat": "code", + "_slide": 85, + "_why": "chip follows the new code (x/sync/semaphore is not used)" + }, + { + "op": "set_notes", + "sldId": 340, + "paragraphs": [ + "A bulkhead per tenant. Each tenant gets its own buffered channel used as a semaphore: a request takes a slot before the work and gives it back afterwards, so at most 20 requests per tenant run at once. A storm from tenant A fills A's channel and blocks only A; tenant B's capacity is untouched.", + "In production, prefer failing fast to queueing: select on the send and a short timer, and answer 503 when the slot does not free up, so callers back off instead of piling up. For socket-level isolation, give each tenant its own http.Client with a bounded Transport (MaxConnsPerHost). For hard isolation, run per-tenant replicas: Kubernetes-level bulkheading." + ], + "cat": "notes", + "_slide": 85, + "_why": "notes describe the new code (aiohttp TCPConnector leftover removed)" + }, + { + "op": "replace", + "scope": "notes", + "find": "rather than hand-building dicts", + "repl": "rather than building maps by hand", + "expect": 1, + "sldIds": [ + 361 + ], + "cat": "stack", + "_slide": 106, + "_why": "Python term" + }, + { + "op": "set_code", + "sldId": 365, + "shape_id": 1213, + "lines": [ + "# schema.graphqls — additive change + @deprecated; no /v2 endpoint ever exists", + "type Order {", + " id: ID!", + " total: Float @deprecated(reason: \"Use totalMinor (integer cents) instead.\")", + " totalMinor: Int! # the replacement field, added additively", + "}", + "# gqlgen still generates and serves the deprecated field's resolver.", + "# Clients see @deprecated in introspection and tooling.", + "# Field-usage metrics show when total has zero callers; only then remove it." + ], + "cat": "code", + "_slide": 110, + "_why": "site 15 Go codetab (GraphQL evolves); SDL comments use # (was // inside SDL)" + }, + { + "op": "set_notes", + "sldId": 365, + "paragraphs": [ + "The mechanics in gqlgen. We add totalMinor additively and mark the old total field with @deprecated and a reason. The field keeps working, nothing breaks, but the deprecation shows in introspection and in tools such as GraphiQL, nudging client developers. gqlgen keeps generating and serving the resolver for the deprecated field.", + "The operational piece: instrument field-level usage on the server, and remove total only when its usage drops to zero. That is the GraphQL approach: evolution governed by data, with the breaking removal deferred until it is provably safe. Compare REST's media types and gRPC's tag rules: each protocol answers the same problem differently." + ], + "cat": "notes", + "_slide": 110, + "_why": "Python names removed" + }, + { + "op": "set_code", + "sldId": 368, + "shape_id": 1246, + "lines": [ + "// ws.go — coder/websocket; per-pod state, the scaling problem starts here", + "var clients sync.Map // user ID -> *websocket.Conn: this pod's connections, not shared", + "func wsHandler(w http.ResponseWriter, r *http.Request) {", + " userID := r.PathValue(\"user_id\") // 1.22+ path wildcard", + " c, err := websocket.Accept(w, r, nil)", + " if err != nil { return }", + " defer c.CloseNow()", + " clients.Store(userID, c) // this pod now owns this connection", + " defer clients.Delete(userID)", + " for { // full-duplex, long-lived", + " var msg Message", + " if err := wsjson.Read(r.Context(), c, &msg); err != nil {", + " return // disconnect", + " }", + " handle(r.Context(), userID, msg)", + " }", + "}" + ], + "cat": "code", + "_slide": 113, + "_why": "site 16 Go codetab (A WebSocket endpoint)" + }, + { + "op": "set_text", + "sldId": 368, + "shape_id": 1247, + "paragraphs": [ + "clients is per-pod state: that one map is the whole scaling problem." + ], + "cat": "voice", + "_slide": 113, + "_why": "watch: 'easy', 'note'; voice unit 511" + }, + { + "op": "set_notes", + "sldId": 368, + "paragraphs": [ + "With coder/websocket the endpoint is short: accept the upgrade, then loop reading and handling messages, full duplex, until the client disconnects. Point at the clients map: it lives in this pod's memory.", + "That one design fact drives the scaling discussion. The connection, and the knowledge of who is connected, live on exactly one pod. Everything stateless about HTTP, where any replica can serve any request, no longer holds. The scaling slides in this appendix show what that does to a Deployment." + ], + "cat": "notes", + "_slide": 113, + "_why": "Python names removed" + }, + { + "op": "set_code", + "sldId": 372, + "shape_id": 1291, + "lines": [ + "// envelope.proto — version the message, not the URL", + "message Envelope {", + " uint32 version = 1; // bump for incompatible payload changes", + " string type = 2; // \"OrderPlaced\", \"OrderCancelled\", ...", + " bytes payload = 3; // the protobuf-encoded body for type", + " uint64 seq = 4; // monotonic per connection: drives resume", + "}", + "func push(ctx context.Context, c *websocket.Conn, seq uint64, ev *pb.OrderPlaced) error {", + " p, _ := proto.Marshal(ev) // nested protobuf", + " out, _ := proto.Marshal(&pb.Envelope{Version: 2, Seq: seq, Type: \"OrderPlaced\", Payload: p})", + " return c.Write(ctx, websocket.MessageBinary, out) // one binary frame", + "}" + ], + "cat": "code", + "_slide": 117, + "_why": "site 16 Go codetab (Binary framing); the box held Python code" + }, + { + "op": "set_text", + "sldId": 372, + "shape_id": 1290, + "paragraphs": [ + "protobuf + go · coder/websocket" + ], + "cat": "code", + "_slide": 117, + "_why": "chip names the library" + }, + { + "op": "set_notes", + "sldId": 372, + "paragraphs": [ + "Two wins in one slide. Performance: protobuf frames written with websocket.MessageBinary are smaller and cheaper to parse than JSON text, which matters when pushing high-frequency updates to many sockets.", + "Versioning: a WebSocket message has no URL to hang a version on, so you version the envelope. The version field lets the client detect an incompatible payload and renegotiate or reconnect; the type field dispatches to the right protobuf message; and a monotonic seq per connection is what makes reconnection and replay possible, the subject of the failure-handling slide. One envelope answers both how to make it fast and how to version a socket message." + ], + "cat": "notes", + "_slide": 117, + "_why": "Python code and names removed" + }, + { + "op": "set_code", + "sldId": 377, + "shape_id": 1346, + "lines": [ + "// saga_orchestrator.go — DB-backed state machine; advance one step per call", + "var steps = []string{\"charge_payment\", \"reserve_stock\", \"book_shipping\"}", + "func advance(ctx context.Context, pool *pgxpool.Pool, sagaID string) error {", + " tx, err := pool.Begin(ctx) // state + command commit together", + " if err != nil { return err }", + " defer tx.Rollback(ctx)", + " s, err := getSagaForUpdate(ctx, tx, sagaID) // SELECT ... FOR UPDATE (row lock)", + " if err != nil || s.Status != \"RUNNING\" { return err } // idempotent: already done", + " step := steps[s.StepIndex]", + " result, err := invoke(ctx, step, s.Context) // call the owning service", + " if err != nil { return markCompensating(ctx, tx, s) } // the unwinder takes over", + " s.Context[step] = result // accumulate output into the context", + " s.StepIndex++", + " if s.StepIndex == len(steps) { s.Status = \"COMPLETED\" }", + " if err := updateSaga(ctx, tx, s); err != nil { return err }", + " return tx.Commit(ctx) // durable after every step", + "}" + ], + "cat": "code", + "_slide": 122, + "_why": "site 17 Go codetab; statuses RUNNING/COMPENSATING/COMPLETED as in examples/17-sagas/go" + }, + { + "op": "replace", + "scope": "slide", + "find": "s.Status = \"FAILED\"", + "repl": "s.Status = \"COMPENSATED\"", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "code", + "_slide": 124, + "_why": "terminal status COMPENSATED as in examples/17-sagas/go and the site" + }, + { + "op": "set_notes", + "sldId": 379, + "paragraphs": [ + "The compensation logic. A map gives each forward step its semantic inverse. The loop walks from StepIndex - 1 down to 0: only the steps that completed, newest first, and invokes each compensation. The saga ends as COMPENSATED.", + "The idempotency key built from the saga ID and the step is what prevents the worst outcome: a retry refunding a customer twice. The compensating service dedupes on that key. A production version also tracks per-step compensation status, so a compensation that crashes halfway resumes in the right place instead of re-running finished ones; the idempotency key makes even a naive re-run safe. This is why compensations are designed up front, as part of each step, not added afterwards." + ], + "cat": "notes", + "_slide": 124, + "_why": "Python idioms (reversed(STEPS[:step_index])) removed" + }, + { + "op": "replace", + "scope": "notes", + "find": "step_index", + "repl": "StepIndex", + "expect": 1, + "sldIds": [ + 381 + ], + "cat": "stack", + "_slide": 126, + "_why": "Go field name" + }, + { + "op": "replace", + "scope": "notes", + "find": "shipping.book", + "repl": "shipping.Book", + "expect": 1, + "sldIds": [ + 381 + ], + "cat": "stack", + "_slide": 126, + "_why": "Go method name" + }, + { + "op": "set_code", + "sldId": 385, + "shape_id": 1439, + "lines": [ + "// one central place: domain error → RFC 9457 problem+json", + "type StockError struct{ Detail string }", + "func (e StockError) Error() string { return e.Detail }", + "func writeProblem(w http.ResponseWriter, r *http.Request, err error) {", + " var se StockError", + " if !errors.As(err, &se) { // domain → wire, one mapping", + " http.Error(w, \"internal\", http.StatusInternalServerError)", + " return", + " }", + " w.Header().Set(\"Content-Type\", \"application/problem+json\") // RFC 9457", + " w.WriteHeader(http.StatusConflict) // 409", + " _ = json.NewEncoder(w).Encode(map[string]any{", + " \"type\": \"https://errors.acme.io/stock\", \"title\": \"Insufficient stock\",", + " \"status\": 409, \"detail\": se.Detail,", + " \"code\": \"STOCK_UNAVAILABLE\", \"traceId\": traceID(r.Context()), // stable code, trace", + " })", + "}" + ], + "cat": "code", + "_slide": 130, + "_why": "site 18 Go codetab (REST); code STOCK_UNAVAILABLE / 409 as in examples/18-errors/go" + }, + { + "op": "set_notes", + "sldId": 385, + "paragraphs": [ + "One function maps domain errors to the wire: domain code returns a typed error, errors.As recognises it, and writeProblem renders RFC 9457 problem+json, so no endpoint invents its own error JSON. Anything unrecognised becomes a plain 500 with no detail.", + "The media type is application/problem+json, the status matches the category (409, a conflict the client can act on), and the body carries the stable code and the traceId for correlation. What is absent by design: stack traces, type names and SQL, which leak internals and widen the attack surface." + ], + "cat": "notes", + "_slide": 130, + "_why": "Python exception wording removed" + }, + { + "op": "set_notes", + "sldId": 387, + "paragraphs": [ + "The rich-error model in Go: status.New with a canonical code, then WithDetails attaches google.rpc details from the errdetails package. Unavailable tells the client and the mesh the failure is transient and retryable; ErrorInfo carries a machine reason and domain; RetryInfo tells the client how long to wait. st.Err() returns an error whose details travel in the trailers.", + "The discipline: do not use Internal for everything. The code is how automated clients decide whether and when to retry, so choosing it well prevents both lost work and retry storms." + ], + "cat": "notes", + "_slide": 132, + "_why": "Python grpcio-status leftovers removed" + }, + { + "op": "set_code", + "sldId": 389, + "shape_id": 1487, + "lines": [ + "func process(ctx context.Context, cl *kgo.Client, r *kgo.Record) error {", + " if err := handle(r); err == nil { return nil } // success", + " attempts := headerInt(r, \"retries\")", + " out := &kgo.Record{Value: r.Value}", + " if attempts < maxRetries {", + " out.Topic = \"orders.retry.30s\" // delayed retry", + " out.Headers = []kgo.RecordHeader{{Key: \"retries\", Value: itob(attempts + 1)}}", + " } else {", + " out.Topic = \"orders.DLQ\" // give up → DLQ", + " out.Headers = []kgo.RecordHeader{{Key: \"error\", Value: []byte(\"poison\")},", + " {Key: \"traceId\", Value: []byte(traceID(ctx))}}", + " }", + " return cl.ProduceSync(ctx, out).FirstErr() // then commit, so the partition keeps moving", + "}" + ], + "cat": "code", + "_slide": 134, + "_why": "site 18 Go codetab (Kafka retry + DLQ)" + }, + { + "op": "set_notes", + "sldId": 389, + "paragraphs": [ + "Bounded in-place retries, then a delayed-retry topic, then a dead-letter queue. The retries header counts the attempts and grows on each hop through orders.retry.30s; after maxRetries the record goes to orders.DLQ with its payload, an error header and a traceId header for investigation.", + "The caller commits the offset in every case, success, retry or DLQ, so one poison record cannot stall the partition. This is the manual version of what frameworks provide declaratively. Make handle idempotent, because with at-least-once delivery a retried record may already have been partly applied." + ], + "cat": "notes", + "_slide": 134, + "_why": "Python except-branch wording removed" + }, + { + "op": "set_code", + "sldId": 391, + "shape_id": 1511, + "lines": [ + "// gqlgen: an error from one field's resolver nulls that field only; siblings resolve", + "func (r *orderResolver) Stock(ctx context.Context, obj *Order) (*int, error) {", + " n, err := r.Inventory.Remaining(ctx, obj.Sku)", + " if err != nil {", + " return nil, &gqlerror.Error{", + " Message: \"inventory unavailable\", // safe message", + " Path: graphql.GetPath(ctx), // the field", + " Extensions: map[string]any{", + " \"code\": \"UNAVAILABLE\", // machine code", + " \"retryable\": true,", + " \"traceId\": traceID(ctx),", + " },", + " }", + " }", + " return &n, nil", + "}" + ], + "cat": "code", + "_slide": 136, + "_why": "site 18 Go codetab (GraphQL partial success)" + }, + { + "op": "set_text", + "sldId": 391, + "shape_id": 1512, + "paragraphs": [ + "Return a gqlerror from the field resolver: gqlgen nulls that field and adds an errors[] entry with your extensions; the rest of data returns." + ], + "cat": "code", + "_slide": 136, + "_why": "caption follows Go terms" + }, + { + "op": "set_notes", + "sldId": 391, + "paragraphs": [ + "Returning an error from a single field's resolver is the right move: gqlgen sets that field to null and appends an entry to errors[] with the message, the path and your extensions, while every other field in the query resolves normally. The extensions object carries the shared error contract in GraphQL: code, retryable, traceId.", + "Do not let a failure escape to the operation level and fail the whole response; that is the blanket-failure anti-pattern. The client sees the order and its total, sees stock is null, reads extensions.code, and decides whether to retry only stock." + ], + "cat": "notes", + "_slide": 136, + "_why": "Python exception wording removed" + }, + { + "op": "set_code", + "sldId": 401, + "shape_id": 1618, + "lines": [ + "// domain/ports.go — the domain declares the small interfaces it needs", + "type OrderRepository interface{ Save(ctx context.Context, order *Order) error } // + finders", + "type EventPublisher interface{ Publish(event OrderPlaced) }", + "// domain/service.go — pure application logic: no framework, no I/O", + "type PlaceOrder struct {", + " repo OrderRepository", + " pub EventPublisher", + "}", + "func (s *PlaceOrder) Execute(ctx context.Context, cmd PlaceOrderCmd) (*Order, error) {", + " if cmd.SKU == \"\" || cmd.Quantity <= 0 { return nil, fmt.Errorf(\"invalid order\") }", + " order := &Order{ID: newUUID(), SKU: cmd.SKU, Quantity: cmd.Quantity, Status: \"placed\"}", + " if err := s.repo.Save(ctx, order); err != nil { return nil, err }", + " s.pub.Publish(OrderPlaced{OrderID: order.ID, SKU: order.SKU, Quantity: order.Quantity})", + " return order, nil", + "}" + ], + "cat": "code", + "_slide": 146, + "_why": "examples/19-ddd-hexagonal/go/order-service/domain/{ports.go,service.go} (site tab differs: see site-fixes)" + }, + { + "op": "set_notes", + "sldId": 401, + "paragraphs": [ + "Walk the two blocks. Ports: the domain declares small Go interfaces for what it needs, a repository and an event publisher, and depends on nothing else. The application service PlaceOrder is pure: it enforces the invariant, creates the order, saves it through the repository port and publishes OrderPlaced through the publisher port. It never imports net/http, pgx or Kafka.", + "The adapters live outside the domain: a Postgres repository and a publisher on the outbound side; an HTTP handler and a CLI on the driving side, each turning its input into a PlaceOrderCmd and calling Execute. The example runs the same use case from both the HTTP server and the CLI. Dependency inversion serves the boundary: protocols are replaceable, the domain is stable." + ], + "cat": "notes", + "_slide": 146, + "_why": "Python 'Protocols' wording removed; matches example 19" + }, + { + "op": "set_code", + "sldId": 419, + "shape_id": 1806, + "lines": [ + "// graceful shutdown — SIGTERM flips readiness first, then drains within a budget", + "func main() {", + " ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)", + " defer stop()", + " var ready atomic.Bool", + " ready.Store(true) // GET /readyz answers 503 once ready is false", + " srv := &http.Server{Addr: \":8080\", Handler: routes(&ready)}", + " go srv.ListenAndServe()", + " <-ctx.Done() // SIGTERM received", + " ready.Store(false) // fail readiness first, before draining", + " shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)", + " defer cancel()", + " _ = srv.Shutdown(shutCtx) // bounds the HTTP drain", + " consumer.Close() // finish, commit offsets, leave the group", + " db.Close() // close pools, flush, release resources", + "}" + ], + "cat": "code", + "_slide": 164, + "_why": "site 21 Go codetab (The application side)" + }, + { + "op": "set_text", + "sldId": 419, + "shape_id": 1807, + "paragraphs": [ + "signal.NotifyContext catches SIGTERM; readiness fails first so the Service stops routing, then Server.Shutdown drains within a 15-second budget." + ], + "cat": "code", + "_slide": 164, + "_why": "uvicorn flag leftover removed" + }, + { + "op": "set_notes", + "sldId": 419, + "paragraphs": [ + "Three moving parts. First, signal.NotifyContext turns SIGTERM into a cancelled context, and the first thing main does is flip the atomic.Bool behind /readyz, so readiness answers 503 and the Service stops sending new traffic.", + "Second, http.Server.Shutdown stops accepting connections and waits for in-flight requests, bounded by a 15-second context so a stuck request cannot hang the rollout. Third, once HTTP has drained, close the franz-go client, which commits offsets and leaves the consumer group, and close the database pool. The order matters: readiness down, drain HTTP, then drain everything else. Pair it with the preStop hook and the grace period on the Kubernetes side." + ], + "cat": "notes", + "_slide": 164, + "_why": "Python lifespan/uvicorn leftovers removed" + }, + { + "op": "set_notes", + "sldId": 442, + "paragraphs": [ + "The in-app routing pattern in Go, composed from small pieces. Requests arrive over REST (or WebSocket, or Kafka) at a net/http handler, which gathers any extra context in an enrich step, asks the Lua ruleset for destinations through gopher-lua, and dispatches the result with a franz-go producer or an HTTP client.", + "gopher-lua is a Lua virtual machine written in Go; the ruleset is a Lua script that domain experts edit. It is the same ruleset the C++ deck loads with sol2. The routing logic lives in the script, separate from the dispatcher, and changes without redeploying the service." + ], + "cat": "notes", + "_slide": 187, + "_why": "Python durable-rules description removed; voice unit 528 superseded" + }, + { + "op": "set_code", + "sldId": 443, + "shape_id": 2076, + "lines": [ + "-- orders_rules.lua — the externalised ruleset; Go loads it with gopher-lua", + "function route(order, ctx)", + " local dests = {}", + " if order.amount_cents > 50000 and ctx.tier == \"VIP\" then", + " table.insert(dests, \"kafka:orders.priority\"); table.insert(dests, \"http:notify-vip\")", + " end", + " if order.region == \"EU\" then table.insert(dests, \"kafka:orders.eu\") end", + " if #dests == 0 then table.insert(dests, \"kafka:orders.default\") end", + " return dests", + "end" + ], + "cat": "code", + "_slide": 188, + "_why": "site 22 Lua tab (shared with C++)" + }, + { + "op": "set_text", + "sldId": 443, + "shape_id": 2077, + "paragraphs": [ + "Each rule is a small business statement: when X is true, route to Y. Owned and reviewed by domain experts." + ], + "cat": "code", + "_slide": 188, + "_why": "where_to (durable-rules) leftover removed" + }, + { + "op": "set_notes", + "sldId": 443, + "paragraphs": [ + "Read the rules as when-then statements. route receives the order and the enriched context and returns a list of destinations: high-value VIP orders go to the priority topic and a VIP notification, EU orders also go to the EU topic, and anything else goes to the default topic.", + "The syntax is not the point. The point is ownership: domain experts, the people who define what VIP or high-value means, own this file and can change it without a developer touching the dispatcher. For larger decision tables, Open Policy Agent (Rego) is the alternative; the shape stays the same." + ], + "cat": "notes", + "_slide": 188, + "_why": "Rete/durable-rules claims removed; voice unit 529 superseded" + }, + { + "op": "set_code", + "sldId": 444, + "shape_id": 2089, + "lines": [ + "// dispatcher.go — net/http + franz-go + gopher-lua (loads the same rules.lua)", + "func (s *Server) route(w http.ResponseWriter, r *http.Request) {", + " order := parse(r)", + " rc := enrich(r.Context(), order) // gather context (DB / HTTP)", + " for _, dest := range s.router.evaluate(r.Context(), order, rc) { // Lua route()", + " proto, target, _ := strings.Cut(dest, \":\")", + " switch proto {", + " case \"kafka\":", + " s.kafka.Produce(r.Context(), record(target, order), nil)", + " case \"http\":", + " s.http.Post(r.Context(), target, order)", + " }", + " }", + " writeJSON(w, http.StatusOK, map[string]any{\"routed_to\": \"ok\"})", + "}", + "// evaluate: a pooled *lua.LState per call (a state is not goroutine-safe), unsafe", + "// libraries removed, a 100 ms context bound, the default topic on any error" + ], + "cat": "code", + "_slide": 189, + "_why": "site 22 Go codetab (dispatcher), excerpt" + }, + { + "op": "set_notes", + "sldId": 444, + "paragraphs": [ + "The dispatcher stays small. enrich gathers any extra context the rules need, such as a database lookup or a call to a customer service. evaluate runs route() from the Lua ruleset in a pooled gopher-lua state, because a state is not safe for concurrent use; the sandbox opens only the base, table, string and math libraries, and a 100-millisecond context bound stops a runaway script.", + "Each destination carries a protocol prefix, kafka: or http:, so one ruleset drives several transports. For event-shaped flows, put the same evaluate call in a franz-go consumer loop instead of the HTTP handler; the ruleset and the dispatch stay the same." + ], + "cat": "notes", + "_slide": 189, + "_why": "Python durable-rules names removed" + }, + { + "op": "set_code", + "sldId": 461, + "shape_id": 2274, + "lines": [ + "// content_router — route by request content during cutover (net/http middleware)", + "func contentRouter(next http.Handler) http.Handler {", + " client := &http.Client{Timeout: 5 * time.Second} // reused: warm connection pool", + " return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {", + " if !strings.HasPrefix(r.URL.Path, \"/orders\") { next.ServeHTTP(w, r); return }", + " body, _ := io.ReadAll(r.Body)", + " var p struct{ Tenant, Region string }", + " _ = json.Unmarshal(body, &p) // decide by content, not URL", + " upstream := monolith // default: legacy", + " switch {", + " case p.Tenant == \"acme\":", + " upstream = newSvc // cut Acme over", + " case p.Region == \"EU\":", + " upstream = euSvc // regional service", + " }", + " forward(w, r, client, upstream, body) // hand the consumed bytes through", + " })", + "}" + ], + "cat": "code", + "_slide": 206, + "_why": "site 24 Go codetab; http.Client with a 5 s timeout as in examples/24-monolith-to-microservices/go/router" + }, + { + "op": "set_text", + "sldId": 461, + "shape_id": 2273, + "paragraphs": [ + "go · net/http" + ], + "cat": "code", + "_slide": 206, + "_why": "httputil no longer used" + }, + { + "op": "set_notes", + "sldId": 461, + "paragraphs": [ + "The middleware shape. Reading the body costs CPU on every routed request, and once it is consumed you must hand the bytes through to the upstream, which is what forward does. The decision is by payload content: tenant, region, feature flag, user segment, not the URL, which stays the same.", + "That is what makes the pattern stronger than path-based proxying: move one tenant at a time to the new service, watch it under live traffic, then widen. The http.Client is created once and reused, so the connection pool stays warm. Behind Envoy or Istio you would express the same logic as a Lua or Wasm filter; the middleware shows the shape. The body parse is the cost: negligible for a migration of days, worth measuring for one of months." + ], + "cat": "notes", + "_slide": 206, + "_why": "Python leftovers (AsyncClient) removed" + }, + { + "op": "replace", + "scope": "notes", + "find": "Common Python implementations: a net/http app that proxies", + "repl": "A common Go implementation: a net/http service that proxies", + "expect": 1, + "sldIds": [ + 462 + ], + "cat": "stack", + "_slide": 207, + "_why": "Python leftover" + }, + { + "op": "set_notes", + "sldId": 468, + "paragraphs": [ + "The minimal cache-aside implementation. Three things to point out. First, the connection pool: a go-redis client is a pool, created once and shared by every request; never create a client per request. Second, the key shape: product:{pid} is a namespace convention; entity:type:id lets you SCAN and delete by pattern for a mass invalidation.", + "Third, invalidate on write: without the delete in the PUT handler, the cache serves stale data for the full TTL. The choice is invalidate (this code) or set the new value (write-through, covered later in this appendix). Treat a Redis error on Get as a miss, so a Redis outage degrades to the database instead of failing reads, and log it. For high-cardinality keys, set a maxmemory policy (allkeys-lru is typical) so the cache cannot grow without bound." + ], + "cat": "notes", + "_slide": 213, + "_why": "Python redis-py idioms removed" + }, + { + "op": "replace", + "scope": "slide", + "find": "every caller sees one .get() method", + "repl": "every caller sees one Get method", + "expect": 1, + "sldIds": [ + 470 + ], + "cat": "stack", + "_slide": 215, + "_why": "Go method name" + }, + { + "op": "replace", + "scope": "notes", + "find": "they call products.get(pid)", + "repl": "they call products.Get(ctx, pid)", + "expect": 1, + "sldIds": [ + 470 + ], + "cat": "stack", + "_slide": 215, + "_why": "Go method name" + }, + { + "op": "replace", + "scope": "slide", + "find": "// deliberately NOT writing the cache — most events are never re-read", + "repl": "// no cache write: most events are never re-read", + "expect": 1, + "sldIds": [ + 474 + ], + "cat": "voice", + "_slide": 219, + "_why": "ban word and caps in code comment" + }, + { + "op": "replace", + "scope": "notes", + "find": "background goroutines task, sleeping for PERIOD_S then SPOPing up to BATCH dirty keys and writing them to the DB in one execute_many call", + "repl": "background goroutine on a ticker, SPOPing up to batch dirty keys and writing them to the DB in one CopyFrom call", + "expect": 1, + "sldIds": [ + 476 + ], + "cat": "stack", + "_slide": 221, + "_why": "Python leftovers" + }, + { + "op": "replace", + "scope": "notes", + "find": "REFRESH_BEFORE_S", + "repl": "refreshBefore", + "expect": 2, + "sldIds": [ + 478 + ], + "cat": "stack", + "_slide": 223, + "_why": "Go constant name" + }, + { + "op": "set_code", + "sldId": 494, + "shape_id": 2646, + "lines": [ + "// retry with exponential backoff + jitter, idempotent calls only (cenkalti/backoff v7)", + "func getQuote(ctx context.Context, symbol string) (Quote, error) {", + " bo := backoff.NewExponentialBackOff() // backoff + jitter built in", + " bo.InitialInterval = 100 * time.Millisecond", + " bo.MaxInterval = 2 * time.Second // capped", + " // Retry stops on success, a backoff.Permanent error, a cancelled ctx or the try cap", + " return backoff.Retry(ctx, func() (Quote, error) {", + " return client.Get(ctx, \"/quote/\"+symbol) // GET is idempotent: safe to retry", + " }, backoff.WithBackOff(bo), backoff.WithMaxTries(4))", + "}", + "// don't retry a non-idempotent POST unless it carries an idempotency key;", + "// pair with a circuit breaker so a down dependency stops retries entirely." + ], + "cat": "code", + "_slide": 239, + "_why": "site 26 Go codetab; backoff v7 API as in examples/26-failure-modes/go/edge-service (was v4)" + }, + { + "op": "set_text", + "sldId": 494, + "shape_id": 2645, + "paragraphs": [ + "go · cenkalti/backoff v7" + ], + "cat": "code", + "_slide": 239, + "_why": "major version" + }, + { + "op": "set_notes", + "sldId": 494, + "paragraphs": [ + "cenkalti/backoff v7 is the Go retry library. The policy encodes the four properties that matter: retry only transient failures (return backoff.Permanent for a 4xx or a business error), exponential backoff with jitter, a hard cap on attempts (WithMaxTries), and the final error returned to the caller. Retry also stops when the context is cancelled.", + "The two comments carry the discipline: retry only idempotent operations, a GET or a POST with an idempotency key, and pair the retry with a circuit breaker so a dependency that is clearly down stops being hammered." + ], + "cat": "notes", + "_slide": 239, + "_why": "Python wording removed; v7 API" + }, + { + "op": "set_code", + "sldId": 496, + "shape_id": 2669, + "lines": [ + "// one breaker per dependency, never one global breaker (sony/gobreaker v2, generic)", + "var paymentCB = gobreaker.NewCircuitBreaker[Receipt](gobreaker.Settings{", + " Name: \"payment\",", + " Timeout: 30 * time.Second, // stay open 30 s, then half-open to test recovery", + " ReadyToTrip: func(c gobreaker.Counts) bool { return c.ConsecutiveFailures >= 5 },", + "})", + "func charge(ctx context.Context, order Order) (Receipt, error) {", + " r, err := paymentCB.Execute(func() (Receipt, error) {", + " return client.Post(ctx, \"/charge\", order) // skipped while the breaker is open", + " })", + " if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {", + " _ = outbox.Enqueue(ctx, order) // graceful fallback", + " return Receipt{Status: \"pending\", ID: order.ID}, nil", + " }", + " return r, err", + "}" + ], + "cat": "code", + "_slide": 241, + "_why": "site 26 Go codetab with the gobreaker v2 generic API used in examples/26-failure-modes/go/edge-service" + }, + { + "op": "set_text", + "sldId": 496, + "shape_id": 2668, + "paragraphs": [ + "go · sony/gobreaker v2" + ], + "cat": "code", + "_slide": 241, + "_why": "major version" + }, + { + "op": "set_notes", + "sldId": 496, + "paragraphs": [ + "sony/gobreaker v2 is a small, generic circuit-breaker library: NewCircuitBreaker[Receipt] makes Execute return a typed Receipt. The breaker is keyed per dependency, payment here, never one global breaker. It opens after five consecutive failures and stays open for 30 seconds before letting a trial request through.", + "While it is open, Execute returns ErrOpenState at once without making the call, which is the fail-fast behaviour that stops a cascade; in the half-open state, extra calls get ErrTooManyRequests. Those two errors take the fallback: enqueue the order in the outbox and return a pending status instead of an error, which is graceful degradation." + ], + "cat": "notes", + "_slide": 241, + "_why": "Python wording removed; v2 API" + }, + { + "op": "replace", + "scope": "notes", + "find": "On SIGTERM, net/http runs the post-yield lifespan code: flip", + "repl": "On SIGTERM, the signal context is cancelled and serve continues: flip", + "expect": 1, + "sldIds": [ + 500 + ], + "cat": "stack", + "_slide": 245, + "_why": "Python lifespan leftover" + }, + { + "op": "set_code", + "sldId": 508, + "shape_id": 8, + "lines": [ + "func setupFlags() error { // OpenFeature Go SDK + flagd provider, once at startup", + " provider, err := flagd.NewProvider(flagd.WithHost(\"flagd\"), flagd.WithPort(8013))", + " if err != nil { return err }", + " return openfeature.SetProviderAndWait(provider)", + "}", + "var flags = openfeature.NewClient(\"checkout\")", + "func checkout(w http.ResponseWriter, r *http.Request) {", + " cart := parseCart(r)", + " evalCtx := openfeature.NewEvaluationContext(r.Header.Get(\"X-User\"), // sticky key", + " map[string]any{\"plan\": cart.Plan, \"region\": cart.Region})", + " useNew, _ := flags.BooleanValue(r.Context(), \"new-checkout\", false, evalCtx) // default off", + " if useNew {", + " newCheckout(w, r, cart)", + " return", + " }", + " legacyCheckout(w, r, cart)", + "}" + ], + "cat": "code", + "_slide": 253, + "_why": "site 27 Go codetab; flagd.NewProvider returns (provider, error) as in examples/27-feature-flags/go" + }, + { + "op": "replace", + "scope": "slide", + "find": "gqlgen generates SDL.", + "repl": "gqlgen generates Go resolvers from the SDL.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "stack", + "_slide": 10, + "_why": "gqlgen is schema-first" + }, + { + "op": "replace", + "scope": "notes", + "find": "Go with net/http, gRPC", + "repl": "Go 1.27.2 with net/http, gRPC", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "version", + "_slide": 1, + "_why": "Go pin" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 866, + "paragraphs": [ + "Valet key — scoped, time-bound, op-restricted" + ], + "cat": "stack", + "_slide": 78, + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 868, + "paragraphs": [ + "go · net/http + golang-jwt v5" + ], + "cat": "stack", + "_slide": 78, + "_amend": "S12 decision 6" + }, + { + "op": "set_code", + "sldId": 333, + "shape_id": 869, + "lines": [ + "// main.go (excerpt), examples/12-security/go: the valet key is an HS256 JWT (golang-jwt v5)", + "type valetClaims struct {", + " Resource string `json:\"res\"`", + " Operation string `json:\"op\"`", + " jwt.RegisteredClaims", + "}", + "func mintValet(resource, operation string) map[string]any {", + " expires := time.Now().Add(5 * time.Minute) // time-bound", + " token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, valetClaims{", + " Resource: resource, Operation: operation, // one object, one operation", + " RegisteredClaims: jwt.RegisteredClaims{Issuer: \"order-service\",", + " ExpiresAt: jwt.NewNumericDate(expires), IssuedAt: jwt.NewNumericDate(time.Now())},", + " }).SignedString([]byte(valetSecret))", + " if err != nil { slog.Error(\"sign valet key\", \"err\", err) }", + " return map[string]any{\"resource\": resource, \"operation\": operation,", + " \"expires\": expires.Unix(), \"token\": token}", + "}", + "// verifyValet: HS256 only, exp required, issuer checked; then res, op and exp must match", + "parsed, err := jwt.ParseWithClaims(token, &claims,", + " func(*jwt.Token) (any, error) { return []byte(valetSecret), nil },", + " jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),", + " jwt.WithExpirationRequired(), jwt.WithIssuer(\"order-service\"))" + ], + "cat": "code", + "_slide": 78, + "_src": "examples/12-security/go/cmd/server/main.go (valetClaims, mintValet, verifyValet)", + "_amend": "S12 decision 6" + }, + { + "op": "set_notes", + "sldId": 333, + "cat": "stack", + "_slide": 78, + "paragraphs": [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HS256 JWT signed with golang-jwt v5: the res and op claims carry the scope, exp the expiry, iss the issuer. POST /valet-key returns the resource, the operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) parses with WithValidMethods so only HS256 is accepted (no alg=none, no algorithm swap), requires exp, checks the issuer, then compares res, op and exp with the request. Any mismatch is a 403.", + "The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth." + ], + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 968, + "paragraphs": [ + "go · franz-go + golang-jwt v5" + ], + "cat": "stack", + "_slide": 87, + "_amend": "S12 decision 6" + }, + { + "op": "set_code", + "sldId": 342, + "shape_id": 969, + "lines": [ + "// claim-check sketch: store is any encrypted object store; the token is the valet-key JWT", + "func publishWithClaim(ctx context.Context, cl *kgo.Client, orderID string, body []byte) error {", + " key := \"payloads/\" + orderID", + " if err := store.Put(ctx, key, body, true); err != nil { return err } // encrypted, ACL'd", + " claim, _ := json.Marshal(map[string]any{", + " \"order_id\": orderID,", + " \"uri\": \"https://objects.internal/\" + key,", + " \"token\": mintValet(key, \"GET\")[\"token\"], // GET only, five minutes", + " })", + " return cl.ProduceSync(ctx, // only the claim goes on the topic", + " &kgo.Record{Topic: \"order.documents\", Key: []byte(orderID), Value: claim}).FirstErr()", + "}", + "func onClaim(ctx context.Context, r *kgo.Record) error {", + " var c Claim", + " if err := json.Unmarshal(r.Value, &c); err != nil { return err }", + " body, err := store.Get(ctx, c.URI, c.Token) // the store verifies the ticket", + " if err != nil { return err }", + " return handle(ctx, body) // PII never touched the topic", + "}" + ], + "cat": "code", + "_slide": 87, + "_src": "_docs/12-security.md claim-check Go tab; token from examples/12-security/go mintValet", + "_amend": "S12 decision 6" + }, + { + "op": "set_notes", + "sldId": 342, + "cat": "stack", + "_slide": 87, + "paragraphs": [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a ticket for that key (the golang-jwt valet key of Example 12: GET only, five minutes), and publishes only a small claim {order_id, uri, token} to order.documents with franz-go's ProduceSync. The PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then lets the offset commit (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag: five minutes fits the demo, not a backlog. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing." + ], + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": " and run Example 05 on minikube and OpenShift Local.", + "repl": " and run Example 05 on minikube.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "repl": "Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'I need to get data from SFTP to Kafka to S3 with these transforms in the middle'", + "repl": "'I need to get data from SFTP to Kafka to an object store with these transforms in the middle'", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "KMS for keys, CloudTrail / audit logging, region", + "repl": "KMS for keys, audit logging, region", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'someone left an S3 bucket open'", + "repl": "'someone left a storage bucket open to the internet'", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: requires the storage service to validate tokens (S3, Azure Blob, GCS all do); ", + "repl": "Cons: requires the storage side to validate tokens; ", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "The canonical implementations: S3 pre-signed URLs (15-minute upload tokens for a specific bucket+key+method), Azure SAS tokens (Shared Access Signatures with per-resource scoping), GCS signed URLs, AWS STS AssumeRole with a tight scoping policy.", + "repl": "Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape.", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD)", + "repl": "validates JWTs from a modern IdP (Keycloak, Dex)", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Edge (IAP): Google IAP, Cloudflare Access, Pomerium, AWS Verified Access — ", + "repl": "Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — ", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Pomerium is open source; Google IAP / Cloudflare Access / AWS Verified Access are managed services.", + "repl": "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "they authenticate against your IdP (Keycloak, Auth0, Azure AD, Google Workspace)", + "repl": "they authenticate against your IdP (Keycloak, or Dex federating an existing directory)", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "into a secure object store (S3, Azure Blob, GCS) and publishes", + "repl": "into a secure object store and publishes", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Valet key requires storage-side validation — S3, Azure Blob, GCS all support it natively, so the cost is usually just understanding the SDK.", + "repl": "Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "; SAS (Shared Access Signature, Azure's scoped token)", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "SAS is Azure's valet-key token; ", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "(Kong, Apigee, Istio gateway, ", + "repl": "(Kong, Istio gateway, ", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "AWS Builders' Library (timeouts, retries, backoff with jitter)", + "repl": "the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling)", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "the AWS Builders' Library has excellent practical articles on timeouts and backoff-with-jitter;", + "repl": "the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol;", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 332, + "shape_id": 856, + "png": "lgtm-presentation/edits/png/12-valet-key.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "labels named S3, Azure SAS, GCS, STS (tools/vendor_figures.py)" + }, + { + "op": "replace_picture", + "sldId": 335, + "shape_id": 889, + "png": "lgtm-presentation/edits/png/12-zero-trust.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "examples named Google IAP, Cloudflare Access, AWS Verified Access" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 30, + "paragraphs": [ + "07 · Workflows & Jobs", + "08 · API Management", + "09 · API Registry", + "10 · API Metadata", + "11 · Observability", + "12 · Security", + "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices K–L · Monolith → Microservices, Caching Patterns", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendix R · Example 05 on minikube", + "Glossary of acronyms" + ], + "cat": "new", + "_why": "agenda gains Appendices R-S (C++: R)" + }, + { + "op": "set_notes", + "sldId": 257, + "paragraphs": [ + "The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns.", + "The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck.", + "Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 516, + "as": "r0", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 3, + "paragraphs": [ + "R" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 4, + "paragraphs": [ + "Example 05 on minikube" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 5, + "paragraphs": [ + "The same services and the same verify.sh on a single-node Kubernetes cluster" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r0", + "paragraphs": [ + "Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod.", + "The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r1", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r1", + "text": "244", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 5, + "paragraphs": [ + "From compose to a cluster" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 6, + "paragraphs": [ + "Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress.", + "Scope: Example 05 (event-driven fan-out), three services, all six languages, one node.", + "One Kafka broker, one Postgres instance, no service mesh: a local topology, not production.", + "The contract carries over: Service names, the Secret, environment variables, the health endpoint.", + "The same verify.sh runs against compose and the cluster: ten checks per language." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 7, + "paragraphs": [ + "Site: Appendix R · Code: examples/05-event-driven/k8s/" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r1", + "paragraphs": [ + "What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it.", + "What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged.", + "The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r2", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r2", + "text": "245", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 5, + "paragraphs": [ + "The minikube runtime" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 6, + "paragraphs": [ + "Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5.", + "8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services.", + "NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation.", + "No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop.", + "Every command passes --context cndp-k8s; setup-profile.sh restores the prior context." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 7, + "paragraphs": [ + "minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r2", + "paragraphs": [ + "minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version.", + "Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1.", + "The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r3", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r3", + "text": "246", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 5, + "paragraphs": [ + "One node, two namespaces" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r3", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-topology.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 7, + "paragraphs": [ + "The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r3", + "paragraphs": [ + "The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret.", + "From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r4", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r4", + "text": "247", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 5, + "paragraphs": [ + "Compose to Kubernetes" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 6, + "paragraphs": [ + "kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1).", + "postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw.", + "Credential → Secret cndp-postgres-app, read through secretKeyRef.", + "build: → docker build, then minikube image load; imagePullPolicy: Never.", + "healthcheck → startup and readiness probes on the order-service /healthz.", + "ports: 8080:8080 → NodePort 30080." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 7, + "paragraphs": [ + "The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r4", + "paragraphs": [ + "Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts.", + "The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r5", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r5", + "text": "248", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 5, + "paragraphs": [ + "One overlay per language" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 6, + "paragraphs": [ + "yaml · kustomize" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r5", + "shape_id": 8, + "lines": [ + "# k8s/overlays/go/kustomization.yaml (excerpt)", + "images:", + " - name: order-service", + " newName: localhost/cndp-05/go-order-service", + " newTag: v1", + " # shipping-consumer and notification-consumer map the same way", + "# appended to each Deployment's environment by a JSON patch", + "- name: DATABASE_URL", + " value: \"postgres://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb\"", + "- name: KAFKA_BOOTSTRAP", + " value: \"cndp-kafka-bootstrap:9092\"", + "# memory: requests 128Mi, limits 256Mi" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 9, + "paragraphs": [ + "base/ is language-neutral; each overlay maps the images and appends the variables its language reads." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r5", + "paragraphs": [ + "The Go overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service.", + "Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r6", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r6", + "text": "249", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 5, + "paragraphs": [ + "Images built on the host" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r6", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-image-flow.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 7, + "paragraphs": [ + "docker build on the host, minikube image load into the node; pods never pull" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r6", + "paragraphs": [ + "The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load.", + "The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r7", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r7", + "text": "250", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 5, + "paragraphs": [ + "Run it" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 6, + "paragraphs": [ + "bash · minikube" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r7", + "shape_id": 8, + "lines": [ + "# from examples/05-event-driven/, with no other profile or container running", + "k8s/setup-profile.sh # create cndp-k8s; check the published ports", + "k8s/build-images.sh go # docker build, then minikube image load", + "k8s/deploy.sh go # Strimzi, Kafka, Postgres, LGTM, then the overlay", + "RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh", + "", + "# all six languages, a fresh namespace for each", + "OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh", + "", + "# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile", + "k8s/teardown.sh --operator", + "minikube stop -p cndp-k8s" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 9, + "paragraphs": [ + "With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r7", + "paragraphs": [ + "The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Go images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay.", + "verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0.", + "What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r8", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r8", + "text": "251", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 5, + "paragraphs": [ + "Recorded results" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 6, + "paragraphs": [ + "10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0).", + "Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s.", + "Tempo received traces from order-service, shipping-consumer and notification-consumer in every language.", + "shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset.", + "Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 7, + "paragraphs": [ + "Times cover the image build and load, the deploy, the checks and the namespace teardown." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r8", + "paragraphs": [ + "The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language.", + "The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster." + ], + "cat": "new" + }, + { + "op": "resize_shape", + "sldId": 256, + "shape_id": 20, + "y": 5007611, + "cy": 467359, + "cat": "fit", + "_fit": "slide 1: text overflows its frame by overflow-top 2.3 pt, overflow-bottom 3.3 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 257, + "shape_id": 30, + "y": 1589592, + "cy": 4688780, + "cat": "fit", + "_fit": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 284, + "shape_id": 327, + "cx": 2519650, + "keep": "right", + "cat": "fit", + "_fit": "slide 29: chip 'go · net/http + pgx + franz-go' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 321, + "shape_id": 740, + "cx": 2214850, + "keep": "right", + "cat": "fit", + "_fit": "slide 66: chip 'go · OpenTelemetry baggage' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 324, + "shape_id": 774, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 328, + "shape_id": 815, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 329, + "shape_id": 825, + "cy": 4597340, + "cat": "fit", + "_fit": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 331, + "shape_id": 846, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 333, + "shape_id": 868, + "cx": 2443450, + "keep": "right", + "cat": "fit", + "_fit": "slide 78: chip 'go · net/http + golang-jwt v5' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 338, + "shape_id": 922, + "cx": 3357849, + "keep": "right", + "cat": "fit", + "_fit": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 340, + "shape_id": 945, + "cx": 1833850, + "keep": "right", + "cat": "fit", + "_fit": "slide 85: chip 'go · buffered channel' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 342, + "shape_id": 968, + "cx": 2443450, + "keep": "right", + "cat": "fit", + "_fit": "slide 87: chip 'go · franz-go + golang-jwt v5' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 372, + "shape_id": 1290, + "cx": 2595849, + "keep": "right", + "cat": "fit", + "_fit": "slide 117: chip 'protobuf + go · coder/websocket' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 379, + "shape_id": 1370, + "pt": 10.5, + "cat": "fit", + "_fit": "slide 124: 21 code lines, 90 columns; 11.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 401, + "shape_id": 1617, + "cx": 1833850, + "keep": "right", + "cat": "fit", + "_fit": "slide 146: chip 'go · ports & adapters' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 420, + "shape_id": 1820, + "cy": 436820, + "cat": "fit", + "_fit": "slide 165: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "fit_font", + "sldId": 431, + "shape_id": 1938, + "pt": 11.0, + "cat": "fit", + "_fit": "slide 176: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 432, + "shape_id": 1950, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 177: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 437, + "shape_id": 2006, + "pt": 10.0, + "cat": "fit", + "_fit": "slide 182: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 438, + "shape_id": 2018, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 183: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 444, + "shape_id": 2088, + "cx": 2214850, + "keep": "right", + "cat": "fit", + "_fit": "slide 189: chip 'go · gopher-lua + franz-go' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 449, + "shape_id": 2143, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 194: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 463, + "shape_id": 2297, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 208: chip 'go · net/http + go-redis + franz-go' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 464, + "shape_id": 2309, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 209: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 470, + "shape_id": 2375, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 215: chip 'go · go-redis (generics)' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 470, + "shape_id": 2376, + "pt": 10.5, + "cat": "fit", + "_fit": "slide 215: 21 code lines, 92 columns; 11.5 pt overflows the box" + }, + { + "op": "fit_font", + "sldId": 474, + "shape_id": 2424, + "pt": 11.0, + "cat": "fit", + "_fit": "slide 219: 20 code lines, 84 columns; 11.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 476, + "shape_id": 2447, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 221: chip 'go · go-redis + goroutine' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 476, + "shape_id": 2448, + "pt": 11.0, + "cat": "fit", + "_fit": "slide 221: 20 code lines, 82 columns; 11.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 478, + "shape_id": 2471, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 223: chip 'go · go-redis + goroutine' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 478, + "shape_id": 2472, + "pt": 10.0, + "cat": "fit", + "_fit": "slide 223: 22 code lines, 90 columns; 10.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 480, + "shape_id": 2493, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 225: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 492, + "shape_id": 2622, + "cx": 1986250, + "keep": "right", + "cat": "fit", + "_fit": "slide 237: chip 'go · net/http + context' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 494, + "shape_id": 2645, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 239: chip 'go · cenkalti/backoff v7' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 496, + "shape_id": 2668, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 241: chip 'go · sony/gobreaker v2' wraps; one line at 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 449, + "shape_id": 2144, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 194: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "cat": "fit", + "_fit": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "cat": "fit", + "_fit": "references: the reference lines run past the frame into the page number; the frame cannot grow" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "fit", + "_fit": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 511 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 410 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 384 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 416 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 436 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 462 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 465 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 467 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 485 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 488 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 491 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 433 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 449 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 378 + ], + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "cat": "code", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 440 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code", + "_src": "capsfix", + "_fixA": true + }, + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure heading 'Dual-write is a lie' -> 'Dual-write is unsafe' (src/04-dual-write-outbox.svg)", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 336 + ], + "find": "validate identity (OAuth/OIDC against your IdP), check device posture, then issue a JWT", + "repl": "validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT", + "expect": 1, + "cat": "code", + "_why": "OAuth2 Proxy has no device-posture check; the proxies enforce per-route identity policy", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 344 + ], + "find": "; Microsoft Cloud Adoption Framework: security patterns.", + "repl": "; OWASP Microservices Security Cheat Sheet.", + "expect": 1, + "cat": "stack", + "_why": "vendor-neutral reference (decision 7)", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "make(chan struct{}, 20)) // 20 per tenant", + "repl": "make(chan struct{}, 5)) // 5 per tenant", + "expect": 1, + "cat": "code", + "_why": "examples/12-security/go bulkheadCapacity = 5", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "at most 20 requests per tenant", + "repl": "at most 5 requests per tenant", + "expect": 1, + "cat": "code", + "_why": "bulkhead 5", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 294 + ], + "find": "Event-driven tools landscape — Kafka", + "repl": "Event-driven tools landscape — Kafka", + "expect": 1, + "cat": "voice", + "_why": "title spacing (one space around the dash, as the other titles)", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 333 + ], + "find": "Valet key — scoped", + "repl": "Valet key — scoped", + "expect": 1, + "cat": "voice", + "_why": "title spacing (Q.fix2 in Quarkus)", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 351 + ], + "find": "Glossary of acronyms (1 of 2) — protocols", + "repl": "Glossary of acronyms (1 of 2) — protocols", + "expect": 1, + "cat": "voice", + "_why": "title spacing, as the (2 of 2) title", + "_src": "content", + "_fixA": true + }, + { + "op": "set_geometry", + "sldId": 531, + "shape": "Image 1", + "x": 2.16, + "y": 1.95, + "w": 9.01, + "h": 4.3, + "cat": "fit", + "_why": "Appendix R topology figure enlarged to the free area, as Q.fix2 does in Quarkus", + "_src": "content", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 362 + ], + "find": "a field with a NEW number", + "repl": "a field with a new number", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: brand-new field", + "repl": "safe: brand-new field", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: new field; old clients", + "repl": "safe: new field; old clients", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "BREAKING (don't)", + "repl": "breaking (don't)", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "IDENTIFY: pick", + "repl": "Identify: pick", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "MOVE: build", + "repl": "Move: build", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 472 + ], + "find": "caches THAT — the canonical value", + "repl": "caches that row — the canonical value", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 474 + ], + "find": "because you DO want", + "repl": "because you do want", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 500 + ], + "find": "stops sending NEW traffic", + "repl": "stops sending new traffic", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 492 + ], + "find": "an EXPLICIT timeout", + "repl": "an explicit timeout", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 429 + ], + "find": " and give you the mesh CRDs we'll see next.", + "repl": " and give you the mesh CRDs (VirtualService, DestinationRule).", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 407 + ], + "find": "Strength is a ladder we'll see next (intrusive to contract).", + "repl": "Strength is a ladder (intrusive to contract).", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 468 + ], + "find": " (write-through, covered later in this appendix)", + "repl": " (write-through)", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "(and the one we'll see code for)", + "repl": "(and the one the code models)", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 490 + ], + "find": "The next slides cover each with code; the final matrix maps patterns to modes.", + "repl": "Each one gets code, and a matrix maps patterns to modes.", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "The next slides cover three ways to do the redirect:", + "repl": "There are three ways to do the redirect:", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 490 + ], + "find": "The next slides give each its own diagram and per-language code", + "repl": "Each gets its own diagram and per-language code", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 270 + ], + "find": "The next slides give you a decision rule and then show each protocol in code", + "repl": "The section gives a decision rule and then shows each protocol in code", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "payload modification that we'll see more of — the L7 router", + "repl": "payload modification — the L7 router", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "state, which is the next section.", + "repl": "state, the subject of stream processing.", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 329 + ], + "find": "that the next slides alternate between pattern-and-implementation pairs", + "repl": "that the section alternates between pattern-and-implementation pairs", + "expect": 1, + "cat": "voice", + "_src": "caps", + "_fixA": true + }, + { + "op": "recolour_code", + "sldId": 363, + "shape_id": 1189, + "allow_noop": true, + "cat": "code", + "_src": "colour", + "_fixA": true + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix1.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix1.json new file mode 100644 index 00000000..0665eed0 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix1.json @@ -0,0 +1,1617 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 511 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 410 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "find": "handlers MUST be idempotent", + "repl": "handlers must be idempotent", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 384 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 416 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 436 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 462 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 465 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 467 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 485 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 488 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 491 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 433 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 449 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 440 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code", + "_src": "capsfix" + }, + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure heading 'Dual-write is a lie' -> 'Dual-write is unsafe' (src/04-dual-write-outbox.svg)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 336 + ], + "find": "validate identity (OAuth/OIDC against your IdP), check device posture, then issue a JWT", + "repl": "validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT", + "expect": 1, + "cat": "code", + "_why": "OAuth2 Proxy has no device-posture check; the proxies enforce per-route identity policy", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 344 + ], + "find": "; Microsoft Cloud Adoption Framework: security patterns.", + "repl": "; OWASP Microservices Security Cheat Sheet.", + "expect": 1, + "cat": "stack", + "_why": "vendor-neutral reference (decision 7)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "Semaphore(50))", + "repl": "Semaphore(5))", + "expect": 1, + "cat": "code", + "_why": "examples/12-security/python BULKHEAD_LIMIT = 5", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "# 50 concurrent requests per tenant;", + "repl": "# 5 concurrent requests per tenant;", + "expect": 1, + "cat": "code", + "_why": "bulkhead 5", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "once 50 are running", + "repl": "once 5 are running", + "expect": 1, + "cat": "code", + "_why": "bulkhead 5", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 294 + ], + "find": "Event-driven tools landscape — Kafka", + "repl": "Event-driven tools landscape — Kafka", + "expect": 1, + "cat": "voice", + "_why": "title spacing (one space around the dash, as the other titles)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 333 + ], + "find": "Valet key — scoped", + "repl": "Valet key — scoped", + "expect": 1, + "cat": "voice", + "_why": "title spacing (Q.fix2 in Quarkus)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 351 + ], + "find": "Glossary of acronyms (1 of 2) — protocols", + "repl": "Glossary of acronyms (1 of 2) — protocols", + "expect": 1, + "cat": "voice", + "_why": "title spacing, as the (2 of 2) title", + "_src": "content" + }, + { + "op": "set_geometry", + "sldId": 531, + "shape": "Image 1", + "x": 2.16, + "y": 1.95, + "w": 9.01, + "h": 4.3, + "cat": "fit", + "_why": "Appendix R topology figure enlarged to the free area, as Q.fix2 does in Quarkus", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 362 + ], + "find": "a field with a NEW number", + "repl": "a field with a new number", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: brand-new field", + "repl": "safe: brand-new field", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: new field; old clients", + "repl": "safe: new field; old clients", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "BREAKING (don't)", + "repl": "breaking (don't)", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 368 + ], + "find": "per-POD state", + "repl": "per-pod state", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "IDENTIFY: pick", + "repl": "Identify: pick", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "MOVE: build", + "repl": "Move: build", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 472 + ], + "find": "caches THAT — the canonical value", + "repl": "caches that row — the canonical value", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 474 + ], + "find": "because you DO want", + "repl": "because you do want", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 500 + ], + "find": "stops sending NEW traffic", + "repl": "stops sending new traffic", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 492 + ], + "find": "an EXPLICIT timeout", + "repl": "an explicit timeout", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 284 + ], + "find": "a DIFFERENT, denormalised", + "repl": "a different, denormalised", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 391 + ], + "find": "nulls THIS field only", + "repl": "nulls this field only", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "fail readiness FIRST", + "repl": "fail readiness first", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 474 + ], + "find": "deliberately NOT writing to cache", + "repl": "no cache write here", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "Retry ONLY idempotent calls", + "repl": "Retry only idempotent calls", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "Do NOT decorate a non-idempotent POST", + "repl": "Do not decorate a non-idempotent POST", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 500 + ], + "find": "flip to NOT ready while draining", + "repl": "flip to not-ready while draining", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 429 + ], + "find": " and give you the mesh CRDs we'll see next.", + "repl": " and give you the mesh CRDs (VirtualService, DestinationRule).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "Pair with a circuit breaker (next slides) so a down dep stops retries entirely.", + "repl": "Pair with a circuit breaker so a down dependency stops retries entirely.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 407 + ], + "find": "Strength is a ladder we'll see next (intrusive to contract).", + "repl": "Strength is a ladder (intrusive to contract).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "(and the one we'll see code for)", + "repl": "(and the one the code models)", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 490 + ], + "find": "The next slides cover each with code; the final matrix maps patterns to modes.", + "repl": "Each one gets code, and a matrix maps patterns to modes.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "The next slides cover three ways to do the redirect:", + "repl": "There are three ways to do the redirect:", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 490 + ], + "find": "The next slides give each its own diagram and per-language code", + "repl": "Each gets its own diagram and per-language code", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 270 + ], + "find": "The next slides give you a decision rule and then show each protocol in code", + "repl": "The section gives a decision rule and then shows each protocol in code", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "payload modification that we'll see more of — the L7 router", + "repl": "payload modification — the L7 router", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "state, which is the next section.", + "repl": "state, the subject of stream processing.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 329 + ], + "find": "that the next slides alternate between pattern-and-implementation pairs", + "repl": "that the section alternates between pattern-and-implementation pairs", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "recolour_code", + "sldId": 273, + "shape_id": 203, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 363, + "shape_id": 1189, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 372, + "shape_id": 1291, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 449, + "shape_id": 2144, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 521, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 522, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 524, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 533, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 535, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix2.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix2.json new file mode 100644 index 00000000..a7d66691 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix2.json @@ -0,0 +1,12 @@ +{ + "ops": [ + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure footer: the absolute 'can never be lost' claim softened (src/04-dual-write-outbox.svg)" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix3.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix3.json new file mode 100644 index 00000000..bf4a0355 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix3.json @@ -0,0 +1,451 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Kafka is the substrate — a partitioned", + "repl": "Kafka is the substrate: a partitioned", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup (6 per paragraph)", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Pulsar is also substrate — same broad category", + "repl": "Pulsar is also substrate, in the same broad category", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Flink is compute — not a transport layer at all, but", + "repl": "Flink is compute: not a transport layer, but", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NiFi is movement — a visual", + "repl": "NiFi is movement: a visual", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "They complement — pick by", + "repl": "They complement each other; pick by", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "This is the slide the user specifically asked for: distinguish auto-injection from auto-instrumentation from manual instrumentation — three things people conflate.", + "repl": "Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request; em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "wrap known frameworks — your HTTP handler", + "repl": "wrap known frameworks: your HTTP handler", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "a Kubernetes-level mechanism — the OpenTelemetry Operator", + "repl": "a Kubernetes-level mechanism; the OpenTelemetry Operator", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Python — ", + "repl": "Python: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Java/Quarkus — ", + "repl": "Java/Quarkus: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "The cost: eventual consistency — there is a small lag", + "repl": "The cost: eventual consistency, a small lag", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "happily handles both — CQRS adds", + "repl": "handles both; CQRS adds", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "for projections — we cover both distinctions in section 5.", + "repl": "for projections; the Event-Driven section draws both distinctions.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup; cross-reference by concept", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 324 + ], + "find": "The sub-bullet is the reminder the user asked for: ", + "repl": "The sub-bullet is the reminder: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "This is the specific case you asked about, and a common source of bad designs.", + "repl": "This case is a common source of bad designs.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 535 + ], + "find": "Fallback: the recorded results that follow.", + "repl": "Fallback: the recorded results.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "This is the DB-backed option made concrete, and it's the one I'd reach for first.", + "repl": "This is the DB-backed option made concrete, and the default choice for a bounded saga.", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "Recovery is beautifully boring: after a crash, a sweeper just loads", + "repl": "Recovery needs no special path: after a crash, a sweeper loads", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 291 + ], + "find": "group_id=\"notification\", # offset tracked per group", + "repl": "group_id=\"notification-group\", # offset tracked per group", + "expect": 1, + "cat": "code", + "_slide": 36, + "_why": "examples/05-event-driven/python/notification-consumer/main.py uses group_id=\"notification-group\" (Go slide and example agree)", + "_src": "content" + }, + { + "op": "set_code", + "sldId": 476, + "shape_id": 2448, + "pt": 9.0, + "line_spacing": 1.0, + "cat": "code", + "_slide": 221, + "_why": "write-back flusher as examples/25-caching/python: decoded keys (mid was bytes, so hgetall read metric:b'..' and json.dumps failed on bytes) and an upsert (metrics.id is the primary key; a key flushed twice failed the plain INSERT); asyncpg's executemany", + "lines": [ + "BATCH, PERIOD_S = 100, 1.0 # excerpt: cache: Redis(decode_responses=True); db: asyncpg", + "async def flusher():", + " while True: # 2. drain the dirty set to the DB", + " await asyncio.sleep(PERIOD_S)", + " ids, rows = await cache.spop(\"metric:dirty\", BATCH), []", + " for mid in ids:", + " data = await cache.hgetall(f\"metric:{mid}\")", + " if data: rows.append((mid, json.dumps(data)))", + " if not rows: continue", + " try: # upsert: one row per key", + " await db.executemany(\"INSERT INTO metrics (id, payload) VALUES ($1, $2) \"", + " \"ON CONFLICT (id) DO UPDATE SET payload = $2\", rows)", + " except Exception: # 3. on failure, re-mark dirty", + " await cache.sadd(\"metric:dirty\", *[m for m, _ in rows])", + "@asynccontextmanager", + "async def lifespan(app):", + " task = asyncio.create_task(flusher())", + " yield", + " task.cancel()", + "app = FastAPI(lifespan=lifespan) # created once, with the flusher", + "@app.put(\"/metrics/{mid}\")", + "async def write_metric(mid: str, body: dict):", + " await cache.hset(f\"metric:{mid}\", mapping=body) # 1. cache only; return now", + " await cache.sadd(\"metric:dirty\", mid) # mark for flush", + " return {\"ok\": True} # fast — microseconds" + ], + "_src": "content" + }, + { + "op": "set_code", + "sldId": 496, + "shape_id": 2669, + "cat": "code", + "_slide": 241, + "_why": "OpenedState was used but never imported; purgatory 3.0.1 exports it only from purgatory.domain.model (not from the package root)", + "lines": [ + "from purgatory import AsyncCircuitBreakerFactory", + "from purgatory.domain.model import OpenedState # raised while the breaker is open", + "# Open after 5 failures; stay open 30s; then half-open to test recovery", + "breakers = AsyncCircuitBreakerFactory(default_threshold=5, default_ttl=30)", + "", + "async def charge(order: dict) -> dict:", + " try:", + " async with await breakers.get_breaker(\"payment\"):", + " # While OPEN this raises immediately — no doomed downstream call", + " r = await client.post(\"/charge\", json=order, timeout=2.0)", + " r.raise_for_status()", + " return r.json()", + " except OpenedState: # breaker is open → degrade", + " await outbox.enqueue(order) # graceful fallback", + " return {\"status\": \"pending\", \"id\": order[\"id\"]}", + "", + "# One breaker per dependency (\"payment\"), never one global breaker." + ], + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 476 + ], + "find": "to the DB in one execute_many call.", + "repl": "to the DB in one executemany upsert (ON CONFLICT (id) DO UPDATE), so a key flushed twice stays one row.", + "expect": 1, + "cat": "notes", + "_slide": 221, + "_why": "notes follow the corrected code", + "_src": "content" + }, + { + "op": "set_code", + "sldId": 419, + "lines": [ + "# main.py (excerpt), examples/21-graceful-shutdown/python", + "import signal", + "from contextlib import asynccontextmanager", + "from fastapi import FastAPI", + "from fastapi.responses import JSONResponse", + "shutting_down, uvicorn_sigterm = False, None", + "def handle_sigterm(signum, frame):", + " global shutting_down", + " if shutting_down: # 2nd signal (docker stop): uvicorn drains HTTP", + " return uvicorn_sigterm(signum, frame)", + " shutting_down = True # 1st signal: fail readiness, keep serving", + "@asynccontextmanager", + "async def lifespan(app):", + " global uvicorn_sigterm # uvicorn's handler is already installed: chain to it", + " uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm)", + " yield # --- runs after uvicorn has drained HTTP ---", + " await db_pool.close() # close pools, flush, release resources", + "app = FastAPI(lifespan=lifespan)", + "@app.get(\"/readyz\")", + "async def readyz(): return JSONResponse({}, 503 if shutting_down else 200)", + "# run: uvicorn main:app --timeout-graceful-shutdown 15 # bounds the HTTP drain" + ], + "cat": "code", + "_why": "examples/21-graceful-shutdown/python: the handler installed at import replaced uvicorn's (uvicorn imports the app inside capture_signals), so uvicorn never drained; the example now chains to uvicorn's handler (verified: second signal drains and exits)", + "_src": "shutdown" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "uvicorn drains in-flight HTTP on SIGTERM (bounded by --timeout-graceful-shutdown); flip readiness early so the Service stops routing before you stop accepting.", + "repl": "The first SIGTERM flips readiness; the next goes to uvicorn, which drains in-flight HTTP (bounded by --timeout-graceful-shutdown).", + "expect": 1, + "cat": "code", + "_why": "caption follows the code", + "_src": "shutdown" + }, + { + "op": "set_notes", + "sldId": 419, + "text": "Three moving parts. First, a SIGTERM handler flips a readiness flag so /readyz returns 503 at once; this is the 'stop new traffic' step, and the process keeps serving while the load balancer stops routing to it. Second, the handler chains to uvicorn's own: uvicorn installs its signal handlers before it imports the app and runs the lifespan, so a handler that replaces them without chaining means uvicorn never starts its drain and the process waits for SIGKILL. In Example 21 the next signal (docker stop) goes to uvicorn, which stops accepting and finishes in-flight HTTP; --timeout-graceful-shutdown bounds that wait so a stuck request cannot hang the deploy. Third, FastAPI's lifespan shutdown block (after the yield) runs once HTTP has drained: that is where you stop consumers (an aiokafka consumer commits offsets and leaves the group) and close pools. The order matters: readiness down, drain HTTP, then drain everything else. Pair this with the preStop hook and the termination grace period in the pod spec.", + "cat": "notes", + "_why": "notes follow the code", + "_src": "shutdown" + }, + { + "op": "set_code", + "sldId": 310, + "lines": [ + "# registry_client.py — register a new version; the registry enforces the rule", + "import requests", + "REGISTRY = \"http://apicurio.registry.svc/apis/registry/v3\"", + "def publish(group: str, artifact_id: str, schema: str):", + " body = {\"content\": {\"content\": schema, # v3 CreateVersion body", + " \"contentType\": \"application/json\"}}", + " r = requests.post(", + " f\"{REGISTRY}/groups/{group}/artifacts/{artifact_id}/versions\",", + " json=body, timeout=10,", + " )", + " if r.status_code == 400: # RuleViolationException: the rule rejected it", + " raise SystemExit(\"Incompatible schema change: blocking the merge\")", + " r.raise_for_status()", + "# rule set once per artifact, e.g. BACKWARD: new consumers can read old data" + ], + "cat": "code", + "_why": "Apicurio Registry 3.3.3 REST v3: POST .../versions takes a CreateVersion JSON body (common/src/main/resources/META-INF/openapi.json); a raw schema body is the v2 shape. examples/09-api-registry/verify.sh sends the same body and expects 400 RuleViolationException", + "_src": "registry" + }, + { + "op": "replace_picture", + "sldId": 487, + "shape": "Google Shape;2567;p234", + "png": "lgtm-presentation/edits/png/25-gray-failure.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (PERCEIVED DIFFERENTLY, NOT fully down, OBSERVE FROM THE CLIENT, GREEN, OUTLIER DETECTION); replaced by the site figure assets/diagrams/25-gray-failure.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 293, + "shape": "Google Shape;429;p40", + "png": "lgtm-presentation/edits/png/05-sourcing-vs-streaming.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (SOURCING/STREAMING, ARE, IS, TRANSPORT, ABOUT, NOT, EVENT, PROPAGATED); replaced by the site figure assets/diagrams/05-sourcing-vs-streaming.svg (caps lowered there too)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 294, + "shape": "Google Shape;440;p41", + "png": "lgtm-presentation/edits/png/05-tools-landscape.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (COMPLEMENT); replaced by the site figure assets/diagrams/05-tools-landscape.svg (SUBSTRATE/COMPUTE/MOVEMENT are design labels)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 410, + "shape": "Google Shape;1711;p157", + "png": "lgtm-presentation/edits/png/20-coupling-quadrant.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure ('strong OR distant'); replaced by the site figure assets/diagrams/20-coupling-quadrant.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost.png", + "cat": "voice", + "_why": "figure footer: 'where p99 goes to die' rewritten without the drama", + "_src": "hop" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix4.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix4.json new file mode 100644 index 00000000..047a8598 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.fix4.json @@ -0,0 +1,185 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 269 + ], + "find": "return {\"status\": \"down\", \"checks\": {\"database\": \"unreachable\"}}", + "repl": "return JSONResponse({\"status\": \"down\", \"checks\": {\"database\": \"unreachable\"}}, 503)", + "expect": 1, + "cat": "code", + "_slide": 14, + "_why": "examples/01 python: readiness answers 503 when the database is down (937f8d7)", + "_src": "align" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 269 + ], + "find": "it runs SELECT 1 on the database pool and reports down otherwise, which takes the pod out of rotation without killing it.", + "repl": "it runs SELECT 1 on the database pool and, when that fails, answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready.", + "expect": 1, + "cat": "notes", + "_slide": 14, + "_why": "notes follow the 503", + "_src": "align" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "The first SIGTERM flips readiness; the next goes to uvicorn, which drains in-flight HTTP (bounded by --timeout-graceful-shutdown).", + "repl": "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS the handler hands over to uvicorn, which drains in-flight HTTP within 20 s.", + "expect": 1, + "cat": "code", + "_slide": 164, + "_why": "examples/21: one SIGTERM drives the drain (9676f32)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 419, + "lines": [ + "# main.py (excerpt), examples/21-graceful-shutdown/python", + "import asyncio, signal # (contextlib and fastapi imports elided)", + "DRAIN_DELAY_SECONDS = 5 # lets endpoint removal propagate (the preStop sleep)", + "shutting_down, uvicorn_sigterm, loop = False, None, None", + "def handle_sigterm(signum, frame):", + " global shutting_down", + " if shutting_down: return # one SIGTERM drives the whole drain", + " shutting_down = True # fail readiness now, keep serving for the delay", + " loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS,", + " uvicorn_sigterm, signum, frame) # then uvicorn drains HTTP", + "@asynccontextmanager", + "async def lifespan(app):", + " global uvicorn_sigterm, loop # uvicorn's handler is already installed: chain to it", + " loop = asyncio.get_running_loop()", + " uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm)", + " yield # --- runs after uvicorn has drained HTTP ---", + " await db_pool.close() # close pools, flush, release resources", + "app = FastAPI(lifespan=lifespan)", + "@app.get(\"/readyz\")", + "async def readyz(): return JSONResponse({}, 503 if shutting_down else 200)", + "# run: uvicorn main:app --timeout-graceful-shutdown 20 # bounds the HTTP drain" + ], + "cat": "code", + "_slide": 164, + "_why": "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight drain within 20 s -> cleanup -> exit 0", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 419, + "cat": "notes", + "_slide": 164, + "_why": "notes follow the code", + "_src": "align", + "text": "Three moving parts, all started by one SIGTERM, the only signal Kubernetes sends before SIGKILL. First, the handler flips a readiness flag so /readyz returns 503 at once; this is the 'stop new traffic' step, and the process keeps serving while the load balancer stops routing to it. Second, after DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) the handler chains to uvicorn's own: uvicorn installs its signal handlers before it imports the app and runs the lifespan, so a handler that replaces them without chaining means uvicorn never starts its drain and the process waits for SIGKILL. uvicorn then stops accepting and finishes in-flight HTTP; --timeout-graceful-shutdown 20 bounds that wait, so delay plus drain stays inside the 30-second grace period. Third, FastAPI's lifespan shutdown block (after the yield) runs once HTTP has drained: that is where you stop consumers (an aiokafka consumer commits offsets and leaves the group) and close pools, and the process exits 0. The order matters: readiness down, drain HTTP, then drain everything else. Pair this with the preStop hook and the termination grace period in the pod spec." + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "The dirty set is the key idea — it tells the flusher what to drain. Failed batches go back on the set for retry.", + "repl": "A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed.", + "expect": 1, + "cat": "code", + "_slide": 221, + "_why": "examples/25: 202 and bounded retry (ebca295)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 476, + "lines": [ + "MAX_FLUSH_ATTEMPTS, MAX_FLUSH_BACKOFF = 5, 8 # excerpt, examples/25-caching/python", + "async def flusher(): # 2. drain the dirty set to the DB", + " failures = 0", + " while True: # back off after a failed flush", + " await asyncio.sleep(min(FLUSH_PERIOD * 2**failures, MAX_FLUSH_BACKOFF))", + " ids = await cache.spop(\"metric:dirty\", FLUSH_BATCH) # cache: decode_responses=True", + " if not ids: continue", + " rows = [(m, json.dumps(d)) for m in ids if (d := await cache.hgetall(f\"metric:{m}\"))]", + " try: # upsert: an id flushed twice is one row", + " async with pool.acquire() as conn:", + " await conn.executemany(\"INSERT INTO metrics (id, payload) VALUES ($1, $2) \"", + " \"ON CONFLICT (id) DO UPDATE SET payload = $2, ts = NOW()\", rows)", + " failures = 0", + " except Exception: # 3. re-queue, bounded by attempts", + " log.exception(\"flusher error: flush failed\"); failures += 1", + " for mid in ids:", + " n = await cache.hincrby(\"metric:attempts\", mid, 1)", + " key = \"metric:failed\" if n >= MAX_FLUSH_ATTEMPTS else \"metric:dirty\"", + " await cache.sadd(key, mid) # parked after 5 tries, never dropped", + "@app.put(\"/write-back/metrics/{mid}\")", + "async def write_back_write(mid: str, body: MetricIn):", + " mapping = {\"value\": str(body.value), \"tags\": json.dumps(body.tags)}", + " await cache.hset(f\"metric:{mid}\", mapping=mapping) # 1. cache only", + " await cache.sadd(\"metric:dirty\", mid) # mark for flush", + " return JSONResponse(status_code=202, content={\"ok\": True, \"action\": \"cached-for-flush\"})" + ], + "cat": "code", + "_slide": 221, + "_why": "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, metric:failed, backoff)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 476, + "cat": "notes", + "_slide": 221, + "_why": "notes follow the code", + "_src": "align", + "text": "Write-back has more moving parts than the other patterns; the slide shows the shape of Example 25. (1) PUT /write-back/metrics/{mid} writes the metric to a Redis hash, adds the id to a 'dirty set', which tells the flusher which ids still have to reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) The flusher runs as a background asyncio task: it SPOPs up to FLUSH_BATCH dirty ids and writes them in one executemany upsert (ON CONFLICT (id) DO UPDATE), so an id flushed twice stays one row. SPOP is atomic, so two flusher instances each get a distinct subset of ids and the flusher scales out. (3) When the flush fails, each id's count in the metric:attempts hash goes up and the id goes back on the dirty set; after five attempts it moves to metric:failed with its value still in the cache and the failure in the log, so an outage never loses a write without a trace. The sleep between cycles doubles after each failed flush, from one second to at most eight. Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale." + }, + { + "op": "set_code", + "sldId": 500, + "lines": [ + "# main.py (excerpt), examples/21-graceful-shutdown/python: one SIGTERM, four steps", + "def handle_sigterm(signum, frame): # installed in the lifespan, chained to uvicorn's", + " global shutting_down", + " if shutting_down: return # already draining", + " shutting_down = True # 1. /readyz answers 503: the LB stops routing", + " loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS, # 2. keep serving 5 s", + " stop_server, signum, frame)", + "def stop_server(signum, frame): # 3. uvicorn stops accepting, finishes in-flight", + " uvicorn_sigterm(signum, frame) # (--timeout-graceful-shutdown 20)", + "@asynccontextmanager", + "async def lifespan(app: FastAPI):", + " global uvicorn_sigterm, loop", + " loop = asyncio.get_running_loop()", + " uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm)", + " yield # ---------- app serves ----------", + " await db_pool.close() # 4. close resources, exit 0", + "@app.get(\"/readyz\")", + "async def readyz():", + " if shutting_down: # liveness (/healthz) stays 200", + " return JSONResponse({\"ready\": False, \"reason\": \"shutting down\"}, 503)", + " return {\"ready\": True} # (the example also pings Postgres here)", + "# K8s: terminationGracePeriodSeconds: 30 >= 5 s delay + 20 s drain + buffer (App. H)" + ], + "cat": "code", + "_slide": 245, + "_why": "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 500, + "cat": "notes", + "_slide": 245, + "_why": "notes follow the code", + "_src": "align", + "text": "The shutdown sequence that prevents dropped requests during a rolling deploy, from Example 21. One SIGTERM starts it: (1) the handler flips a flag so /readyz returns 503 and Kubernetes stops sending new traffic, while /healthz stays 200; (2) the service keeps serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) the handler then hands over to uvicorn, which stops accepting and finishes in-flight requests within --timeout-graceful-shutdown; (4) the lifespan exit closes the pools and the process exits 0. The ordering matters: stop taking new work before draining existing work. This is the application half; Appendix H covers the Kubernetes half (preStop hook, terminationGracePeriodSeconds). Together they give zero-downtime rollouts." + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.json new file mode 100644 index 00000000..a061bd9d --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Python.json @@ -0,0 +1,5301 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "both", + "find": "Only this step is language-specific; the Newman command is identical for all five.", + "repl": "Only this step is language-specific; the Newman command is identical for all six.", + "expect": 1, + "sldIds": [ + 521 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The order is deliberate: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "repl": "The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "AI coding tools — Claude, Claude Code, Cursor, Copilot, every frontier model — are now in production engineering workflows.", + "repl": "AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They produce code very fluently.", + "repl": "They produce code fluently.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side — plausible enough to compile, deploy and run — from a one-sentence prompt.", + "repl": "From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns — the focus of this deck — span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "repl": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘Least-worst decision’: every architecture choice is a set of trades — latency for throughput, consistency for availability, simplicity for flexibility — and the job is not to find the optionless option but to make the trades visible and deliberate.", + "repl": "‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions easy and cheap.", + "repl": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The visual companion to the previous slide.", + "repl": "The visual companion to the three layers of patterns literacy.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Three stacked boxes: design patterns at the bottom (Gang of Four — factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns — the scope of this deck — sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch.", + "repl": "Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch).", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The four maxims at the bottom in red are the philosophical anchors that follow you through the rest of the deck — every concrete choice you'll see is a deliberate exercise in those four principles.", + "repl": "The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pillars are the rubric for the rest of the talk — every design decision later either earns or spends one of these properties.", + "repl": "The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties.", + "expect": 1, + "sldIds": [ + 263 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The contract — OpenAPI, .proto, AsyncAPI — is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "repl": "The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Make the point that this is what actually delivers independent deployability: the promise the contract encodes is the service boundary.", + "repl": "Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Treat it as a pre-flight check — “will this survive the platform?” — the next slides list the twelve factors and map each to where it lives in our architecture.", + "repl": "Treat it as a pre-flight check: “will this survive the platform?” The twelve factors are listed and mapped to where each lives in our architecture.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The key reframing here is ownership — don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides for free (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "repl": "The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that in a microservices/event-driven world several factors deepen — backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "repl": "In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two takeaways to say: first, this 2011 list essentially predicted cloud-native a decade early; second, notice how many are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "repl": "Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That sets up the next slide, which maps each factor onto our architecture.", + "repl": "That sets up the map of each factor onto our architecture.", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the payoff — not the traditional twelve-factor table, but a map of where each factor actually lives in the system we're building.", + "repl": "This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building.", + "expect": 1, + "sldIds": [ + 268 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The .proto IS the contract and it lives in the registry just like OpenAPI.", + "repl": "The .proto is the contract, and it lives in the registry like OpenAPI.", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows the actual config.", + "repl": "The configuration itself is an Istio VirtualService.", + "expect": 1, + "sldIds": [ + 275 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, the services stay unaware of each other — the gateway owns the composition.", + "repl": "The services stay unaware of each other; the gateway owns the composition.", + "expect": 1, + "sldIds": [ + 278 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the resolver code.", + "repl": "The resolver code shows how the gateway composes the calls.", + "expect": 1, + "sldIds": [ + 278 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "That quietly rebuilds the monolith with network latency between the pieces.", + "repl": "That rebuilds the monolith with network latency between the pieces.", + "expect": 1, + "sldIds": [ + 280 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Section four, and arguably the hardest discipline to hold.", + "repl": "Section four, and the hardest discipline to hold.", + "expect": 1, + "sldIds": [ + 281 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The read side is a separate GET that queries a denormalised 'order_summary' table that holds the JOINed, aggregated view the UI actually wants.", + "repl": "The read side is a separate GET that queries a denormalised 'order_summary' table that holds the JOINed, aggregated view the UI needs.", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The read-model table is intentionally not 3NF: it holds customer_name (denormalised from customers), line_count (aggregated from order_lines), and any other field the query needs without a JOIN.", + "repl": "The read-model table is denormalised, not 3NF: it holds customer_name (denormalised from customers), line_count (aggregated from order_lines), and any other field the query needs without a JOIN.", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "CQRS adds complexity — use it when the read and write shapes have genuinely diverged, not as a default.", + "repl": "CQRS adds complexity: use it when the read and write shapes have diverged, not as a default.", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "CQRS — Command Query Responsibility Segregation — is the pattern of using DIFFERENT models for writes and reads.", + "repl": "CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The two are linked by a stream of events — either domain events emitted by the write side, or the database commit log itself via CDC (the next slide).", + "repl": "The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Use this pattern when reads and writes have GENUINELY different shapes and access patterns.", + "repl": "Use this pattern when reads and writes have different shapes and access patterns.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The mechanism that makes CQRS projections AND the outbox pattern (next slide) work: Change Data Capture, or CDC.", + "repl": "The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Debezium — an Apache 2.0 open-source project running as a set of Kafka Connect connectors — tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "repl": "Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is why log tailing matters before we get to the outbox slides: the outbox is just a deliberate use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "repl": "This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide is the actual transaction.", + "repl": "The outbox write itself is one database transaction, shown in code.", + "expect": 1, + "sldIds": [ + 286 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "When a step fails — payment fails — you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "repl": "When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "expect": 1, + "sldIds": [ + 288 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Flag that Appendix D goes deep on saga state, compensation and a genuinely tricky data-dependency case, for anyone who wants the mechanics.", + "repl": "Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics.", + "expect": 1, + "sldIds": [ + 288 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This inverts the dependency direction and is what truly decouples services in time and ownership.", + "repl": "This inverts the dependency direction, and it is what decouples services in time and ownership.", + "expect": 1, + "sldIds": [ + 289 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The schema-aware serde line foreshadows the next slide.", + "repl": "The schema-aware serde line leads into schemas and the registry.", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Events need contracts just as much as REST and gRPC do — arguably more, because consumers are anonymous and you can't just call them to coordinate.", + "repl": "Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate.", + "expect": 1, + "sldIds": [ + 292 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what makes adding-a-consumer-safely actually safe.", + "repl": "This is what makes adding a consumer safe.", + "expect": 1, + "sldIds": [ + 292 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "These are NOT mutually exclusive.", + "repl": "These are not mutually exclusive.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In most enterprise architectures, streaming is far more common than sourcing — sourcing is a deliberate choice for domains where audit and replay are core.", + "repl": "In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Four tools, four different jobs.", + "repl": "Each of the four tools does a different job.", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Messages aren't deleted when read — they're retained, and each consumer group tracks its own offset.", + "repl": "Messages are retained after they are read, and each consumer group tracks its own offset.", + "expect": 1, + "sldIds": [ + 295 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Once events are a durable, replayable stream, you can compute over them continuously — filtering, joining, windowing, aggregating — and emit new derived streams.", + "repl": "Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams.", + "expect": 1, + "sldIds": [ + 296 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The Table is the key idea: it's local state — a running revenue total per merchant — but it's backed by a Kafka changelog topic, so if the pod dies, the state rebuilds by replaying the changelog.", + "repl": "The Table is the key idea: it's local state (a running revenue total per merchant), but it's backed by a Kafka changelog topic, so if the pod dies, the state rebuilds by replaying the changelog.", + "expect": 1, + "sldIds": [ + 298 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right scaling signal for an event consumer is consumer lag — how far behind the log it is — not CPU.", + "repl": "The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "KEDA reads that lag and scales workers up, and crucially down to zero when there's no lag.", + "repl": "KEDA reads that lag and scales workers up, and down to zero when there's no lag.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows both configs.", + "repl": "The KEDA configuration covers both: lag for events and RPS for HTTP.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "A lot of real work isn't a synchronous request at all — it's a multi-step business process, or batch and scheduled work.", + "repl": "A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This section covers how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "repl": "Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Orchestration: a central coordinator explicitly commands each service in turn — easy to follow and observe, but the coordinator can become a god-service.", + "repl": "Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service.", + "expect": 1, + "sldIds": [ + 302 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Neither is universally right; the next slide gives the selection criteria.", + "repl": "Neither is universally right; choose by the selection criteria in the comparison.", + "expect": 1, + "sldIds": [ + 302 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Management isn't a product you buy — it's four cooperating concerns: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "repl": "Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "expect": 1, + "sldIds": [ + 305 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Signal plane: observability (section 11) tells you what's actually happening.", + "repl": "Signal plane: observability (section 11) tells you what is happening.", + "expect": 1, + "sldIds": [ + 306 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The core discipline: evolve additively — add optional fields and new endpoints, never repurpose or silently remove.", + "repl": "The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period.", + "expect": 1, + "sldIds": [ + 307 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the CI gate that turns this into an automatic guardrail.", + "repl": "A CI gate turns this into an automatic guardrail.", + "expect": 1, + "sldIds": [ + 309 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "It ingests from many sources — OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas — and builds one searchable catalog.", + "repl": "It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog.", + "expect": 1, + "sldIds": [ + 312 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide lists what consumers actually get from it.", + "repl": "What consumers get from the catalog is the point of running it.", + "expect": 1, + "sldIds": [ + 312 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The key word is correlate: traces, metrics and logs only become powerful when they share a trace_id so you can pivot between them.", + "repl": "The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them.", + "expect": 1, + "sldIds": [ + 314 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The LGTM-style stack stores them — Tempo for traces, Mimir/Prometheus for metrics, Loki for logs — and Grafana joins them.", + "repl": "The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them.", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the instrumentation, then the trace.", + "repl": "The instrumentation comes first, then the trace.", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Expand the 'three pillars' framing into the five signals modern observability actually uses.", + "repl": "Expand the 'three pillars' framing into the five signals modern observability uses.", + "expect": 1, + "sldIds": [ + 316 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) for free along the same mechanism — and then filter traces and logs by tenant.", + "repl": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant.", + "expect": 1, + "sldIds": [ + 317 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Spring — Micrometer Tracing + the OTel bridge, or the Java agent. .NET — OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "repl": "Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The common idiom is to ALSO set it as a span attribute (set_attribute) so the value is queryable in the trace UI — 'show me all slow traces for tenant acme.' Caution: baggage travels on the wire to every downstream service, including third parties if you call them — never put secrets or PII in baggage.", + "repl": "The common idiom is to also set it as a span attribute (set_attribute) so the value is queryable in the trace UI: 'show me all slow traces for tenant acme.' Caution: baggage travels on the wire to every downstream service, including third parties if you call them, so never put secrets or PII in baggage.", + "expect": 1, + "sldIds": [ + 321 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The common production setup is BOTH: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "repl": "The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "expect": 1, + "sldIds": [ + 323 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that metrics and logs are usually NOT sampled the same way — metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "repl": "Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "expect": 1, + "sldIds": [ + 323 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tail-based is the most powerful: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which is exactly the traces you actually want during an incident.", + "repl": "Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident.", + "expect": 1, + "sldIds": [ + 324 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Keep testing pragmatic — you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and literally see the KEDA http-add-on add replicas. ghz is the gRPC load tester — confirm your Istio timeouts and retries behave under pressure.", + "repl": "Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure.", + "expect": 1, + "sldIds": [ + 325 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each pattern here exists because the layer below it is not enough on its own.", + "repl": "Each pattern here exists because no single layer is enough on its own.", + "expect": 1, + "sldIds": [ + 326 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Cloud is the outermost — IAM, VPC, KMS, audit logs, region selection — owned by the platform team or cloud admin.", + "repl": "Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cluster is the Kubernetes layer — RBAC, NetworkPolicy, Pod Security Standards, admission control — owned by platform/SRE.", + "repl": "Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Container is the workload — minimal base images, non-root users, signed images, vulnerability scanning, secret mounting — owned jointly by app teams and platform.", + "repl": "Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code is the innermost — secure defaults, input validation, dependency scanning, secrets handling — owned by app teams.", + "repl": "Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide covers what each layer's controls actually are.", + "repl": "The 4 C's table lists each layer's controls and its owner.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide opens the pattern catalog — seven patterns that show up across these four layers.", + "repl": "The pattern catalog holds seven patterns that show up across these four layers.", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that these patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "repl": "These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Why this is powerful: ONE security policy applied uniformly across every workload regardless of language.", + "repl": "Why this matters: one security policy applied uniformly across every workload regardless of language.", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set ONLY by the sidecar; the app must reject requests that don't have it.", + "repl": "Set only by the sidecar; the app must reject requests that don't have it.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The audience point: the sidecar pattern only works if the app actually USES the headers the sidecar attaches.", + "repl": "The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows the code.", + "repl": "The code shows the pattern in the service's language.", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted' — once an attacker is in, they move laterally trivially.", + "repl": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance.", + "expect": 1, + "sldIds": [ + 335 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Equivalent Kyverno expression is much more YAML-native if your team prefers it; Rego is more powerful for complex logic.", + "repl": "An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic.", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For really critical isolation, you can also run per-tenant Pod replicas of the service — Kubernetes-level bulkheading.", + "repl": "For critical isolation, you can also run per-tenant Pod replicas of the service: Kubernetes-level bulkheading.", + "expect": 1, + "sldIds": [ + 340 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In software, you isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 339 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pattern also has performance benefits — you don't push 50MB messages through the message bus — but in this section we frame it for SECURITY.", + "repl": "The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security.", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost of NOT having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "repl": "The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost lines are deliberately included — every pattern is a trade.", + "repl": "The cost lines are there because every pattern is a trade.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the take-home: real production systems use all seven, in deliberate combinations per concern — not as a one-size-fits-all checklist.", + "repl": "The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide turns these into positive heuristics.", + "repl": "The anti-pattern heuristics turn these into positive rules.", + "expect": 1, + "sldIds": [ + 346 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "(1) If two services must deploy together, they're really one — merge them or fix the contract.", + "repl": "(1) If two services must deploy together, they are one service: merge them or fix the contract.", + "expect": 1, + "sldIds": [ + 347 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the slides and these references are enough to actually build the system, not just talk about it.", + "repl": "The slides and these references are enough to build the system, not only to discuss it.", + "expect": 1, + "sldIds": [ + 349 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They cover the questions that always come up: how the three protocols really differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "repl": "They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "expect": 1, + "sldIds": [ + 350 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "REST, gRPC and GraphQL — how they actually differ.", + "repl": "REST, gRPC and GraphQL: how they differ.", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS — both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "repl": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The ACL ambiguity is worth flagging out loud: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "repl": "Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration — the request-driven metrics method); USE (Utilisation, Saturation, Errors — the resource metrics method); MDC (Mapped Diagnostic Context — per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "repl": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what actually travels on the wire.", + "repl": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire.", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most arguments about 'which is best' are really arguments about which mental model fits the problem — and often the answer is different per interaction.", + "repl": "Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction.", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape — no field selection, but extremely fast.", + "repl": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three.", + "expect": 1, + "sldIds": [ + 355 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What actually travels, and why it constrains your choice.", + "repl": "What travels on the wire, and why it constrains your choice.", + "expect": 1, + "sldIds": [ + 356 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The decision guide, said plainly.", + "repl": "The decision guide.", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold closing point ties back to the whole talk: forcing one protocol everywhere — 'we're a gRPC shop' or 'everything is GraphQL' — is itself an anti-pattern, because you'll be paying gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "repl": "The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and arguably breaks REST's idea that a URL identifies a resource, not a representation.", + "repl": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Query param: easy to default but muddies caches.", + "repl": "Query param: simple to default but muddies caches.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Custom header: keeps URLs clean but is invisible and easy to forget.", + "repl": "Custom header: keeps URLs clean but is invisible and often forgotten.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation, the URL stays stable and identifies the resource, the representation is what's versioned — this is the genuinely RESTful approach.", + "repl": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The honest take: URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "repl": "In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code on the next slide.", + "repl": "The media-type versioning code shows how.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "We removed an old 'notes' field — so we reserve both its tag number (4) AND its name, permanently, so nobody can ever reuse them and silently corrupt old data on the wire.", + "repl": "We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire.", + "expect": 1, + "sldIds": [ + 363 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We add currency and state as brand-new fields with new tag numbers; old clients simply ignore unknown fields, new clients read them — that's forward and backward compatibility for free.", + "repl": "We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work.", + "expect": 1, + "sldIds": [ + 363 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The field keeps working — nothing breaks — but the deprecation shows up in introspection and in tools like Playground, nudging client developers.", + "repl": "The field keeps working and nothing breaks, but the deprecation shows up in introspection and in tools like Playground, nudging client developers.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Compare and contrast with REST's media types and gRPC's tag rules; three protocols, three genuinely different answers to the same problem.", + "repl": "Compare and contrast with REST's media types and gRPC's tag rules: each protocol answers the same problem differently.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The lifecycle on screen: ADD new fields and types (additive is always safe — existing queries are unaffected because they didn't ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; WATCH field-level usage metrics to see who still queries the old field and drive their migration; and only RETIRE — the single breaking step — once usage hits zero.", + "repl": "The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero.", + "expect": 1, + "sldIds": [ + 364 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Never break silently — deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "repl": "Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "expect": 1, + "sldIds": [ + 366 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That one design fact is the whole rest of the appendix: the connection and the knowledge of who's connected live on exactly one pod.", + "repl": "That one design fact drives everything in this appendix: the connection and the knowledge of who's connected live on exactly one pod.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Everything stateless about HTTP — any replica can serve any request — is now false.", + "repl": "Everything stateless about HTTP (any replica can serve any request) is now false.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows what that does to scaling.", + "repl": "The scaling problem follows from that.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Appendix C tackles the protocol we deliberately left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "repl": "Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "expect": 1, + "sldIds": [ + 367 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections — only new ones — so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "repl": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fix isn't to avoid scaling — it's to stop making any single pod the home of a conversation, which is the backplane.", + "repl": "The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each pod still owns only its own sockets — that's fine — but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "repl": "Each pod still owns only its own sockets, which is fine, but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that actually holds X's socket delivers it.", + "repl": "When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that holds X's socket delivers it.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the resource shape is different — the bold point — you scale on open-connection count and file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "repl": "And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "expect": 1, + "sldIds": [ + 370 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Every one of these is solved or eased by the backplane on the next slide.", + "repl": "The pub/sub backplane solves or eases every one of these.", + "expect": 1, + "sldIds": [ + 370 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "A version field lets the client detect an incompatible payload format and renegotiate or reconnect; the type field dispatches to the right protobuf message; and — critically for the next slide — a monotonic seq number per connection is what makes reconnection and replay possible.", + "repl": "A version field lets the client detect an incompatible payload format and renegotiate or reconnect; the type field dispatches to the right protobuf message; and a monotonic seq number per connection is what makes reconnection and replay possible, the basis of resume-don't-restart failure handling.", + "expect": 1, + "sldIds": [ + 372 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "And the security note in bold that's easy to forget: authenticate on connect AND re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "repl": "And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "expect": 1, + "sldIds": [ + 374 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each successful step accumulates its result into the context blob (remember that — it's the whole point of the next-but-one slide) and bumps the step index, and the row is saved durably after every step inside the same transaction as the work record.", + "repl": "Each successful step accumulates its result into the context blob, which is what lets a later step read data an earlier step produced, and bumps the step index; the row is saved durably after every step inside the same transaction as the work record.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three hard parts that the happy-path picture glosses over: where the orchestrator actually keeps its state so it survives a crash, how compensation really works when a step fails midway, and a specific gnarly case people hit — when step B needs data that only step A produced, and how to resume after a crash between them.", + "repl": "Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them.", + "expect": 1, + "sldIds": [ + 375 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We iterate reversed(STEPS[:step_index]) — only the steps that actually completed, newest first — and invoke each compensation.", + "repl": "We iterate reversed(STEPS[:step_index]), only the steps that completed, newest first, and invoke each compensation.", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Because step A persisted its output into the saga context (committed durably with the state transition), step B simply reads reservation_id and region out of context — it calls shipping.book with data that originated in A, but it never calls A.", + "repl": "Because step A persisted its output into the saga context (committed durably with the state transition), step B reads reservation_id and region out of context: it calls shipping.book with data that originated in A, but it never calls A.", + "expect": 1, + "sldIds": [ + 381 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each forward step that committed — payment.charge, inventory.reserve — has a defined semantic inverse: payment.refund, inventory.release.", + "repl": "Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When book_shipping fails, you don't undo a database transaction (the payment really happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "repl": "When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the specific case you asked about, and it's a genuinely common source of bad designs.", + "repl": "This is the specific case you asked about, and a common source of bad designs.", + "expect": 1, + "sldIds": [ + 380 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What's deliberately absent: any stack trace, exception class name, or SQL — those leak internals and are an attack surface.", + "repl": "What's absent: any stack trace, exception class name, or SQL; those leak internals and are an attack surface.", + "expect": 1, + "sldIds": [ + 385 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Error handling is where cloud-native systems quietly fall apart — not in the happy path.", + "repl": "Error handling is where cloud-native systems fall apart, more often than the happy path.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We finish on the failure mode that turns a blip into an outage — the retry storm — and the handful of controls that prevent it.", + "repl": "We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope — type, title, status, detail, instance — and you extend it with your stable code and the traceId.", + "repl": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId.", + "expect": 1, + "sldIds": [ + 384 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The Istio config from section 2 gives you several of these at the mesh layer for free.", + "repl": "The Istio config from section 2 provides several of these at the mesh layer without code changes.", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Bound every retry — timeout, small max, backoff + jitter, circuit breaker, retry budget — and make handlers idempotent.", + "repl": "Bound every retry (timeout, small max, backoff + jitter, circuit breaker, retry budget) and make handlers idempotent.", + "expect": 1, + "sldIds": [ + 393 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC gives you a small, fixed set of canonical status codes — and choosing the right one IS the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "repl": "gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Beyond the code, attach a google.rpc.Status in the trailers with rich details — ErrorInfo (machine reason + domain) and RetryInfo (how long to wait) — so the client gets the same five facts as everywhere else.", + "repl": "Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, you commit the offset either way, so the main consumer keeps making progress.", + "repl": "You commit the offset either way, so the main consumer keeps making progress.", + "expect": 1, + "sldIds": [ + 388 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When one field fails — say stock can't reach inventory — that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "repl": "When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "expect": 1, + "sldIds": [ + 390 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the failure mode that turns a brief blip into a full outage, and it's worth dwelling on.", + "repl": "This is the failure mode that turns a brief blip into a full outage.", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The loop: a service slows, calls start timing out, clients retry — often 3x — which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "repl": "The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Ports: the domain declares plain interfaces (Protocols) for what it needs — a repository and an event publisher — and depends on nothing else.", + "repl": "Ports: the domain declares plain interfaces (Protocols) for what it needs, a repository and an event publisher, and depends on nothing else.", + "expect": 1, + "sldIds": [ + 401 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Where the boundaries — and the contracts — come from.", + "repl": "Where the boundaries and the contracts come from.", + "expect": 1, + "sldIds": [ + 394 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions — where to split, what the contract should contain, what stays private — are really boundary questions, and DDD is the discipline for boundaries.", + "repl": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries.", + "expect": 1, + "sldIds": [ + 395 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge — complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them simply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "repl": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "expect": 1, + "sldIds": [ + 396 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The vivid example is the word 'order' meaning different things in fulfillment versus billing — that's why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "repl": "The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "expect": 1, + "sldIds": [ + 397 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The relationship patterns between contexts — and which ones your API actually implements.", + "repl": "The relationship patterns between contexts, and which ones your API implements.", + "expect": 1, + "sldIds": [ + 398 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The starred three are the ones to design deliberately.", + "repl": "The starred three are the ones to design explicitly.", + "expect": 1, + "sldIds": [ + 398 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Published Language: the shared, versioned schema that API speaks — OpenAPI, .proto, Avro — which is exactly what lives in the Apicurio registry.", + "repl": "Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "So the registry-backed contract from section 9 IS a Published Language, and edge/gateway translation IS an Anticorruption Layer.", + "repl": "So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the payoff: students have already built these things in sections 9 and 8 without the names — now they have the names and the theory.", + "repl": "The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driving adapters on the left — REST, gRPC, GraphQL — translate an incoming protocol into a call on an inbound port.", + "repl": "Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port.", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driven adapters on the right — a Postgres repository, a Kafka publisher — implement the outbound ports.", + "repl": "Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports.", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The aggregate is the consistency boundary — invariants hold within it and one transaction commits it — and it typically maps to a single API resource.", + "repl": "The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource.", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the small-aggregate rule — one per transaction, reference others by id, connect via events — is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "repl": "And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The message: DDD isn't a separate methodology bolted on — it's the naming and reasoning behind the boundaries the whole architecture already relies on.", + "repl": "The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on.", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The reframe: coupling is necessary — a system with zero coupling does nothing — so the goal is to balance it, not eliminate it.", + "repl": "The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it.", + "expect": 1, + "sldIds": [ + 404 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Coupling has three dimensions — integration strength, distance and volatility — and a coupling only hurts when all three are high together.", + "repl": "Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together.", + "expect": 1, + "sldIds": [ + 404 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Khononov's model evaluates the connection between two components — not the components themselves — along three dimensions: integration strength, distance and volatility.", + "repl": "Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility.", + "expect": 1, + "sldIds": [ + 405 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Distance is how far apart they sit — same method, class, module, service, or system — and it sets the cost of a change that has to cross the boundary.", + "repl": "Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is how often the coupled thing actually changes.", + "repl": "Volatility is how often the coupled thing changes.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost box at the bottom is the key relationship: strong plus distant is global complexity — the distributed monolith — but, crucially, it only bites when the code is volatile.", + "repl": "The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is Khononov's modularity rule rendered as a slogan — strength XOR distance — you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "repl": "The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "expect": 1, + "sldIds": [ + 407 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Contract coupling — APIs and event schemas in the registry — is the only strength level appropriate across service distance.", + "repl": "Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance.", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is what decides whether a less-than-ideal coupling actually matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling that's actually on hot, ever-changing core code can still hurt.", + "repl": "Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt.", + "expect": 1, + "sldIds": [ + 411 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you deliberately route your strongest couplings away from core and toward stable, generic areas.", + "repl": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas.", + "expect": 1, + "sldIds": [ + 411 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pair this with the preStop and grace-period settings on the next slide.", + "repl": "Pair this with the preStop hook and the termination grace period in the pod spec.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "And liveness deliberately points at a separate /livez that stays UP during the drain, so the kubelet doesn't restart a pod that's cleanly winding down.", + "repl": "Liveness points at a separate /livez that stays UP during the drain, so the kubelet doesn't restart a pod that's cleanly winding down.", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Close by pointing to both Khononov books — Learning Domain-Driven Design for the boundary tools in Appendix F, and Balancing Coupling in Software Design for this model — as the two references that underpin both of these appendices.", + "repl": "Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices.", + "expect": 1, + "sldIds": [ + 413 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done — the 5xx blips users actually notice during a rollout.", + "repl": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "It's a protocol between your app and the platform — not a single setting — and the platform gives you a fixed budget to do it in.", + "repl": "It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The rest of the appendix walks that sequence, then shows the code on both sides of it.", + "repl": "This appendix walks that sequence, then shows the code on both sides of it.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Liveness must stay UP during the drain, or the kubelet restarts a pod that's deliberately winding down.", + "repl": "Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down.", + "expect": 1, + "sldIds": [ + 417 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The distinction that trips people up is readiness vs liveness during shutdown: you WANT readiness to fail (it stops new traffic) but you must NOT let liveness fail (the kubelet would restart a pod that's intentionally draining).", + "repl": "The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining).", + "expect": 1, + "sldIds": [ + 417 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "North/south traffic (clients into the system) enters through the EDGE/INGRESS layer (HAProxy, Nginx, Envoy, Traefik) — where TLS terminates and coarse host/path routing happens — then through the API GATEWAY (Kong, Apigee, Istio gateway, Spring Cloud Gateway) which handles product-level concerns like auth, rate limiting and transformation.", + "repl": "North/south traffic (clients into the system) enters through the EDGE/INGRESS layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API GATEWAY (Kong, Apigee, Istio gateway, Spring Cloud Gateway), which handles product-level concerns like auth, rate limiting and transformation.", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes actually stops routing after you report DOWN — here roughly ten seconds, which your preStop sleep should cover.", + "repl": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after you report DOWN: here roughly ten seconds, which your preStop sleep should cover.", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Only HTTP drains for free — everything else needs explicit drain code in your shutdown hook.", + "repl": "Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook.", + "expect": 1, + "sldIds": [ + 421 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The closing bold point is the philosophical anchor of the whole appendix: you cannot guarantee a graceful exit — the kubelet will sometimes SIGKILL you — so idempotency is what actually makes the system safe, and graceful shutdown just makes the unsafe window rare.", + "repl": "The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare.", + "expect": 1, + "sldIds": [ + 421 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fourth point is the one that turns shutdown from fragile to robust — idempotency means the occasional hard kill is a non-event.", + "repl": "The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event.", + "expect": 1, + "sldIds": [ + 422 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Open the appendix by reframing what L7 routing IS in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "repl": "Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "expect": 1, + "sldIds": [ + 424 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The TLS SNI line in both columns is worth highlighting — L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "repl": "The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you actually see what's happening across the four layers — ties straight back to the observability section.", + "repl": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section.", + "expect": 1, + "sldIds": [ + 426 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Worth calling out: the resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms — the mesh implements many of them for free, but you still need bounded retries and budgets to prevent amplification.", + "repl": "The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification.", + "expect": 1, + "sldIds": [ + 426 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each layer is the right place for a different KIND of decision — we'll see that throughout the rest of the appendix.", + "repl": "Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them.", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each box can refuse, transform or branch the request, and each emits a trace span so the full journey is observable end-to-end.", + "repl": "Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end.", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point teases the rule-based section: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place isn't a YAML file in the cluster — it's a rule engine inside the app.", + "repl": "The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster.", + "expect": 1, + "sldIds": [ + 429 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three match rules — by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie — each sending to a different DestinationRule subset.", + "repl": "Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie.", + "expect": 1, + "sldIds": [ + 432 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use it as a deliberate choice for a known constraint, never as a default.", + "repl": "Use it only for a known constraint, never as a default.", + "expect": 1, + "sldIds": [ + 434 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as truly disposable.", + "repl": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable.", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie deliberately and NAT doesn't break it.", + "repl": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it.", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Shadow / mirror: copy a percentage of real traffic to a new version silently, observe its behaviour, but don't return its responses to the client — great for testing under realistic load.", + "repl": "Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load.", + "expect": 1, + "sldIds": [ + 436 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The dispatcher is small on purpose.", + "repl": "The dispatcher is small by design.", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Header inject/strip and URL rewrites are essentially free — the router is already parsing the HTTP line and headers anyway.", + "repl": "Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers.", + "expect": 1, + "sldIds": [ + 439 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two examples worth mentioning: trace context propagation (the W3C traceparent header is how the spans from earlier in the journey stitch together with the spans downstream — that's why the request-id injection in the Nginx slide mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "repl": "Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "expect": 1, + "sldIds": [ + 439 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert — a really clean pattern, identity at the network layer.", + "repl": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert: a clean pattern that puts identity at the network layer.", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set expectations honestly.", + "repl": "Set expectations with the numbers.", + "expect": 1, + "sldIds": [ + 445 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out) that means every layer must be lean and earn its place.", + "repl": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency.", + "expect": 1, + "sldIds": [ + 445 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The defining property is on the slide: change anywhere, redeploy everything; bug in Reporting can take down Payments; one scaling unit for very different workloads.", + "repl": "The defining property is on the slide: change anywhere, redeploy everything; bug in Reporting can take down Payments; one scaling unit for workloads that scale differently.", + "expect": 1, + "sldIds": [ + 454 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the modern answer to 'how do you build a microservices system that actually works in production.'", + "repl": "This is the modern answer to 'how do you build a microservices system that works in production.'", + "expect": 1, + "sldIds": [ + 447 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "If you DO need to split later — different scaling profiles, different teams, different release cadences — you have a small refactor, not a rewrite.", + "repl": "If you need to split later for different scaling profiles, teams or release cadences, you have a small refactor, not a rewrite.", + "expect": 1, + "sldIds": [ + 455 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Splitting the database is the genuinely hard part of decomposition — schema dependencies, cross-table joins, and reporting queries all create coupling that's invisible until you try to move data.", + "repl": "Splitting the database is the hard part of decomposition: schema dependencies, cross-table joins, and reporting queries all create coupling that stays invisible until you try to move data.", + "expect": 1, + "sldIds": [ + 456 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "(And many teams discover at this point that they don't actually need separate services — the modular monolith with decomposed data is enough.) Practical tip: start with the highest-coupling module first; the second hardest is the database that the reporting layer joins across, so do that one early.", + "repl": "(Many teams discover at this point that they don't need separate services: the modular monolith with decomposed data is enough.) Practical tip: start with the highest-coupling module first; the second hardest is the database that the reporting layer joins across, so do that one early.", + "expect": 1, + "sldIds": [ + 456 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what the 'mesh' actually IS: the same routing CRDs the edge uses, but everywhere, between every pair of services, configured centrally and applied by the sidecars.", + "repl": "This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars.", + "expect": 1, + "sldIds": [ + 449 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC has a few sharp edges that are worth calling out separately.", + "repl": "gRPC has a few sharp edges of its own.", + "expect": 1, + "sldIds": [ + 450 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Now the proxy earns its keep: by path (/orders/* → new service), by header (X-Tenant: acme → new service), by percentage (1% of all traffic → new service), or by feature flag (users in the beta group → new service).", + "repl": "Now the proxy does the routing work: by path (/orders/* → new service), by header (X-Tenant: acme → new service), by percentage (1% of all traffic → new service), or by feature flag (users in the beta group → new service).", + "expect": 1, + "sldIds": [ + 459 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This pattern is genuinely useful for two cases: extracting reads first (the new service is a read replica with a nicer API), and as a stepping stone when the data migration is a separate project.", + "repl": "This pattern is useful for two cases: extracting reads first (the new service is a read replica with a nicer API), and as a stepping stone when the data migration is a separate project.", + "expect": 1, + "sldIds": [ + 460 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "But this is NOT a destination — every schema change now needs both apps to agree, and the operational reality is that schema migrations become a coordination tax that grows with every team that owns part of the same database.", + "repl": "But this is not a destination: every schema change now needs both apps to agree, and schema migrations become a coordination tax that grows with every team that owns part of the same database.", + "expect": 1, + "sldIds": [ + 460 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what makes the pattern more powerful than path-based proxying: you can roll out the new service to one tenant at a time, see how it behaves under real traffic, then widen.", + "repl": "This makes the pattern more flexible than path-based proxying: you can roll out the new service to one tenant at a time, see how it behaves under real traffic, then widen.", + "expect": 1, + "sldIds": [ + 461 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is also the right shape for adding capabilities your vendor can't — PII redaction, retention policies, multi-region failover — to a system you can't modify.", + "repl": "This is also the right shape for adding capabilities your vendor can't provide, such as PII redaction, retention policies or multi-region failover, to a system you can't modify.", + "expect": 1, + "sldIds": [ + 463 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The data split is the genuinely hard part; doing it in one process is much cheaper than coordinating across services.", + "repl": "The data split is the hard part; doing it in one process is much cheaper than coordinating across services.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most powerful for vendor / 3rd-party / legacy-mainframe wrappers.", + "repl": "Most useful for vendor / 3rd-party / legacy-mainframe wrappers.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Five reasons to cache (the next slide) and six patterns to do it well (the next six).", + "repl": "Five reasons to cache, then six patterns to do it well.", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Pick the pattern that matches the consistency you actually need — the next six slides.", + "repl": "Pick the pattern that matches the consistency you need from the six patterns in this appendix.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Predictable performance is the operational win that's hard to measure but very real: a cache layer flattens p99 because most requests don't contend for DB locks.", + "repl": "Predictable performance is the operational win that is hard to measure but real: a cache layer flattens p99 because most requests don't contend for DB locks.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next six slides cover the six standard patterns; pick the one that matches the consistency story you actually need, not the one that sounds best.", + "repl": "This appendix covers the six standard patterns; pick the one that matches the consistency story you need, not the one that sounds best.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The code on the next slide is two functions; the discipline is to remember to invalidate on writes (which we'll see in write-through and write-around).", + "repl": "The cache-aside code is two functions; the discipline is to remember to invalidate on writes (covered in write-through and write-around).", + "expect": 1, + "sldIds": [ + 467 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Pros: no cache pollution by write-only data; cache holds only the things actually being read.", + "repl": "Pros: no cache pollution by write-only data; the cache holds only what is being read.", + "expect": 1, + "sldIds": [ + 473 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use this pattern only when (a) write throughput is the constraint, (b) eventual durability is genuinely acceptable, and (c) you've thought about what 'lost the last 5 seconds of writes' actually means for your business.", + "repl": "Use this pattern only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've worked out what 'lost the last 5 seconds of writes' means for your business.", + "expect": 1, + "sldIds": [ + 475 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Refresh-ahead pre-empts this: a background task watches the cache (or maintains a set of 'hot' keys) and refreshes their values from the DB BEFORE the TTL fires.", + "repl": "Refresh-ahead pre-empts this: a background task watches the cache (or maintains a set of 'hot' keys) and refreshes their values from the DB before the TTL fires.", + "expect": 1, + "sldIds": [ + 477 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: extra DB load for keys that may not actually be requested again before expiry; you need a way to identify which keys are hot.", + "repl": "Cons: extra DB load for keys that may not be requested again before expiry; you need a way to identify which keys are hot.", + "expect": 1, + "sldIds": [ + 477 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold final line is the architectural take-home: reversibility — every step should be a config change you can undo without coordinating a release — is the property to design for.", + "repl": "The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Refresh those keys when their TTL drops below a threshold.", + "repl": "Refresh those keys when their TTL falls under a threshold.", + "expect": 1, + "sldIds": [ + 478 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The background refresher periodically checks each hot key's TTL; when it drops below REFRESH_BEFORE_S, the refresher re-reads the DB and re-populates the cache before the user-facing path would see a miss.", + "repl": "The background refresher periodically checks each hot key's TTL; when it falls under REFRESH_BEFORE_S, the refresher re-reads the DB and re-populates the cache before the user-facing path would see a miss.", + "expect": 1, + "sldIds": [ + 478 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "This pattern shines for read-heavy workloads where a small set of keys serves most traffic — the 80/20 product page, the top tweets, the trending articles — and miss latency is user-visible.", + "repl": "This pattern shines for read-heavy workloads where a small set of keys serves most traffic (the 80/20 product page, the top tweets, the trending articles) and miss latency is user-visible.", + "expect": 1, + "sldIds": [ + 478 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The first point is the most important and most often missed: caching is a complexity tax, only worth paying when reads genuinely hurt.", + "repl": "The first point is the most important and most often missed: caching is a complexity tax, only worth paying when reads hurt.", + "expect": 1, + "sldIds": [ + 479 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Mitigations: distributed lock per key (only one fetches, rest wait); refresh-ahead (the previous slide); TTL jitter (add random ms to TTL so similar keys don't expire together).", + "repl": "Mitigations: distributed lock per key (only one fetches, the rest wait); refresh-ahead; TTL jitter (add random ms to TTL so similar keys don't expire together).", + "expect": 1, + "sldIds": [ + 479 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Write-through trades write latency for read consistency — worth it when stale reads after writes are genuinely harmful (financial, healthcare, regulatory).", + "repl": "Write-through trades write latency for read consistency, which pays off when stale reads after writes are harmful (financial, healthcare, regulatory).", + "expect": 1, + "sldIds": [ + 480 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When data is written far more often than it's read — audit logs, event records, telemetry — caching every write is wasteful: most cached entries will expire before being read again.", + "repl": "When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful: most cached entries expire before being read again.", + "expect": 1, + "sldIds": [ + 473 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Unbounded caches grow until they evict at random, which gives you the worst kind of failure — unpredictable.", + "repl": "Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable.", + "expect": 1, + "sldIds": [ + 479 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The mistake that causes most production incidents is treating a remote call like a local one — no timeout, no fallback, no circuit breaker — so a single down dependency hangs the caller.", + "repl": "The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller.", + "expect": 1, + "sldIds": [ + 486 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on actual traffic, SLO burn-rate alerts, and — critically — Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "repl": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "expect": 1, + "sldIds": [ + 487 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Production resilience is the deliberate composition of several of these, tuned together.", + "repl": "Production resilience is the composition of several of these, tuned together.", + "expect": 1, + "sldIds": [ + 490 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The code on the next slide shows the language-specific mechanics.", + "repl": "The code example shows the language-specific mechanics.", + "expect": 1, + "sldIds": [ + 491 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The code on the next slide shows the idiomatic library for each language.", + "repl": "The code example shows the idiomatic library for each language.", + "expect": 1, + "sldIds": [ + 493 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Libraries differ per language; the next slide shows each.", + "repl": "Libraries differ per language; the code example shows each.", + "expect": 1, + "sldIds": [ + 495 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 497 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Load shedding is counter-intuitive to engineers — deliberately rejecting requests feels like giving up — but it is the difference between a system that degrades and one that collapses.", + "repl": "Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses.", + "expect": 1, + "sldIds": [ + 498 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each failure mode has characteristic signals, and the point is that you must instrument for them deliberately — a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "repl": "Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "expect": 1, + "sldIds": [ + 501 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what actually failed' (the logs on that trace) — the correlation story from section 11 applied to incident response.", + "repl": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response.", + "expect": 1, + "sldIds": [ + 501 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two observations worth stating aloud: first, quorum/consensus is the ONLY real answer to split-brain — no amount of retrying or circuit-breaking helps when the problem is two leaders; you need a majority rule.", + "repl": "Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule.", + "expect": 1, + "sldIds": [ + 502 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Mention that FastAPI emits the OpenAPI spec for free, which is what we register and gate.", + "repl": "FastAPI emits the OpenAPI spec from the handlers without extra code, and that spec is what we register and gate.", + "expect": 1, + "sldIds": [ + 272 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "There is deliberately no await-on-kafka inside the request path — if the broker is down, the order still commits and the event ships later when CDC catches up.", + "repl": "The request path never awaits Kafka: if the broker is down, the order still commits and the event ships later when CDC catches up.", + "expect": 1, + "sldIds": [ + 287 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "First, enable_auto_commit=False and commit AFTER the side-effect (send_email) succeeds — if you commit before and then crash, you've lost the work.", + "repl": "First, enable_auto_commit=False, and commit only once the side-effect (send_email) has succeeded: commit first and then crash, and the work is lost.", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log — the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live — how long a cached key lives before expiring); LRU (Least Recently Used — a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command — sets a key and its TTL atomically);", + "repl": "Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log: the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used: a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command: sets a key and its TTL atomically);", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Runtime & build — WSGI / ASGI (Web / Asynchronous Server Gateway Interface — Python's sync / async web server contracts);", + "repl": "Runtime & build — WSGI / ASGI (Web / Asynchronous Server Gateway Interface: Python's sync / async web server contracts);", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Easy to write — but note clients{} is per-pod.", + "repl": "Short to write, but clients{} is per-pod.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "then circle back to east/west where the most decisions actually happen.", + "repl": "then circle back to east/west, where most routing decisions happen.", + "expect": 1, + "sldIds": [ + 423 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The durable-rules box on the right is the rule engine — a Rete-based forward-chaining engine written in C with a Python ruleset DSL — and it's the part domain experts edit.", + "repl": "The durable-rules box on the right is the rule engine: a Rete-based forward-chaining engine written in C with a Python ruleset DSL, and the part domain experts edit.", + "expect": 1, + "sldIds": [ + 442 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The point isn't the syntax; it's that this file is owned by domain experts — the same people who define what 'VIP' or 'high-value' mean — and can be redeployed without a developer touching the FastAPI dispatcher code.", + "repl": "Domain experts own this file (the same people who define what 'VIP' or 'high-value' mean), and it can be redeployed without a developer touching the FastAPI dispatcher code.", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC users get deadline propagation for free via the deadline parameter;", + "repl": "gRPC propagates deadlines through the deadline parameter without extra code;", + "expect": 1, + "sldIds": [ + 492 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "then four appendices go deep on protocol internals, versioning, WebSockets and saga mechanics.", + "repl": "then the appendices go deep on protocols, versioning, WebSockets, sagas, errors, caching, failure modes and API testing, and run Example 05 on minikube and OpenShift Local.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people there are four deep-dive appendices at the end (protocols, versioning, WebSockets, sagas) they can jump to.", + "repl": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The four appendices that follow are deep-dive backup material — pull them up on demand during Q&A, or work through them directly if you're running the full-length session.", + "repl": "The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session.", + "expect": 1, + "sldIds": [ + 350 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "That's the entire menu of inputs every later slide will use to make routing decisions.", + "repl": "That's the entire menu of inputs the routing patterns in this appendix use to make routing decisions.", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The sticky-session one ties back to the earlier slide.", + "repl": "The sticky-session one ties back to the sticky-sessions comparison.", + "expect": 1, + "sldIds": [ + 446 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Choose: invalidate (this code) or set the new value directly (write-through, two slides later).", + "repl": "Choose: invalidate (this code) or set the new value directly (write-through).", + "expect": 1, + "sldIds": [ + 468 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "lets one ruleset drive multiple transports, For event-shaped flows, swap the FastAPI endpoint for a Faust agent or an aiokafka consumer loop;", + "repl": "lets one ruleset drive multiple transports. For event-shaped flows, swap the FastAPI endpoint for a faust-streaming agent or an aiokafka consumer loop;", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "stack" + }, + { + "op": "set_code", + "sldId": 519, + "shape_id": 8, + "lines": [ + "{", + " \"name\": \"Create order\",", + " \"request\": {", + " \"method\": \"POST\",", + " \"header\": [{ \"key\": \"Idempotency-Key\", \"value\": \"{{$guid}}\" }],", + " \"url\": \"{{base_url}}/orders\",", + " \"body\": { \"mode\": \"raw\", \"raw\": \"{\\\"sku\\\":\\\"A-1\\\",\\\"qty\\\":2}\" }", + " },", + " \"event\": [{", + " \"listen\": \"test\",", + " \"script\": { \"exec\": [", + " \"pm.test('201 Created', () => pm.response.to.have.status(201));\",", + " \"pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));\",", + " \"const body = pm.response.json();\",", + " \"pm.test('returns an id', () => pm.expect(body).to.have.property('id'));\",", + " \"pm.collectionVariables.set('order_id', body.id);\"", + " ] }", + " }]", + "}" + ], + "cat": "code", + "_why": "_docs/28 Anatomy of a collection (adds the response-time assertion)" + }, + { + "op": "replace_picture", + "sldId": 520, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-black-box.png", + "cat": "stack", + "_why": "the old figure shows five backends; the site figure shows all six" + }, + { + "op": "set_code", + "sldId": 521, + "shape_id": 8, + "lines": [ + "# Python / FastAPI — serve, then the same collection", + "uvicorn app.main:app --port 8080 & # http://localhost:8080", + "newman run orders.postman_collection.json -e local.postman_environment.json" + ], + "cat": "code", + "_why": "_docs/28 codetab 'One collection, six backends', this deck's language" + }, + { + "op": "set_code", + "sldId": 522, + "shape_id": 8, + "lines": [ + "# install once (Node.js):", + "# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1", + "newman run orders.postman_collection.json \\", + " --environment local.postman_environment.json \\", + " --reporters cli,junit,htmlextra \\", + " --reporter-junit-export results/newman.xml \\", + " --timeout-request 5000 \\", + " --bail", + "# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure", + "", + "# data-driven: one iteration per CSV row; the columns become {{variables}}", + "newman run orders.postman_collection.json -e local.postman_environment.json \\", + " --iteration-data cases.csv" + ], + "cat": "code", + "_why": "_docs/28 Running Newman + Data-driven tests; pinned install; no comment after a line continuation" + }, + { + "op": "set_code", + "sldId": 524, + "shape_id": 8, + "lines": [ + "# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js)", + "jobs:", + " api-tests:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v7", + " - run: docker compose up -d --build --wait # order, payment, inventory, ...", + " - run: ./scripts/wait-for-ready.sh # poll /readyz before testing", + " - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1", + " - run: |", + " newman run orders.postman_collection.json \\", + " -e ci.postman_environment.json \\", + " --reporters cli,junit \\", + " --reporter-junit-export results/newman.xml", + " - uses: actions/upload-artifact@v7 # keep the report even on failure", + " if: always()", + " with: { name: newman-results, path: results/ }" + ], + "cat": "code", + "_why": "_docs/28 Newman in CI on Docker Engine; actions at v7 as in .github/workflows" + }, + { + "op": "replace_picture", + "sldId": 525, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-ci-gate.png", + "cat": "stack", + "_why": "the old figure shows the old container engine; site figure 27-ci-gate" + }, + { + "op": "replace_picture", + "sldId": 268, + "shape_id": 143, + "png": "lgtm-presentation/edits/png/01-factors-mapped-py.png", + "cat": "stack", + "_slide": 13, + "_why": "Poetry (examples use pip + requirements.txt); Go deck showed Python tooling" + }, + { + "op": "replace_picture", + "sldId": 283, + "shape_id": 315, + "png": "lgtm-presentation/edits/png/04-cqrs.png", + "cat": "voice", + "_slide": 28, + "_why": "image text had a positional reference ('the next slide') and 'genuinely'" + }, + { + "op": "replace_picture", + "sldId": 285, + "shape_id": 338, + "png": "lgtm-presentation/edits/png/04-log-tailing.png", + "cat": "positional", + "_slide": 30, + "_why": "image text had '(next slide)'" + }, + { + "op": "replace_picture", + "sldId": 297, + "shape_id": 472, + "png": "lgtm-presentation/edits/png/06-derive-streams.png", + "cat": "stack", + "_slide": 42, + "_why": "caption 'Faust/Kafka Streams style' (faust-streaming; goka for Go)" + }, + { + "op": "replace_picture", + "sldId": 346, + "shape_id": 1009, + "png": "lgtm-presentation/edits/png/13-antipatterns-grid.png", + "cat": "voice", + "_slide": 91, + "_why": "box text broken mid-word (layout defect)" + }, + { + "op": "replace_picture", + "sldId": 428, + "shape_id": 1902, + "png": "lgtm-presentation/edits/png/22-l7-journey.png", + "cat": "voice", + "_slide": 173, + "_why": "subtitle 'each layer earns its place' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost.png", + "cat": "voice", + "_slide": 190, + "_why": "'every hop must earn its place' (ban tier); rule-engine row per language" + }, + { + "op": "replace_picture", + "sldId": 447, + "shape_id": 2121, + "png": "lgtm-presentation/edits/png/22-east-west.png", + "cat": "voice", + "_slide": 192, + "_why": "caption 'for free' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 460, + "shape_id": 2261, + "png": "lgtm-presentation/edits/png/24-shared-database.png", + "cat": "positional", + "_slide": 205, + "_why": "'(decomposed DB above)'" + }, + { + "op": "replace_picture", + "sldId": 483, + "shape_id": 2523, + "png": "lgtm-presentation/edits/png/25-failure-taxonomy.png", + "cat": "voice", + "_slide": 228, + "_why": "'the rest of this appendix' (ban tier)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# Istio: retries, timeout and outlier detection for inventory-service", + "repl": "# Istio: bounded retries and timeout for inventory-service", + "expect": 1, + "sldIds": [ + 276 + ], + "cat": "code", + "_why": "the manifest has no outlier detection; matches _docs/02 comment" + }, + { + "op": "set_code", + "sldId": 300, + "shape_id": 509, + "lines": [ + "apiVersion: keda.sh/v1alpha1", + "kind: ScaledObject", + "metadata: { name: payment-consumer }", + "spec:", + " scaleTargetRef: { name: payment-consumer }", + " minReplicaCount: 0", + " maxReplicaCount: 20", + " triggers:", + " - type: kafka", + " metadata: { bootstrapServers: \"cndp-kafka-bootstrap:9092\", topic: order.placed,", + " consumerGroup: payment, lagThreshold: \"100\" }", + "---", + "# HTTP: scale the FastAPI service on concurrent requests (KEDA http-add-on)", + "apiVersion: http.keda.sh/v1alpha1", + "kind: HTTPScaledObject", + "metadata: { name: order-service }", + "spec:", + " hosts: [orders.example.com]", + " scaleTargetRef: { name: order-service, service: order-service, port: 8080 }", + " replicas: { min: 0, max: 30 }" + ], + "cat": "code", + "_why": "ScaledObject needs metadata.name and the kafka scaler needs bootstrapServers; HTTPScaledObject needs apiVersion, name, hosts and a full scaleTargetRef (KEDA v2.21 / http-add-on)", + "pt": 11.0, + "_fit": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "/livez", + "repl": "/healthz", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "code", + "_why": "examples/21-graceful-shutdown: liveness endpoint is /healthz in all six languages" + }, + { + "op": "replace", + "scope": "slide", + "find": "# must NOT fail during the drain", + "repl": "# keeps answering during the drain", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "code", + "_why": "comment, no ALL-CAPS emphasis" + }, + { + "op": "set_notes", + "sldId": 420, + "paragraphs": [ + "The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover.", + "Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages.", + "The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number." + ], + "cat": "notes", + "_why": "notes named /livez (/healthz/live on .NET); examples use /readyz and /healthz" + }, + { + "op": "replace", + "scope": "slide", + "find": "ssl crt /etc/haproxy/certs/ ", + "repl": "ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 ", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "gRPC needs HTTP/2 on the TLS listener (ALPN h2)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# TLS termination here", + "repl": "# TLS here; h2 for gRPC", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "comment matches the ALPN change" + }, + { + "op": "replace", + "scope": "slide", + "find": "listen 443 ssl http2;", + "repl": "listen 443 ssl; http2 on;", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "code", + "_why": "nginx 1.25.1+ deprecates the http2 listen parameter; http2 is its own directive" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-request-id: \"%REQ(x-request-id)%\"", + "repl": "x-client-ip: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "setting x-request-id to itself is a no-op; Envoy generates it" + }, + { + "op": "replace", + "scope": "slide", + "find": "# propagate or create", + "repl": "# caller address", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "comment matches the new header" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-tenant-id: \"%DOWNSTREAM_PEER_CERT_V_END%\"", + "repl": "x-peer-identity: \"%DOWNSTREAM_PEER_URI_SAN%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "DOWNSTREAM_PEER_CERT_V_END is the certificate expiry, not an identity; the URI SAN carries the SPIFFE ID" + }, + { + "op": "set_notes", + "sldId": 440, + "paragraphs": [ + "Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders.", + "The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none.", + "The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved." + ], + "cat": "notes", + "_why": "notes described the old headers (tenant id from the cert expiry operator)" + }, + { + "op": "set_code", + "sldId": 509, + "shape_id": 8, + "lines": [ + "{", + " \"$schema\": \"https://flagd.dev/schema/v0/flags.json\",", + " \"flags\": {", + " \"new-checkout\": {", + " \"state\": \"ENABLED\",", + " \"variants\": { \"on\": true, \"off\": false },", + " \"defaultVariant\": \"off\",", + " \"targeting\": {", + " \"if\": [", + " { \"==\": [ {\"var\": \"plan\"}, \"enterprise\" ] }, \"on\",", + " { \"fractional\": [ {\"var\": \"targetingKey\"},", + " [\"on\", 25], [\"off\", 75] ] }", + " ]", + " }", + " }", + " }", + "}" + ], + "cat": "code", + "_why": "examples/27-feature-flags/flags/flags.json carries the $schema line" + }, + { + "op": "replace", + "scope": "slide", + "find": "Poetry for reproducible Python builds", + "repl": "pip with a fully pinned requirements.txt for reproducible Python builds", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "stack", + "_slide": 10, + "_why": "examples install a full == freeze with pip (examples/*/python/*/requirements.txt)" + }, + { + "op": "set_code", + "sldId": 269, + "shape_id": 156, + "lines": [ + "# main.py (excerpt); requirements.txt pins every package with ==", + "class Settings(BaseSettings): # config from env — factor III", + " database_url: str", + " kafka_bootstrap: str = \"\"", + " service_version: str = \"0.0.0\"", + "settings = Settings()", + "app = FastAPI(title=\"order-service\", version=settings.service_version, lifespan=lifespan)", + "@app.get(\"/healthz\") # liveness — the process is up", + "def healthz(): return {\"status\": \"ok\"}", + "@app.get(\"/readyz\") # readiness — dependencies are reachable", + "async def readyz():", + " try:", + " async with pool.acquire() as conn:", + " await conn.fetchval(\"SELECT 1\")", + " except Exception:", + " return {\"status\": \"down\", \"checks\": {\"database\": \"unreachable\"}}", + " return {\"status\": \"ready\", \"checks\": {\"database\": \"ok\"}}" + ], + "cat": "code", + "_slide": 14, + "_why": "Poetry/pyproject gone; matches examples/01-cloud-native-principles/python/main.py" + }, + { + "op": "set_notes", + "sldId": 269, + "paragraphs": [ + "Walk the code: config comes from the environment through pydantic-settings (factor III), never from baked-in files, and requirements.txt pins every package with == so the image build is reproducible. The version on the FastAPI app comes from SERVICE_VERSION and shows up in OpenAPI and the registry.", + "The two probes are the contract with Kubernetes. /healthz is liveness (is the process alive? if not, restart it) and never checks dependencies. /readyz is readiness (can it serve?): it runs SELECT 1 on the database pool and reports down otherwise, which takes the pod out of rotation without killing it. Getting liveness and readiness wrong is a top cause of cascading restarts, so stress the distinction." + ], + "cat": "notes", + "_slide": 14, + "_why": "notes follow the example code" + }, + { + "op": "set_code", + "sldId": 273, + "shape_id": 203, + "lines": [ + "// inventory.proto — the contract, registered in Apicurio", + "syntax = \"proto3\";", + "service Inventory {", + " rpc ReserveStock (ReserveRequest) returns (ReserveReply);", + "}", + "message ReserveRequest { string sku = 1; int32 quantity = 2; }", + "message ReserveReply { bool reserved = 1; int32 remaining = 2; }", + "# server.py — generated stubs from protoc / buf", + "class InventoryServicer(inventory_pb2_grpc.InventoryServicer):", + " def ReserveStock(self, request, context):", + " remaining = stock.reserve(request.sku, request.quantity)", + " return inventory_pb2.ReserveReply(", + " reserved=remaining >= 0, remaining=max(remaining, 0))" + ], + "cat": "code", + "_slide": 18, + "_why": "servicer matches _docs/02 Python tab and examples/02-communications/python/inventory-service (sync grpc.server)" + }, + { + "op": "set_code", + "sldId": 279, + "shape_id": 272, + "lines": [ + "import strawberry # modern, code-first GraphQL for Python", + "", + "@strawberry.type", + "class Order:", + " id: str; sku: str; status: str", + "", + " @strawberry.field # resolved on demand, from another service", + " async def stock(self) -> int:", + " reply = await inventory_client.reserve_stock_view(self.sku) # gRPC", + " return reply.remaining", + "", + "@strawberry.type", + "class Query:", + " @strawberry.field", + " async def order(self, id: str) -> Order:", + " return await order_client.get(id) # REST call to order-service", + "", + "schema = strawberry.Schema(query=Query) # SDL exported to the registry" + ], + "cat": "code", + "_slide": 24, + "_why": "matches _docs/03 Python tab (snake_case client method)", + "pt": 12.0, + "_fit": "slide 24: 18 code lines, 87 columns; 12.5 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "topic=\"order.created\", payload=order.json())", + "repl": "topic=\"order.placed\", payload=order.model_dump_json())", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "code", + "_slide": 29, + "_why": "Pydantic 2 model_dump_json; the system's topic is order.placed" + }, + { + "op": "replace", + "scope": "slide", + "find": "# subscribes to order.created", + "repl": "# subscribes to order.placed", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "code", + "_slide": 29, + "_why": "topic name" + }, + { + "op": "set_code", + "sldId": 287, + "shape_id": 362, + "lines": [ + "async def create(body: OrderIn) -> Order:", + " async with db.transaction(): # single local transaction", + " order = await db.execute(insert_order, body)", + " await db.execute(", + " insert_outbox, # same txn -> atomic with the state change", + " topic=\"order.placed\",", + " key=order.id,", + " payload=order.model_dump_json(),", + " )", + " return order # Debezium tails the WAL and publishes", + "", + "# No await-kafka on the request path: if the broker is down, the order still", + "# commits and the event is delivered when CDC catches up." + ], + "cat": "code", + "_slide": 32, + "_why": "matches _docs/04 Python tab (Pydantic 2 model_dump_json)" + }, + { + "op": "set_text", + "sldId": 298, + "shape_id": 482, + "paragraphs": [ + "A windowed aggregation with faust-streaming" + ], + "cat": "stack", + "_slide": 43, + "_why": "Faust -> faust-streaming 0.15.3 (locked stack)" + }, + { + "op": "set_text", + "sldId": 298, + "shape_id": 484, + "paragraphs": [ + "python · faust-streaming" + ], + "cat": "stack", + "_slide": 43, + "_why": "chip" + }, + { + "op": "set_code", + "sldId": 298, + "shape_id": 485, + "lines": [ + "import faust # faust-streaming 0.15.3; the import name is faust", + "from datetime import timedelta", + "app = faust.App(\"stream-processor\", broker=\"kafka://my-cluster-kafka-bootstrap\",", + " store=\"memory://\") # state is rebuilt from the changelog topic", + "orders_topic = app.topic(\"order.placed\", value_type=Order)", + "revenue_topic = app.topic(\"revenue.by-merchant\", value_type=Revenue)", + "# table = local, changelog-backed state: tumbling windows, no grace, by record time", + "revenue = app.Table(\"revenue\", default=float) \\", + " .tumbling(300, expires=timedelta(seconds=300)) \\", + " .relative_to_stream()", + "@app.agent(orders_topic)", + "async def aggregate(stream):", + " async for order in stream.group_by(Order.merchant_id): # repartition by merchant", + " revenue[order.merchant_id] += order.total # stateful, per key and window", + " start = int(faust.current_event().message.timestamp // 300) * 300", + " await revenue_topic.send(key=order.merchant_id, value=Revenue(", + " window_start=start, window_end=start + 300, merchant_id=order.merchant_id,", + " revenue=revenue[order.merchant_id].value())) # derived stream out" + ], + "cat": "code", + "_slide": 43, + "_why": "matches _docs/06 Python tab; same topics, memory store and windowing as examples/06-stream-processing/python/stream-processor/main.py", + "pt": 12.0, + "_fit": "slide 43: 18 code lines, 91 columns; 12.5 pt overflows the box" + }, + { + "op": "set_notes", + "sldId": 298, + "paragraphs": [ + "faust-streaming 0.15.3 is the maintained fork of Faust; the import name is still faust. The Table is the key idea: local state, a running revenue total per merchant and window, backed by a Kafka changelog topic, so when the pod dies the state rebuilds by replaying the changelog. The store is memory:// because every write is mirrored to the changelog: fault-tolerant local state without an external database in the hot path.", + "The windows are tumbling five-minute windows with no grace, placed by each record's own timestamp (relative_to_stream). Every update goes to revenue.by-merchant, a derived stream other services consume. The example keeps the same contract as the Kafka Streams, Streamiz and goka versions; mention Flink as the cluster-scale alternative if asked." + ], + "cat": "notes", + "_slide": 43, + "_why": "notes follow the faust-streaming code" + }, + { + "op": "set_code", + "sldId": 310, + "shape_id": 621, + "lines": [ + "# registry_client.py — register a new version; the registry enforces the rule", + "import requests", + "REGISTRY = \"http://apicurio.registry.svc/apis/registry/v3\"", + "def publish(group: str, artifact_id: str, schema: bytes):", + " r = requests.post(", + " f\"{REGISTRY}/groups/{group}/artifacts/{artifact_id}/versions\",", + " headers={\"Content-Type\": \"application/json\"},", + " data=schema,", + " )", + " if r.status_code == 400: # the compatibility rule rejected it", + " raise SystemExit(\"Incompatible schema change: blocking the merge\")", + " r.raise_for_status()", + "# rule set once per artifact, e.g. BACKWARD: new consumers can read old data" + ], + "cat": "code", + "_slide": 55, + "_why": "Apicurio 3.3 returns 400 (not 409) on a rule violation; _docs/09 Python tab and examples/09-api-registry/verify.sh" + }, + { + "op": "replace", + "scope": "notes", + "find": "Apicurio returns 409", + "repl": "Apicurio returns 400", + "expect": 1, + "sldIds": [ + 310 + ], + "cat": "version", + "_slide": 55, + "_why": "Apicurio 3.3.3 answers 400 to an incompatible schema" + }, + { + "op": "replace", + "scope": "slide", + "find": "async def request_upload(user: User = Depends(current_user),", + "repl": "async def request_upload(body: SignRequest,", + "expect": 1, + "sldIds": [ + 333 + ], + "cat": "code", + "_slide": 78, + "_why": "a parameter without a default cannot follow Depends(...): SyntaxError" + }, + { + "op": "replace", + "scope": "slide", + "find": " body: SignRequest) -> UploadTicket:", + "repl": " user: User = Depends(current_user)) -> UploadTicket:", + "expect": 1, + "sldIds": [ + 333 + ], + "cat": "code", + "_slide": 78, + "_why": "same" + }, + { + "op": "replace", + "scope": "slide", + "find": "order.dict()", + "repl": "order.model_dump()", + "expect": 2, + "sldIds": [ + 361 + ], + "cat": "code", + "_slide": 106, + "_why": "Pydantic 2.14: .dict() is deprecated, model_dump() replaces it" + }, + { + "op": "set_code", + "sldId": 372, + "shape_id": 1291, + "lines": [ + "// envelope.proto — one versioned wrapper for every WebSocket message", + "message Envelope {", + " uint32 version = 1; // bump for an incompatible payload change", + " uint64 seq = 2; // per-connection monotonic counter — drives resume", + " string type = 3; // payload discriminator, e.g. \"OrderPlaced\"", + " bytes payload = 4; // a nested protobuf message, already serialised", + "}", + "# server: one binary frame per event instead of JSON text", + "async def push(sock: WebSocket, seq: int, event: OrderPlaced):", + " env = Envelope(", + " version=2, seq=seq, type=\"OrderPlaced\",", + " payload=event.SerializeToString()) # nested protobuf", + " await sock.send_bytes(env.SerializeToString()) # one binary frame", + "# client checks env.version; unknown major version -> reconnect & renegotiate" + ], + "cat": "code", + "_slide": 117, + "_why": "field numbers and type values match _docs/16 envelope.proto and Python tab" + }, + { + "op": "replace", + "scope": "slide", + "find": "s.status = \"DONE\"", + "repl": "s.status = \"COMPLETED\"", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "code", + "_slide": 122, + "_why": "example status COMPLETED" + }, + { + "op": "replace", + "scope": "slide", + "find": "s.status = \"FAILED\"", + "repl": "s.status = \"COMPENSATED\"", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "code", + "_slide": 124, + "_why": "example status COMPENSATED" + }, + { + "op": "replace", + "scope": "notes", + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "notes", + "_slide": 123, + "_why": "example status" + }, + { + "op": "replace", + "scope": "slide", + "find": "a Faust agent for streams", + "repl": "a faust-streaming agent for streams", + "expect": 1, + "sldIds": [ + 441 + ], + "cat": "stack", + "_slide": 186, + "_why": "locked stack name" + }, + { + "op": "set_code", + "sldId": 476, + "shape_id": 2448, + "lines": [ + "BATCH, PERIOD_S = 100, 1.0 # excerpt: cache (redis.asyncio) and db defined elsewhere", + "async def flusher():", + " while True: # 2. drain the dirty set to the DB", + " await asyncio.sleep(PERIOD_S)", + " ids = await cache.spop(\"metric:dirty\", BATCH)", + " rows = []", + " for mid in ids:", + " data = await cache.hgetall(f\"metric:{mid}\")", + " if data: rows.append((mid.decode(), json.dumps(dict(data))))", + " if not rows: continue", + " try:", + " await db.execute_many(\"INSERT INTO metrics (id, payload) VALUES ($1, $2)\", rows)", + " except Exception: # 3. on failure, re-mark dirty", + " await cache.sadd(\"metric:dirty\", *[m for m, _ in rows])", + "@asynccontextmanager", + "async def lifespan(app):", + " task = asyncio.create_task(flusher())", + " yield", + " task.cancel()", + "app = FastAPI(lifespan=lifespan) # created once, with the flusher", + "@app.put(\"/metrics/{mid}\")", + "async def write_metric(mid: str, body: dict):", + " await cache.hset(f\"metric:{mid}\", mapping=body) # 1. cache only; return now", + " await cache.sadd(\"metric:dirty\", mid) # mark for flush", + " return {\"ok\": True} # fast — microseconds" + ], + "cat": "code", + "_slide": 221, + "_why": "@app.on_event is deprecated in FastAPI 0.143.0; lifespan as in _docs/25 Python tab", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 221: 25 code lines, 93 columns; 9.5 pt overflows the box" + }, + { + "op": "set_code", + "sldId": 478, + "shape_id": 2472, + "lines": [ + "# refresh-ahead (excerpt): cache, db, time and asyncio as in cache-aside", + "TTL_S = 60", + "REFRESH_BEFORE_S = 10 # refresh when TTL drops below this", + "async def refresher():", + " while True:", + " await asyncio.sleep(5)", + " cutoff = time.time() - 300 # keys touched in last 5 min", + " await cache.zremrangebyscore(\"product:hot\", 0, cutoff)", + " for pid_b in await cache.zrange(\"product:hot\", 0, -1):", + " pid = pid_b.decode()", + " ttl = await cache.ttl(f\"product:{pid}\")", + " if 0 < ttl < REFRESH_BEFORE_S: # refresh window", + " row = await db.fetch_one(\"SELECT * FROM products WHERE id=$1\", pid)", + " if row:", + " await cache.setex(f\"product:{pid}\", TTL_S, json.dumps(dict(row)))", + "@asynccontextmanager", + "async def lifespan(app):", + " task = asyncio.create_task(refresher())", + " yield", + " task.cancel()", + "app = FastAPI(lifespan=lifespan) # created once, with the refresher", + "@app.get(\"/products/{pid}\")", + "async def get_product(pid: str):", + " await cache.zadd(\"product:hot\", {pid: time.time()}) # touch hot-list", + " ... # then cache-aside on the key" + ], + "cat": "code", + "_slide": 223, + "_why": "@app.on_event is deprecated; lifespan as in _docs/25 Python tab (read path shortened)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 223: 25 code lines, 91 columns; 9.0 pt overflows the box" + }, + { + "op": "replace", + "scope": "both", + "find": " and run Example 05 on minikube and OpenShift Local.", + "repl": " and run Example 05 on minikube.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "repl": "Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'I need to get data from SFTP to Kafka to S3 with these transforms in the middle'", + "repl": "'I need to get data from SFTP to Kafka to an object store with these transforms in the middle'", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "KMS for keys, CloudTrail / audit logging, region", + "repl": "KMS for keys, audit logging, region", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'someone left an S3 bucket open'", + "repl": "'someone left a storage bucket open to the internet'", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: requires the storage service to validate tokens (S3, Azure Blob, GCS all do); ", + "repl": "Cons: requires the storage side to validate tokens; ", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "The canonical implementations: S3 pre-signed URLs (15-minute upload tokens for a specific bucket+key+method), Azure SAS tokens (Shared Access Signatures with per-resource scoping), GCS signed URLs, AWS STS AssumeRole with a tight scoping policy.", + "repl": "Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape.", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD)", + "repl": "validates JWTs from a modern IdP (Keycloak, Dex)", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Edge (IAP): Google IAP, Cloudflare Access, Pomerium, AWS Verified Access — ", + "repl": "Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — ", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Pomerium is open source; Google IAP / Cloudflare Access / AWS Verified Access are managed services.", + "repl": "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "they authenticate against your IdP (Keycloak, Auth0, Azure AD, Google Workspace)", + "repl": "they authenticate against your IdP (Keycloak, or Dex federating an existing directory)", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "into a secure object store (S3, Azure Blob, GCS) and publishes", + "repl": "into a secure object store and publishes", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Valet key requires storage-side validation — S3, Azure Blob, GCS all support it natively, so the cost is usually just understanding the SDK.", + "repl": "Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "; SAS (Shared Access Signature, Azure's scoped token)", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "SAS is Azure's valet-key token; ", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "(Kong, Apigee, Istio gateway, ", + "repl": "(Kong, Istio gateway, ", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "AWS Builders' Library (timeouts, retries, backoff with jitter)", + "repl": "the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling)", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "the AWS Builders' Library has excellent practical articles on timeouts and backoff-with-jitter;", + "repl": "the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol;", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 866, + "paragraphs": [ + "Valet key — scoped, time-bound, op-restricted" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 868, + "paragraphs": [ + "python · FastAPI + hmac (stdlib)" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 333, + "shape_id": 869, + "lines": [ + "# main.py (excerpt), examples/12-security/python: an HMAC-SHA256 valet key", + "VALET_SECRET = os.environ.get(\"VALET_SECRET\", \"demo-secret-do-not-use-in-prod\")", + "", + "def _mint_valet(resource: str, operation: str, ttl: int = 300) -> dict:", + " expires = int(time.time()) + ttl # time-bound", + " payload = f\"{resource}:{operation}:{expires}\" # one object, one operation", + " sig = hmac.new(VALET_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()", + " return {\"resource\": resource, \"operation\": operation, \"expires\": expires, \"token\": sig}", + "", + "def _verify_valet(resource: str, operation: str, expires: int, token: str) -> bool:", + " if time.time() > expires:", + " return False # expired: worthless", + " payload = f\"{resource}:{operation}:{expires}\"", + " expected = hmac.new(", + " VALET_SECRET.encode(), payload.encode(), hashlib.sha256", + " ).hexdigest()", + " return hmac.compare_digest(token, expected) # constant-time compare", + "", + "@app.post(\"/valet-key\")", + "async def mint_key(resource: str, operation: str = \"GET\"):", + " return _mint_valet(resource, operation)", + "# GET /verify-valet answers 403 for an expired or tampered key" + ], + "cat": "code", + "_src": "examples/12-security/python/order-service/main.py (_mint_valet, _verify_valet, /valet-key)", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 333, + "paragraphs": [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (hmac.new with hashlib.sha256). POST /valet-key returns the resource, the operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with hmac.compare_digest, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403.", + "The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 968, + "paragraphs": [ + "python · aiokafka + valet key" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 342, + "shape_id": 969, + "lines": [ + "# claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "async def publish_with_claim(producer: AIOKafkaProducer, order_id: str, body: bytes):", + " key = f\"payloads/{order_id}\"", + " await store.put(key, body, encrypt=True) # encrypted at rest, ACL'd", + " claim = {", + " \"order_id\": order_id,", + " \"uri\": f\"https://objects.internal/{key}\",", + " **_mint_valet(key, \"GET\", ttl=900), # resource, op, expires, token", + " }", + " await producer.send_and_wait( # only the claim goes on the topic", + " \"order.documents\", json.dumps(claim).encode(), key=order_id.encode())", + "", + "# consumer: read the claim, fetch the body out of band", + "async for msg in consumer:", + " claim = json.loads(msg.value)", + " body = await store.get(claim[\"uri\"], token=claim[\"token\"]) # the store checks the ticket", + " await handle(body) # PII never touched the topic", + " await consumer.commit() # after the side effect" + ], + "cat": "code", + "_src": "_docs/12-security.md claim-check codetab (site-fixes.md); ticket from examples/12-security", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 342, + "paragraphs": [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's _mint_valet (900 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with aiokafka's send_and_wait. The PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 332, + "shape_id": 856, + "png": "lgtm-presentation/edits/png/12-valet-key.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "labels named S3, Azure SAS, GCS, STS (tools/vendor_figures.py)" + }, + { + "op": "replace_picture", + "sldId": 335, + "shape_id": 889, + "png": "lgtm-presentation/edits/png/12-zero-trust.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "examples named Google IAP, Cloudflare Access, AWS Verified Access" + }, + { + "op": "replace_picture", + "sldId": 427, + "shape_id": 1891, + "png": "lgtm-presentation/edits/png/22-l7-layers.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "API gateway row named Apigee; site figure as in the other decks" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 30, + "paragraphs": [ + "07 · Workflows & Jobs", + "08 · API Management", + "09 · API Registry", + "10 · API Metadata", + "11 · Observability", + "12 · Security", + "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices K–L · Monolith → Microservices, Caching Patterns", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendix R · Example 05 on minikube", + "Glossary of acronyms" + ], + "cat": "new", + "_why": "agenda gains Appendices R-S (C++: R)" + }, + { + "op": "set_notes", + "sldId": 257, + "paragraphs": [ + "The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns.", + "The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck.", + "Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 516, + "as": "r0", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 3, + "paragraphs": [ + "R" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 4, + "paragraphs": [ + "Example 05 on minikube" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 5, + "paragraphs": [ + "The same services and the same verify.sh on a single-node Kubernetes cluster" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r0", + "paragraphs": [ + "Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod.", + "The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r1", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r1", + "text": "244", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 5, + "paragraphs": [ + "From compose to a cluster" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 6, + "paragraphs": [ + "Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress.", + "Scope: Example 05 (event-driven fan-out), three services, all six languages, one node.", + "One Kafka broker, one Postgres instance, no service mesh: a local topology, not production.", + "The contract carries over: Service names, the Secret, environment variables, the health endpoint.", + "The same verify.sh runs against compose and the cluster: ten checks per language." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 7, + "paragraphs": [ + "Site: Appendix R · Code: examples/05-event-driven/k8s/" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r1", + "paragraphs": [ + "What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it.", + "What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged.", + "The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r2", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r2", + "text": "245", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 5, + "paragraphs": [ + "The minikube runtime" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 6, + "paragraphs": [ + "Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5.", + "8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services.", + "NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation.", + "No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop.", + "Every command passes --context cndp-k8s; setup-profile.sh restores the prior context." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 7, + "paragraphs": [ + "minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r2", + "paragraphs": [ + "minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version.", + "Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1.", + "The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r3", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r3", + "text": "246", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 5, + "paragraphs": [ + "One node, two namespaces" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r3", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-topology.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 7, + "paragraphs": [ + "The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r3", + "paragraphs": [ + "The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret.", + "From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r4", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r4", + "text": "247", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 5, + "paragraphs": [ + "Compose to Kubernetes" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 6, + "paragraphs": [ + "kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1).", + "postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw.", + "Credential → Secret cndp-postgres-app, read through secretKeyRef.", + "build: → docker build, then minikube image load; imagePullPolicy: Never.", + "healthcheck → startup and readiness probes on the order-service /healthz.", + "ports: 8080:8080 → NodePort 30080." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 7, + "paragraphs": [ + "The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r4", + "paragraphs": [ + "Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts.", + "The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r5", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r5", + "text": "248", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 5, + "paragraphs": [ + "One overlay per language" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 6, + "paragraphs": [ + "yaml · kustomize" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r5", + "shape_id": 8, + "lines": [ + "# k8s/overlays/python/kustomization.yaml (excerpt)", + "images:", + " - name: order-service", + " newName: localhost/cndp-05/python-order-service", + " newTag: v1", + " # shipping-consumer and notification-consumer map the same way", + "# appended to each Deployment's environment by a JSON patch", + "- name: DATABASE_URL", + " value: \"postgres://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb\"", + "- name: KAFKA_BOOTSTRAP", + " value: \"cndp-kafka-bootstrap:9092\"", + "# memory: requests 128Mi, limits 256Mi" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 9, + "paragraphs": [ + "base/ is language-neutral; each overlay maps the images and appends the variables its language reads." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r5", + "paragraphs": [ + "The Python overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service.", + "Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r6", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r6", + "text": "249", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 5, + "paragraphs": [ + "Images built on the host" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r6", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-image-flow.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 7, + "paragraphs": [ + "docker build on the host, minikube image load into the node; pods never pull" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r6", + "paragraphs": [ + "The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load.", + "The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r7", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r7", + "text": "250", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 5, + "paragraphs": [ + "Run it" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 6, + "paragraphs": [ + "bash · minikube" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r7", + "shape_id": 8, + "lines": [ + "# from examples/05-event-driven/, with no other profile or container running", + "k8s/setup-profile.sh # create cndp-k8s; check the published ports", + "k8s/build-images.sh python # docker build, then minikube image load", + "k8s/deploy.sh python # Strimzi, Kafka, Postgres, LGTM, then the overlay", + "RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh", + "", + "# all six languages, a fresh namespace for each", + "OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh", + "", + "# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile", + "k8s/teardown.sh --operator", + "minikube stop -p cndp-k8s" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 9, + "paragraphs": [ + "With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r7", + "paragraphs": [ + "The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Python images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay.", + "verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0.", + "What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r8", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r8", + "text": "251", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 5, + "paragraphs": [ + "Recorded results" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 6, + "paragraphs": [ + "10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0).", + "Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s.", + "Tempo received traces from order-service, shipping-consumer and notification-consumer in every language.", + "shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset.", + "Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 7, + "paragraphs": [ + "Times cover the image build and load, the deploy, the checks and the namespace teardown." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r8", + "paragraphs": [ + "The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language.", + "The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster." + ], + "cat": "new" + }, + { + "op": "resize_shape", + "sldId": 256, + "shape_id": 20, + "y": 5007611, + "cy": 467359, + "cat": "fit", + "_fit": "slide 1: text overflows its frame by overflow-top 2.3 pt, overflow-bottom 3.3 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 257, + "shape_id": 30, + "y": 1589592, + "cy": 4688780, + "cat": "fit", + "_fit": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "op": "fit_font", + "sldId": 272, + "shape_id": 190, + "pt": 12.0, + "cat": "fit", + "_fit": "slide 17: 18 code lines, 77 columns; 12.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 273, + "shape_id": 202, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 18: chip 'protobuf + python · grpc' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 284, + "shape_id": 327, + "cx": 3053050, + "keep": "right", + "cat": "fit", + "_fit": "slide 29: chip 'python · FastAPI + asyncpg + aiokafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 287, + "shape_id": 361, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 32: chip 'python · asyncpg + Debezium' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 298, + "shape_id": 484, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 43: chip 'python · faust-streaming' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 319, + "shape_id": 716, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 64: chip 'python · OpenTelemetry' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 321, + "shape_id": 740, + "cx": 2824450, + "keep": "right", + "cat": "fit", + "_fit": "slide 66: chip 'python · OpenTelemetry baggage API' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 324, + "shape_id": 774, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 328, + "shape_id": 815, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 329, + "shape_id": 825, + "cy": 4597340, + "cat": "fit", + "_fit": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 331, + "shape_id": 846, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 333, + "shape_id": 868, + "cx": 2672050, + "keep": "right", + "cat": "fit", + "_fit": "slide 78: chip 'python · FastAPI + hmac (stdlib)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 338, + "shape_id": 922, + "cx": 3357849, + "keep": "right", + "cat": "fit", + "_fit": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 340, + "shape_id": 945, + "cx": 2214850, + "keep": "right", + "cat": "fit", + "_fit": "slide 85: chip 'python · asyncio + aiohttp' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 342, + "shape_id": 968, + "cx": 2400000, + "keep": "right", + "cat": "fit", + "_fit": "slide 87: chip 'python · aiokafka + valet key' on one line at 10.0 pt, clear of the first code line" + }, + { + "op": "resize_shape", + "sldId": 387, + "shape_id": 1462, + "cx": 2443450, + "keep": "right", + "cat": "fit", + "_fit": "slide 132: chip 'python · grpc + grpcio-status' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 401, + "shape_id": 1617, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 146: chip 'python · ports & adapters' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 419, + "shape_id": 1805, + "cx": 2214850, + "keep": "right", + "cat": "fit", + "_fit": "slide 164: chip 'python · FastAPI + uvicorn' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 419, + "shape_id": 1807, + "cy": 436820, + "cat": "fit", + "_fit": "slide 164: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 420, + "shape_id": 1820, + "cy": 436820, + "cat": "fit", + "_fit": "slide 165: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "fit_font", + "sldId": 431, + "shape_id": 1938, + "pt": 11.0, + "cat": "fit", + "_fit": "slide 176: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 432, + "shape_id": 1950, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 177: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 437, + "shape_id": 2006, + "pt": 10.0, + "cat": "fit", + "_fit": "slide 182: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 438, + "shape_id": 2018, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 183: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 443, + "shape_id": 2075, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 188: chip 'python · durable-rules' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 444, + "shape_id": 2088, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 189: chip 'python · FastAPI + aiokafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 449, + "shape_id": 2143, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 194: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 461, + "shape_id": 2273, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 206: chip 'python · FastAPI + httpx' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 463, + "shape_id": 2297, + "cx": 3510250, + "keep": "right", + "cat": "fit", + "_fit": "slide 208: chip 'python · FastAPI + httpx + redis + aiokafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 464, + "shape_id": 2309, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 209: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 468, + "shape_id": 2351, + "cx": 3510250, + "keep": "right", + "cat": "fit", + "_fit": "slide 213: chip 'python · FastAPI + redis.asyncio + Postgres' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 470, + "shape_id": 2375, + "cx": 3738850, + "keep": "right", + "cat": "fit", + "_fit": "slide 215: chip 'python · read-through wrapper on redis.asyncio' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 472, + "shape_id": 2399, + "cx": 4196050, + "keep": "right", + "cat": "fit", + "_fit": "slide 217: chip 'python · write-through with redis.asyncio + Postgres' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 472, + "shape_id": 2400, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 217: 24 code lines, 80 columns; 10.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 474, + "shape_id": 2423, + "cx": 4348450, + "keep": "right", + "cat": "fit", + "_fit": "slide 219: chip 'python · write-around with selective read-side caching' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 476, + "shape_id": 2447, + "cx": 3891250, + "keep": "right", + "cat": "fit", + "_fit": "slide 221: chip 'python · write-back with asyncio background task' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 478, + "shape_id": 2471, + "cx": 3586450, + "keep": "right", + "cat": "fit", + "_fit": "slide 223: chip 'python · refresh-ahead with Redis sorted set' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 480, + "shape_id": 2493, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 225: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 492, + "shape_id": 2622, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 237: chip 'python · httpx + asyncio' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 500, + "shape_id": 2711, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 245: chip 'python · FastAPI lifespan' wraps; one line at 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 340, + "shape_id": 946, + "cuts": [ + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 8, + "to": 8, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 85: 28 code lines do not fit the box at 9 pt; excerpt of 26 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 449, + "shape_id": 2144, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 194: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 461, + "shape_id": 2274, + "cuts": [ + { + "from": 2, + "to": 4, + "first": "# In production this would typically live in a separate sidecar / proxy,", + "last": "" + }, + { + "from": 6, + "to": 6, + "first": "from fastapi.responses import StreamingResponse" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 14, + "to": 14, + "first": "" + }, + { + "from": 16, + "to": 16, + "first": "" + }, + { + "from": 31, + "to": 39, + "first": "", + "last": " headers=dict(r.headers))", + "ellipsis": "# ... forward(): replays the request to upstream with httpx and streams the reply" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 206: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 463, + "shape_id": 2298, + "cuts": [ + { + "from": 5, + "to": 6, + "first": "# The legacy service is unchanged.", + "last": "" + }, + { + "from": 7, + "to": 10, + "first": "from fastapi import FastAPI", + "last": "import httpx, json", + "ellipsis": "# ... imports: FastAPI, redis.asyncio.Redis, aiokafka.AIOKafkaProducer, httpx, json" + }, + { + "from": 11, + "to": 11, + "first": "" + }, + { + "from": 16, + "to": 16, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 208: 32 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.5 pt" + }, + { + "op": "cut_code", + "sldId": 468, + "shape_id": 2352, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 14, + "to": 14, + "first": "" + }, + { + "from": 19, + "to": 19, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 213: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 11.0 pt" + }, + { + "op": "cut_code", + "sldId": 470, + "shape_id": 2376, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "# Wrap Redis with a loader function. Callers just call get(); the wrapper", + "last": "" + }, + { + "from": 4, + "to": 6, + "first": "from redis.asyncio import Redis", + "last": "import json", + "ellipsis": "# ... imports: redis.asyncio.Redis, typing, json" + }, + { + "from": 11, + "to": 14, + "first": " self.redis = redis", + "last": " self.prefix = prefix", + "ellipsis": " # ... keeps redis, loader (called on cache miss), ttl and prefix" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 31, + "to": 31, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 215: 34 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 474, + "shape_id": 2424, + "cuts": [ + { + "from": 13, + "to": 20, + "first": "# Optional defensive variant: invalidate any stale entry, but don't populate", + "last": "", + "ellipsis": "# ... optional variant: the same INSERT, then cache.delete(f\"event:{id}\")" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 219: 31 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "cat": "fit", + "_fit": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "cat": "fit", + "_fit": "references: the reference lines run past the frame into the page number; the frame cannot grow" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "fit", + "_fit": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix1.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix1.json new file mode 100644 index 00000000..badd360c --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix1.json @@ -0,0 +1,1872 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 284 + ], + "find": "an @Incoming(\"order-created\") method (topic order.created)", + "repl": "an @Incoming(\"order-placed\") method (topic order.placed)", + "expect": 1, + "cat": "code", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 321 + ], + "find": "@Incoming(\"order.placed\")", + "repl": "@Incoming(\"order-placed\") // topic order.placed", + "expect": 1, + "cat": "code", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "new Semaphore(20)", + "repl": "new Semaphore(5)", + "expect": 1, + "cat": "code", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 340 + ], + "find": "fills its 20 slots", + "repl": "fills its 5 slots", + "expect": 1, + "cat": "code", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "Metric.persistAll(loadFromCache(ids)); // batch insert", + "repl": "Metric.persistAll(loadFromCache(ids)); Metric.flush(); // batch insert", + "expect": 1, + "cat": "code", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "// MicroProfile applies the breaker first, then retry within the closed state.", + "repl": "// Retry wraps the breaker; an open breaker fails each retry fast.", + "expect": 1, + "cat": "code", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 511 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 410 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 417 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 441 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 451 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 458 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 464 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "constraint AND eventual durability is acceptable AND your", + "repl": "constraint, eventual durability is acceptable and your", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 482 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 487 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 501 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 503 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 494 + ], + "find": "// ALWAYS pair with a breaker", + "repl": "// always pair with a breaker", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "# BUILD time", + "repl": "# build time", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 381 + ], + "find": "each task's OUTPUT becomes the next task's INPUT", + "repl": "each task's output becomes the next task's input", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "find": "handlers MUST be idempotent", + "repl": "handlers must be idempotent", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "WITHOUT registering it", + "repl": "without registering it", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 382 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 384 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 392 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 416 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 419 + ], + "find": "a BUILD-time flag", + "repl": "a build-time flag", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 436 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 454 + ], + "find": "when does THIS monolith", + "repl": "when does this monolith", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 455 + ], + "find": "If you DO need", + "repl": "If you do need", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 462 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 465 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 467 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 485 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 488 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 491 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 499 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 502 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 381 + ], + "find": "line from two slides back", + "repl": "line in the persisted-state code", + "expect": 1, + "cat": "positional", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 433 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 449 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "storm two slides back", + "repl": "storm from the amplification-loop discussion", + "expect": 1, + "cat": "positional", + "_src": "Q.fix1" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 333 + ], + "find": "Valet key — scoped", + "repl": "Valet key — scoped", + "expect": 1, + "cat": "voice", + "_src": "Q.fix2" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 378 + ], + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "cat": "code", + "_src": "Q.fix2" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 440 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code", + "_src": "Q.fix2" + }, + { + "op": "fit_font", + "sldId": 472, + "shape_id": 2400, + "pt": 11.5, + "cat": "fit", + "_src": "Q.fix2" + }, + { + "op": "set_geometry", + "sldId": 531, + "shape": "Image 1", + "x": 2.16, + "y": 1.95, + "w": 9.01, + "h": 4.3, + "cat": "fit", + "_src": "Q.fix2" + }, + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure heading 'Dual-write is a lie' -> 'Dual-write is unsafe' (src/04-dual-write-outbox.svg)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 336 + ], + "find": "validate identity (OAuth/OIDC against your IdP), check device posture, then issue a JWT", + "repl": "validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT", + "expect": 1, + "cat": "code", + "_why": "OAuth2 Proxy has no device-posture check; the proxies enforce per-route identity policy", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 344 + ], + "find": "; Microsoft Cloud Adoption Framework: security patterns.", + "repl": "; OWASP Microservices Security Cheat Sheet.", + "expect": 1, + "cat": "stack", + "_why": "vendor-neutral reference (decision 7)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "tracer.start_span(...), span.set_attribute(...), span.add_event(...)", + "repl": "tracer.spanBuilder(...).startSpan(), span.setAttribute(...), span.addEvent(...)", + "expect": 1, + "cat": "code", + "_why": "OpenTelemetry Java API names, not Python's", + "_src": "content" + }, + { + "op": "set_code", + "sldId": 298, + "lines": [ + "// quarkus-kafka-streams: the topology is a CDI-produced bean", + "@ApplicationScoped", + "public class RevenueTopology {", + " @Produces", + " public Topology build() {", + " StreamsBuilder b = new StreamsBuilder();", + " var windowSerde = WindowedSerdes.timeWindowedSerdeFrom(String.class, 300_000L);", + " b.stream(\"order.placed\", Consumed.with(Serdes.String(), orderSerde))", + " .groupBy((k, o) -> o.merchantId())", + " .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))", + " .aggregate(() -> 0.0, // local, changelog-backed", + " (mId, o, total) -> total + o.total(),", + " Materialized.as(\"revenue-by-merchant\")) // state store", + " .toStream()", + " .to(\"revenue.by-merchant\", Produced.with(windowSerde, Serdes.Double()));", + " return b.build();", + " }", + "}" + ], + "cat": "code", + "_why": "Kafka Streams: the windowed Double result needs explicit serdes on .to(...)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 294 + ], + "find": "Event-driven tools landscape — Kafka", + "repl": "Event-driven tools landscape — Kafka", + "expect": 1, + "cat": "voice", + "_why": "title spacing (one space around the dash, as the other titles)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 351 + ], + "find": "Glossary of acronyms (1 of 2) — protocols", + "repl": "Glossary of acronyms (1 of 2) — protocols", + "expect": 1, + "cat": "voice", + "_why": "title spacing, as the (2 of 2) title", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 362 + ], + "find": "a field with a NEW number", + "repl": "a field with a new number", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: brand-new field", + "repl": "safe: brand-new field", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: new field; old clients", + "repl": "safe: new field; old clients", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "BREAKING (don't)", + "repl": "breaking (don't)", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 368 + ], + "find": "per-POD registry", + "repl": "per-pod registry", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "IDENTIFY: pick", + "repl": "Identify: pick", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "MOVE: build", + "repl": "Move: build", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 429 + ], + "find": " and give you the mesh CRDs we'll see next.", + "repl": " and give you the mesh CRDs (VirtualService, DestinationRule).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 407 + ], + "find": "Strength is a ladder we'll see next (intrusive to contract).", + "repl": "Strength is a ladder (intrusive to contract).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 441 + ], + "find": "(and the one we'll see code for)", + "repl": "(and the one the code models)", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 490 + ], + "find": "The next slides cover each with code; the final matrix maps patterns to modes.", + "repl": "Each one gets code, and a matrix maps patterns to modes.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 457 + ], + "find": "The next slides cover three ways to do the redirect:", + "repl": "There are three ways to do the redirect:", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 490 + ], + "find": "The next slides give each its own diagram and per-language code", + "repl": "Each gets its own diagram and per-language code", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 270 + ], + "find": "The next slides give you a decision rule and then show each protocol in code", + "repl": "The section gives a decision rule and then shows each protocol in code", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "payload modification that we'll see more of — the L7 router", + "repl": "payload modification — the L7 router", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "state, which is the next section.", + "repl": "state, the subject of stream processing.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 329 + ], + "find": "that the next slides alternate between pattern-and-implementation pairs", + "repl": "that the section alternates between pattern-and-implementation pairs", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "recolour_code", + "sldId": 273, + "shape_id": 203, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 279, + "shape_id": 272, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 287, + "shape_id": 362, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 291, + "shape_id": 407, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 319, + "shape_id": 717, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 321, + "shape_id": 741, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 333, + "shape_id": 869, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 340, + "shape_id": 946, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 342, + "shape_id": 969, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 361, + "shape_id": 1165, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 363, + "shape_id": 1189, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 365, + "shape_id": 1213, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 368, + "shape_id": 1246, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 372, + "shape_id": 1291, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 379, + "shape_id": 1370, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 381, + "shape_id": 1394, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 389, + "shape_id": 1487, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 401, + "shape_id": 1618, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 419, + "shape_id": 1806, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 443, + "shape_id": 2076, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 444, + "shape_id": 2089, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 449, + "shape_id": 2144, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 461, + "shape_id": 2274, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 470, + "shape_id": 2376, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 472, + "shape_id": 2400, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 474, + "shape_id": 2424, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 476, + "shape_id": 2448, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 478, + "shape_id": 2472, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 492, + "shape_id": 2623, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 494, + "shape_id": 2646, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 500, + "shape_id": 2712, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 508, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 521, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 522, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 524, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 533, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 535, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix2.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix2.json new file mode 100644 index 00000000..a7d66691 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix2.json @@ -0,0 +1,12 @@ +{ + "ops": [ + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure footer: the absolute 'can never be lost' claim softened (src/04-dual-write-outbox.svg)" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix3.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix3.json new file mode 100644 index 00000000..24753473 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix3.json @@ -0,0 +1,382 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Kafka is the substrate — a partitioned", + "repl": "Kafka is the substrate: a partitioned", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup (6 per paragraph)", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Pulsar is also substrate — same broad category", + "repl": "Pulsar is also substrate, in the same broad category", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Flink is compute — not a transport layer at all, but", + "repl": "Flink is compute: not a transport layer, but", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NiFi is movement — a visual", + "repl": "NiFi is movement: a visual", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "They complement — pick by", + "repl": "They complement each other; pick by", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "This is the slide the user specifically asked for: distinguish auto-injection from auto-instrumentation from manual instrumentation — three things people conflate.", + "repl": "Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request; em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "wrap known frameworks — your HTTP handler", + "repl": "wrap known frameworks: your HTTP handler", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "a Kubernetes-level mechanism — the OpenTelemetry Operator", + "repl": "a Kubernetes-level mechanism; the OpenTelemetry Operator", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Python — ", + "repl": "Python: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Java/Quarkus — ", + "repl": "Java/Quarkus: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "The cost: eventual consistency — there is a small lag", + "repl": "The cost: eventual consistency, a small lag", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "happily handles both — CQRS adds", + "repl": "handles both; CQRS adds", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "for projections — we cover both distinctions in section 5.", + "repl": "for projections; the Event-Driven section draws both distinctions.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup; cross-reference by concept", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 324 + ], + "find": "The sub-bullet is the reminder the user asked for: ", + "repl": "The sub-bullet is the reminder: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "This is the specific case you asked about, and a common source of bad designs.", + "repl": "This case is a common source of bad designs.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 535 + ], + "find": "Fallback: the recorded results that follow.", + "repl": "Fallback: the recorded results.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "and it's the one I'd reach for first when I want to own the transitions.", + "repl": "and the default choice when the team wants to own the transitions.", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "Recovery is beautifully boring:", + "repl": "Recovery needs no special path:", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "set_code", + "sldId": 298, + "cat": "code", + "_why": "Kafka Streams serdes: the old box grouped and materialized with default serdes (runtime ClassCastException) and used a 5-minute window; now the excerpt of examples/06-stream-processing/quarkus RevenueTopology (explicit Grouped/Materialized serdes, window.seconds, String JSON output)", + "lines": [ + "// stream-processor RevenueTopology.java (excerpt): order.placed -> revenue.by-merchant", + "@ApplicationScoped", + "public class RevenueTopology {", + " @Produces // quarkus-kafka-streams runs it", + " public Topology topology() {", + " StreamsBuilder builder = new StreamsBuilder();", + " ObjectMapperSerde accSerde = new ObjectMapperSerde<>(Acc.class);", + " builder.stream(ORDERS, Consumed.with(Serdes.String(), Serdes.String()))", + " .map((key, json) -> parse(json)) // re-key by merchant_id", + " .filter((merchant, amount) -> merchant != null)", + " .groupByKey(Grouped.with(Serdes.String(), Serdes.String()))", + " .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(windowSeconds)))", + " .aggregate(() -> new Acc(0, BigDecimal.ZERO), // local, changelog-backed", + " (merchant, amount, acc) -> add(merchant, acc, amount),", + " Materialized.with(Serdes.String(), accSerde))", + " .toStream()", + " .map((window, acc) -> KeyValue.pair(window.key(), toJson(window, acc)))", + " .to(REVENUE, Produced.with(Serdes.String(), Serdes.String()));", + " return builder.build();", + " }", + "}" + ], + "pt": 10.5, + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 298 + ], + "expect": 1, + "cat": "notes", + "_why": "window and serde as in examples/06-stream-processing/quarkus", + "find": "The 5-minute tumbling window buckets the aggregation by time.", + "repl": "The tumbling window (window.seconds, 10 seconds by default) buckets the aggregation by time; the accumulator, a count and a BigDecimal total, is serialized with ObjectMapperSerde, and each update is written to the output topic as a JSON row (window, merchant_id, order_count, revenue).", + "_src": "content" + }, + { + "op": "set_code", + "sldId": 310, + "lines": [ + "", + "", + " io.apicurio", + " apicurio-registry-maven-plugin", + " 3.3.3", + " ", + " register", + " ", + " http://apicurio.registry.svc/apis/registry/v3", + " true ", + " ", + " orders", + " order-placed", + " AVRO", + " CREATE_VERSION ", + " src/main/avro/order-placed.avsc", + " ", + " ", + " ", + "" + ], + "cat": "code", + "_why": "apicurio-registry-maven-plugin 3.3.3: test-update is deprecated in favour of register + dryRun (TestUpdateRegistryMojo javadoc); dryRun only applies with ifExists set (RegisterRegistryMojo); artifact order-placed as Example 09; plugin version pinned", + "_src": "registry" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 310 + ], + "find": "The test-update goal fails the build", + "repl": "The dry-run register fails the build", + "expect": 1, + "cat": "code", + "_why": "caption follows the goal", + "_src": "registry" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "The apicurio-registry-maven-plugin's test-update goal validates the new schema against the artifact's compatibility rule in the registry without registering it — and fails the build (and the merge) if it's incompatible.", + "repl": "The apicurio-registry-maven-plugin's register goal with dryRun set checks the new schema against the artifact's compatibility rule in the registry without storing it, and fails the build (and the merge) if it is incompatible. The older test-update goal still exists in 3.3.3 but is deprecated in favour of dryRun.", + "expect": 1, + "cat": "notes", + "_why": "notes follow the goal", + "_src": "registry" + }, + { + "op": "replace_picture", + "sldId": 487, + "shape": "Google Shape;2567;p234", + "png": "lgtm-presentation/edits/png/25-gray-failure.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (PERCEIVED DIFFERENTLY, NOT fully down, OBSERVE FROM THE CLIENT, GREEN, OUTLIER DETECTION); replaced by the site figure assets/diagrams/25-gray-failure.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 293, + "shape": "Google Shape;429;p40", + "png": "lgtm-presentation/edits/png/05-sourcing-vs-streaming.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (SOURCING/STREAMING, ARE, IS, TRANSPORT, ABOUT, NOT, EVENT, PROPAGATED); replaced by the site figure assets/diagrams/05-sourcing-vs-streaming.svg (caps lowered there too)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 294, + "shape": "Google Shape;440;p41", + "png": "lgtm-presentation/edits/png/05-tools-landscape.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (COMPLEMENT); replaced by the site figure assets/diagrams/05-tools-landscape.svg (SUBSTRATE/COMPUTE/MOVEMENT are design labels)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 410, + "shape": "Google Shape;1711;p157", + "png": "lgtm-presentation/edits/png/20-coupling-quadrant.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure ('strong OR distant'); replaced by the site figure assets/diagrams/20-coupling-quadrant.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost-drools.png", + "cat": "voice", + "_why": "figure footer: 'where p99 goes to die' rewritten without the drama", + "_src": "hop" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix4.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix4.json new file mode 100644 index 00000000..422d40a2 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.fix4.json @@ -0,0 +1,288 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 269 + ], + "find": "java · Quarkus + SmallRye Health", + "repl": "java · Quarkus REST", + "expect": 1, + "cat": "code", + "_slide": 14, + "_why": "examples/01 quarkus hand-writes /healthz and /readyz", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 269, + "lines": [ + "// HealthResource.java (excerpt), examples/01-cloud-native-principles/quarkus", + "@Path(\"/\") @Produces(MediaType.APPLICATION_JSON)", + "public class HealthResource {", + " @ConfigProperty(name = \"quarkus.datasource.jdbc.url\") String jdbcUrl; // env, factor III", + " @GET @Path(\"healthz\") // liveness: the process is up", + " public Map healthz() { return Map.of(\"status\", \"ok\"); }", + " @GET @Path(\"readyz\") // readiness: SELECT 1 (one retry elided)", + " public Response readyz() {", + " try (var conn = DriverManager.getConnection(jdbcUrl, dbUser, dbPass);", + " var rs = conn.createStatement().executeQuery(\"SELECT 1\")) {", + " return Response.ok(Map.of(\"status\", \"ready\",", + " \"checks\", Map.of(\"database\", \"ok\"))).build();", + " } catch (Exception e) { // down: 503, out of rotation", + " return Response.status(Response.Status.SERVICE_UNAVAILABLE)", + " .entity(Map.of(\"status\", \"down\", \"checks\", Map.of(\"database\", \"unreachable\")))", + " .build();", + " }", + " }" + ], + "cat": "code", + "_slide": 14, + "_why": "examples/01: hand-written /healthz and /readyz; readiness answers 503 when the database is down", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 269, + "cat": "notes", + "_slide": 14, + "_why": "notes follow the code", + "_src": "align", + "text": "Walk the code: config comes from the environment via MicroProfile Config (factor III): QUARKUS_DATASOURCE_JDBC_URL, the username and password and SERVICE_VERSION override application.properties, never a baked-in file. The two probes are plain JAX-RS handlers in Example 01: /healthz is liveness (am I alive? if not, restart me) and never checks a dependency; /readyz is readiness (can I serve?): it runs SELECT 1 over JDBC, retrying once after a second, and when the database is unreachable it answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. SmallRye Health (@Liveness and @Readiness checks on /q/health/live and /q/health/ready) is the extension that serves the same pair without handler code; the example writes them by hand so every language serves the same paths and bodies. Getting liveness vs readiness wrong is a top cause of cascading restarts, so stress the distinction. Maven with the quarkus-bom (platform 3.40.1) pins every dependency reproducibly." + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 287 + ], + "find": "java · Hibernate Panache + Debezium", + "repl": "java · Agroal JDBC + Debezium", + "expect": 1, + "cat": "code", + "_slide": 32, + "_why": "examples/04 quarkus: plain JDBC inserts into the outbox table", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 287, + "lines": [ + "// OrderResource.java (excerpt), examples/04-data/quarkus: two rows, one transaction", + "@POST @Transactional // one JTA transaction around both inserts", + "public Response createOrder(CreateOrderRequest request) throws Exception {", + " String orderId = UUID.randomUUID().toString();", + " try (Connection conn = dataSource.getConnection(); // Agroal enlists it in the txn", + " PreparedStatement stmt = conn.prepareStatement(", + " \"INSERT INTO orders (id, sku, quantity, status) VALUES (?, ?, ?, ?) \"", + " + \"RETURNING created_at\")) {", + " // ... bind orderId, sku, quantity, \"confirmed\"; executeQuery (the state change)", + " try (PreparedStatement outboxStmt = conn.prepareStatement(", + " \"INSERT INTO outbox (aggregate_id, event_type, payload) \"", + " + \"VALUES (?, ?, ?::jsonb)\")) {", + " // ... setString: orderId, \"order.placed\", payloadJson {order_id, sku, quantity}", + " outboxStmt.executeUpdate(); // same transaction: the outbox row", + " }", + " return Response.status(Response.Status.CREATED).entity(order).build();", + " }", + "}", + "// No Kafka call on the request path: Debezium tails the WAL and publishes order.placed." + ], + "cat": "code", + "_slide": 32, + "_why": "examples/04 quarkus: plain JDBC inserts into orders and outbox in one @Transactional method", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 287, + "cat": "notes", + "_slide": 32, + "_why": "notes follow the code", + "_src": "align", + "paragraphs": [ + "The whole trick is the @Transactional method: the insert of the order row and the insert of the outbox row run on one Agroal connection inside one JTA transaction, so they commit or roll back together. There is no Kafka call inside the request path: if the broker is down, the order still commits and the event ships later when Debezium tails the write-ahead log and relays the outbox row in commit order. This decouples request latency and availability from Kafka's.", + "Example 04 needs no outbox extension: the outbox is a small table (aggregate id, event type, JSON payload, created_at), the payload is serialized with Jackson, and a standard Debezium Postgres connector handles the CDC. The Debezium Outbox Event Router single-message transform unwraps the row into the event envelope on the connector side. The same commit-together discipline reappears in the saga appendix." + ] + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 419 + ], + "find": "delay flips readiness DOWN first (a build-time setting), then in-flight HTTP gets `timeout` to finish; only HTTP is automatic — drain the rest in a ShutdownEvent observer.", + "repl": "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, Quarkus.asyncExit(0) drains in-flight HTTP within quarkus.shutdown.timeout (20 s).", + "expect": 1, + "cat": "code", + "_slide": 164, + "_why": "examples/21 quarkus: Signal handler, drain delay, asyncExit (9676f32)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 419, + "lines": [ + "// SignalHandler.java (excerpt), examples/21-graceful-shutdown/quarkus", + "// application.properties: quarkus.shutdown.timeout=20s (bounds the in-flight wait)", + "@ApplicationScoped", + "public class SignalHandler {", + " @Inject ShutdownState shutdownState; // GET /readyz answers 503 once it is set", + " void onStart(@Observes StartupEvent ev) { // replaces the JVM's default SIGTERM handler", + " Signal.handle(new Signal(\"TERM\"), signal -> {", + " if (!shutdownState.markShuttingDown()) return; // one SIGTERM drives the drain", + " long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5", + " new Thread(() -> {", + " try { Thread.sleep(delay * 1000); } // keep serving: endpoints update", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " Quarkus.asyncExit(0); // stop accepting, finish in-flight, close the pool", + " }, \"drain\").start();", + " });", + " }", + "}" + ], + "cat": "code", + "_slide": 164, + "_why": "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight drain within 20 s -> cleanup -> exit 0", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 419, + "cat": "notes", + "_slide": 164, + "_why": "notes follow the code", + "_src": "align", + "text": "Example 21 handles the signal itself, so the whole sequence is visible. A sun.misc.Signal handler, registered on StartupEvent, replaces the JVM's default SIGTERM handler, which would start the shutdown hooks at once. One SIGTERM, the only signal Kubernetes sends before SIGKILL, drives everything: the handler flips the ShutdownState flag, so the hand-written /readyz answers 503 and the Service stops routing, and a drain thread keeps the service serving for DRAIN_DELAY_SECONDS (5 s; it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls Quarkus.asyncExit(0): Quarkus stops accepting connections, waits for in-flight requests up to quarkus.shutdown.timeout (20 s), closes the datasource and exits 0, inside the 30-second grace period. A ShutdownEvent observer is the hook for what Quarkus leaves to you: Kafka consumers, the scheduler, WebSockets. Quarkus also has a built-in pre-shutdown delay (quarkus.shutdown.delay-enabled, a build-time flag) that reports readiness down through SmallRye Health; the example keeps its own /readyz, as Example 01 does. Pair it with the Kubernetes side: probes, preStop and the grace period." + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "java · Quarkus + @Scheduled + Redis + Panache", + "repl": "java · Quarkus + @Scheduled + Redis + JDBC", + "expect": 1, + "cat": "code", + "_slide": 221, + "_why": "examples/25 quarkus flushes with JDBC per-id upserts", + "_src": "align" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "The dirty set is the key idea — it tells the flusher what to drain. Failed batches go back on the set for retry.", + "repl": "A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed.", + "expect": 1, + "cat": "code", + "_slide": 221, + "_why": "examples/25: 202 and bounded retry (ebca295)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 476, + "lines": [ + "// CacheResource + BackgroundTasks (excerpt), examples/25-caching/quarkus: write-back", + "@PUT @Path(\"write-back/metrics/{mid}\") // CacheResource", + "public Response writeBackWrite(@PathParam(\"mid\") String mid, Map body) {", + " // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down)", + " return Response.accepted(Map.of(\"ok\", true, \"pattern\", \"write-back\",", + " \"action\", \"cached-for-flush\")).build(); // 202: queued, not yet durable", + "}", + "@Scheduled(every = \"${cache-service.flush-period:1s}\") // concurrent runs are skipped", + "void flusher() { // BackgroundTasks", + " if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure", + " Set ids = cache.spop(\"metric:dirty\", FLUSH_BATCH);", + " if (ids == null || ids.isEmpty()) return;", + " List failed = new ArrayList<>();", + " for (String mid : ids) {", + " try (var conn = dataSource.getConnection(); var ps = conn.prepareStatement(", + " \"INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) \"", + " + \"ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()\")) {", + " // ... bind mid and the cached hash as JSON; executeUpdate (one row per id)", + " cache.hdel(\"metric:attempts\", mid);", + " } catch (Exception e) { failed.add(mid); } // Log.errorf: flush failed for ", + " }", + " if (failed.isEmpty()) { failures = 0; return; }", + " nextFlushAt = System.currentTimeMillis() + Math.min(FLUSH_PERIOD_MS << ++failures, 8000);", + " requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5", + "}" + ], + "cat": "code", + "_slide": 221, + "_why": "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, metric:failed, backoff)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 476, + "cat": "notes", + "_slide": 221, + "_why": "notes follow the code", + "_src": "align", + "paragraphs": [ + "Quarkus write-back, as Example 25 runs it. (1) PUT /write-back/metrics/{mid} writes the metric to a Redis hash, adds the id to a dirty set, which tells the flusher what still has to reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) A @Scheduled method runs every second (a run is skipped while one is in flight), pops up to FLUSH_BATCH dirty ids with SPOP (atomic, so two flusher instances get distinct subsets and the flusher scales out) and upserts each one over JDBC with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice stays one row. (3) An id whose upsert fails is re-queued: its count in the metric:attempts hash goes up and it goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in the cache and the failure in the log. After a failed run the flusher waits two seconds, then four, then eight before it tries again.", + "This is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale." + ] + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 500 + ], + "find": "java · Quarkus + SmallRye Health", + "repl": "java · Quarkus", + "expect": 1, + "cat": "code", + "_slide": 245, + "_why": "examples/21 quarkus: hand-written /readyz", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 500, + "lines": [ + "// OrderResource + SignalHandler (excerpt), examples/21-graceful-shutdown/quarkus", + "@GET @Path(\"readyz\") // OrderResource", + "public Response readyz() {", + " if (shutdownState.isShuttingDown()) // 1. readiness flips: 503", + " return Response.status(503)", + " .entity(Map.of(\"ready\", false, \"reason\", \"shutting down\")).build();", + " // ... SELECT 1 on the datasource: 200 {\"ready\": true}, or 503 \"db unreachable\"", + "}", + "@GET @Path(\"healthz\") // liveness stays 200 while draining", + "public Map healthz() { return Map.of(\"status\", \"ok\"); }", + "void onStart(@Observes StartupEvent ev) { // SignalHandler", + " Signal.handle(new Signal(\"TERM\"), signal -> { // one SIGTERM drives the drain", + " if (!shutdownState.markShuttingDown()) return;", + " new Thread(() -> {", + " try { Thread.sleep(drainDelaySeconds() * 1000); } // 2. the LB stops routing", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " Quarkus.asyncExit(0); // 3. drain in-flight HTTP (quarkus.shutdown.timeout=20s)", + " }, \"drain\").start(); // 4. Quarkus closes pools, exit 0", + " });", + "}" + ], + "cat": "code", + "_slide": 245, + "_why": "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 500, + "cat": "notes", + "_slide": 245, + "_why": "notes follow the code", + "_src": "align", + "text": "Example 21 handles SIGTERM itself. A sun.misc.Signal handler, registered on StartupEvent, takes the single SIGTERM: (1) it flips the ShutdownState flag, so the hand-written /readyz answers 503 and Kubernetes stops routing new traffic, while /healthz stays 200; (2) a drain thread keeps the service serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) it calls Quarkus.asyncExit(0), and Quarkus stops accepting and drains in-flight HTTP within quarkus.shutdown.timeout (20 s); (4) Quarkus closes the datasource and the process exits 0. Appendix H covers the Kubernetes-side preStop and terminationGracePeriodSeconds." + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.json new file mode 100644 index 00000000..4bb9b0b7 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Quarkus.json @@ -0,0 +1,5538 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "both", + "find": "Only this step is language-specific; the Newman command is identical for all five.", + "repl": "Only this step is language-specific; the Newman command is identical for all six.", + "expect": 1, + "sldIds": [ + 521 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The order is deliberate: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "repl": "The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "AI coding tools — Claude, Claude Code, Cursor, Copilot, every frontier model — are now in production engineering workflows.", + "repl": "AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They produce code very fluently.", + "repl": "They produce code fluently.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side — plausible enough to compile, deploy and run — from a one-sentence prompt.", + "repl": "From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns — the focus of this deck — span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "repl": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘Least-worst decision’: every architecture choice is a set of trades — latency for throughput, consistency for availability, simplicity for flexibility — and the job is not to find the optionless option but to make the trades visible and deliberate.", + "repl": "‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions easy and cheap.", + "repl": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The visual companion to the previous slide.", + "repl": "The visual companion to the three layers of patterns literacy.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Three stacked boxes: design patterns at the bottom (Gang of Four — factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns — the scope of this deck — sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch.", + "repl": "Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch).", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The four maxims at the bottom in red are the philosophical anchors that follow you through the rest of the deck — every concrete choice you'll see is a deliberate exercise in those four principles.", + "repl": "The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pillars are the rubric for the rest of the talk — every design decision later either earns or spends one of these properties.", + "repl": "The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties.", + "expect": 1, + "sldIds": [ + 263 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The contract — OpenAPI, .proto, AsyncAPI — is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "repl": "The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Make the point that this is what actually delivers independent deployability: the promise the contract encodes is the service boundary.", + "repl": "Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Treat it as a pre-flight check — “will this survive the platform?” — the next slides list the twelve factors and map each to where it lives in our architecture.", + "repl": "Treat it as a pre-flight check: “will this survive the platform?” The twelve factors are listed and mapped to where each lives in our architecture.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The key reframing here is ownership — don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides for free (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "repl": "The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that in a microservices/event-driven world several factors deepen — backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "repl": "In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two takeaways to say: first, this 2011 list essentially predicted cloud-native a decade early; second, notice how many are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "repl": "Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That sets up the next slide, which maps each factor onto our architecture.", + "repl": "That sets up the map of each factor onto our architecture.", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the payoff — not the traditional twelve-factor table, but a map of where each factor actually lives in the system we're building.", + "repl": "This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building.", + "expect": 1, + "sldIds": [ + 268 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The .proto IS the contract and it lives in the registry just like OpenAPI.", + "repl": "The .proto is the contract, and it lives in the registry like OpenAPI.", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows the actual config.", + "repl": "The configuration itself is an Istio VirtualService.", + "expect": 1, + "sldIds": [ + 275 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, the services stay unaware of each other — the gateway owns the composition.", + "repl": "The services stay unaware of each other; the gateway owns the composition.", + "expect": 1, + "sldIds": [ + 278 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the resolver code.", + "repl": "The resolver code shows how the gateway composes the calls.", + "expect": 1, + "sldIds": [ + 278 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "That quietly rebuilds the monolith with network latency between the pieces.", + "repl": "That rebuilds the monolith with network latency between the pieces.", + "expect": 1, + "sldIds": [ + 280 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Section four, and arguably the hardest discipline to hold.", + "repl": "Section four, and the hardest discipline to hold.", + "expect": 1, + "sldIds": [ + 281 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "CQRS — Command Query Responsibility Segregation — is the pattern of using DIFFERENT models for writes and reads.", + "repl": "CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The two are linked by a stream of events — either domain events emitted by the write side, or the database commit log itself via CDC (the next slide).", + "repl": "The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Use this pattern when reads and writes have GENUINELY different shapes and access patterns.", + "repl": "Use this pattern when reads and writes have different shapes and access patterns.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The mechanism that makes CQRS projections AND the outbox pattern (next slide) work: Change Data Capture, or CDC.", + "repl": "The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Debezium — an Apache 2.0 open-source project running as a set of Kafka Connect connectors — tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "repl": "Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is why log tailing matters before we get to the outbox slides: the outbox is just a deliberate use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "repl": "This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide is the actual transaction.", + "repl": "The outbox write itself is one database transaction, shown in code.", + "expect": 1, + "sldIds": [ + 286 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "When a step fails — payment fails — you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "repl": "When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "expect": 1, + "sldIds": [ + 288 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Flag that Appendix D goes deep on saga state, compensation and a genuinely tricky data-dependency case, for anyone who wants the mechanics.", + "repl": "Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics.", + "expect": 1, + "sldIds": [ + 288 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This inverts the dependency direction and is what truly decouples services in time and ownership.", + "repl": "This inverts the dependency direction, and it is what decouples services in time and ownership.", + "expect": 1, + "sldIds": [ + 289 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Events need contracts just as much as REST and gRPC do — arguably more, because consumers are anonymous and you can't just call them to coordinate.", + "repl": "Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate.", + "expect": 1, + "sldIds": [ + 292 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what makes adding-a-consumer-safely actually safe.", + "repl": "This is what makes adding a consumer safe.", + "expect": 1, + "sldIds": [ + 292 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "These are NOT mutually exclusive.", + "repl": "These are not mutually exclusive.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In most enterprise architectures, streaming is far more common than sourcing — sourcing is a deliberate choice for domains where audit and replay are core.", + "repl": "In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Four tools, four different jobs.", + "repl": "Each of the four tools does a different job.", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Messages aren't deleted when read — they're retained, and each consumer group tracks its own offset.", + "repl": "Messages are retained after they are read, and each consumer group tracks its own offset.", + "expect": 1, + "sldIds": [ + 295 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Once events are a durable, replayable stream, you can compute over them continuously — filtering, joining, windowing, aggregating — and emit new derived streams.", + "repl": "Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams.", + "expect": 1, + "sldIds": [ + 296 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right scaling signal for an event consumer is consumer lag — how far behind the log it is — not CPU.", + "repl": "The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "KEDA reads that lag and scales workers up, and crucially down to zero when there's no lag.", + "repl": "KEDA reads that lag and scales workers up, and down to zero when there's no lag.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows both configs.", + "repl": "The KEDA configuration covers both: lag for events and RPS for HTTP.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "A lot of real work isn't a synchronous request at all — it's a multi-step business process, or batch and scheduled work.", + "repl": "A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This section covers how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "repl": "Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Orchestration: a central coordinator explicitly commands each service in turn — easy to follow and observe, but the coordinator can become a god-service.", + "repl": "Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service.", + "expect": 1, + "sldIds": [ + 302 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Neither is universally right; the next slide gives the selection criteria.", + "repl": "Neither is universally right; choose by the selection criteria in the comparison.", + "expect": 1, + "sldIds": [ + 302 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Management isn't a product you buy — it's four cooperating concerns: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "repl": "Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "expect": 1, + "sldIds": [ + 305 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Signal plane: observability (section 11) tells you what's actually happening.", + "repl": "Signal plane: observability (section 11) tells you what is happening.", + "expect": 1, + "sldIds": [ + 306 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The core discipline: evolve additively — add optional fields and new endpoints, never repurpose or silently remove.", + "repl": "The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period.", + "expect": 1, + "sldIds": [ + 307 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the CI gate that turns this into an automatic guardrail.", + "repl": "A CI gate turns this into an automatic guardrail.", + "expect": 1, + "sldIds": [ + 309 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "It ingests from many sources — OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas — and builds one searchable catalog.", + "repl": "It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog.", + "expect": 1, + "sldIds": [ + 312 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide lists what consumers actually get from it.", + "repl": "What consumers get from the catalog is the point of running it.", + "expect": 1, + "sldIds": [ + 312 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The key word is correlate: traces, metrics and logs only become powerful when they share a trace_id so you can pivot between them.", + "repl": "The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them.", + "expect": 1, + "sldIds": [ + 314 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The LGTM-style stack stores them — Tempo for traces, Mimir/Prometheus for metrics, Loki for logs — and Grafana joins them.", + "repl": "The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them.", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Next slide shows the instrumentation, then the trace.", + "repl": "The instrumentation comes first, then the trace.", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Expand the 'three pillars' framing into the five signals modern observability actually uses.", + "repl": "Expand the 'three pillars' framing into the five signals modern observability uses.", + "expect": 1, + "sldIds": [ + 316 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) for free along the same mechanism — and then filter traces and logs by tenant.", + "repl": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant.", + "expect": 1, + "sldIds": [ + 317 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Spring — Micrometer Tracing + the OTel bridge, or the Java agent. .NET — OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "repl": "Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The common production setup is BOTH: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "repl": "The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "expect": 1, + "sldIds": [ + 323 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that metrics and logs are usually NOT sampled the same way — metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "repl": "Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "expect": 1, + "sldIds": [ + 323 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tail-based is the most powerful: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which is exactly the traces you actually want during an incident.", + "repl": "Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident.", + "expect": 1, + "sldIds": [ + 324 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Keep testing pragmatic — you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and literally see the KEDA http-add-on add replicas. ghz is the gRPC load tester — confirm your Istio timeouts and retries behave under pressure.", + "repl": "Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure.", + "expect": 1, + "sldIds": [ + 325 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each pattern here exists because the layer below it is not enough on its own.", + "repl": "Each pattern here exists because no single layer is enough on its own.", + "expect": 1, + "sldIds": [ + 326 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Cloud is the outermost — IAM, VPC, KMS, audit logs, region selection — owned by the platform team or cloud admin.", + "repl": "Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cluster is the Kubernetes layer — RBAC, NetworkPolicy, Pod Security Standards, admission control — owned by platform/SRE.", + "repl": "Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Container is the workload — minimal base images, non-root users, signed images, vulnerability scanning, secret mounting — owned jointly by app teams and platform.", + "repl": "Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code is the innermost — secure defaults, input validation, dependency scanning, secrets handling — owned by app teams.", + "repl": "Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide covers what each layer's controls actually are.", + "repl": "The 4 C's table lists each layer's controls and its owner.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide opens the pattern catalog — seven patterns that show up across these four layers.", + "repl": "The pattern catalog holds seven patterns that show up across these four layers.", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that these patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "repl": "These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Why this is powerful: ONE security policy applied uniformly across every workload regardless of language.", + "repl": "Why this matters: one security policy applied uniformly across every workload regardless of language.", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set ONLY by the sidecar; the app must reject requests that don't have it.", + "repl": "Set only by the sidecar; the app must reject requests that don't have it.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The audience point: the sidecar pattern only works if the app actually USES the headers the sidecar attaches.", + "repl": "The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows the code.", + "repl": "The code shows the pattern in the service's language.", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted' — once an attacker is in, they move laterally trivially.", + "repl": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance.", + "expect": 1, + "sldIds": [ + 335 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Equivalent Kyverno expression is much more YAML-native if your team prefers it; Rego is more powerful for complex logic.", + "repl": "An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic.", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In software, you isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 339 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pattern also has performance benefits — you don't push 50MB messages through the message bus — but in this section we frame it for SECURITY.", + "repl": "The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security.", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost of NOT having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "repl": "The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost lines are deliberately included — every pattern is a trade.", + "repl": "The cost lines are there because every pattern is a trade.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the take-home: real production systems use all seven, in deliberate combinations per concern — not as a one-size-fits-all checklist.", + "repl": "The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide turns these into positive heuristics.", + "repl": "The anti-pattern heuristics turn these into positive rules.", + "expect": 1, + "sldIds": [ + 346 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "(1) If two services must deploy together, they're really one — merge them or fix the contract.", + "repl": "(1) If two services must deploy together, they are one service: merge them or fix the contract.", + "expect": 1, + "sldIds": [ + 347 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the slides and these references are enough to actually build the system, not just talk about it.", + "repl": "The slides and these references are enough to build the system, not only to discuss it.", + "expect": 1, + "sldIds": [ + 349 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They cover the questions that always come up: how the three protocols really differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "repl": "They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "expect": 1, + "sldIds": [ + 350 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "REST, gRPC and GraphQL — how they actually differ.", + "repl": "REST, gRPC and GraphQL: how they differ.", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS — both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "repl": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The ACL ambiguity is worth flagging out loud: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "repl": "Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration — the request-driven metrics method); USE (Utilisation, Saturation, Errors — the resource metrics method); MDC (Mapped Diagnostic Context — per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "repl": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what actually travels on the wire.", + "repl": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire.", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most arguments about 'which is best' are really arguments about which mental model fits the problem — and often the answer is different per interaction.", + "repl": "Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction.", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape — no field selection, but extremely fast.", + "repl": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three.", + "expect": 1, + "sldIds": [ + 355 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What actually travels, and why it constrains your choice.", + "repl": "What travels on the wire, and why it constrains your choice.", + "expect": 1, + "sldIds": [ + 356 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The decision guide, said plainly.", + "repl": "The decision guide.", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold closing point ties back to the whole talk: forcing one protocol everywhere — 'we're a gRPC shop' or 'everything is GraphQL' — is itself an anti-pattern, because you'll be paying gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "repl": "The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and arguably breaks REST's idea that a URL identifies a resource, not a representation.", + "repl": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Query param: easy to default but muddies caches.", + "repl": "Query param: simple to default but muddies caches.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Custom header: keeps URLs clean but is invisible and easy to forget.", + "repl": "Custom header: keeps URLs clean but is invisible and often forgotten.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation, the URL stays stable and identifies the resource, the representation is what's versioned — this is the genuinely RESTful approach.", + "repl": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The honest take: URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "repl": "In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code on the next slide.", + "repl": "The media-type versioning code shows how.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "We removed an old 'notes' field — so we reserve both its tag number (4) AND its name, permanently, so nobody can ever reuse them and silently corrupt old data on the wire.", + "repl": "We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire.", + "expect": 1, + "sldIds": [ + 363 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We add currency and state as brand-new fields with new tag numbers; old clients simply ignore unknown fields, new clients read them — that's forward and backward compatibility for free.", + "repl": "We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work.", + "expect": 1, + "sldIds": [ + 363 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The lifecycle on screen: ADD new fields and types (additive is always safe — existing queries are unaffected because they didn't ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; WATCH field-level usage metrics to see who still queries the old field and drive their migration; and only RETIRE — the single breaking step — once usage hits zero.", + "repl": "The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero.", + "expect": 1, + "sldIds": [ + 364 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three protocols, three genuinely different answers to the same problem.", + "repl": "Each protocol answers the same problem differently.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Never break silently — deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "repl": "Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "expect": 1, + "sldIds": [ + 366 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That one design fact is the whole rest of the appendix: the connection and the knowledge of who's connected live on exactly one pod.", + "repl": "That one design fact drives everything in this appendix: the connection and the knowledge of who's connected live on exactly one pod.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Everything stateless about HTTP — any replica can serve any request — is now false.", + "repl": "Everything stateless about HTTP (any replica can serve any request) is now false.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The next slide shows what that does to scaling.", + "repl": "The scaling problem follows from that.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Appendix C tackles the protocol we deliberately left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "repl": "Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "expect": 1, + "sldIds": [ + 367 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections — only new ones — so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "repl": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fix isn't to avoid scaling — it's to stop making any single pod the home of a conversation, which is the backplane.", + "repl": "The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each pod still owns only its own sockets — that's fine — but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "repl": "Each pod still owns only its own sockets, which is fine, but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that actually holds X's socket delivers it.", + "repl": "When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that holds X's socket delivers it.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the resource shape is different — the bold point — you scale on open-connection count and file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "repl": "And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "expect": 1, + "sldIds": [ + 370 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Every one of these is solved or eased by the backplane on the next slide.", + "repl": "The pub/sub backplane solves or eases every one of these.", + "expect": 1, + "sldIds": [ + 370 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "And the security note in bold that's easy to forget: authenticate on connect AND re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "repl": "And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "expect": 1, + "sldIds": [ + 374 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three hard parts that the happy-path picture glosses over: where the orchestrator actually keeps its state so it survives a crash, how compensation really works when a step fails midway, and a specific gnarly case people hit — when step B needs data that only step A produced, and how to resume after a crash between them.", + "repl": "Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them.", + "expect": 1, + "sldIds": [ + 375 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We iterate the completed steps (subList up to stepIndex) in reverse — newest first — and invoke each compensation.", + "repl": "We iterate the completed steps (subList up to stepIndex) in reverse, newest first, and invoke each compensation.", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each forward step that committed — payment.charge, inventory.reserve — has a defined semantic inverse: payment.refund, inventory.release.", + "repl": "Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When book_shipping fails, you don't undo a database transaction (the payment really happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "repl": "When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the specific case you asked about, and it's a genuinely common source of bad designs.", + "repl": "This is the specific case you asked about, and a common source of bad designs.", + "expect": 1, + "sldIds": [ + 380 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What's deliberately absent: any stack trace, exception class name, or SQL — those leak internals and are an attack surface.", + "repl": "What's absent: any stack trace, exception class name, or SQL; those leak internals and are an attack surface.", + "expect": 1, + "sldIds": [ + 385 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Error handling is where cloud-native systems quietly fall apart — not in the happy path.", + "repl": "Error handling is where cloud-native systems fall apart, more often than the happy path.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We finish on the failure mode that turns a blip into an outage — the retry storm — and the handful of controls that prevent it.", + "repl": "We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope — type, title, status, detail, instance — and you extend it with your stable code and the traceId.", + "repl": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId.", + "expect": 1, + "sldIds": [ + 384 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC gives you a small, fixed set of canonical status codes — and choosing the right one IS the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "repl": "gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Beyond the code, attach a google.rpc.Status in the trailers with rich details — ErrorInfo (machine reason + domain) and RetryInfo (how long to wait) — so the client gets the same five facts as everywhere else.", + "repl": "Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, you commit the offset either way, so the main consumer keeps making progress.", + "repl": "You commit the offset either way, so the main consumer keeps making progress.", + "expect": 1, + "sldIds": [ + 388 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When one field fails — say stock can't reach inventory — that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "repl": "When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "expect": 1, + "sldIds": [ + 390 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the failure mode that turns a brief blip into a full outage, and it's worth dwelling on.", + "repl": "This is the failure mode that turns a brief blip into a full outage.", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The loop: a service slows, calls start timing out, clients retry — often 3x — which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "repl": "The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Bound every retry — @Timeout, @Retry + jitter, @CircuitBreaker, retry budget — and make handlers idempotent.", + "repl": "Bound every retry (@Timeout, @Retry + jitter, @CircuitBreaker, retry budget) and make handlers idempotent.", + "expect": 1, + "sldIds": [ + 393 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Where the boundaries — and the contracts — come from.", + "repl": "Where the boundaries and the contracts come from.", + "expect": 1, + "sldIds": [ + 394 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions — where to split, what the contract should contain, what stays private — are really boundary questions, and DDD is the discipline for boundaries.", + "repl": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries.", + "expect": 1, + "sldIds": [ + 395 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge — complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them simply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "repl": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "expect": 1, + "sldIds": [ + 396 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The vivid example is the word 'order' meaning different things in fulfillment versus billing — that's why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "repl": "The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "expect": 1, + "sldIds": [ + 397 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The relationship patterns between contexts — and which ones your API actually implements.", + "repl": "The relationship patterns between contexts, and which ones your API implements.", + "expect": 1, + "sldIds": [ + 398 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The starred three are the ones to design deliberately.", + "repl": "The starred three are the ones to design explicitly.", + "expect": 1, + "sldIds": [ + 398 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Published Language: the shared, versioned schema that API speaks — OpenAPI, .proto, Avro — which is exactly what lives in the Apicurio registry.", + "repl": "Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "So the registry-backed contract from section 9 IS a Published Language, and edge/gateway translation IS an Anticorruption Layer.", + "repl": "So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the payoff: students have already built these things in sections 9 and 8 without the names — now they have the names and the theory.", + "repl": "The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driving adapters on the left — REST, gRPC, GraphQL — translate an incoming protocol into a call on an inbound port.", + "repl": "Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port.", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driven adapters on the right — a Postgres repository, a Kafka publisher — implement the outbound ports.", + "repl": "Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports.", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The aggregate is the consistency boundary — invariants hold within it and one transaction commits it — and it typically maps to a single API resource.", + "repl": "The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource.", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the small-aggregate rule — one per transaction, reference others by id, connect via events — is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "repl": "And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The message: DDD isn't a separate methodology bolted on — it's the naming and reasoning behind the boundaries the whole architecture already relies on.", + "repl": "The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on.", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The reframe: coupling is necessary — a system with zero coupling does nothing — so the goal is to balance it, not eliminate it.", + "repl": "The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it.", + "expect": 1, + "sldIds": [ + 404 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Coupling has three dimensions — integration strength, distance and volatility — and a coupling only hurts when all three are high together.", + "repl": "Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together.", + "expect": 1, + "sldIds": [ + 404 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Khononov's model evaluates the connection between two components — not the components themselves — along three dimensions: integration strength, distance and volatility.", + "repl": "Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility.", + "expect": 1, + "sldIds": [ + 405 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Distance is how far apart they sit — same method, class, module, service, or system — and it sets the cost of a change that has to cross the boundary.", + "repl": "Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is how often the coupled thing actually changes.", + "repl": "Volatility is how often the coupled thing changes.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost box at the bottom is the key relationship: strong plus distant is global complexity — the distributed monolith — but, crucially, it only bites when the code is volatile.", + "repl": "The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is Khononov's modularity rule rendered as a slogan — strength XOR distance — you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "repl": "The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "expect": 1, + "sldIds": [ + 407 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Contract coupling — APIs and event schemas in the registry — is the only strength level appropriate across service distance.", + "repl": "Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance.", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is what decides whether a less-than-ideal coupling actually matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling that's actually on hot, ever-changing core code can still hurt.", + "repl": "Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt.", + "expect": 1, + "sldIds": [ + 411 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you deliberately route your strongest couplings away from core and toward stable, generic areas.", + "repl": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas.", + "expect": 1, + "sldIds": [ + 411 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And liveness deliberately points at a separate /livez that stays UP during the drain, so the kubelet doesn't restart a pod that's cleanly winding down.", + "repl": "Liveness points at a separate /livez that stays UP during the drain, so the kubelet doesn't restart a pod that's cleanly winding down.", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Close by pointing to both Khononov books — Learning Domain-Driven Design for the boundary tools in Appendix F, and Balancing Coupling in Software Design for this model — as the two references that underpin both of these appendices.", + "repl": "Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices.", + "expect": 1, + "sldIds": [ + 413 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done — the 5xx blips users actually notice during a rollout.", + "repl": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "It's a protocol between your app and the platform — not a single setting — and the platform gives you a fixed budget to do it in.", + "repl": "It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The rest of the appendix walks that sequence, then shows the code on both sides of it.", + "repl": "This appendix walks that sequence, then shows the code on both sides of it.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Liveness must stay UP during the drain, or the kubelet restarts a pod that's deliberately winding down.", + "repl": "Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down.", + "expect": 1, + "sldIds": [ + 417 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The distinction that trips people up is readiness vs liveness during shutdown: you WANT readiness to fail (it stops new traffic) but you must NOT let liveness fail (the kubelet would restart a pod that's intentionally draining).", + "repl": "The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining).", + "expect": 1, + "sldIds": [ + 417 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "North/south traffic (clients into the system) enters through the EDGE/INGRESS layer (HAProxy, Nginx, Envoy, Traefik) — where TLS terminates and coarse host/path routing happens — then through the API GATEWAY (Kong, Apigee, Istio gateway, Spring Cloud Gateway) which handles product-level concerns like auth, rate limiting and transformation.", + "repl": "North/south traffic (clients into the system) enters through the EDGE/INGRESS layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API GATEWAY (Kong, Apigee, Istio gateway, Spring Cloud Gateway), which handles product-level concerns like auth, rate limiting and transformation.", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes actually stops routing after you report DOWN — here roughly ten seconds, which your preStop sleep should cover.", + "repl": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after you report DOWN: here roughly ten seconds, which your preStop sleep should cover.", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Only HTTP drains for free — everything else needs explicit drain code in your shutdown hook.", + "repl": "Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook.", + "expect": 1, + "sldIds": [ + 421 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The closing bold point is the philosophical anchor of the whole appendix: you cannot guarantee a graceful exit — the kubelet will sometimes SIGKILL you — so idempotency is what actually makes the system safe, and graceful shutdown just makes the unsafe window rare.", + "repl": "The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare.", + "expect": 1, + "sldIds": [ + 421 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fourth point is the one that turns shutdown from fragile to robust — idempotency means the occasional hard kill is a non-event.", + "repl": "The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event.", + "expect": 1, + "sldIds": [ + 422 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Open the appendix by reframing what L7 routing IS in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "repl": "Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "expect": 1, + "sldIds": [ + 424 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The TLS SNI line in both columns is worth highlighting — L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "repl": "The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you actually see what's happening across the four layers — ties straight back to the observability section.", + "repl": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section.", + "expect": 1, + "sldIds": [ + 426 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Worth calling out: the resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms — the mesh implements many of them for free, but you still need bounded retries and budgets to prevent amplification.", + "repl": "The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification.", + "expect": 1, + "sldIds": [ + 426 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each layer is the right place for a different KIND of decision — we'll see that throughout the rest of the appendix.", + "repl": "Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them.", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each box can refuse, transform or branch the request, and each emits a trace span so the full journey is observable end-to-end.", + "repl": "Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end.", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point teases the rule-based section: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place isn't a YAML file in the cluster — it's a rule engine inside the app.", + "repl": "The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster.", + "expect": 1, + "sldIds": [ + 429 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three match rules — by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie — each sending to a different DestinationRule subset.", + "repl": "Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie.", + "expect": 1, + "sldIds": [ + 432 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use it as a deliberate choice for a known constraint, never as a default.", + "repl": "Use it only for a known constraint, never as a default.", + "expect": 1, + "sldIds": [ + 434 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as truly disposable.", + "repl": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable.", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie deliberately and NAT doesn't break it.", + "repl": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it.", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Shadow / mirror: copy a percentage of real traffic to a new version silently, observe its behaviour, but don't return its responses to the client — great for testing under realistic load.", + "repl": "Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load.", + "expect": 1, + "sldIds": [ + 436 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Header inject/strip and URL rewrites are essentially free — the router is already parsing the HTTP line and headers anyway.", + "repl": "Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers.", + "expect": 1, + "sldIds": [ + 439 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two examples worth mentioning: trace context propagation (the W3C traceparent header is how the spans from earlier in the journey stitch together with the spans downstream — that's why the request-id injection in the Nginx slide mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "repl": "Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "expect": 1, + "sldIds": [ + 439 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert — a really clean pattern, identity at the network layer.", + "repl": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert: a clean pattern that puts identity at the network layer.", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set expectations honestly.", + "repl": "Set expectations with the numbers.", + "expect": 1, + "sldIds": [ + 445 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out) that means every layer must be lean and earn its place.", + "repl": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency.", + "expect": 1, + "sldIds": [ + 445 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The defining property is on the slide: change anywhere, redeploy everything; bug in Reporting can take down Payments; one scaling unit for very different workloads.", + "repl": "The defining property is on the slide: change anywhere, redeploy everything; bug in Reporting can take down Payments; one scaling unit for workloads that scale differently.", + "expect": 1, + "sldIds": [ + 454 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the modern answer to 'how do you build a microservices system that actually works in production.'", + "repl": "This is the modern answer to 'how do you build a microservices system that works in production.'", + "expect": 1, + "sldIds": [ + 447 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what the 'mesh' actually IS: the same routing CRDs the edge uses, but everywhere, between every pair of services, configured centrally and applied by the sidecars.", + "repl": "This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars.", + "expect": 1, + "sldIds": [ + 449 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC has a few sharp edges that are worth calling out separately.", + "repl": "gRPC has a few sharp edges of its own.", + "expect": 1, + "sldIds": [ + 450 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most powerful for vendor / 3rd-party / legacy-mainframe wrappers.", + "repl": "Most useful for vendor / 3rd-party / legacy-mainframe wrappers.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pick the pattern that matches the consistency you actually need — the next six slides.", + "repl": "Pick the pattern that matches the consistency you need from the six patterns in this appendix.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Splitting the database is the genuinely hard part of decomposition.", + "repl": "Splitting the database is the hard part of decomposition.", + "expect": 1, + "sldIds": [ + 456 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use this pattern only when (a) write throughput is the constraint, (b) eventual durability is genuinely acceptable, and (c) you've thought about what 'lost the last 5 seconds of writes' actually means for your business.", + "repl": "Use this pattern only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've worked out what 'lost the last 5 seconds of writes' means for your business.", + "expect": 1, + "sldIds": [ + 475 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Refresh-ahead pre-empts this: a background task watches the cache (or maintains a set of 'hot' keys) and refreshes their values from the DB BEFORE the TTL fires.", + "repl": "Refresh-ahead pre-empts this: a background task watches the cache (or maintains a set of 'hot' keys) and refreshes their values from the DB before the TTL fires.", + "expect": 1, + "sldIds": [ + 477 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold final line is the architectural take-home: reversibility — every step should be a config change you can undo without coordinating a release — is the property to design for.", + "repl": "The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The first point is the most important and most often missed: caching is a complexity tax, only worth paying when reads genuinely hurt.", + "repl": "The first point is the most important and most often missed: caching is a complexity tax, only worth paying when reads hurt.", + "expect": 1, + "sldIds": [ + 479 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Mitigations: distributed lock per key (only one fetches, rest wait); refresh-ahead (the previous slide); TTL jitter (add random ms to TTL so similar keys don't expire together).", + "repl": "Mitigations: distributed lock per key (only one fetches, the rest wait); refresh-ahead; TTL jitter (add random ms to TTL so similar keys don't expire together).", + "expect": 1, + "sldIds": [ + 479 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Write-through trades write latency for read consistency — worth it when stale reads after writes are genuinely harmful (financial, healthcare, regulatory).", + "repl": "Write-through trades write latency for read consistency, which pays off when stale reads after writes are harmful (financial, healthcare, regulatory).", + "expect": 1, + "sldIds": [ + 480 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When data is written far more often than it's read — audit logs, event records, telemetry — caching every write is wasteful: most cached entries will expire before being read again.", + "repl": "When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful: most cached entries expire before being read again.", + "expect": 1, + "sldIds": [ + 473 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Unbounded caches grow until they evict at random, which gives you the worst kind of failure — unpredictable.", + "repl": "Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable.", + "expect": 1, + "sldIds": [ + 479 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The mistake that causes most production incidents is treating a remote call like a local one — no timeout, no fallback, no circuit breaker — so a single down dependency hangs the caller.", + "repl": "The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller.", + "expect": 1, + "sldIds": [ + 486 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on actual traffic, SLO burn-rate alerts, and — critically — Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "repl": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "expect": 1, + "sldIds": [ + 487 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Production resilience is the deliberate composition of several of these, tuned together.", + "repl": "Production resilience is the composition of several of these, tuned together.", + "expect": 1, + "sldIds": [ + 490 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The code on the next slide shows the language-specific mechanics.", + "repl": "The code example shows the language-specific mechanics.", + "expect": 1, + "sldIds": [ + 491 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The code on the next slide shows the idiomatic library for each language.", + "repl": "The code example shows the idiomatic library for each language.", + "expect": 1, + "sldIds": [ + 493 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Libraries differ per language; the next slide shows each.", + "repl": "Libraries differ per language; the code example shows each.", + "expect": 1, + "sldIds": [ + 495 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 497 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Load shedding is counter-intuitive to engineers — deliberately rejecting requests feels like giving up — but it is the difference between a system that degrades and one that collapses.", + "repl": "Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses.", + "expect": 1, + "sldIds": [ + 498 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each failure mode has characteristic signals, and the point is that you must instrument for them deliberately — a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "repl": "Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "expect": 1, + "sldIds": [ + 501 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what actually failed' (the logs on that trace) — the correlation story from section 11 applied to incident response.", + "repl": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response.", + "expect": 1, + "sldIds": [ + 501 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two observations worth stating aloud: first, quorum/consensus is the ONLY real answer to split-brain — no amount of retrying or circuit-breaking helps when the problem is two leaders; you need a majority rule.", + "repl": "Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule.", + "expect": 1, + "sldIds": [ + 502 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The platform around the code — Strimzi, Apicurio, OpenMetadata, the LGTM stack — is unchanged from any language choice; Quarkus is just how we write the services.", + "repl": "The platform around the code (Strimzi, Apicurio, OpenMetadata, the LGTM stack) is the same for every language; Quarkus is how we write the services.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "SmallRye Health gives you the two probes Kubernetes calls for free at /q/health/live and /q/health/ready: liveness (am I alive? if not, restart me) and readiness (can I serve? checks the DB — if not, take me out of rotation but don't kill me).", + "repl": "SmallRye Health serves the two probes Kubernetes calls, with no extra code, at /q/health/live and /q/health/ready: liveness (am I alive? if not, restart me) and readiness (can I serve? it checks the DB; if not, take me out of rotation but don't kill me).", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "(2) request contracts validated declaratively — here a Java record with Bean Validation annotations (@NotBlank, @Positive, @Valid), the Quarkus equivalent of Pydantic models, so bad input never reaches your logic;", + "repl": "(2) request contracts validated declaratively: here a Java record with Bean Validation annotations (@NotBlank, @Positive, @Valid), the Quarkus equivalent of Pydantic models, so bad input never reaches your logic;", + "expect": 1, + "sldIds": [ + 272 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The Uni return type is Mutiny — Quarkus's reactive type — so the endpoint is non-blocking.", + "repl": "The method is blocking and Quarkus REST runs it on a worker thread; return Mutiny's Uni, Quarkus's reactive type, when the endpoint should be non-blocking.", + "expect": 1, + "sldIds": [ + 272 + ], + "cat": "code" + }, + { + "op": "replace", + "scope": "both", + "find": "SmallRye OpenAPI emits the spec for free, which is what we register and gate.", + "repl": "SmallRye OpenAPI generates the spec from the resource, and that spec is what we register and gate.", + "expect": 1, + "sldIds": [ + 272 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "There is deliberately no Kafka call inside the request path — if the broker is down, the order still commits and the event ships later when the Debezium connector tails the write-ahead log and relays the outbox row in commit order.", + "repl": "There is no Kafka call inside the request path: if the broker is down, the order still commits and the event ships later when the Debezium connector tails the write-ahead log and relays the outbox row in commit order.", + "expect": 1, + "sldIds": [ + 287 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two things to drill home: the channel is configured for manual ack, and we ack only AFTER the side-effect (sendEmail) succeeds — if you ack before and then crash, you've lost the work.", + "repl": "Two points: the channel is configured for manual ack, and we ack only once the side-effect (sendEmail) has succeeded. Ack first and then crash, and the work is lost.", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The serde is wired to Apicurio — the next slide.", + "repl": "The serde is wired to Apicurio, the schema registry.", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "For orchestration in this stack, Quarkus Flow — a CNCF Serverless Workflow engine that runs inside your Quarkus app — is the natural choice; we use it concretely in Appendix D for the saga orchestrator.", + "repl": "For orchestration in this stack, Quarkus Flow (a CNCF Serverless Workflow engine that runs inside your Quarkus app) is the natural choice; Appendix D uses it for the saga orchestrator.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "WAL (Write-Ahead Log — the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live — how long a cached key lives before expiring); LRU (Least Recently Used — a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command — sets a key and its TTL atomically);", + "repl": "WAL (Write-Ahead Log: the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used: a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command: sets a key and its TTL atomically);", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "AOT (Ahead-Of-Time compilation — GraalVM native image);", + "repl": "AOT (Ahead-Of-Time compilation, as in a GraalVM or Mandrel native image);", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The field keeps working — nothing breaks — but the deprecation shows up in introspection and in GraphiQL, nudging client developers.", + "repl": "The field keeps working and nothing breaks, but the deprecation shows up in introspection and in GraphiQL, nudging client developers.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Declarative and easy — but OpenConnections is per-pod.", + "repl": "Declarative, but OpenConnections is per-pod.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A version field lets the client detect an incompatible payload format and renegotiate; the type field dispatches to the right protobuf message; and — critically for the next slide — a monotonic seq number per connection is what makes reconnection and replay possible.", + "repl": "A version field lets the client detect an incompatible payload format and renegotiate; the type field dispatches to the right protobuf message; and a monotonic seq number per connection is what makes reconnection and replay possible, the subject of the failure-handling slide.", + "expect": 1, + "sldIds": [ + 372 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Each successful step accumulates its result into the context map (remember that — it's the whole point of the next-but-one slide) and bumps the step index; @Transactional makes the persist durable after every step, atomic with the work record.", + "repl": "Each successful step accumulates its result into the context map (the data-dependency slide builds on it) and bumps the step index; @Transactional makes the persist durable after every step, atomic with the work record.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The managed alternative is Quarkus Flow, which gives you this state machine, retries and timers for free — shown on the data-dependency slide.", + "repl": "The managed alternative is Quarkus Flow, which provides this state machine, retries and timers out of the box; the data-dependency slide shows it.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In the CNCF Serverless Workflow model that Flow implements, each task's output becomes the next task's input — the workflow data document IS the saga context, carried forward automatically.", + "repl": "In the CNCF Serverless Workflow model that Flow implements, each task's output becomes the next task's input: the workflow data document is the saga context, carried forward automatically.", + "expect": 1, + "sldIds": [ + 381 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Ports: the domain declares plain Java interfaces — a repository and an event publisher — and depends on nothing else.", + "repl": "Ports: the domain declares plain Java interfaces, a repository and an event publisher, and depends on nothing else.", + "expect": 1, + "sldIds": [ + 401 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The two @Observes methods make the sequence visible and, more importantly, give you the hook (onStop / ShutdownEvent) to drain what Quarkus does NOT handle for you — Kafka consumers, the scheduler, WebSockets.", + "repl": "The two @Observes methods make the sequence visible and give you the hook (onStop / ShutdownEvent) to drain what Quarkus leaves to you: Kafka consumers, the scheduler, WebSockets.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pair with the Kubernetes settings on the next slide.", + "repl": "Pair it with the Kubernetes side: probes, preStop and the grace period.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "then circle back to east/west where the most decisions actually happen.", + "repl": "then circle back to east/west, where most routing decisions happen.", + "expect": 1, + "sldIds": [ + 423 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "the actual routing logic lives in the .drl rules on the next slide, owned by domain experts and editable without a Quarkus rebuild.", + "repl": "the routing logic lives in the .drl rules, owned by domain experts and editable without a Quarkus rebuild.", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The point isn't the syntax; it's that this file is owned by domain experts — the same people who decide what 'VIP' or 'high-value' actually mean — and can be redeployed without a developer touching Camel or Quarkus code.", + "repl": "This file is owned by domain experts, the people who decide what 'VIP' or 'high-value' mean, and can be redeployed without a developer touching Camel or Quarkus code.", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Now the proxy earns its keep: by path (/orders/* → new Quarkus service), by header (X-Tenant: acme → new service), by percentage (1% → new), or by feature flag (beta users → new).", + "repl": "Now the proxy routes: by path (/orders/* → new Quarkus service), by header (X-Tenant: acme → new service), by percentage (1% → new), or by feature flag (beta users → new).", + "expect": 1, + "sldIds": [ + 459 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "NOT a destination — every schema change now needs both apps to agree, and the coordination tax grows.", + "repl": "A stage, not a destination: every schema change now needs both apps to agree, and the coordination tax grows.", + "expect": 1, + "sldIds": [ + 460 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The data split is the genuinely hard part; doing it with multiple Quarkus datasources is much cheaper than coordinating across services.", + "repl": "The data split is the hard part; doing it with multiple Quarkus datasources is much cheaper than coordinating across services.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Predictable performance is the operational win that's hard to measure but very real: a cache layer flattens p99.", + "repl": "Predictable performance is the operational win that is hard to measure: a cache layer flattens p99.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "pick the pattern that matches the consistency you actually need.", + "repl": "pick the pattern that matches the consistency you need.", + "expect": 1, + "sldIds": [ + 466 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: extra DB load for keys that may not actually be requested again; you need a way to identify which keys are hot.", + "repl": "Cons: extra DB load for keys that may not be requested again; you need a way to identify which keys are hot.", + "expect": 1, + "sldIds": [ + 477 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "then four appendices go deep on protocol internals, versioning, WebSockets and saga mechanics.", + "repl": "then the appendices go deep on protocols, versioning, WebSockets, sagas, errors, caching, failure modes and API testing, and run Example 05 on minikube and OpenShift Local.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people there are four deep-dive appendices at the end (protocols, versioning, WebSockets, sagas) they can jump to.", + "repl": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The four appendices that follow are deep-dive backup material — pull them up on demand during Q&A, or work through them directly if you're running the full-length session.", + "repl": "The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session.", + "expect": 1, + "sldIds": [ + 350 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "That's the entire menu of inputs every later slide will use to make routing decisions.", + "repl": "That's the entire menu of inputs the routing patterns in this appendix use to make routing decisions.", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The sticky-session one ties back to the earlier slide.", + "repl": "The sticky-session one ties back to the sticky-sessions comparison.", + "expect": 1, + "sldIds": [ + 446 + ], + "cat": "positional" + }, + { + "op": "set_code", + "sldId": 519, + "shape_id": 8, + "lines": [ + "{", + " \"name\": \"Create order\",", + " \"request\": {", + " \"method\": \"POST\",", + " \"header\": [{ \"key\": \"Idempotency-Key\", \"value\": \"{{$guid}}\" }],", + " \"url\": \"{{base_url}}/orders\",", + " \"body\": { \"mode\": \"raw\", \"raw\": \"{\\\"sku\\\":\\\"A-1\\\",\\\"qty\\\":2}\" }", + " },", + " \"event\": [{", + " \"listen\": \"test\",", + " \"script\": { \"exec\": [", + " \"pm.test('201 Created', () => pm.response.to.have.status(201));\",", + " \"pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));\",", + " \"const body = pm.response.json();\",", + " \"pm.test('returns an id', () => pm.expect(body).to.have.property('id'));\",", + " \"pm.collectionVariables.set('order_id', body.id);\"", + " ] }", + " }]", + "}" + ], + "cat": "code", + "_why": "_docs/28 Anatomy of a collection (adds the response-time assertion)" + }, + { + "op": "replace_picture", + "sldId": 520, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-black-box.png", + "cat": "stack", + "_why": "the old figure shows five backends; the site figure shows all six" + }, + { + "op": "set_code", + "sldId": 521, + "shape_id": 8, + "lines": [ + "# Quarkus — dev mode, then the same collection", + "./mvnw quarkus:dev & # http://localhost:8080", + "newman run orders.postman_collection.json -e local.postman_environment.json" + ], + "cat": "code", + "_why": "_docs/28 codetab 'One collection, six backends', this deck's language" + }, + { + "op": "set_code", + "sldId": 522, + "shape_id": 8, + "lines": [ + "# install once (Node.js):", + "# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1", + "newman run orders.postman_collection.json \\", + " --environment local.postman_environment.json \\", + " --reporters cli,junit,htmlextra \\", + " --reporter-junit-export results/newman.xml \\", + " --timeout-request 5000 \\", + " --bail", + "# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure", + "", + "# data-driven: one iteration per CSV row; the columns become {{variables}}", + "newman run orders.postman_collection.json -e local.postman_environment.json \\", + " --iteration-data cases.csv" + ], + "cat": "code", + "_why": "_docs/28 Running Newman + Data-driven tests; pinned install; no comment after a line continuation" + }, + { + "op": "set_code", + "sldId": 524, + "shape_id": 8, + "lines": [ + "# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js)", + "jobs:", + " api-tests:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v7", + " - run: docker compose up -d --build --wait # order, payment, inventory, ...", + " - run: ./scripts/wait-for-ready.sh # poll /readyz before testing", + " - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1", + " - run: |", + " newman run orders.postman_collection.json \\", + " -e ci.postman_environment.json \\", + " --reporters cli,junit \\", + " --reporter-junit-export results/newman.xml", + " - uses: actions/upload-artifact@v7 # keep the report even on failure", + " if: always()", + " with: { name: newman-results, path: results/ }" + ], + "cat": "code", + "_why": "_docs/28 Newman in CI on Docker Engine; actions at v7 as in .github/workflows" + }, + { + "op": "replace_picture", + "sldId": 525, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-ci-gate.png", + "cat": "stack", + "_why": "the old figure shows the old container engine; site figure 27-ci-gate" + }, + { + "op": "replace_picture", + "sldId": 283, + "shape_id": 315, + "png": "lgtm-presentation/edits/png/04-cqrs.png", + "cat": "voice", + "_slide": 28, + "_why": "image text had a positional reference ('the next slide') and 'genuinely'" + }, + { + "op": "replace_picture", + "sldId": 285, + "shape_id": 338, + "png": "lgtm-presentation/edits/png/04-log-tailing.png", + "cat": "positional", + "_slide": 30, + "_why": "image text had '(next slide)'" + }, + { + "op": "replace_picture", + "sldId": 346, + "shape_id": 1009, + "png": "lgtm-presentation/edits/png/13-antipatterns-grid.png", + "cat": "voice", + "_slide": 91, + "_why": "box text broken mid-word (layout defect)" + }, + { + "op": "replace_picture", + "sldId": 428, + "shape_id": 1902, + "png": "lgtm-presentation/edits/png/22-l7-journey.png", + "cat": "voice", + "_slide": 173, + "_why": "subtitle 'each layer earns its place' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 445, + "shape_id": 2100, + "png": "lgtm-presentation/edits/png/22-hop-cost-drools.png", + "cat": "voice", + "_slide": 190, + "_why": "'every hop must earn its place' (ban tier); rule-engine row per language" + }, + { + "op": "replace_picture", + "sldId": 447, + "shape_id": 2121, + "png": "lgtm-presentation/edits/png/22-east-west.png", + "cat": "voice", + "_slide": 192, + "_why": "caption 'for free' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 460, + "shape_id": 2261, + "png": "lgtm-presentation/edits/png/24-shared-database.png", + "cat": "positional", + "_slide": 205, + "_why": "'(decomposed DB above)'" + }, + { + "op": "replace_picture", + "sldId": 483, + "shape_id": 2523, + "png": "lgtm-presentation/edits/png/25-failure-taxonomy.png", + "cat": "voice", + "_slide": 228, + "_why": "'the rest of this appendix' (ban tier)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# Istio: retries, timeout and outlier detection for inventory-service", + "repl": "# Istio: bounded retries and timeout for inventory-service", + "expect": 1, + "sldIds": [ + 276 + ], + "cat": "code", + "_why": "the manifest has no outlier detection; matches _docs/02 comment" + }, + { + "op": "set_code", + "sldId": 300, + "shape_id": 509, + "lines": [ + "apiVersion: keda.sh/v1alpha1", + "kind: ScaledObject", + "metadata: { name: payment-consumer }", + "spec:", + " scaleTargetRef: { name: payment-consumer }", + " minReplicaCount: 0", + " maxReplicaCount: 20", + " triggers:", + " - type: kafka", + " metadata: { bootstrapServers: \"cndp-kafka-bootstrap:9092\", topic: order.placed,", + " consumerGroup: payment, lagThreshold: \"100\" }", + "---", + "# HTTP: scale the Quarkus service on concurrent requests (KEDA http-add-on)", + "apiVersion: http.keda.sh/v1alpha1", + "kind: HTTPScaledObject", + "metadata: { name: order-service }", + "spec:", + " hosts: [orders.example.com]", + " scaleTargetRef: { name: order-service, service: order-service, port: 8080 }", + " replicas: { min: 0, max: 30 }" + ], + "cat": "code", + "_why": "ScaledObject needs metadata.name and the kafka scaler needs bootstrapServers; HTTPScaledObject needs apiVersion, name, hosts and a full scaleTargetRef (KEDA v2.21 / http-add-on)", + "pt": 11.0, + "_fit": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "/livez", + "repl": "/healthz", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "code", + "_why": "examples/21-graceful-shutdown: liveness endpoint is /healthz in all six languages" + }, + { + "op": "replace", + "scope": "slide", + "find": "# must NOT fail during the drain", + "repl": "# keeps answering during the drain", + "expect": 1, + "sldIds": [ + 420 + ], + "cat": "code", + "_why": "comment, no ALL-CAPS emphasis" + }, + { + "op": "set_notes", + "sldId": 420, + "paragraphs": [ + "The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover.", + "Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages.", + "The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number." + ], + "cat": "notes", + "_why": "notes named /livez (/healthz/live on .NET); examples use /readyz and /healthz" + }, + { + "op": "replace", + "scope": "slide", + "find": "ssl crt /etc/haproxy/certs/ ", + "repl": "ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 ", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "gRPC needs HTTP/2 on the TLS listener (ALPN h2)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# TLS termination here", + "repl": "# TLS here; h2 for gRPC", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "code", + "_why": "comment matches the ALPN change" + }, + { + "op": "replace", + "scope": "slide", + "find": "listen 443 ssl http2;", + "repl": "listen 443 ssl; http2 on;", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "code", + "_why": "nginx 1.25.1+ deprecates the http2 listen parameter; http2 is its own directive" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-request-id: \"%REQ(x-request-id)%\"", + "repl": "x-client-ip: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "setting x-request-id to itself is a no-op; Envoy generates it" + }, + { + "op": "replace", + "scope": "slide", + "find": "# propagate or create", + "repl": "# caller address", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "comment matches the new header" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-tenant-id: \"%DOWNSTREAM_PEER_CERT_V_END%\"", + "repl": "x-peer-identity: \"%DOWNSTREAM_PEER_URI_SAN%\"", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "code", + "_why": "DOWNSTREAM_PEER_CERT_V_END is the certificate expiry, not an identity; the URI SAN carries the SPIFFE ID" + }, + { + "op": "set_notes", + "sldId": 440, + "paragraphs": [ + "Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders.", + "The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none.", + "The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved." + ], + "cat": "notes", + "_why": "notes described the old headers (tenant id from the cert expiry operator)" + }, + { + "op": "set_code", + "sldId": 509, + "shape_id": 8, + "lines": [ + "{", + " \"$schema\": \"https://flagd.dev/schema/v0/flags.json\",", + " \"flags\": {", + " \"new-checkout\": {", + " \"state\": \"ENABLED\",", + " \"variants\": { \"on\": true, \"off\": false },", + " \"defaultVariant\": \"off\",", + " \"targeting\": {", + " \"if\": [", + " { \"==\": [ {\"var\": \"plan\"}, \"enterprise\" ] }, \"on\",", + " { \"fractional\": [ {\"var\": \"targetingKey\"},", + " [\"on\", 25], [\"off\", 75] ] }", + " ]", + " }", + " }", + " }", + "}" + ], + "cat": "code", + "_why": "examples/27-feature-flags/flags/flags.json carries the $schema line" + }, + { + "op": "replace", + "scope": "notes", + "find": "on Quarkus 3.35", + "repl": "on Quarkus 3.40.1 (LTS, Java 25)", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "version", + "_slide": 1 + }, + { + "op": "set_code", + "sldId": 269, + "shape_id": 156, + "lines": [ + "# application.properties — config from the environment (factor III)", + "quarkus.application.name=order-service", + "quarkus.application.version=1.4.0", + "%prod.kafka.bootstrap.servers=${KAFKA_BOOTSTRAP}", + "// SmallRye Health publishes the probes Kubernetes calls", + "@Liveness // /q/health/live — process is up", + "public class Alive implements HealthCheck {", + " public HealthCheckResponse call() { return HealthCheckResponse.up(\"alive\"); }", + "}", + "@Readiness // /q/health/ready — deps reachable", + "public class Ready implements HealthCheck {", + " @Inject Pool db; // reactive SQL client (Mutiny API)", + " public HealthCheckResponse call() {", + " try { db.query(\"SELECT 1\").executeAndAwait(); return up(\"ready\"); }", + " catch (Exception e) { return down(\"ready\"); } }", + "}" + ], + "cat": "code", + "_slide": 14, + "_why": "PgPool is deprecated in the reactive SQL client; Pool is the injected type. The old readiness compared a Query object to null, which is always true." + }, + { + "op": "replace", + "scope": "notes", + "find": "Maven (with the quarkus-bom) pins every dependency reproducibly — that's the Poetry equivalent for the Java world.", + "repl": "Maven with the quarkus-bom (platform 3.40.1) pins every dependency reproducibly.", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "stack", + "_slide": 14, + "_why": "Poetry is not the Python tool any more; the comparison is dropped" + }, + { + "op": "set_code", + "sldId": 272, + "shape_id": 190, + "lines": [ + "@Path(\"/orders\")", + "@Produces(MediaType.APPLICATION_JSON)", + "public class OrderResource { // Quarkus REST", + "", + " record OrderIn(@NotBlank String sku, @Positive int quantity) {} // validated", + "", + " @POST", + " @Transactional", + " public RestResponse place(@Valid OrderIn body) {", + " Order o = orders.create(body);", + " events.publish(\"order.placed\", o); // emit the fact (the outbox makes it safe)", + " return RestResponse.status(Response.Status.CREATED, o); // 201, not 200", + " }", + "", + " @GET // cursor pagination, not offset", + " public Page list(@RestQuery String after,", + " @RestQuery @DefaultValue(\"50\") int limit) {", + " return orders.page(after, limit);", + " }", + "}" + ], + "cat": "code", + "_slide": 17, + "_why": "site _docs/02-communications.md Quarkus codetab; the deck had a hand-edited body in a proportional font and a reactive variant the site does not show", + "pt": 11.0, + "_fit": "slide 17: 20 code lines, 89 columns; 11.5 pt overflows the box" + }, + { + "op": "set_code", + "sldId": 273, + "shape_id": 203, + "lines": [ + "// inventory.proto — the contract, registered in Apicurio", + "syntax = \"proto3\";", + "service Inventory {", + " rpc ReserveStock (ReserveRequest) returns (ReserveReply);", + "}", + "message ReserveRequest { string sku = 1; int32 quantity = 2; }", + "message ReserveReply { bool reserved = 1; int32 remaining = 2; }", + "// Quarkus gRPC generates the Mutiny service interface from the .proto at build time", + "@GrpcService", + "public class InventoryService implements Inventory { // generated interface", + " @Override", + " public Uni reserveStock(ReserveRequest req) {", + " return stock.reserve(req.getSku(), req.getQuantity())", + " .map(r -> ReserveReply.newBuilder()", + " .setReserved(r.ok()).setRemaining(r.remaining()).build());", + " }", + "}" + ], + "cat": "code", + "_slide": 18, + "_why": "examples/02-communications/quarkus InventoryService implements the generated Mutiny interface Inventory, not InventoryGrpc.InventoryImplBase" + }, + { + "op": "replace", + "scope": "notes", + "find": "you implement the generated base class", + "repl": "you implement the generated Mutiny interface", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "code", + "_slide": 18 + }, + { + "op": "set_code", + "sldId": 279, + "shape_id": 272, + "lines": [ + "@GraphQLApi // the gateway's schema (SmallRye GraphQL)", + "public class OrderApi {", + "", + " @Query // REST call to order-service", + " public Order order(String id) {", + " return orderClient.get(id);", + " }", + "", + " // @Source adds a 'stock' field to Order, fetched only when queried", + " public Integer stock(@Source Order order) { // via gRPC", + " return inventory.reserveStockView(order.sku()).getRemaining();", + " }", + "}", + "// SmallRye GraphQL exports the SDL to the registry automatically." + ], + "cat": "code", + "_slide": 24, + "_why": "site _docs/03-composition.md Quarkus codetab" + }, + { + "op": "set_code", + "sldId": 284, + "shape_id": 328, + "lines": [ + "@Path(\"/orders\") // write side: command handler", + "public class OrderWriteResource {", + " @Inject WriteOrderRepository writes;", + " @Inject @Channel(\"order-placed\") Emitter events;", + " @POST @Transactional // blocking: worker thread", + " public Order create(OrderIn body) {", + " Order order = new Order(body);", + " writes.persist(order); // write model", + " events.send(new OrderCreatedEvent(order)); // → projection", + " return order;", + " }", + "}", + "@Path(\"/orders\") // read side: query handler, denormalised store", + "public class OrderReadResource {", + " @Inject ReadOrderSummaryRepository reads; // hits read DB", + " @GET @Path(\"/{id}/summary\")", + " public OrderSummary summary(@PathParam(\"id\") String id) {", + " return reads.findByIdOptional(id) // never the write store", + " .orElseThrow(() -> new WebApplicationException(404));", + " }", + "}", + "@ApplicationScoped // projection: events upsert the read model", + "public class OrderSummaryProjection {", + " @Inject ReadOrderSummaryRepository reads;", + " @Incoming(\"order-placed\") @Transactional // topic order.placed", + " public void on(OrderCreatedEvent ev) { reads.upsert(OrderSummary.from(ev)); }", + "}" + ], + "cat": "code", + "_slide": 29, + "_why": "no site codetab; blocking Panache persist inside a Uni-returning method would run JDBC on the event loop; channel names follow the examples' convention (channel order-created, topic order.created)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 29: 30 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "op": "replace", + "scope": "notes", + "find": "an @Incoming(\"order.created\") method", + "repl": "an @Incoming(\"order-created\") method (topic order.created)", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "code", + "_slide": 29 + }, + { + "op": "set_code", + "sldId": 291, + "shape_id": 407, + "lines": [ + "// producer — order-service emits the fact; channel order-placed → topic order.placed", + "@ApplicationScoped", + "public class OrderEvents {", + " @Inject @Channel(\"order-placed\") Emitter emitter; // Reactive Messaging", + " public void publish(Order o) {", + " emitter.send(KafkaRecord.of(o.id(), o)); // keyed; schema-aware serde", + " }", + "}", + "// consumer — notification-service, Kafka-only by design", + "@ApplicationScoped", + "public class Notifier {", + " @Incoming(\"order-placed\") // offset tracked per group", + " public Uni onOrder(Message msg) {", + " return sendEmail(msg.getPayload())", + " .chain(msg::ack); // ack after the side-effect — at-least-once", + " }", + "}" + ], + "cat": "code", + "_slide": 36, + "_why": "examples/05-event-driven/quarkus: @Channel/@Incoming(\"order-placed\") with mp.messaging...topic=order.placed" + }, + { + "op": "set_code", + "sldId": 298, + "shape_id": 485, + "lines": [ + "// quarkus-kafka-streams: the topology is a CDI-produced bean", + "@ApplicationScoped", + "public class RevenueTopology {", + " @Produces", + " public Topology build() {", + " StreamsBuilder b = new StreamsBuilder();", + " b.stream(\"order.placed\", Consumed.with(Serdes.String(), orderSerde))", + " .groupBy((k, o) -> o.merchantId())", + " .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))", + " .aggregate(() -> 0.0, // local, changelog-backed", + " (mId, o, total) -> total + o.total(),", + " Materialized.as(\"revenue-by-merchant\")) // state store", + " .toStream()", + " .to(\"revenue.by-merchant\");", + " return b.build();", + " }", + "}" + ], + "cat": "code", + "_slide": 43, + "_why": "examples/06-stream-processing/quarkus RevenueTopology writes revenue.by-merchant (site 06 codetab); the deck wrote revenue.5m" + }, + { + "op": "replace", + "scope": "notes", + "find": "natural Quarkus corollary to Faust", + "repl": "natural Quarkus counterpart to faust-streaming in the Python deck", + "expect": 1, + "sldIds": [ + 298 + ], + "cat": "stack", + "_slide": 43 + }, + { + "op": "replace", + "scope": "notes", + "find": "The output revenue.5m topic", + "repl": "The output revenue.by-merchant topic", + "expect": 1, + "sldIds": [ + 298 + ], + "cat": "code", + "_slide": 43 + }, + { + "op": "set_code", + "sldId": 310, + "shape_id": 621, + "lines": [ + "", + "", + " io.apicurio", + " apicurio-registry-maven-plugin", + " 3.3.3", + " ", + " test-update ", + " ", + " http://apicurio.registry.svc/apis/registry/v3", + " ", + " ", + " orders", + " order-placed", + " src/main/avro/order-placed.avsc ", + " ", + " ", + " ", + " ", + "" + ], + "cat": "code", + "_slide": 55, + "_why": "plugin 3.3.3 (= Apicurio pin; test-update goal verified in its plugin.xml); group orders / artifact order-placed as examples/09-api-registry" + }, + { + "op": "set_code", + "sldId": 340, + "shape_id": 946, + "lines": [ + "// OrderService.java — declarative bulkhead (SmallRye Fault Tolerance)", + "@ApplicationScoped", + "public class OrderService {", + " @Inject @RestClient PaymentClient payments;", + "", + " @Bulkhead(value = 50, waitingTaskQueue = 100) // 50 concurrent, 100 waiting", + " @Timeout(3000) // fail fast after 3 s", + " @AsynchronousNonBlocking // Uni return, no worker thread", + " public Uni place(OrderRequest req) {", + " return payments.charge(req).map(p -> Order.from(req, p));", + " }", + "}", + "", + "// TenantBulkhead.java — one semaphore per tenant (as examples/12-security)", + "@ApplicationScoped", + "public class TenantBulkhead {", + " private final ConcurrentMap sems = new ConcurrentHashMap<>();", + "", + " public T handle(String tenant, Callable work) throws Exception {", + " Semaphore sem = sems.computeIfAbsent(tenant, t -> new Semaphore(20));", + " if (!sem.tryAcquire()) throw new BulkheadException(tenant); // this tenant only", + " try { return work.call(); } finally { sem.release(); }", + " }", + "}" + ], + "cat": "code", + "_slide": 85, + "_why": "FaultToleranceProvider.bulkhead(...) is not a SmallRye Fault Tolerance API; @Asynchronous needs CompletionStage, a Uni uses @AsynchronousNonBlocking; per-tenant part from site 12-security codetab and examples/12 BulkheadService" + }, + { + "op": "set_notes", + "sldId": 340, + "paragraphs": [ + "Quarkus ships SmallRye Fault Tolerance, the MicroProfile Fault Tolerance implementation. @Bulkhead(value = 50, waitingTaskQueue = 100) admits at most 50 concurrent invocations, queues up to 100 and rejects the rest; @Timeout fails the call after three seconds. @AsynchronousNonBlocking guards a method that returns a Mutiny Uni without moving it to a worker thread, which is what lets the waiting queue apply.", + "Per-tenant bulkheads need one limit per tenant, so the example keeps a semaphore per tenant: a tenant that fills its 20 slots gets a BulkheadException while every other tenant keeps its capacity. Fault-tolerance metrics are exported through Micrometer, so dashboards can show saturation. At the network layer, Istio's DestinationRule connectionPool adds mesh-level bulkheading on top of these in-process limits." + ], + "cat": "notes", + "_slide": 85, + "_why": "notes described the removed programmatic API" + }, + { + "op": "set_code", + "sldId": 372, + "shape_id": 1291, + "lines": [ + "// envelope.proto — version the message, not the URL", + "message Envelope {", + " uint32 version = 1; // bump for incompatible payload changes", + " string type = 2; // \"order.updated\", \"ping\", ...", + " bytes payload = 3; // the protobuf-encoded body for `type`", + " uint64 seq = 4; // monotonic per-connection — drives resume", + "}", + "// server: send compact binary frames instead of JSON text", + "@OnBinaryMessage // Envelope bound by a BinaryMessageCodec", + "public Uni onFrame(Envelope env) { return dispatch(env); }", + "Envelope frame = Envelope.newBuilder().setVersion(2)", + " .setType(\"order.updated\").setSeq(n)", + " .setPayload(order.toByteString()).build();", + "connection.sendBinaryAndAwait(Buffer.buffer(frame.toByteArray()));", + "// client checks env.version; unknown major version -> reconnect & renegotiate" + ], + "cat": "code", + "_slide": 117, + "_why": "send call as in site _docs/16-appendix-c-websockets.md Quarkus codetab; binding bytes to Envelope needs a codec" + }, + { + "op": "replace", + "scope": "notes", + "find": "WebSockets Next binds the incoming bytes straight to the generated Envelope type", + "repl": "With a BinaryMessageCodec registered for Envelope, WebSockets Next binds the incoming bytes to the generated type", + "expect": 1, + "sldIds": [ + 372 + ], + "cat": "code", + "_slide": 117 + }, + { + "op": "set_code", + "sldId": 377, + "shape_id": 1346, + "lines": [ + "static final List STEPS =", + " List.of(\"charge_payment\", \"reserve_stock\", \"book_shipping\");", + "@Transactional // state + command commit together", + "public void advance(String sagaId) {", + " Saga s = Saga.findById(sagaId, LockModeType.PESSIMISTIC_WRITE); // row lock", + " if (!\"RUNNING\".equals(s.status)) return; // idempotent: already done/failed", + " String step = STEPS.get(s.stepIndex);", + " try {", + " var result = invoke(step, s.context); // call the service", + " s.context.put(step, result); // accumulate output", + " if (++s.stepIndex == STEPS.size()) s.status = \"COMPLETED\";", + " } catch (StepException e) {", + " s.status = \"COMPENSATING\"; // hand off to the unwinder", + " }", + " s.persist(); // durable after every step", + "}" + ], + "cat": "code", + "_slide": 122, + "_why": "status names as examples/17-sagas/quarkus SagaService (COMPLETED, not DONE)" + }, + { + "op": "set_code", + "sldId": 379, + "shape_id": 1370, + "lines": [ + "static final Map COMPENSATIONS = Map.of( // inverse per step", + " \"charge_payment\", \"refund_payment\",", + " \"reserve_stock\", \"release_stock\",", + " \"book_shipping\", \"cancel_shipping\");", + "@Transactional", + "public void compensate(String sagaId) {", + " Saga s = Saga.findById(sagaId, LockModeType.PESSIMISTIC_WRITE);", + " // undo only the steps that completed, newest first", + " for (String step : reversed(STEPS.subList(0, s.stepIndex))) {", + " invoke(COMPENSATIONS.get(step), s.context,", + " sagaId + \":\" + step); // idempotency key — safe to retry", + " }", + " s.status = \"COMPENSATED\";", + " s.persist();", + "}" + ], + "cat": "code", + "_slide": 124, + "_why": "terminal status COMPENSATED as examples/17 and site 17-appendix-d-sagas codetab (not FAILED)" + }, + { + "op": "replace", + "scope": "slide", + "find": "Quarkus 3.32+", + "repl": "Quarkus 3.40.1", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "version", + "_slide": 164, + "_why": "code comment; pinned Quarkus" + }, + { + "op": "replace", + "scope": "notes", + "find": "As of Quarkus 3.32 the HTTP stack", + "repl": "On Quarkus 3.40.1 the HTTP stack", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "version", + "_slide": 164 + }, + { + "op": "replace", + "scope": "notes", + "find": "is the RESTEasy Reactive pattern", + "repl": "is the Quarkus REST pattern", + "expect": 1, + "sldIds": [ + 385 + ], + "cat": "stack", + "_slide": 130, + "_why": "RESTEasy Reactive was renamed Quarkus REST" + }, + { + "op": "set_text", + "sldId": 461, + "shape_id": 2273, + "paragraphs": [ + "java · Quarkus REST + REST Client" + ], + "cat": "code", + "_slide": 206 + }, + { + "op": "set_code", + "sldId": 461, + "shape_id": 2274, + "lines": [ + "// Quarkus REST + MicroProfile REST Client — a content-aware proxy that reads", + "// the body and forwards to the new service or the monolith. In production this", + "// typically lives in a sidecar; the resource shows the shape cleanly.", + "@Path(\"/orders\")", + "public class OrderRouter {", + " @Inject @RestClient MonolithClient monolith; // legacy", + " @Inject @RestClient OrdersClient newSvc; // extracted", + " @Inject @RestClient OrdersEuClient euSvc; // regional", + "", + " @POST", + " @Consumes(\"application/json\")", + " @Produces(\"application/json\")", + " public JsonObject route(JsonObject body) {", + " String tenant = body.getString(\"tenant\", \"\"); // decide by content, not URL", + " String region = body.getString(\"region\", \"\");", + " if (\"acme\".equals(tenant)) return newSvc.placeOrder(body); // cut Acme over", + " if (\"EU\".equals(region)) return euSvc.placeOrder(body); // regional", + " return monolith.placeOrder(body); // default: legacy", + " }", + "}", + "", + "@RegisterRestClient(configKey = \"orders-new\")", + "public interface OrdersClient {", + " @POST JsonObject placeOrder(JsonObject body);", + "}" + ], + "cat": "code", + "_slide": 206, + "_why": "site _docs/24-appendix-k-monolith-to-microservices.md Quarkus codetab; the deck's route lines ran to 150 columns", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 206: 25 code lines, 88 columns; 9.5 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 461, + "shape_id": 2275, + "paragraphs": [ + "Read the body, decide by content (tenant / region / feature flag), forward through a typed REST Client." + ], + "cat": "code", + "_slide": 206 + }, + { + "op": "set_notes", + "sldId": 461, + "paragraphs": [ + "The Quarkus shape. The proxy is a Quarkus REST resource that reads the body and decides which downstream to call. The MicroProfile REST Client (@RegisterRestClient) gives typed outbound calls; declare Uni on the client interface when the proxy should not block a worker thread.", + "The decision is by payload content (tenant, region, feature flag), not by URL pattern. That makes the pattern more flexible than path-based proxying: you can roll the new service out to one tenant at a time, watch it under production traffic, then widen. Behind a proxy (nginx, Envoy, Istio) the same logic becomes a Lua or WASM filter or an EnvoyFilter at the mesh layer; the resource version shows the shape.", + "Parsing the body costs CPU. Over a migration of months that cost adds up; over a migration of days it is small next to the risk control it buys." + ], + "cat": "notes", + "_slide": 206, + "_why": "notes described REST Client Reactive Uni code that is no longer on the slide" + }, + { + "op": "set_text", + "sldId": 463, + "shape_id": 2297, + "paragraphs": [ + "java · Quarkus REST + REST Client + Redis + Reactive Messaging" + ], + "cat": "code", + "_slide": 208 + }, + { + "op": "set_code", + "sldId": 463, + "shape_id": 2298, + "lines": [ + "@Path(\"/orders\")", + "public class OrdersDecorator {", + " @Inject @RestClient LegacyOrdersClient legacy;", + " @Inject @Channel(\"order-placed\") Emitter events;", + " final ValueCommands cache;", + " public OrdersDecorator(RedisDataSource redis) {", + " this.cache = redis.value(Order.class);", + " }", + " @GET @Path(\"/{id}\")", + " public Order getOrder(String id) {", + " Order cached = cache.get(\"order:\" + id); // 1. cache hit", + " if (cached != null) return cached;", + " Order order = legacy.get(id); // 2. legacy on miss", + " cache.setex(\"order:\" + id, 60, order); // 3. populate", + " return order;", + " }", + " @POST", + " public Order placeOrder(Order body) {", + " Order order = legacy.create(body); // 1. legacy first", + " events.send(order); // 2. emit event", + " cache.del(\"order:\" + order.id()); // 3. invalidate", + " return order;", + " }", + "}" + ], + "cat": "code", + "_slide": 208, + "_why": "site _docs/24 Quarkus decorator codetab (blocking RedisDataSource, as examples/24 and 25)", + "pt": 9.0, + "_fit": "slide 208: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "set_notes", + "sldId": 463, + "paragraphs": [ + "The Quarkus decorator. RedisDataSource is the Quarkus Redis client (quarkus-redis-client) with type-safe commands; value(Order.class) serializes the Order for you. The GET path tries the cache, falls back to the legacy service through the REST Client on a miss, and populates the cache with a 60-second TTL.", + "The POST path delegates the write to the legacy service, which stays the source of truth, then emits a Kafka event through a Reactive Messaging Emitter on the order-placed channel, then invalidates the cache key. The legacy service does not know it is wrapped, and clients do not know they talk to a decorator.", + "Over time more behaviour moves into the decorator: validation rules, idempotency keys, deduplication, multi-source reads. To retire the legacy service, implement the order storage inside the decorator (or in a new service it calls) and stop forwarding; the API stays the same for clients." + ], + "cat": "notes", + "_slide": 208, + "_why": "notes described the reactive version" + }, + { + "op": "set_text", + "sldId": 468, + "shape_id": 2351, + "paragraphs": [ + "java · Quarkus REST + Panache + Redis" + ], + "cat": "code", + "_slide": 213 + }, + { + "op": "set_code", + "sldId": 468, + "shape_id": 2352, + "lines": [ + "@Path(\"/products\")", + "public class ProductResource {", + " private static final int TTL_S = 60;", + " private final ValueCommands cache;", + " public ProductResource(RedisDataSource redis) {", + " this.cache = redis.value(Product.class);", + " }", + " @GET @Path(\"/{id}\")", + " public Product get(String id) {", + " String key = \"product:\" + id;", + " Product cached = cache.get(key); // 1. cache lookup", + " if (cached != null) return cached; // hit", + " Product p = Product.findById(id); // 2. DB on miss (Panache)", + " if (p == null) throw new WebApplicationException(404);", + " cache.setex(key, TTL_S, p); // 3. populate", + " return p;", + " }", + " @PUT @Path(\"/{id}\") @Transactional", + " public Product update(String id, Product body) {", + " Product p = Product.findById(id);", + " p.update(body);", + " cache.del(\"product:\" + id); // 4. invalidate on write", + " return p;", + " }", + "}" + ], + "cat": "code", + "_slide": 213, + "_why": "site _docs/25-appendix-l-caching.md Quarkus cache-aside codetab (blank lines dropped to fit)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 213: 26 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "op": "set_notes", + "sldId": 468, + "paragraphs": [ + "Quarkus cache-aside. ValueCommands is the typed value-command interface of quarkus-redis-client, and the Product class is serialized for you. The GET path looks in the cache, falls back to a Panache query on a miss, and populates the cache with a 60-second TTL. The PUT path updates the row in a transaction and then deletes the cache key; setting the new value instead is write-through.", + "A Redis outage should not take reads down: wrap the cache calls so a failure falls through to the database and is logged, as the example's SafeRedis wrapper does. For high-cardinality keys, set a maxmemory-policy on Redis (allkeys-lru is typical) so the cache cannot grow without bound." + ], + "cat": "notes", + "_slide": 213, + "_why": "notes described the Mutiny chaining version" + }, + { + "op": "set_text", + "sldId": 470, + "shape_id": 2375, + "paragraphs": [ + "java · Quarkus Cache + quarkus-redis-cache" + ], + "cat": "code", + "_slide": 215 + }, + { + "op": "set_code", + "sldId": 470, + "shape_id": 2376, + "lines": [ + "// Quarkus Cache annotations give read-through semantics declaratively.", + "// quarkus-cache + quarkus-redis-cache; loader = the method body.", + "@ApplicationScoped", + "public class ProductService {", + " @CacheResult(cacheName = \"product-cache\") // runs only on a miss", + " public Product findById(String id) {", + " Product p = Product.findById(id); // Panache", + " if (p == null) throw new WebApplicationException(404);", + " return p;", + " }", + " @CacheInvalidate(cacheName = \"product-cache\")", + " @Transactional", + " public Product update(String id, Product body) {", + " Product p = Product.findById(id);", + " p.update(body);", + " return p;", + " }", + "}", + "// ProductResource calls products.findById(id): no if/else; the cache is invisible.", + "// application.properties:", + "// quarkus.cache.type=redis", + "// quarkus.cache.redis.product-cache.expire-after-write=60S" + ], + "cat": "code", + "_slide": 215, + "_why": "site _docs/25 Quarkus read-through codetab (resource class reduced to a comment to fit)", + "pt": 9.0, + "_fit": "slide 215: 22 code lines, 83 columns; 9.5 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 472, + "shape_id": 2399, + "paragraphs": [ + "java · Quarkus REST + Panache + Redis" + ], + "cat": "code", + "_slide": 217 + }, + { + "op": "set_code", + "sldId": 472, + "shape_id": 2400, + "lines": [ + "// Quarkus blocking: write the DB first, SET the cache second.", + "@Path(\"/products\")", + "public class ProductResource {", + " private final ValueCommands cache;", + " public ProductResource(RedisDataSource redis) {", + " this.cache = redis.value(Product.class);", + " }", + "", + " @PUT @Path(\"/{id}\") @Transactional", + " public Product update(String id, Product body) {", + " Product p = Product.findById(id); // 1. DB first (source of truth)", + " p.update(body);", + " cache.setex(\"product:\" + id, 60, p); // 2. cache second — SET, not invalidate", + " return p;", + " }", + "}" + ], + "cat": "code", + "_slide": 217, + "_why": "site _docs/25 Quarkus write-through codetab" + }, + { + "op": "set_text", + "sldId": 472, + "shape_id": 2401, + "paragraphs": [ + "DB first (source of truth), cache second: the managed entity is what gets cached." + ], + "cat": "code", + "_slide": 217 + }, + { + "op": "set_notes", + "sldId": 472, + "paragraphs": [ + "Write-through in Quarkus. The update loads the row through Panache, applies the change inside the transaction, and caches the managed entity: the canonical value, including server-side defaults. The DB write comes first because the DB is the source of truth; if the cache write fails, you lose cache freshness, not data.", + "Writing the request body straight to the cache would save a read but can diverge from the row when the database applies defaults, triggers or computed columns. The TTL is the safety net: if cache and DB drift briefly, they converge when the entry expires and the next read repopulates it." + ], + "cat": "notes", + "_slide": 217, + "_why": "notes described two reactive variants" + }, + { + "op": "set_text", + "sldId": 474, + "shape_id": 2423, + "paragraphs": [ + "java · Quarkus REST + Panache + Redis" + ], + "cat": "code", + "_slide": 219 + }, + { + "op": "set_code", + "sldId": 474, + "shape_id": 2424, + "lines": [ + "// Quarkus blocking: write the DB only; read side is cache-aside.", + "@Path(\"/events\")", + "public class EventResource {", + " private final ValueCommands cache;", + " public EventResource(RedisDataSource redis) {", + " this.cache = redis.value(Event.class);", + " }", + " @POST @Transactional", + " public Response record(Event body) {", + " body.persist(); // DB only — cache untouched", + " return Response.created(URI.create(\"/events/\" + body.id)).entity(body).build();", + " }", + " @GET @Path(\"/{id}\")", + " public Event get(String id) {", + " String key = \"event:\" + id;", + " Event cached = cache.get(key); // standard cache-aside read", + " if (cached != null) return cached;", + " Event e = Event.findById(id);", + " if (e == null) throw new WebApplicationException(404);", + " cache.setex(key, 60, e);", + " return e;", + " }", + "}" + ], + "cat": "code", + "_slide": 219, + "_why": "site _docs/25 Quarkus write-around codetab (blank lines dropped)" + }, + { + "op": "set_notes", + "sldId": 474, + "paragraphs": [ + "Write-around: the write goes to the database through Panache and the cache is not touched; the read side is plain cache-aside, so the first read populates the entry. If a concurrent reader may have cached a stale sibling entry, delete that key after the insert; it is cheap insurance on high-contention paths.", + "The pattern fits workloads where most writes are never read back. A security event log might take 10,000 writes a minute and 100 reads a day for active alerts; caching every write would churn entries. A product catalog is the opposite: every read is a probable hit, so write-through makes the writer's update visible to the next reader." + ], + "cat": "notes", + "_slide": 219, + "_why": "notes described the reactive variants removed from the slide" + }, + { + "op": "set_text", + "sldId": 476, + "shape_id": 2447, + "paragraphs": [ + "java · Quarkus + @Scheduled + Redis + Panache" + ], + "cat": "code", + "_slide": 221 + }, + { + "op": "set_code", + "sldId": 476, + "shape_id": 2448, + "lines": [ + "// Quarkus blocking: @Scheduled flusher drains the dirty-set to the DB.", + "@Path(\"/metrics\")", + "@ApplicationScoped", + "public class MetricResource {", + " private static final int BATCH = 100;", + " private final HashCommands cache;", + " private final SetCommands dirty;", + " // ... constructor: cache = redis.hash(String.class), dirty = redis.set(String.class)", + " @PUT @Path(\"/{id}\")", + " public Response write(String id, Map body) {", + " cache.hset(\"metric:\" + id, body); // 1. cache only; return now", + " dirty.sadd(\"metric:dirty\", id); // mark for flush", + " return Response.accepted().build(); // 202 — microseconds", + " }", + " @Scheduled(every = \"1s\") @Transactional // 2. background flush", + " void flush() {", + " Set ids = dirty.spop(\"metric:dirty\", BATCH);", + " if (ids == null || ids.isEmpty()) return;", + " try {", + " Metric.persistAll(loadFromCache(ids)); // batch insert", + " } catch (Exception e) {", + " dirty.sadd(\"metric:dirty\", ids.toArray(new String[0])); // 3. retry", + " }", + " }", + "}" + ], + "cat": "code", + "_slide": 221, + "_why": "site _docs/25 Quarkus write-back codetab (blank lines dropped)", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 221: 28 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "op": "set_notes", + "sldId": 476, + "paragraphs": [ + "Quarkus write-back. (1) A write goes to a Redis hash and adds the id to a dirty set, which tells the flusher what still has to reach the database; the handler returns 202 Accepted immediately. (2) A @Scheduled method runs every second, pops up to BATCH dirty ids with SPOP (atomic, so two flusher instances get distinct subsets and the flusher scales out) and writes them to the database in one transaction. (3) On failure the ids go back on the dirty set for the next run.", + "This is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale." + ], + "cat": "notes", + "_slide": 221, + "_why": "notes described Mutiny chaining" + }, + { + "op": "set_text", + "sldId": 478, + "shape_id": 2471, + "paragraphs": [ + "java · Quarkus + @Scheduled + Redis sorted set" + ], + "cat": "code", + "_slide": 223 + }, + { + "op": "set_code", + "sldId": 478, + "shape_id": 2472, + "lines": [ + "// Quarkus blocking: @Scheduled refreshes hot keys before TTL.", + "@ApplicationScoped", + "public class ProductService {", + " private static final int TTL_S = 60, REFRESH_BEFORE_S = 10;", + " private final ValueCommands cache;", + " private final SortedSetCommands hot;", + " private final KeyCommands keys;", + " public ProductService(RedisDataSource redis) {", + " this.cache = redis.value(Product.class);", + " this.hot = redis.sortedSet(String.class);", + " this.keys = redis.key();", + " }", + " // findById(): zadd(\"product:hot\", now, id) on every read, then cache-aside", + " @Scheduled(every = \"5s\")", + " void refreshHot() {", + " long cutoff = System.currentTimeMillis() - 5 * 60_000L;", + " hot.zremrangebyscore(\"product:hot\", 0, cutoff);", + " for (String id : hot.zrange(\"product:hot\", 0, -1)) {", + " long ttl = keys.ttl(\"product:\" + id);", + " if (ttl > 0 && ttl < REFRESH_BEFORE_S) { // danger zone", + " Product p = Product.findById(id);", + " if (p != null) cache.setex(\"product:\" + id, TTL_S, p); // refresh", + " }", + " }", + " }", + "}" + ], + "cat": "code", + "_slide": 223, + "_why": "site _docs/25 Quarkus refresh-ahead codetab (findById reduced to a comment to fit); hot set product:hot and 5 s period as examples/25 BackgroundTasks" + }, + { + "op": "set_notes", + "sldId": 478, + "paragraphs": [ + "Refresh-ahead in Quarkus. A Redis sorted set tracks which keys are hot, scored by the last access time; findById touches it on every read. A @Scheduled task runs every 5 seconds: it drops keys not read in the last 5 minutes (ZREMRANGEBYSCORE), then for each remaining key checks the TTL and, when it is under REFRESH_BEFORE_S, re-reads the row and resets the entry.", + "A variant subscribes to Redis keyspace notifications for expiry events and refreshes on the event; it is cleaner but needs more setup. The pattern fits read-heavy workloads where a small set of keys serves most traffic and miss latency is visible to users." + ], + "cat": "notes", + "_slide": 223, + "_why": "notes described Mutiny chaining; also removes a positional word" + }, + { + "op": "set_code", + "sldId": 508, + "shape_id": 8, + "lines": [ + "// OpenFeature SDK 1.23.0 + flagd provider 0.14.2 (as examples/27-feature-flags)", + "// startup: register the provider once (flagd.host / flagd.port from config)", + "void onStart(@Observes StartupEvent ev) {", + " OpenFeatureAPI.getInstance().setProvider(new FlagdProvider(", + " FlagdOptions.builder().host(flagdHost).port(flagdPort).build()));", + "}", + "@Path(\"/checkout\")", + "public class CheckoutResource {", + " @POST", + " public Receipt checkout(Cart cart, @HeaderParam(\"X-User\") String user) {", + " Client flags = OpenFeatureAPI.getInstance().getClient();", + " var ctx = new MutableContext(user) // targeting key", + " .add(\"plan\", cart.plan()).add(\"region\", cart.region());", + " boolean useNew = flags.getBooleanValue(\"new-checkout\", false, ctx);", + " return useNew ? newCheckout(cart) : legacyCheckout(cart);", + " }", + "}" + ], + "cat": "code", + "_slide": 253, + "_why": "examples/27-feature-flags/quarkus registers FlagdProvider at StartupEvent and uses OpenFeatureAPI.getClient(); there is no quarkus.openfeature.flagd config in the example" + }, + { + "op": "replace", + "scope": "both", + "find": " and run Example 05 on minikube and OpenShift Local.", + "repl": " and run Example 05 on minikube.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "repl": "Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'I need to get data from SFTP to Kafka to S3 with these transforms in the middle'", + "repl": "'I need to get data from SFTP to Kafka to an object store with these transforms in the middle'", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "KMS for keys, CloudTrail / audit logging, region", + "repl": "KMS for keys, audit logging, region", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'someone left an S3 bucket open'", + "repl": "'someone left a storage bucket open to the internet'", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: requires the storage service to validate tokens (S3, Azure Blob, GCS all do); ", + "repl": "Cons: requires the storage side to validate tokens; ", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "The canonical implementations: S3 pre-signed URLs (15-minute upload tokens for a specific bucket+key+method), Azure SAS tokens (Shared Access Signatures with per-resource scoping), GCS signed URLs, AWS STS AssumeRole with a tight scoping policy.", + "repl": "Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape.", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD)", + "repl": "validates JWTs from a modern IdP (Keycloak, Dex)", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Edge (IAP): Google IAP, Cloudflare Access, Pomerium, AWS Verified Access — ", + "repl": "Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — ", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Pomerium is open source; Google IAP / Cloudflare Access / AWS Verified Access are managed services.", + "repl": "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "they authenticate against your IdP (Keycloak, Auth0, Azure AD, Google Workspace)", + "repl": "they authenticate against your IdP (Keycloak, or Dex federating an existing directory)", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "into a secure object store (S3, Azure Blob, GCS) and publishes", + "repl": "into a secure object store and publishes", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Valet key requires storage-side validation — S3, Azure Blob, GCS all support it natively, so the cost is usually just understanding the SDK.", + "repl": "Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "; SAS (Shared Access Signature, Azure's scoped token)", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "SAS is Azure's valet-key token; ", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "(Kong, Apigee, Istio gateway, ", + "repl": "(Kong, Istio gateway, ", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "AWS Builders' Library (timeouts, retries, backoff with jitter)", + "repl": "the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling)", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "the AWS Builders' Library has excellent practical articles on timeouts and backoff-with-jitter;", + "repl": "the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol;", + "expect": 1, + "sldIds": [ + 503 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 866, + "paragraphs": [ + "Valet key — scoped, time-bound, op-restricted" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 868, + "paragraphs": [ + "java · Quarkus REST + javax.crypto (HmacSHA256)" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 333, + "shape_id": 869, + "lines": [ + "// ValetKeyService.java (excerpt), examples/12-security/quarkus: HMAC-SHA256 valet key", + "@ApplicationScoped", + "public class ValetKeyService {", + " private static final int TTL_SECONDS = 300; // time-bound", + " private final byte[] secret; // VALET_SECRET, injected through @ConfigProperty", + " public Map mint(String resource, String operation) {", + " long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS;", + " String payload = resource + \":\" + operation + \":\" + expires; // one object, one op", + " Map result = new LinkedHashMap<>();", + " result.put(\"resource\", resource);", + " result.put(\"operation\", operation);", + " result.put(\"expires\", expires);", + " result.put(\"token\", hmac(payload)); // HmacSHA256, hex-encoded", + " return result;", + " }", + " public boolean verify(String resource, String operation, long expires, String token) {", + " if (System.currentTimeMillis() / 1000 > expires) return false; // expired", + " String expected = hmac(resource + \":\" + operation + \":\" + expires);", + " return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time", + " }", + "}", + "// OrderResource maps POST /valet-key to mint() and GET /verify-valet to verify()" + ], + "cat": "code", + "_src": "examples/12-security/quarkus/order-service/src/main/java/com/cndp/order/ValetKeyService.java", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 333, + "paragraphs": [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (javax.crypto.Mac with HmacSHA256). POST /valet-key returns the resource, the operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with MessageDigest.isEqual, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403.", + "The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 968, + "paragraphs": [ + "java · Quarkus + SmallRye Reactive Messaging" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 342, + "shape_id": 969, + "lines": [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "@ApplicationScoped", + "public class ClaimPublisher {", + " @Inject ObjectStore store; // any encrypted object store", + " @Inject ValetKeyService valet; // examples/12-security", + " @Inject @Channel(\"documents-out\") Emitter claims; // topic order.documents", + " public void publish(String orderId, byte[] body) {", + " String key = \"payloads/\" + orderId;", + " store.put(key, body, true); // encrypted at rest, ACL'd", + " JsonObject claim = new JsonObject(valet.mint(key, \"GET\"))", + " .put(\"order_id\", orderId)", + " .put(\"uri\", \"https://objects.internal/\" + key);", + " claims.send(claim); // only the claim on the topic", + " }", + "}", + "@ApplicationScoped", + "public class ClaimConsumer {", + " @Inject ObjectStore store;", + " @Incoming(\"documents-in\") // topic order.documents", + " public void onClaim(JsonObject claim) {", + " byte[] body = store.get(claim.getString(\"uri\"), claim.getString(\"token\"));", + " handle(body); // PII never touched the topic", + " }", + "}" + ], + "cat": "code", + "_src": "_docs/12-security.md claim-check codetab (site-fixes.md); ticket from examples/12-security", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 342, + "paragraphs": [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's ValetKeyService.mint (300 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with a Reactive Messaging Emitter. The PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 332, + "shape_id": 856, + "png": "lgtm-presentation/edits/png/12-valet-key.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "labels named S3, Azure SAS, GCS, STS (tools/vendor_figures.py)" + }, + { + "op": "replace_picture", + "sldId": 335, + "shape_id": 889, + "png": "lgtm-presentation/edits/png/12-zero-trust.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "examples named Google IAP, Cloudflare Access, AWS Verified Access" + }, + { + "op": "replace_picture", + "sldId": 427, + "shape_id": 1891, + "png": "lgtm-presentation/edits/png/22-l7-layers.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "API gateway row named Apigee; site figure as in the other decks" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 30, + "paragraphs": [ + "07 · Workflows & Jobs", + "08 · API Management", + "09 · API Registry", + "10 · API Metadata", + "11 · Observability", + "12 · Security", + "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices K–L · Monolith → Microservices, Caching Patterns", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendix R · Example 05 on minikube", + "Glossary of acronyms" + ], + "cat": "new", + "_why": "agenda gains Appendices R-S (C++: R)" + }, + { + "op": "set_notes", + "sldId": 257, + "paragraphs": [ + "The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns.", + "The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck.", + "Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 516, + "as": "r0", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 3, + "paragraphs": [ + "R" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 4, + "paragraphs": [ + "Example 05 on minikube" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 5, + "paragraphs": [ + "The same services and the same verify.sh on a single-node Kubernetes cluster" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r0", + "paragraphs": [ + "Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod.", + "The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r1", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r1", + "text": "244", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 5, + "paragraphs": [ + "From compose to a cluster" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 6, + "paragraphs": [ + "Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress.", + "Scope: Example 05 (event-driven fan-out), three services, all six languages, one node.", + "One Kafka broker, one Postgres instance, no service mesh: a local topology, not production.", + "The contract carries over: Service names, the Secret, environment variables, the health endpoint.", + "The same verify.sh runs against compose and the cluster: ten checks per language." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 7, + "paragraphs": [ + "Site: Appendix R · Code: examples/05-event-driven/k8s/" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r1", + "paragraphs": [ + "What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it.", + "What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged.", + "The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r2", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r2", + "text": "245", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 5, + "paragraphs": [ + "The minikube runtime" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 6, + "paragraphs": [ + "Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5.", + "8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services.", + "NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation.", + "No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop.", + "Every command passes --context cndp-k8s; setup-profile.sh restores the prior context." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 7, + "paragraphs": [ + "minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r2", + "paragraphs": [ + "minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version.", + "Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1.", + "The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r3", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r3", + "text": "246", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 5, + "paragraphs": [ + "One node, two namespaces" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r3", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-topology.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 7, + "paragraphs": [ + "The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r3", + "paragraphs": [ + "The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret.", + "From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r4", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r4", + "text": "247", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 5, + "paragraphs": [ + "Compose to Kubernetes" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 6, + "paragraphs": [ + "kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1).", + "postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw.", + "Credential → Secret cndp-postgres-app, read through secretKeyRef.", + "build: → docker build, then minikube image load; imagePullPolicy: Never.", + "healthcheck → startup and readiness probes on the order-service /healthz.", + "ports: 8080:8080 → NodePort 30080." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 7, + "paragraphs": [ + "The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r4", + "paragraphs": [ + "Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts.", + "The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r5", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r5", + "text": "248", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 5, + "paragraphs": [ + "One overlay per language" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 6, + "paragraphs": [ + "yaml · kustomize" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r5", + "shape_id": 8, + "lines": [ + "# k8s/overlays/quarkus/kustomization.yaml (excerpt)", + "images:", + " - name: order-service", + " newName: localhost/cndp-05/quarkus-order-service", + " newTag: v1", + " # shipping-consumer and notification-consumer map the same way", + "# appended to each Deployment's environment by a JSON patch", + "- name: QUARKUS_DATASOURCE_JDBC_URL", + " value: \"jdbc:postgresql://cndp-postgres-rw:5432/appdb\"", + "- name: QUARKUS_DATASOURCE_USERNAME", + " value: \"$(PG_USER)\"", + "- name: QUARKUS_DATASOURCE_PASSWORD", + " value: \"$(PG_PASSWORD)\"", + "- name: KAFKA_BOOTSTRAP_SERVERS", + " value: \"cndp-kafka-bootstrap:9092\"" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 9, + "paragraphs": [ + "base/ is language-neutral; each overlay maps the images and appends the variables its language reads." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r5", + "paragraphs": [ + "The Quarkus overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service.", + "Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r6", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r6", + "text": "249", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 5, + "paragraphs": [ + "Images built on the host" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r6", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-image-flow.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 7, + "paragraphs": [ + "docker build on the host, minikube image load into the node; pods never pull" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r6", + "paragraphs": [ + "The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load.", + "The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 521, + "as": "r7", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r7", + "text": "250", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 5, + "paragraphs": [ + "Run it" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 6, + "paragraphs": [ + "bash · minikube" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r7", + "shape_id": 8, + "lines": [ + "# from examples/05-event-driven/, with no other profile or container running", + "k8s/setup-profile.sh # create cndp-k8s; check the published ports", + "k8s/build-images.sh quarkus # docker build, then minikube image load", + "k8s/deploy.sh quarkus # Strimzi, Kafka, Postgres, LGTM, then the overlay", + "RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh", + "", + "# all six languages, a fresh namespace for each", + "OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh", + "", + "# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile", + "k8s/teardown.sh --operator", + "minikube stop -p cndp-k8s" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 9, + "paragraphs": [ + "With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r7", + "paragraphs": [ + "The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Quarkus images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay.", + "verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0.", + "What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 527, + "as": "r8", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r8", + "text": "251", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 5, + "paragraphs": [ + "Recorded results" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 6, + "paragraphs": [ + "10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0).", + "Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s.", + "Tempo received traces from order-service, shipping-consumer and notification-consumer in every language.", + "shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset.", + "Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 7, + "paragraphs": [ + "Times cover the image build and load, the deploy, the checks and the namespace teardown." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r8", + "paragraphs": [ + "The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language.", + "The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster." + ], + "cat": "new" + }, + { + "op": "resize_shape", + "sldId": 257, + "shape_id": 30, + "y": 1589592, + "cy": 4688780, + "cat": "fit", + "_fit": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 269, + "shape_id": 155, + "cx": 2672050, + "keep": "right", + "cat": "fit", + "_fit": "slide 14: chip 'java · Quarkus + SmallRye Health' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 273, + "shape_id": 202, + "cx": 2519650, + "keep": "right", + "cat": "fit", + "_fit": "slide 18: chip 'protobuf + java · Quarkus gRPC' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 279, + "shape_id": 271, + "cx": 1986250, + "keep": "right", + "cat": "fit", + "_fit": "slide 24: chip 'java · SmallRye GraphQL' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 284, + "shape_id": 327, + "cx": 4729450, + "keep": "right", + "cat": "fit", + "_fit": "slide 29: chip 'java · Quarkus REST + Panache + SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 287, + "shape_id": 361, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 32: chip 'java · Hibernate Panache + Debezium' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 291, + "shape_id": 406, + "cx": 2824450, + "keep": "right", + "cat": "fit", + "_fit": "slide 36: chip 'java · SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 298, + "shape_id": 484, + "cx": 2367250, + "keep": "right", + "cat": "fit", + "_fit": "slide 43: chip 'java · Quarkus Kafka Streams' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 310, + "shape_id": 620, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 55: chip 'xml · Maven + Apicurio' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 319, + "shape_id": 716, + "cx": 2367250, + "keep": "right", + "cat": "fit", + "_fit": "slide 64: chip 'java · Quarkus OpenTelemetry' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 321, + "shape_id": 740, + "cx": 3434050, + "keep": "right", + "cat": "fit", + "_fit": "slide 66: chip 'java · Quarkus + OpenTelemetry baggage API' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 324, + "shape_id": 774, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 328, + "shape_id": 815, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 329, + "shape_id": 825, + "cy": 4597340, + "cat": "fit", + "_fit": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 331, + "shape_id": 846, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 333, + "shape_id": 868, + "cx": 3815050, + "keep": "right", + "cat": "fit", + "_fit": "slide 78: chip 'java · Quarkus REST + javax.crypto (HmacSHA256)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 338, + "shape_id": 922, + "cx": 3357849, + "keep": "right", + "cat": "fit", + "_fit": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 340, + "shape_id": 945, + "cx": 4500850, + "keep": "right", + "cat": "fit", + "_fit": "slide 85: chip 'java · Quarkus + SmallRye Fault Tolerance (MicroProfile)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 342, + "shape_id": 968, + "cx": 3586450, + "keep": "right", + "cat": "fit", + "_fit": "slide 87: chip 'java · Quarkus + SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 365, + "shape_id": 1212, + "cx": 1986250, + "keep": "right", + "cat": "fit", + "_fit": "slide 110: chip 'java · SmallRye GraphQL' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 368, + "shape_id": 1245, + "cx": 2519650, + "keep": "right", + "cat": "fit", + "_fit": "slide 113: chip 'java · Quarkus WebSockets Next' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 377, + "shape_id": 1345, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 122: chip 'java · Hibernate Panache' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 379, + "shape_id": 1369, + "cx": 2062450, + "keep": "right", + "cat": "fit", + "_fit": "slide 124: chip 'java · Hibernate Panache' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 389, + "shape_id": 1486, + "cx": 2824450, + "keep": "right", + "cat": "fit", + "_fit": "slide 134: chip 'java · SmallRye Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 391, + "shape_id": 1510, + "cx": 1986250, + "keep": "right", + "cat": "fit", + "_fit": "slide 136: chip 'java · SmallRye GraphQL' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 401, + "shape_id": 1617, + "cx": 1986250, + "keep": "right", + "cat": "fit", + "_fit": "slide 146: chip 'java · ports & adapters' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 419, + "shape_id": 1807, + "cy": 436820, + "cat": "fit", + "_fit": "slide 164: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 420, + "shape_id": 1820, + "cy": 436820, + "cat": "fit", + "_fit": "slide 165: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "fit_font", + "sldId": 431, + "shape_id": 1938, + "pt": 11.0, + "cat": "fit", + "_fit": "slide 176: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 432, + "shape_id": 1950, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 177: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 437, + "shape_id": 2006, + "pt": 10.0, + "cat": "fit", + "_fit": "slide 182: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 438, + "shape_id": 2018, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 183: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 443, + "shape_id": 2075, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 188: chip 'java · Quarkus + Camel' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 444, + "shape_id": 2089, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 189: 24 code lines, 70 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 449, + "shape_id": 2143, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 194: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 461, + "shape_id": 2273, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 206: chip 'java · Quarkus REST + REST Client' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 463, + "shape_id": 2297, + "cx": 4958049, + "keep": "right", + "cat": "fit", + "_fit": "slide 208: chip 'java · Quarkus REST + REST Client + Redis + Reactive Messaging' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 464, + "shape_id": 2309, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 209: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 468, + "shape_id": 2351, + "cx": 3053050, + "keep": "right", + "cat": "fit", + "_fit": "slide 213: chip 'java · Quarkus REST + Panache + Redis' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 470, + "shape_id": 2375, + "cx": 3434050, + "keep": "right", + "cat": "fit", + "_fit": "slide 215: chip 'java · Quarkus Cache + quarkus-redis-cache' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 472, + "shape_id": 2399, + "cx": 3053050, + "keep": "right", + "cat": "fit", + "_fit": "slide 217: chip 'java · Quarkus REST + Panache + Redis' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 474, + "shape_id": 2423, + "cx": 3053050, + "keep": "right", + "cat": "fit", + "_fit": "slide 219: chip 'java · Quarkus REST + Panache + Redis' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 476, + "shape_id": 2447, + "cx": 3662650, + "keep": "right", + "cat": "fit", + "_fit": "slide 221: chip 'java · Quarkus + @Scheduled + Redis + Panache' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 478, + "shape_id": 2471, + "cx": 3738850, + "keep": "right", + "cat": "fit", + "_fit": "slide 223: chip 'java · Quarkus + @Scheduled + Redis sorted set' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 480, + "shape_id": 2493, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 225: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 492, + "shape_id": 2622, + "cx": 3662650, + "keep": "right", + "cat": "fit", + "_fit": "slide 237: chip 'java · Quarkus + MicroProfile Fault Tolerance' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 494, + "shape_id": 2645, + "cx": 3662650, + "keep": "right", + "cat": "fit", + "_fit": "slide 239: chip 'java · Quarkus + MicroProfile Fault Tolerance' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 496, + "shape_id": 2668, + "cx": 3662650, + "keep": "right", + "cat": "fit", + "_fit": "slide 241: chip 'java · Quarkus + MicroProfile Fault Tolerance' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 500, + "shape_id": 2711, + "cx": 2672050, + "keep": "right", + "cat": "fit", + "_fit": "slide 245: chip 'java · Quarkus + SmallRye Health' wraps; one line at 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 449, + "shape_id": 2144, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 194: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "cat": "fit", + "_fit": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "space_before": 6, + "cat": "fit", + "_fit": "references: the reference lines run past the frame into the page number; the frame cannot grow" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "fit", + "_fit": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "op": "fit_font", + "sldId": 479, + "shape_id": 2483, + "pt": 16, + "cat": "fit", + "_fit": "caching pitfalls: six long bullets run 15 pt past the frame; the frame cannot grow (page number below)" + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix1.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix1.json new file mode 100644 index 00000000..e355b527 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix1.json @@ -0,0 +1,3 @@ +{"ops":[ + {"op":"replace","scope":"slide","sldIds":[539],"find":"k8s/build-images.sh spring-boot# docker build","repl":"k8s/build-images.sh spring-boot # docker build","expect":1,"_cat":"code","_why":"missing space before the comment (the language name filled the padding)"} +]} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix2.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix2.json new file mode 100644 index 00000000..e70f4c07 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix2.json @@ -0,0 +1,1812 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 515 + ], + "find": "on unless deliberately killed", + "repl": "on until an operator kills it", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "LEAST-WORST", + "repl": "least-worst", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "RISK management", + "repl": "risk management", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "FREE LUNCH", + "repl": "free lunch", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 259 + ], + "find": "MADE WORSE", + "repl": "made worse", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 293 + ], + "find": "events are the TRANSPORT.", + "repl": "events are the transport.", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "in THIS request", + "repl": "in this request", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 317 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "Needed for BUSINESS operations", + "repl": "Needed for business operations", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "agent INTO the pod", + "repl": "agent into the pod", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ESSENTIAL in distributed systems", + "repl": "Essential in distributed systems", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "ALL errors, ALL traces", + "repl": "all errors, all traces", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 324 + ], + "find": "PLUS tail sampling", + "repl": "plus tail sampling", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 328 + ], + "find": "at the WRONG layer", + "repl": "at the wrong layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 414 + ], + "find": "Strong OR distant", + "repl": "Strong or distant", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 421 + ], + "find": "stop NEW traffic", + "repl": "stop new traffic", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 445 + ], + "find": "a BUSINESS rule", + "repl": "a business rule", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 445 + ], + "find": "It runs INSIDE one service", + "repl": "It runs inside one service", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 455 + ], + "find": "affects ALL clients", + "repl": "affects all clients", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 462 + ], + "find": "routing layer FIRST", + "repl": "routing layer first", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 468 + ], + "find": "is a TRANSITION state", + "repl": "is a transition state", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 486 + ], + "find": "to BOUND the blast radius", + "repl": "to bound the blast radius", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 491 + ], + "find": "is PERCEIVED DIFFERENTLY by", + "repl": "is perceived differently by", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 502 + ], + "find": "reject excess work IMMEDIATELY", + "repl": "reject excess work immediately", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 505 + ], + "find": "RED metrics PER DEPENDENCY", + "repl": "RED metrics per dependency", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 507 + ], + "find": "at ONE layer", + "repl": "at one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "the SINGLE source of truth", + "repl": "the single source of truth", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 286 + ], + "find": "in ONE local transaction", + "repl": "in one local transaction", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "find": "handlers MUST be idempotent", + "repl": "handlers must be idempotent", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT SOURCING means", + "repl": "Event sourcing means", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "EVENT STREAMING means", + "repl": "Event streaming means", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 293 + ], + "find": "a propagated FACT about", + "repl": "a propagated fact about", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "KAFKA is the SUBSTRATE", + "repl": "Kafka is the substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "PULSAR is also substrate", + "repl": "Pulsar is also substrate", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "pub/sub PLUS queue", + "repl": "pub/sub plus queue", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "FLINK is COMPUTE", + "repl": "Flink is compute", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "sits ON TOP of", + "repl": "sits on top of", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NIFI is MOVEMENT", + "repl": "NiFi is movement", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "WITHOUT registering it", + "repl": "without registering it", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "answer WHERE the time went", + "repl": "answer where the time went", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "what EXACTLY happened", + "repl": "what exactly happened", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "propagates ALONG the whole trace", + "repl": "propagates along the whole trace", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "which FUNCTION is burning", + "repl": "which function is burning", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 316 + ], + "find": "you can PIVOT", + "repl": "you can pivot", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 317 + ], + "find": "took 200ms but WHY", + "repl": "took 200ms but why", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "auto-INJECTION from auto-INSTRUMENTATION from MANUAL instrumentation", + "repl": "auto-injection from auto-instrumentation from manual instrumentation", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "travelling WITH the request", + "repl": "travelling with the request", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 320 + ], + "find": "is CONSTANT across", + "repl": "is constant across", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "question is WHEN the", + "repl": "question is when the", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "HEAD sampling decides at the START", + "repl": "Head sampling decides at the start", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "it's BLIND at", + "repl": "it is blind at", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "TAIL sampling decides at the END", + "repl": "Tail sampling decides at the end", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 323 + ], + "find": "sample by TRACE not by span", + "repl": "sample by trace, not by span", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 337 + ], + "find": "are REJECTED", + "repl": "are rejected", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "defines the LOGIC", + "repl": "defines the logic", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 338 + ], + "find": "is the INSTANCE that", + "repl": "is the instance that", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 341 + ], + "find": "a CLAIM TICKET (", + "repl": "a claim ticket (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 386 + ], + "find": "agree ONE error contract", + "repl": "agree one error contract", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 387 + ], + "find": "a STABLE machine-readable", + "repl": "a stable machine-readable", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 388 + ], + "find": "status CLASS, which", + "repl": "status class, which", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 396 + ], + "find": "backoff PLUS jitter", + "repl": "backoff plus jitter", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 420 + ], + "find": "lag, THEN stop accepting", + "repl": "lag, then stop accepting", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 427 + ], + "find": "north/south AND east/west", + "repl": "north/south and east/west", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 440 + ], + "find": "The DIFFICULT part", + "repl": "The difficult part", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 445 + ], + "find": "sits BEHIND the mesh", + "repl": "sits behind the mesh", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 451 + ], + "find": "(encryption AND identity)", + "repl": "(encryption and identity)", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 451 + ], + "find": "with NO app code", + "repl": "with no app code", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 458 + ], + "find": "a DEPLOYMENT-organisation property", + "repl": "a deployment-organisation property", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 466 + ], + "find": "owns BEHAVIOUR", + "repl": "owns behaviour", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 469 + ], + "find": "differ in WHO reads/writes the cache, WHEN, and WHO owns", + "repl": "differ in who reads/writes the cache, when, and who owns", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 471 + ], + "find": "in WHO does the work", + "repl": "in who does the work", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 489 + ], + "find": "is QUORUM:", + "repl": "is quorum:", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 492 + ], + "find": "exactly ONE layer", + "repl": "exactly one layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 493 + ], + "find": "for ALL callers", + "repl": "for all callers", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 495 + ], + "find": "call MUST have a timeout", + "repl": "call must have a timeout", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 503 + ], + "find": "it EJECTS that host", + "repl": "it ejects that host", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 503 + ], + "find": "don't ALSO retry", + "repl": "don't also retry", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 506 + ], + "find": "across a ROW", + "repl": "across a row", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 506 + ], + "find": "down a COLUMN", + "repl": "down a column", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "the EDGE/INGRESS layer", + "repl": "the edge/ingress layer", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "the API GATEWAY (", + "repl": "the API gateway (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "the SERVICE MESH (", + "repl": "the service mesh (", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 431 + ], + "find": "into the APP itself", + "repl": "into the app itself", + "expect": 1, + "cat": "voice", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 437 + ], + "find": "so the latency-budget slide later makes sense", + "repl": "so the L7 latency budget makes sense", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 453 + ], + "find": "as the canary slide earlier", + "repl": "as the weighted canary", + "expect": 1, + "cat": "positional", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 378 + ], + "find": "persisted FAILED state", + "repl": "persisted COMPENSATED state", + "expect": 1, + "cat": "code", + "_src": "capsfix" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 444 + ], + "find": "inject correlation and identity", + "repl": "inject the caller address and identity", + "expect": 1, + "cat": "code", + "_src": "capsfix" + }, + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure heading 'Dual-write is a lie' -> 'Dual-write is unsafe' (src/04-dual-write-outbox.svg)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 336 + ], + "find": "validate identity (OAuth/OIDC against your IdP), check device posture, then issue a JWT", + "repl": "validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT", + "expect": 1, + "cat": "code", + "_why": "OAuth2 Proxy has no device-posture check; the proxies enforce per-route identity policy", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 344 + ], + "find": "; Microsoft Cloud Adoption Framework: security patterns.", + "repl": "; OWASP Microservices Security Cheat Sheet.", + "expect": 1, + "cat": "stack", + "_why": "vendor-neutral reference (decision 7)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 318 + ], + "find": "tracer.start_span(...), span.set_attribute(...), span.add_event(...)", + "repl": "tracer.spanBuilder(...).startSpan(), span.setAttribute(...), span.addEvent(...)", + "expect": 1, + "cat": "code", + "_why": "OpenTelemetry Java API names, not Python's", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 340 + ], + "find": "// 50 by default", + "repl": "// maxConcurrentCalls: 5", + "expect": 1, + "cat": "code", + "_why": "examples/12-security/spring-boot CAPACITY = 5; Resilience4j's own default is 25, not 50", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 294 + ], + "find": "Event-driven tools landscape — Kafka", + "repl": "Event-driven tools landscape — Kafka", + "expect": 1, + "cat": "voice", + "_why": "title spacing (one space around the dash, as the other titles)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 333 + ], + "find": "Valet key — scoped", + "repl": "Valet key — scoped", + "expect": 1, + "cat": "voice", + "_why": "title spacing (Q.fix2 in Quarkus)", + "_src": "content" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 351 + ], + "find": "Glossary of acronyms (1 of 2) — protocols", + "repl": "Glossary of acronyms (1 of 2) — protocols", + "expect": 1, + "cat": "voice", + "_why": "title spacing, as the (2 of 2) title", + "_src": "content" + }, + { + "op": "set_geometry", + "sldId": 535, + "shape": "Image 1", + "x": 2.16, + "y": 1.95, + "w": 9.01, + "h": 4.3, + "cat": "fit", + "_why": "Appendix R topology figure enlarged to the free area, as Q.fix2 does in Quarkus", + "_src": "content" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 362 + ], + "find": "a field with a NEW number", + "repl": "a field with a new number", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: brand-new field", + "repl": "safe: brand-new field", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "SAFE: new field; old clients", + "repl": "safe: new field; old clients", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 363 + ], + "find": "BREAKING (don't)", + "repl": "breaking (don't)", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 368 + ], + "find": "per-POD registry", + "repl": "per-pod registry", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 461 + ], + "find": "IDENTIFY: pick", + "repl": "Identify: pick", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 461 + ], + "find": "MOVE: build", + "repl": "Move: build", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 284 + ], + "find": "// NOT write store", + "repl": "// read store only", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 285 + ], + "find": "projections AND the outbox pattern (covered next) work", + "repl": "projections and the outbox pattern work", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "plain Java that LOOKS synchronous", + "repl": "plain Java that looks synchronous", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 383 + ], + "find": "workflow-local variable AND in Temporal's", + "repl": "workflow-local variable and in Temporal's", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 475 + ], + "find": "always invokes the method AND writes", + "repl": "always invokes the method and writes", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 476 + ], + "find": "Always runs the method body AND writes", + "repl": "Always runs the method body and writes", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 479 + ], + "find": "the constraint AND eventual durability is acceptable AND you", + "repl": "the constraint, eventual durability is acceptable and you", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 484 + ], + "find": "is the constraint AND eventual durability is acceptable.", + "repl": "is the constraint and eventual durability is acceptable.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 480 + ], + "find": "only ONE instance flushes", + "repl": "only one instance flushes", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 498 + ], + "find": "ALWAYS pair retry with a breaker", + "repl": "always pair retry with a breaker", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 462 + ], + "find": "Pods — OR ", + "repl": "Pods — or ", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 433 + ], + "find": " and give you the mesh CRDs we'll see next.", + "repl": " and give you the mesh CRDs (VirtualService, DestinationRule).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 411 + ], + "find": "Strength is a ladder we'll see next (intrusive to contract).", + "repl": "Strength is a ladder (intrusive to contract).", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 445 + ], + "find": "(and the one we'll see code for)", + "repl": "(and the one the code models)", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 450 + ], + "find": "ties back to the stickiness point covered earlier.", + "repl": "ties back to the session-affinity trade-off.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 435 + ], + "find": "payload modification that we'll see more of — the L7 router", + "repl": "payload modification — the L7 router", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 295 + ], + "find": "state, which is the next section.", + "repl": "state, the subject of stream processing.", + "expect": 1, + "cat": "voice", + "_src": "caps" + }, + { + "op": "recolour_code", + "sldId": 269, + "shape_id": 156, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 273, + "shape_id": 203, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 279, + "shape_id": 272, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 284, + "shape_id": 328, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 287, + "shape_id": 362, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 291, + "shape_id": 407, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 298, + "shape_id": 485, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 321, + "shape_id": 741, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 333, + "shape_id": 869, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 340, + "shape_id": 946, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 342, + "shape_id": 969, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 361, + "shape_id": 1165, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 363, + "shape_id": 1189, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 365, + "shape_id": 1213, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 368, + "shape_id": 1246, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 372, + "shape_id": 1291, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 379, + "shape_id": 1370, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 383, + "shape_id": 1415, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 385, + "shape_id": 1439, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 389, + "shape_id": 1484, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 393, + "shape_id": 1532, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 395, + "shape_id": 1556, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 405, + "shape_id": 1663, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 423, + "shape_id": 1851, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 447, + "shape_id": 2121, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 448, + "shape_id": 2134, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 453, + "shape_id": 2189, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 465, + "shape_id": 2319, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 467, + "shape_id": 2343, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 472, + "shape_id": 2397, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 474, + "shape_id": 2421, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 476, + "shape_id": 2445, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 478, + "shape_id": 2469, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 480, + "shape_id": 2493, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 482, + "shape_id": 2517, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 496, + "shape_id": 2668, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 498, + "shape_id": 2691, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 500, + "shape_id": 2714, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 504, + "shape_id": 2757, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 525, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 526, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 528, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 537, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + }, + { + "op": "recolour_code", + "sldId": 539, + "shape_id": 8, + "allow_noop": true, + "cat": "code", + "_src": "colour" + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix3.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix3.json new file mode 100644 index 00000000..a7d66691 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix3.json @@ -0,0 +1,12 @@ +{ + "ops": [ + { + "op": "replace_picture", + "sldId": 286, + "shape": "Google Shape;349;p33", + "png": "lgtm-presentation/edits/png/04-dual-write-outbox.png", + "cat": "voice", + "_why": "figure footer: the absolute 'can never be lost' claim softened (src/04-dual-write-outbox.svg)" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix4.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix4.json new file mode 100644 index 00000000..04da2d2b --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix4.json @@ -0,0 +1,493 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Kafka is the substrate — a partitioned", + "repl": "Kafka is the substrate: a partitioned", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup (6 per paragraph)", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Pulsar is also substrate — same broad category", + "repl": "Pulsar is also substrate, in the same broad category", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "Flink is compute — not a transport layer at all, but", + "repl": "Flink is compute: not a transport layer, but", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "NiFi is movement — a visual", + "repl": "NiFi is movement: a visual", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 294 + ], + "find": "They complement — pick by", + "repl": "They complement each other; pick by", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "This is the slide the user specifically asked for: distinguish auto-injection from auto-instrumentation from manual instrumentation — three things people conflate.", + "repl": "Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request; em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "wrap known frameworks — your HTTP handler", + "repl": "wrap known frameworks: your HTTP handler", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "a Kubernetes-level mechanism — the OpenTelemetry Operator", + "repl": "a Kubernetes-level mechanism; the OpenTelemetry Operator", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Python — ", + "repl": "Python: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 318 + ], + "find": "Java/Quarkus — ", + "repl": "Java/Quarkus: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "linked by a stream of events — either", + "repl": "linked by a stream of events: either", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "via CDC, which we cover next.", + "repl": "via CDC, the log-tailing pattern.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference ('we cover next')", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "The cost: eventual consistency — there is a small lag", + "repl": "The cost: eventual consistency, a small lag", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "happily handles both — CQRS adds", + "repl": "handles both; CQRS adds", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 283 + ], + "find": "for projections — we cover both distinctions in section 5.", + "repl": "for projections; the Event-Driven section draws both distinctions.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): em-dash pileup; cross-reference by concept", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 324 + ], + "find": "The sub-bullet is the reminder the user asked for: ", + "repl": "The sub-bullet is the reminder: ", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 380 + ], + "find": "This is the specific case you asked about, and a common source of bad designs.", + "repl": "This case is a common source of bad designs.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): leaked authoring request", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 291 + ], + "find": "Avro / Protobuf SerDes, which we cover next.", + "repl": "Avro / Protobuf SerDes, so the registry enforces the schema.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 370 + ], + "find": "solved or eased by the backplane we cover next.", + "repl": "solved or eased by a pub/sub backplane.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "Pair with the Kubernetes settings we cover next.", + "repl": "Pair them with the Kubernetes settings: readiness, preStop and the grace period.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 447 + ], + "find": "lives in the .drl rules we cover next, owned by", + "repl": "lives in the .drl rules, owned by", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 539 + ], + "find": "Fallback: the recorded results that follow.", + "repl": "Fallback: the recorded results.", + "expect": 1, + "cat": "voice", + "_why": "watch triage (final validation): soft positional reference", + "_src": "watch" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "and it's the one I'd reach for first when I want to own the transitions.", + "repl": "and the default choice when the team wants to own the transitions.", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 377 + ], + "find": "Recovery is beautifully boring:", + "repl": "Recovery needs no special path:", + "expect": 1, + "cat": "voice", + "_why": "first-person and soft-hype tone in notes", + "_src": "tone" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 423 + ], + "find": "Tomcat/Jetty/Undertow/Netty", + "repl": "Tomcat, Jetty or Reactor Netty", + "expect": 1, + "cat": "stack", + "_why": "Spring Boot 4 removed Undertow support", + "_src": "tone" + }, + { + "op": "set_code", + "sldId": 273, + "cat": "code", + "_why": "the excerpt never decremented the stock on a successful reservation; examples/02-communications/spring-boot InventoryServiceApplication stores the remaining count (stock.put)", + "lines": [ + "// inventory.proto — the contract, registered in Apicurio", + "syntax = \"proto3\";", + "service Inventory { rpc ReserveStock (ReserveRequest) returns (ReserveReply); }", + "message ReserveRequest { string sku = 1; int32 quantity = 2; }", + "message ReserveReply { bool reserved = 1; int32 remaining = 2; }", + "// inventory-service (excerpt): grpc-java server; protobuf-maven-plugin generates the stubs", + "@SpringBootApplication", + "public class InventoryServiceApplication extends InventoryGrpc.InventoryImplBase", + " implements CommandLineRunner {", + " @Override public void run(String... args) throws Exception {", + " server = ServerBuilder.forPort(grpcPort).addService(this).build().start();", + " server.awaitTermination();", + " }", + " @Override public void reserveStock(ReserveRequest req, StreamObserver obs) {", + " int remaining = stock.get(req.getSku()) - req.getQuantity();", + " if (remaining >= 0) stock.put(req.getSku(), remaining); // reserve", + " obs.onNext(ReserveReply.newBuilder().setReserved(remaining >= 0)", + " .setRemaining(remaining >= 0 ? remaining : stock.get(req.getSku())).build());", + " obs.onCompleted();", + " }", + "}" + ], + "pt": 10.5, + "_src": "content" + }, + { + "op": "set_code", + "sldId": 377, + "cat": "code", + "_why": "STEPS was declared outside the class (not valid Java); moved into SagaCoordinator, same lines", + "lines": [ + "@Service", + "public class SagaCoordinator {", + " static final List STEPS =", + " List.of(\"charge_payment\", \"reserve_stock\", \"book_shipping\");", + " private final SagaRepository repo;", + " private final EntityManager em;", + " public SagaCoordinator(SagaRepository r, EntityManager em) {", + " this.repo = r; this.em = em;", + " }", + " @Transactional // state + command commit together", + " public void advance(String sagaId) {", + " Saga s = em.find(Saga.class, sagaId, LockModeType.PESSIMISTIC_WRITE);", + " if (!\"RUNNING\".equals(s.getStatus())) return; // idempotent", + " String step = STEPS.get(s.getStepIndex());", + " try {", + " var result = invoke(step, s.getContext()); // call the service", + " s.getContext().put(step, result); // accumulate output", + " if (s.incStep() == STEPS.size()) s.setStatus(\"COMPLETED\");", + " } catch (StepException e) {", + " s.setStatus(\"COMPENSATING\"); // hand off to the unwinder", + " }", + " repo.save(s); // durable after every step", + " }", + "}" + ], + "_src": "content" + }, + { + "op": "set_code", + "sldId": 310, + "lines": [ + "", + "", + " io.apicurio", + " apicurio-registry-maven-plugin", + " 3.3.3", + " ", + " register", + " ", + " http://apicurio.registry.svc/apis/registry/v3", + " true ", + " ", + " orders", + " order-placed", + " AVRO", + " CREATE_VERSION ", + " src/main/avro/order-placed.avsc", + " ", + " ", + " ", + "" + ], + "cat": "code", + "_why": "apicurio-registry-maven-plugin 3.3.3: test-update is deprecated in favour of register + dryRun (TestUpdateRegistryMojo javadoc); dryRun only applies with ifExists set (RegisterRegistryMojo); artifact order-placed as Example 09; plugin version pinned", + "_src": "registry" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 310 + ], + "find": "The test-update goal fails the build", + "repl": "The dry-run register fails the build", + "expect": 1, + "cat": "code", + "_why": "caption follows the goal", + "_src": "registry" + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 310 + ], + "find": "The apicurio-registry-maven-plugin's test-update goal validates the new schema against the artifact's compatibility rule in the registry without registering it — and fails the build (and the merge) if it's incompatible.", + "repl": "The apicurio-registry-maven-plugin's register goal with dryRun set checks the new schema against the artifact's compatibility rule in the registry without storing it, and fails the build (and the merge) if it is incompatible. The older test-update goal still exists in 3.3.3 but is deprecated in favour of dryRun.", + "expect": 1, + "cat": "notes", + "_why": "notes follow the goal", + "_src": "registry" + }, + { + "op": "replace_picture", + "sldId": 491, + "shape": "Google Shape;2612;p238", + "png": "lgtm-presentation/edits/png/25-gray-failure.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (PERCEIVED DIFFERENTLY, NOT fully down, OBSERVE FROM THE CLIENT, GREEN, OUTLIER DETECTION); replaced by the site figure assets/diagrams/25-gray-failure.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 293, + "shape": "Google Shape;429;p40", + "png": "lgtm-presentation/edits/png/05-sourcing-vs-streaming.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (SOURCING/STREAMING, ARE, IS, TRANSPORT, ABOUT, NOT, EVENT, PROPAGATED); replaced by the site figure assets/diagrams/05-sourcing-vs-streaming.svg (caps lowered there too)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 294, + "shape": "Google Shape;440;p41", + "png": "lgtm-presentation/edits/png/05-tools-landscape.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure (COMPLEMENT); replaced by the site figure assets/diagrams/05-tools-landscape.svg (SUBSTRATE/COMPUTE/MOVEMENT are design labels)", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 414, + "shape": "Google Shape;1756;p161", + "png": "lgtm-presentation/edits/png/20-coupling-quadrant.png", + "cat": "voice", + "_why": "ALL-CAPS emphasis baked into the old figure ('strong OR distant'); replaced by the site figure assets/diagrams/20-coupling-quadrant.svg", + "_src": "figures" + }, + { + "op": "replace_picture", + "sldId": 449, + "shape_id": 2145, + "png": "lgtm-presentation/edits/png/22-hop-cost-drools.png", + "cat": "voice", + "_why": "figure footer: 'where p99 goes to die' rewritten without the drama", + "_src": "hop" + } + ] +} \ No newline at end of file diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix5.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix5.json new file mode 100644 index 00000000..5059589d --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.fix5.json @@ -0,0 +1,236 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 269 + ], + "find": "java · Spring Boot 4 + Actuator", + "repl": "java · Spring Boot 4", + "expect": 1, + "cat": "code", + "_slide": 14, + "_why": "examples/01 spring-boot hand-writes /healthz and /readyz", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 269, + "lines": [ + "// HealthController.java (excerpt), examples/01-cloud-native-principles/spring-boot", + "// config from env (factor III): SPRING_DATASOURCE_URL, SERVICE_VERSION -> service.version", + "@RestController", + "public class HealthController {", + " private final DataSource dataSource;", + " @Value(\"${service.version}\")", + " private String serviceVersion;", + " public HealthController(DataSource dataSource) { this.dataSource = dataSource; }", + " @GetMapping(\"/healthz\") // liveness: the process is up", + " public Map healthz() { return Map.of(\"status\", \"ok\"); }", + " @GetMapping(\"/readyz\") // readiness: SELECT 1 on the pool", + " public ResponseEntity> readyz() {", + " try (var conn = dataSource.getConnection()) {", + " conn.createStatement().executeQuery(\"SELECT 1\").close();", + " } catch (Exception e) { // down: 503, the pod leaves the Service", + " return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)", + " .body(Map.of(\"status\", \"down\", \"checks\", Map.of(\"database\", \"unreachable\")));", + " }", + " return ResponseEntity.ok(Map.of(\"status\", \"ready\",", + " \"checks\", Map.of(\"database\", \"ok\")));", + " }", + "}" + ], + "cat": "code", + "_slide": 14, + "_why": "examples/01: hand-written /healthz and /readyz; readiness answers 503 when the database is down", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 269, + "cat": "notes", + "_slide": 14, + "_why": "notes follow the code", + "_src": "align", + "text": "Walk the code: config comes from the environment through Spring's property resolution (factor III): SPRING_DATASOURCE_URL and its credentials configure the DataSource and SERVICE_VERSION feeds service.version, never a baked-in file; Spring profiles override per environment. The application name (spring.application.name) shows up in Micrometer metrics and traces. The two probes are a plain @RestController in Example 01: /healthz is liveness (am I alive? if not, restart me) and never checks a dependency; /readyz is readiness (can I serve?): it runs SELECT 1 on a pooled connection and, when that fails, answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. Spring Boot Actuator offers the same pair as /actuator/health/liveness and /actuator/health/readiness with HealthIndicator beans; the example writes them by hand so every language serves the same paths and bodies. Getting liveness vs readiness wrong is a top cause of cascading restarts, so stress the distinction. Maven with spring-boot-starter-parent pins every dependency reproducibly. Constructor injection is the idiomatic Spring style: the bean stays immutable and is unit-testable without a container." + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 423 + ], + "find": "server.shutdown=graceful drains HTTP; publish a Readiness REFUSING_TRAFFIC event to flip the probe; drain everything else in ContextClosedEvent.", + "repl": "One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, System.exit(0) runs Spring's graceful shutdown, bounded at 20 s per phase.", + "expect": 1, + "cat": "code", + "_slide": 168, + "_why": "examples/21 spring-boot: Signal handler, drain delay, System.exit (9676f32)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 423, + "lines": [ + "// OrderServiceApplication.java (excerpt), examples/21-graceful-shutdown/spring-boot", + "// application.properties: server.shutdown=graceful", + "// spring.lifecycle.timeout-per-shutdown-phase=20s", + "@SpringBootApplication", + "public class OrderServiceApplication {", + " public static void main(String[] args) {", + " // registered before Spring starts: replaces the JVM's default SIGTERM handler", + " Signal.handle(new Signal(\"TERM\"), signal -> {", + " ShutdownState state = ShutdownState.getInstance(); // GET /readyz reads it: 503", + " if (!state.markShuttingDown()) return; // one SIGTERM drives the drain", + " long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5", + " new Thread(() -> {", + " try { Thread.sleep(delay * 1000); } // keep serving: endpoints update", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " System.exit(0); // shutdown hook: stop accepting, finish in-flight, close pool", + " }, \"drain\").start();", + " });", + " SpringApplication.run(OrderServiceApplication.class, args);", + " }", + "}", + "// Kafka consumers, the scheduler and WebSockets drain in a ContextClosedEvent listener." + ], + "cat": "code", + "_slide": 168, + "_why": "examples/21: one SIGTERM -> readiness 503 -> DRAIN_DELAY_SECONDS -> stop accepting -> in-flight drain within 20 s -> cleanup -> exit 0", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 423, + "cat": "notes", + "_slide": 168, + "_why": "notes follow the code", + "_src": "align", + "text": "Example 21 takes the signal itself, so the whole sequence is visible. A sun.misc.Signal handler registered before Spring starts replaces the JVM's default SIGTERM handler, which would run the shutdown hooks at once. One SIGTERM, the only signal Kubernetes sends before SIGKILL, drives everything: the handler flips ShutdownState, so the hand-written /readyz answers 503 and the Service stops routing, and a drain thread keeps the service serving for DRAIN_DELAY_SECONDS (5 s; it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls System.exit(0), which runs Spring's shutdown hook: with server.shutdown=graceful the embedded Tomcat, Jetty or Reactor Netty stops accepting new connections and finishes in-flight HTTP, bounded by spring.lifecycle.timeout-per-shutdown-phase (20 s here; the default is 30 s), then the context closes the pool and the process exits 0, inside the 30-second grace period. Use @EventListener(ContextClosedEvent.class), @PreDestroy or SmartLifecycle to drain what Spring does not drain for you: Kafka consumers (consumer.wakeup() then commit on close), the scheduler (stop accepting new triggers), WebSockets (send a close frame so clients reconnect elsewhere). With Actuator, publishing ReadinessState.REFUSING_TRAFFIC flips /actuator/health/readiness the same way; the example keeps its own /readyz, as Example 01 does. Pair it with the Kubernetes settings: readiness, preStop and the grace period." + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "java · Spring Boot 4 + @Scheduled + ShedLock + Spring Data Redis + JPA", + "repl": "java · Spring Boot 4 + @Scheduled + Spring Data Redis + JdbcClient", + "expect": 1, + "cat": "code", + "_slide": 225, + "_why": "examples/25 spring-boot: JdbcClient upserts, no ShedLock", + "_src": "align" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 480 + ], + "find": "The dirty set is the key idea — it tells the flusher what to drain. ShedLock ensures only one Pod flushes at a time.", + "repl": "A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed.", + "expect": 1, + "cat": "code", + "_slide": 225, + "_why": "examples/25: 202 and bounded retry (ebca295)", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 480, + "lines": [ + "@PutMapping(\"/write-back/metrics/{mid}\") // CacheController (excerpt)", + "public ResponseEntity> writeBackWrite(", + " @PathVariable String mid, @RequestBody Map body) {", + " // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down)", + " return ResponseEntity.accepted().body(Map.of(\"ok\", true, \"pattern\", \"write-back\",", + " \"action\", \"cached-for-flush\")); // 202: queued, not yet durable", + "} // examples/25-caching/spring-boot", + "@Scheduled(fixedDelayString = \"${cache-service.flush-period-ms:1000}\")", + "public void flusher() { // BackgroundTasks", + " if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure", + " Set ids = cache.spop(\"metric:dirty\", FLUSH_BATCH);", + " if (ids == null || ids.isEmpty()) return;", + " List failed = new ArrayList<>();", + " for (String mid : ids) {", + " try { // upsert: an id flushed twice is one row", + " jdbc.sql(\"INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) \"", + " + \"ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()\")", + " .param(mid).param(json).param(json).update(); // json: the cached hash", + " cache.hdel(\"metric:attempts\", mid);", + " } catch (Exception e) { failed.add(mid); } // logged: flush failed for ", + " }", + " if (failed.isEmpty()) { failures = 0; return; }", + " nextFlushAt = System.currentTimeMillis() + Math.min(flushPeriodMs << ++failures, 8000);", + " requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5" + ], + "cat": "code", + "_slide": 225, + "_why": "examples/25: /write-back/metrics/{id} answers 202; upsert; bounded retry (metric:attempts, metric:failed, backoff)", + "_src": "align", + "shape_id": 2493 + }, + { + "op": "set_notes", + "sldId": 480, + "cat": "notes", + "_slide": 225, + "_why": "notes follow the code", + "_src": "align", + "text": "Spring's @Scheduled drives write-back in Example 25. (1) PUT /write-back/metrics/{mid} writes the metric to a Redis hash, adds the id to a 'dirty set', which tells the flusher what still has to reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) @Scheduled(fixedDelayString = ...) runs every second, SPOPs up to FLUSH_BATCH dirty ids and upserts each one through JdbcClient with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice stays one row. SPOP is atomic, so two replicas each get a distinct subset of ids and no lock is needed to scale out. (3) An id whose upsert fails is re-queued: its count in the metric:attempts hash goes up and it goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in the cache and the failure in the log. After a failed run the flusher skips runs for two seconds, then four, then eight. Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale." + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 504 + ], + "find": "java · Spring Boot 4 + Actuator", + "repl": "java · Spring Boot 4", + "expect": 1, + "cat": "code", + "_slide": 249, + "_why": "examples/21 spring-boot: hand-written /readyz", + "_src": "align" + }, + { + "op": "set_code", + "sldId": 504, + "lines": [ + "// excerpt, examples/21-graceful-shutdown/spring-boot: one SIGTERM drives the drain", + "# application.properties: server.shutdown=graceful, timeout-per-shutdown-phase=20s", + "Signal.handle(new Signal(\"TERM\"), signal -> { // registered before SpringApplication.run", + " ShutdownState state = ShutdownState.getInstance();", + " if (!state.markShuttingDown()) return; // 1. readiness flips: /readyz answers 503", + " new Thread(() -> {", + " try { Thread.sleep(drainDelaySeconds() * 1000); } // 2. the LB stops routing (5 s)", + " catch (InterruptedException e) { Thread.currentThread().interrupt(); }", + " System.exit(0); // 3. graceful shutdown drains in-flight HTTP (20 s)", + " }, \"drain\").start(); // 4. the context closes the pool, exit 0", + "});", + "@GetMapping(\"/readyz\") // OrderController", + "public ResponseEntity> readyz() {", + " if (shutdownState.isShuttingDown())", + " return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)", + " .body(Map.of(\"ready\", false, \"reason\", \"shutting down\"));", + " // ... SELECT 1: 200 {\"ready\": true}, or 503 \"db unreachable\"; /healthz stays 200", + "}" + ], + "cat": "code", + "_slide": 249, + "_why": "Appendix M recap follows examples/21 (one SIGTERM, drain delay, 20 s drain)", + "_src": "align" + }, + { + "op": "set_notes", + "sldId": 504, + "cat": "notes", + "_slide": 249, + "_why": "notes follow the code", + "_src": "align", + "text": "Example 21 takes SIGTERM itself. A sun.misc.Signal handler registered before Spring starts flips ShutdownState on the single SIGTERM: (1) the hand-written /readyz answers 503, so the Kubernetes readiness probe sees the pod go not-ready and stops routing, while /healthz stays 200; (2) a drain thread keeps the service serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) System.exit(0) runs Spring's graceful shutdown, which stops accepting and drains in-flight HTTP within timeout-per-shutdown-phase (20 s); (4) the context closes the pool and the process exits 0. Appendix H covers the Kubernetes preStop + terminationGracePeriodSeconds side." + } + ] +} diff --git a/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.json b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.json new file mode 100644 index 00000000..6c20ab92 --- /dev/null +++ b/lgtm-presentation/edits/Designing-Cloud-Native-APIs-Spring.json @@ -0,0 +1,5734 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "both", + "find": "Only this step is language-specific; the Newman command is identical for all five.", + "repl": "Only this step is language-specific; the Newman command is identical for all six.", + "expect": 1, + "sldIds": [ + 525 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The order is deliberate: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "repl": "The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "AI coding tools — Claude, Claude Code, Cursor, Copilot, every frontier model — are now in production engineering workflows.", + "repl": "AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They produce code very fluently.", + "repl": "They produce code fluently.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side — plausible enough to compile, deploy and run — from a one-sentence prompt.", + "repl": "From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns — the focus of this deck — span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "repl": "Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing).", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘Least-worst decision’: every architecture choice is a set of trades — latency for throughput, consistency for availability, simplicity for flexibility — and the job is not to find the optionless option but to make the trades visible and deliberate.", + "repl": "‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions easy and cheap.", + "repl": "‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three stacked boxes: design patterns at the bottom (Gang of Four — factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns — the scope of this deck — sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch.", + "repl": "Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch).", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The four maxims at the bottom in red are the philosophical anchors that follow you through the rest of the deck — every concrete choice you'll see is a deliberate exercise in those four principles.", + "repl": "The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles.", + "expect": 1, + "sldIds": [ + 260 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pillars are the rubric for the rest of the talk — every design decision later either earns or spends one of these properties.", + "repl": "The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties.", + "expect": 1, + "sldIds": [ + 263 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The contract — OpenAPI, .proto, AsyncAPI — is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "repl": "The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Make the point that this is what actually delivers independent deployability: the promise the contract encodes is the service boundary.", + "repl": "Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The key reframing here is ownership — don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides for free (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "repl": "The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services).", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that in a microservices/event-driven world several factors deepen — backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "repl": "In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two takeaways to say: first, this 2011 list essentially predicted cloud-native a decade early; second, notice how many are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "repl": "Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams).", + "expect": 1, + "sldIds": [ + 267 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the payoff — not the traditional twelve-factor table, but a map of where each factor actually lives in the system we're building.", + "repl": "This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building.", + "expect": 1, + "sldIds": [ + 268 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The .proto IS the contract and it lives in the registry just like OpenAPI.", + "repl": "The .proto is the contract, and it lives in the registry like OpenAPI.", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, the services stay unaware of each other — the gateway owns the composition.", + "repl": "The services stay unaware of each other; the gateway owns the composition.", + "expect": 1, + "sldIds": [ + 278 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That quietly rebuilds the monolith with network latency between the pieces.", + "repl": "That rebuilds the monolith with network latency between the pieces.", + "expect": 1, + "sldIds": [ + 280 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Section four, and arguably the hardest discipline to hold.", + "repl": "Section four, and the hardest discipline to hold.", + "expect": 1, + "sldIds": [ + 281 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "CQRS — Command Query Responsibility Segregation — is the pattern of using DIFFERENT models for writes and reads.", + "repl": "CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use this pattern when reads and writes have GENUINELY different shapes and access patterns.", + "repl": "Use this pattern when reads and writes have different shapes and access patterns.", + "expect": 1, + "sldIds": [ + 283 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Debezium — an Apache 2.0 open-source project running as a set of Kafka Connect connectors — tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "repl": "Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is why log tailing matters before we get to the outbox slides: the outbox is just a deliberate use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "repl": "This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use.", + "expect": 1, + "sldIds": [ + 285 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When a step fails — payment fails — you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "repl": "When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order).", + "expect": 1, + "sldIds": [ + 288 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Flag that Appendix D goes deep on saga state, compensation and a genuinely tricky data-dependency case, for anyone who wants the mechanics.", + "repl": "Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics.", + "expect": 1, + "sldIds": [ + 288 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This inverts the dependency direction and is what truly decouples services in time and ownership.", + "repl": "This inverts the dependency direction, and it is what decouples services in time and ownership.", + "expect": 1, + "sldIds": [ + 289 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Events need contracts just as much as REST and gRPC do — arguably more, because consumers are anonymous and you can't just call them to coordinate.", + "repl": "Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate.", + "expect": 1, + "sldIds": [ + 292 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what makes adding-a-consumer-safely actually safe.", + "repl": "This is what makes adding a consumer safe.", + "expect": 1, + "sldIds": [ + 292 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "These are NOT mutually exclusive.", + "repl": "These are not mutually exclusive.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In most enterprise architectures, streaming is far more common than sourcing — sourcing is a deliberate choice for domains where audit and replay are core.", + "repl": "In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core.", + "expect": 1, + "sldIds": [ + 293 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Four tools, four different jobs.", + "repl": "Each of the four tools does a different job.", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Messages aren't deleted when read — they're retained, and each consumer group tracks its own offset.", + "repl": "Messages are retained after they are read, and each consumer group tracks its own offset.", + "expect": 1, + "sldIds": [ + 295 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Once events are a durable, replayable stream, you can compute over them continuously — filtering, joining, windowing, aggregating — and emit new derived streams.", + "repl": "Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams.", + "expect": 1, + "sldIds": [ + 296 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right scaling signal for an event consumer is consumer lag — how far behind the log it is — not CPU.", + "repl": "The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "KEDA reads that lag and scales workers up, and crucially down to zero when there's no lag.", + "repl": "KEDA reads that lag and scales workers up, and down to zero when there's no lag.", + "expect": 1, + "sldIds": [ + 299 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A lot of real work isn't a synchronous request at all — it's a multi-step business process, or batch and scheduled work.", + "repl": "A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This section covers how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "repl": "Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Orchestration: a central coordinator explicitly commands each service in turn — easy to follow and observe, but the coordinator can become a god-service.", + "repl": "Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service.", + "expect": 1, + "sldIds": [ + 302 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Management isn't a product you buy — it's four cooperating concerns: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "repl": "Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability).", + "expect": 1, + "sldIds": [ + 305 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Signal plane: observability (section 11) tells you what's actually happening.", + "repl": "Signal plane: observability (section 11) tells you what is happening.", + "expect": 1, + "sldIds": [ + 306 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The core discipline: evolve additively — add optional fields and new endpoints, never repurpose or silently remove.", + "repl": "The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period.", + "expect": 1, + "sldIds": [ + 307 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "It ingests from many sources — OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas — and builds one searchable catalog.", + "repl": "It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog.", + "expect": 1, + "sldIds": [ + 312 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The key word is correlate: traces, metrics and logs only become powerful when they share a trace_id so you can pivot between them.", + "repl": "The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them.", + "expect": 1, + "sldIds": [ + 314 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The LGTM-style stack stores them — Tempo for traces, Mimir/Prometheus for metrics, Loki for logs — and Grafana joins them.", + "repl": "The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them.", + "expect": 1, + "sldIds": [ + 315 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Expand the 'three pillars' framing into the five signals modern observability actually uses.", + "repl": "Expand the 'three pillars' framing into the five signals modern observability uses.", + "expect": 1, + "sldIds": [ + 316 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) for free along the same mechanism — and then filter traces and logs by tenant.", + "repl": "Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant.", + "expect": 1, + "sldIds": [ + 317 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Spring — Micrometer Tracing + the OTel bridge, or the Java agent. .NET — OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "repl": "Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource.", + "expect": 1, + "sldIds": [ + 318 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The common production setup is BOTH: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "repl": "The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate.", + "expect": 1, + "sldIds": [ + 323 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that metrics and logs are usually NOT sampled the same way — metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "repl": "Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy.", + "expect": 1, + "sldIds": [ + 323 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tail-based is the most powerful: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which is exactly the traces you actually want during an incident.", + "repl": "Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident.", + "expect": 1, + "sldIds": [ + 324 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Keep testing pragmatic — you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and literally see the KEDA http-add-on add replicas. ghz is the gRPC load tester — confirm your Istio timeouts and retries behave under pressure.", + "repl": "Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure.", + "expect": 1, + "sldIds": [ + 325 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each pattern here exists because the layer below it is not enough on its own.", + "repl": "Each pattern here exists because no single layer is enough on its own.", + "expect": 1, + "sldIds": [ + 326 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Cloud is the outermost — IAM, VPC, KMS, audit logs, region selection — owned by the platform team or cloud admin.", + "repl": "Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cluster is the Kubernetes layer — RBAC, NetworkPolicy, Pod Security Standards, admission control — owned by platform/SRE.", + "repl": "Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Container is the workload — minimal base images, non-root users, signed images, vulnerability scanning, secret mounting — owned jointly by app teams and platform.", + "repl": "Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Code is the innermost — secure defaults, input validation, dependency scanning, secrets handling — owned by app teams.", + "repl": "Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note that these patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "repl": "These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation).", + "expect": 1, + "sldIds": [ + 329 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Why this is powerful: ONE security policy applied uniformly across every workload regardless of language.", + "repl": "Why this matters: one security policy applied uniformly across every workload regardless of language.", + "expect": 1, + "sldIds": [ + 330 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set ONLY by the sidecar; the app must reject requests that don't have it.", + "repl": "Set only by the sidecar; the app must reject requests that don't have it.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The audience point: the sidecar pattern only works if the app actually USES the headers the sidecar attaches.", + "repl": "The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches.", + "expect": 1, + "sldIds": [ + 331 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted' — once an attacker is in, they move laterally trivially.", + "repl": "The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance.", + "expect": 1, + "sldIds": [ + 335 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Equivalent Kyverno expression is much more YAML-native if your team prefers it; Rego is more powerful for complex logic.", + "repl": "An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic.", + "expect": 1, + "sldIds": [ + 338 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In software, you isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 339 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pattern also has performance benefits — you don't push 50MB messages through the message bus — but in this section we frame it for SECURITY.", + "repl": "The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security.", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost of NOT having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "repl": "The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost lines are deliberately included — every pattern is a trade.", + "repl": "The cost lines are there because every pattern is a trade.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the take-home: real production systems use all seven, in deliberate combinations per concern — not as a one-size-fits-all checklist.", + "repl": "The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "(1) If two services must deploy together, they're really one — merge them or fix the contract.", + "repl": "(1) If two services must deploy together, they are one service: merge them or fix the contract.", + "expect": 1, + "sldIds": [ + 347 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the slides and these references are enough to actually build the system, not just talk about it.", + "repl": "The slides and these references are enough to build the system, not only to discuss it.", + "expect": 1, + "sldIds": [ + 349 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "They cover the questions that always come up: how the three protocols really differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "repl": "They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation.", + "expect": 1, + "sldIds": [ + 350 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "REST, gRPC and GraphQL — how they actually differ.", + "repl": "REST, gRPC and GraphQL: how they differ.", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS — both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "repl": "Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface).", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The ACL ambiguity is worth flagging out loud: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "repl": "Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List.", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration — the request-driven metrics method); USE (Utilisation, Saturation, Errors — the resource metrics method); MDC (Mapped Diagnostic Context — per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "repl": "Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency).", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what actually travels on the wire.", + "repl": "We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire.", + "expect": 1, + "sldIds": [ + 353 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most arguments about 'which is best' are really arguments about which mental model fits the problem — and often the answer is different per interaction.", + "repl": "Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction.", + "expect": 1, + "sldIds": [ + 354 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape — no field selection, but extremely fast.", + "repl": "GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three.", + "expect": 1, + "sldIds": [ + 355 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What actually travels, and why it constrains your choice.", + "repl": "What travels on the wire, and why it constrains your choice.", + "expect": 1, + "sldIds": [ + 356 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The decision guide, said plainly.", + "repl": "The decision guide.", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold closing point ties back to the whole talk: forcing one protocol everywhere — 'we're a gRPC shop' or 'everything is GraphQL' — is itself an anti-pattern, because you'll be paying gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "repl": "The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done.", + "expect": 1, + "sldIds": [ + 357 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and arguably breaks REST's idea that a URL identifies a resource, not a representation.", + "repl": "URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Query param: easy to default but muddies caches.", + "repl": "Query param: simple to default but muddies caches.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Custom header: keeps URLs clean but is invisible and easy to forget.", + "repl": "Custom header: keeps URLs clean but is invisible and often forgotten.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation, the URL stays stable and identifies the resource, the representation is what's versioned — this is the genuinely RESTful approach.", + "repl": "Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The honest take: URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "repl": "In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct.", + "expect": 1, + "sldIds": [ + 360 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We removed an old 'notes' field — so we reserve both its tag number (4) AND its name, permanently, so nobody can ever reuse them and silently corrupt old data on the wire.", + "repl": "We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire.", + "expect": 1, + "sldIds": [ + 363 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We add currency and state as brand-new fields with new tag numbers; old clients simply ignore unknown fields, new clients read them — that's forward and backward compatibility for free.", + "repl": "We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work.", + "expect": 1, + "sldIds": [ + 363 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The lifecycle on screen: ADD new fields and types (additive is always safe — existing queries are unaffected because they didn't ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; WATCH field-level usage metrics to see who still queries the old field and drive their migration; and only RETIRE — the single breaking step — once usage hits zero.", + "repl": "The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero.", + "expect": 1, + "sldIds": [ + 364 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three protocols, three genuinely different answers to the same problem.", + "repl": "Each protocol answers the same problem differently.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Never break silently — deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "repl": "Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing.", + "expect": 1, + "sldIds": [ + 366 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "That one design fact is the whole rest of the appendix: the connection and the knowledge of who's connected live on exactly one pod.", + "repl": "That one design fact drives everything in this appendix: the connection and the knowledge of who's connected live on exactly one pod.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Everything stateless about HTTP — any replica can serve any request — is now false.", + "repl": "Everything stateless about HTTP (any replica can serve any request) is now false.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Appendix C tackles the protocol we deliberately left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "repl": "Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes.", + "expect": 1, + "sldIds": [ + 367 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections — only new ones — so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "repl": "Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fix isn't to avoid scaling — it's to stop making any single pod the home of a conversation, which is the backplane.", + "repl": "The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling.", + "expect": 1, + "sldIds": [ + 369 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each pod still owns only its own sockets — that's fine — but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "repl": "Each pod still owns only its own sockets, which is fine, but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that actually holds X's socket delivers it.", + "repl": "When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that holds X's socket delivers it.", + "expect": 1, + "sldIds": [ + 371 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the resource shape is different — the bold point — you scale on open-connection count and file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "repl": "And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too.", + "expect": 1, + "sldIds": [ + 370 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the security note in bold that's easy to forget: authenticate on connect AND re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "repl": "And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere.", + "expect": 1, + "sldIds": [ + 374 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three hard parts that the happy-path picture glosses over: where the orchestrator actually keeps its state so it survives a crash, how compensation really works when a step fails midway, and a specific gnarly case people hit — when step B needs data that only step A produced, and how to resume after a crash between them.", + "repl": "Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them.", + "expect": 1, + "sldIds": [ + 375 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We iterate the completed steps (subList up to stepIndex) in reverse — newest first — and invoke each compensation.", + "repl": "We iterate the completed steps (subList up to stepIndex) in reverse, newest first, and invoke each compensation.", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each forward step that committed — payment.charge, inventory.reserve — has a defined semantic inverse: payment.refund, inventory.release.", + "repl": "Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When book_shipping fails, you don't undo a database transaction (the payment really happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "repl": "When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first.", + "expect": 1, + "sldIds": [ + 378 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the specific case you asked about, and it's a genuinely common source of bad designs.", + "repl": "This is the specific case you asked about, and a common source of bad designs.", + "expect": 1, + "sldIds": [ + 380 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "In the middle, the Temporal Server: a cluster of Frontend, Matching and History services backed by Postgres (or MySQL / Cassandra) that persists every workflow event — every activity invocation, every result, every timer firing — so the workflow can be reconstructed from scratch by replay.", + "repl": "In the middle, the Temporal Server: a cluster of Frontend, Matching and History services backed by Postgres (or MySQL / Cassandra) that persists every workflow event (every activity invocation, every result, every timer firing), so the workflow can be reconstructed from scratch by replay.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Error handling is where cloud-native systems quietly fall apart — not in the happy path.", + "repl": "Error handling is where cloud-native systems fall apart, more often than the happy path.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We finish on the failure mode that turns a blip into an outage — the retry storm — and the handful of controls that prevent it.", + "repl": "We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it.", + "expect": 1, + "sldIds": [ + 386 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope — type, title, status, detail, instance — and you extend it with your stable code and the traceId.", + "repl": "Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId.", + "expect": 1, + "sldIds": [ + 388 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC gives you a small, fixed set of canonical status codes — and choosing the right one IS the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "repl": "gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it.", + "expect": 1, + "sldIds": [ + 390 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Beyond the code, attach a google.rpc.Status in the trailers with rich details — ErrorInfo (machine reason + domain) and RetryInfo (how long to wait) — so the client gets the same five facts as everywhere else.", + "repl": "Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else.", + "expect": 1, + "sldIds": [ + 390 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Crucially, you commit the offset either way, so the main consumer keeps making progress.", + "repl": "You commit the offset either way, so the main consumer keeps making progress.", + "expect": 1, + "sldIds": [ + 392 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When one field fails — say stock can't reach inventory — that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "repl": "When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId.", + "expect": 1, + "sldIds": [ + 394 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the failure mode that turns a brief blip into a full outage, and it's worth dwelling on.", + "repl": "This is the failure mode that turns a brief blip into a full outage.", + "expect": 1, + "sldIds": [ + 396 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The loop: a service slows, calls start timing out, clients retry — often 3x — which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "repl": "The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries.", + "expect": 1, + "sldIds": [ + 396 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Bound every retry — @Timeout, @Retry + jitter, @CircuitBreaker, retry budget — and make handlers idempotent.", + "repl": "Bound every retry (@Timeout, @Retry + jitter, @CircuitBreaker, retry budget) and make handlers idempotent.", + "expect": 1, + "sldIds": [ + 397 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Where the boundaries — and the contracts — come from.", + "repl": "Where the boundaries and the contracts come from.", + "expect": 1, + "sldIds": [ + 398 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions — where to split, what the contract should contain, what stays private — are really boundary questions, and DDD is the discipline for boundaries.", + "repl": "The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries.", + "expect": 1, + "sldIds": [ + 399 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge — complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them simply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "repl": "Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications).", + "expect": 1, + "sldIds": [ + 400 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The vivid example is the word 'order' meaning different things in fulfillment versus billing — that's why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "repl": "The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead.", + "expect": 1, + "sldIds": [ + 401 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The relationship patterns between contexts — and which ones your API actually implements.", + "repl": "The relationship patterns between contexts, and which ones your API implements.", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The starred three are the ones to design deliberately.", + "repl": "The starred three are the ones to design explicitly.", + "expect": 1, + "sldIds": [ + 402 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Published Language: the shared, versioned schema that API speaks — OpenAPI, .proto, Avro — which is exactly what lives in the Apicurio registry.", + "repl": "Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry.", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "So the registry-backed contract from section 9 IS a Published Language, and edge/gateway translation IS an Anticorruption Layer.", + "repl": "So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer.", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is the payoff: students have already built these things in sections 9 and 8 without the names — now they have the names and the theory.", + "repl": "The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory.", + "expect": 1, + "sldIds": [ + 403 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driving adapters on the left — REST, gRPC, GraphQL — translate an incoming protocol into a call on an inbound port.", + "repl": "Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port.", + "expect": 1, + "sldIds": [ + 404 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Driven adapters on the right — a Postgres repository, a Kafka publisher — implement the outbound ports.", + "repl": "Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports.", + "expect": 1, + "sldIds": [ + 404 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The aggregate is the consistency boundary — invariants hold within it and one transaction commits it — and it typically maps to a single API resource.", + "repl": "The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource.", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And the small-aggregate rule — one per transaction, reference others by id, connect via events — is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "repl": "And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D).", + "expect": 1, + "sldIds": [ + 406 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The message: DDD isn't a separate methodology bolted on — it's the naming and reasoning behind the boundaries the whole architecture already relies on.", + "repl": "The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on.", + "expect": 1, + "sldIds": [ + 407 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The reframe: coupling is necessary — a system with zero coupling does nothing — so the goal is to balance it, not eliminate it.", + "repl": "The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it.", + "expect": 1, + "sldIds": [ + 408 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Coupling has three dimensions — integration strength, distance and volatility — and a coupling only hurts when all three are high together.", + "repl": "Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together.", + "expect": 1, + "sldIds": [ + 408 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Khononov's model evaluates the connection between two components — not the components themselves — along three dimensions: integration strength, distance and volatility.", + "repl": "Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility.", + "expect": 1, + "sldIds": [ + 409 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Distance is how far apart they sit — same method, class, module, service, or system — and it sets the cost of a change that has to cross the boundary.", + "repl": "Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary.", + "expect": 1, + "sldIds": [ + 410 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is how often the coupled thing actually changes.", + "repl": "Volatility is how often the coupled thing changes.", + "expect": 1, + "sldIds": [ + 410 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The cost box at the bottom is the key relationship: strong plus distant is global complexity — the distributed monolith — but, crucially, it only bites when the code is volatile.", + "repl": "The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile.", + "expect": 1, + "sldIds": [ + 410 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold line is Khononov's modularity rule rendered as a slogan — strength XOR distance — you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "repl": "The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith.", + "expect": 1, + "sldIds": [ + 411 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Contract coupling — APIs and event schemas in the registry — is the only strength level appropriate across service distance.", + "repl": "Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance.", + "expect": 1, + "sldIds": [ + 413 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Volatility is what decides whether a less-than-ideal coupling actually matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling that's actually on hot, ever-changing core code can still hurt.", + "repl": "Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you deliberately route your strongest couplings away from core and toward stable, generic areas.", + "repl": "The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas.", + "expect": 1, + "sldIds": [ + 415 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And liveness deliberately points at a separate /livez that stays UP during the drain, so the kubelet doesn't restart a pod that's cleanly winding down.", + "repl": "Liveness points at a separate /livez that stays UP during the drain, so the kubelet doesn't restart a pod that's cleanly winding down.", + "expect": 1, + "sldIds": [ + 424 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Close by pointing to both Khononov books — Learning Domain-Driven Design for the boundary tools in Appendix F, and Balancing Coupling in Software Design for this model — as the two references that underpin both of these appendices.", + "repl": "Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices.", + "expect": 1, + "sldIds": [ + 417 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done — the 5xx blips users actually notice during a rollout.", + "repl": "An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "It's a protocol between your app and the platform — not a single setting — and the platform gives you a fixed budget to do it in.", + "repl": "It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The rest of the appendix walks that sequence, then shows the code on both sides of it.", + "repl": "This appendix walks that sequence, then shows the code on both sides of it.", + "expect": 1, + "sldIds": [ + 419 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Liveness must stay UP during the drain, or the kubelet restarts a pod that's deliberately winding down.", + "repl": "Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down.", + "expect": 1, + "sldIds": [ + 421 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The distinction that trips people up is readiness vs liveness during shutdown: you WANT readiness to fail (it stops new traffic) but you must NOT let liveness fail (the kubelet would restart a pod that's intentionally draining).", + "repl": "The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining).", + "expect": 1, + "sldIds": [ + 421 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "North/south traffic (clients into the system) enters through the EDGE/INGRESS layer (HAProxy, Nginx, Envoy, Traefik) — where TLS terminates and coarse host/path routing happens — then through the API GATEWAY (Kong, Apigee, Istio gateway, Spring Cloud Gateway) which handles product-level concerns like auth, rate limiting and transformation.", + "repl": "North/south traffic (clients into the system) enters through the EDGE/INGRESS layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API GATEWAY (Kong, Apigee, Istio gateway, Spring Cloud Gateway), which handles product-level concerns like auth, rate limiting and transformation.", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes actually stops routing after you report DOWN — here roughly ten seconds, which your preStop sleep should cover.", + "repl": "The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after you report DOWN: here roughly ten seconds, which your preStop sleep should cover.", + "expect": 1, + "sldIds": [ + 424 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Only HTTP drains for free — everything else needs explicit drain code in your shutdown hook.", + "repl": "Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook.", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The closing bold point is the philosophical anchor of the whole appendix: you cannot guarantee a graceful exit — the kubelet will sometimes SIGKILL you — so idempotency is what actually makes the system safe, and graceful shutdown just makes the unsafe window rare.", + "repl": "The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare.", + "expect": 1, + "sldIds": [ + 425 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fourth point is the one that turns shutdown from fragile to robust — idempotency means the occasional hard kill is a non-event.", + "repl": "The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event.", + "expect": 1, + "sldIds": [ + 426 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Open the appendix by reframing what L7 routing IS in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "repl": "Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else.", + "expect": 1, + "sldIds": [ + 428 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The TLS SNI line in both columns is worth highlighting — L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "repl": "The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower).", + "expect": 1, + "sldIds": [ + 429 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you actually see what's happening across the four layers — ties straight back to the observability section.", + "repl": "The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section.", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Worth calling out: the resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms — the mesh implements many of them for free, but you still need bounded retries and budgets to prevent amplification.", + "repl": "The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification.", + "expect": 1, + "sldIds": [ + 430 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each layer is the right place for a different KIND of decision — we'll see that throughout the rest of the appendix.", + "repl": "Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them.", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each box can refuse, transform or branch the request, and each emits a trace span so the full journey is observable end-to-end.", + "repl": "Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end.", + "expect": 1, + "sldIds": [ + 432 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold last point teases the rule-based section: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place isn't a YAML file in the cluster — it's a rule engine inside the app.", + "repl": "The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster.", + "expect": 1, + "sldIds": [ + 433 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three match rules — by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie — each sending to a different DestinationRule subset.", + "repl": "Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie.", + "expect": 1, + "sldIds": [ + 436 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Use it as a deliberate choice for a known constraint, never as a default.", + "repl": "Use it only for a known constraint, never as a default.", + "expect": 1, + "sldIds": [ + 438 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as truly disposable.", + "repl": "The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable.", + "expect": 1, + "sldIds": [ + 439 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie deliberately and NAT doesn't break it.", + "repl": "Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it.", + "expect": 1, + "sldIds": [ + 439 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Shadow / mirror: copy a percentage of real traffic to a new version silently, observe its behaviour, but don't return its responses to the client — great for testing under realistic load.", + "repl": "Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load.", + "expect": 1, + "sldIds": [ + 440 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Header inject/strip and URL rewrites are essentially free — the router is already parsing the HTTP line and headers anyway.", + "repl": "Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers.", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two examples worth mentioning: trace context propagation (the W3C traceparent header is how the spans from earlier in the journey stitch together with the spans downstream — that's why the request-id injection in the Nginx slide mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "repl": "Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate).", + "expect": 1, + "sldIds": [ + 443 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert — a really clean pattern, identity at the network layer.", + "repl": "Request header set propagates the request-id (creating one if absent) and injects a tenant-id derived from the client mTLS cert: a clean pattern that puts identity at the network layer.", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Set expectations honestly.", + "repl": "Set expectations with the numbers.", + "expect": 1, + "sldIds": [ + 449 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out) that means every layer must be lean and earn its place.", + "repl": "With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency.", + "expect": 1, + "sldIds": [ + 449 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the modern answer to 'how do you build a microservices system that actually works in production.'", + "repl": "This is the modern answer to 'how do you build a microservices system that works in production.'", + "expect": 1, + "sldIds": [ + 451 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is what the 'mesh' actually IS: the same routing CRDs the edge uses, but everywhere, between every pair of services, configured centrally and applied by the sidecars.", + "repl": "This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars.", + "expect": 1, + "sldIds": [ + 453 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "gRPC has a few sharp edges that are worth calling out separately.", + "repl": "gRPC has a few sharp edges of its own.", + "expect": 1, + "sldIds": [ + 454 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The defining property: change anywhere, redeploy everything; a bug in Reporting can take down Payments; one scaling unit for very different workloads.", + "repl": "The defining property: change anywhere, redeploy everything; a bug in Reporting can take down Payments; one scaling unit for workloads that scale differently.", + "expect": 1, + "sldIds": [ + 458 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Splitting the database is the genuinely hard part of decomposition.", + "repl": "Splitting the database is the hard part of decomposition.", + "expect": 1, + "sldIds": [ + 460 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The bold final line is the architectural take-home: reversibility — every step should be a config change you can undo without coordinating a release — is the property to design for.", + "repl": "The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release.", + "expect": 1, + "sldIds": [ + 468 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pick the pattern that matches the consistency you actually need.", + "repl": "Pick the pattern that matches the consistency you need.", + "expect": 1, + "sldIds": [ + 470 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The sub-bullet is what trips teams up: every cache adds complexity; pick the pattern that matches the consistency you actually need.", + "repl": "The sub-bullet is what trips teams up: every cache adds complexity; pick the pattern that matches the consistency you need.", + "expect": 1, + "sldIds": [ + 470 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: extra DB load for keys that may not actually be requested again.", + "repl": "Cons: extra DB load for keys that may not be requested again.", + "expect": 1, + "sldIds": [ + 481 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Unbounded caches grow until they evict at random, which gives you the worst kind of failure — unpredictable.", + "repl": "Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable.", + "expect": 1, + "sldIds": [ + 483 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The mistake that causes most production incidents is treating a remote call like a local one — no timeout, no fallback, no circuit breaker — so a single down dependency hangs the caller.", + "repl": "The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller.", + "expect": 1, + "sldIds": [ + 490 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on actual traffic, SLO burn-rate alerts, and — critically — Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "repl": "The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims.", + "expect": 1, + "sldIds": [ + 491 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Production resilience is the deliberate composition of several of these, tuned together.", + "repl": "Production resilience is the composition of several of these, tuned together.", + "expect": 1, + "sldIds": [ + 494 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Isolate resources — connection pools, thread pools, async semaphores, queues — by tenant, by call type, or by priority.", + "repl": "Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority.", + "expect": 1, + "sldIds": [ + 501 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Load shedding is counter-intuitive to engineers — deliberately rejecting requests feels like giving up — but it is the difference between a system that degrades and one that collapses.", + "repl": "Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses.", + "expect": 1, + "sldIds": [ + 502 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each failure mode has characteristic signals, and the point is that you must instrument for them deliberately — a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "repl": "Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop.", + "expect": 1, + "sldIds": [ + 505 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what actually failed' (the logs on that trace) — the correlation story from section 11 applied to incident response.", + "repl": "The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response.", + "expect": 1, + "sldIds": [ + 505 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two observations worth stating aloud: first, quorum/consensus is the ONLY real answer to split-brain — no amount of retrying or circuit-breaking helps when the problem is two leaders; you need a majority rule.", + "repl": "Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule.", + "expect": 1, + "sldIds": [ + 506 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The platform around the code — Strimzi, Apicurio, OpenMetadata, the LGTM stack — is unchanged from any language choice; Spring Boot is just how we write the services.", + "repl": "The platform around the code (Strimzi, Apicurio, OpenMetadata, the LGTM stack) is the same whatever the language; Spring Boot is how these services are written.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Treat it as a pre-flight check — “will this survive the platform?” — we list the twelve factors and map each to where it lives in our architecture.", + "repl": "Treat it as a pre-flight check (“will this survive the platform?”): we list the twelve factors and map each to where it lives in our architecture.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Constructor injection (no @Autowired needed) is the Spring 4.0 idiomatic style — keeps the bean immutable and trivially testable.", + "repl": "Constructor injection (no @Autowired needed) is the idiomatic Spring style: the bean stays immutable and is unit-testable without a container.", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs); (2) request contracts validated declaratively — a Java record with Bean Validation 3.1 annotations (@NotBlank, @Positive, @Valid), and Spring's @Valid triggers the validator so bad input never reaches your logic; (3) correct status codes — HttpStatus.CREATED on create, not 200; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth.", + "repl": "Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs); (2) request contracts validated declaratively, a Java record with Bean Validation 3.1 annotations (@NotBlank, @Positive, @Valid), where Spring's @Valid triggers the validator so bad input never reaches your logic; (3) correct status codes: HttpStatus.CREATED on create, not 200; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth.", + "expect": 1, + "sldIds": [ + 272 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "For non-blocking I/O, swap @RestController for WebFlux's reactive style with Mono and Flux — same controller, reactive runtime. springdoc-openapi emits the OpenAPI spec for free, which is what we register and gate.", + "repl": "For non-blocking I/O, swap @RestController for WebFlux's reactive style with Mono and Flux: same controller, reactive runtime. springdoc-openapi generates the OpenAPI spec from the controllers, and that spec is what we register and gate.", + "expect": 1, + "sldIds": [ + 272 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "There is deliberately no Kafka call inside the request path — if the broker is down, the order still commits and the event ships later when Debezium tails the write-ahead log and relays the outbox row in commit order.", + "repl": "There is no Kafka call inside the request path: if the broker is down, the order still commits and the event ships later when Debezium tails the write-ahead log and relays the outbox row in commit order.", + "expect": 1, + "sldIds": [ + 287 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Two things to drill home: we ack only AFTER sendEmail succeeds — if you ack before and then crash, you've lost the work.", + "repl": "Two things to drill home: we ack only once sendEmail has succeeded; ack first and then crash, and the work is lost.", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We then list what consumers actually get from it.", + "repl": "We then list what consumers get from it.", + "expect": 1, + "sldIds": [ + 312 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We then cover what each layer's controls actually are.", + "repl": "We then cover what each layer's controls are.", + "expect": 1, + "sldIds": [ + 327 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Resilience4j is the Spring Boot answer to fault tolerance — lighter and more focused than Hystrix (which is in maintenance). @Bulkhead declares a named bulkhead; the configuration (maxConcurrentCalls, maxWaitDuration) lives in application.yml under resilience4j.bulkhead.instances.orders. @TimeLimiter pairs with it for fail-fast on slow calls. fallbackMethod runs when the bulkhead rejects — you can return a sentinel value, a cached response, or fail with a 503.", + "repl": "Resilience4j (the resilience4j-spring-boot4 starter) is the Spring Boot answer to fault tolerance; Hystrix is in maintenance. @Bulkhead declares a named bulkhead; the configuration (maxConcurrentCalls, maxWaitDuration) lives in application.yml under resilience4j.bulkhead.instances.orders. @TimeLimiter pairs with it for fail-fast on slow calls. fallbackMethod runs when the bulkhead rejects: return a sentinel value, a cached response, or fail with a 503.", + "expect": 1, + "sldIds": [ + 340 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log — the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live — how long a cached key lives before expiring); LRU (Least Recently Used — a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command — sets a key and its TTL atomically); ORM (Object-Relational Mapping); JPA (Jakarta Persistence API); Spring Data (Spring's repository abstraction, not an acronym).", + "repl": "Data & persistence: CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log, the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used, a cache eviction policy); SETEX (the Redis \"SET with EXpiry\" command, which sets a key and its TTL atomically); ORM (Object-Relational Mapping); JPA (Jakarta Persistence API); Spring Data (Spring's repository abstraction, not an acronym).", + "expect": 1, + "sldIds": [ + 351 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Runtime & build — JVM (Java Virtual Machine); JIT (Just-In-Time compilation); AOT (Ahead-Of-Time compilation — GraalVM / Spring AOT); GC (Garbage Collection); IoC (Inversion of Control); DI (Dependency Injection); DTO (Data Transfer Object); POJO (Plain Old Java Object); SpEL (Spring Expression Language).", + "repl": "Runtime & build: JVM (Java Virtual Machine); JIT (Just-In-Time compilation); AOT (Ahead-Of-Time compilation: GraalVM / Spring AOT); GC (Garbage Collection); IoC (Inversion of Control); DI (Dependency Injection); DTO (Data Transfer Object); POJO (Plain Old Java Object); SpEL (Spring Expression Language).", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Spring for GraphQL is schema-first: the .graphqls file IS the contract.", + "repl": "Spring for GraphQL is schema-first: the .graphqls file is the contract.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The Java record on the data side simply contains both fields and Spring binds them automatically when the field is queried (custom resolvers are only needed when a field comes from a different source).", + "repl": "The Java record on the data side contains both fields and Spring binds them when the field is queried (custom resolvers are needed only when a field comes from a different source).", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The field keeps working — nothing breaks — but the deprecation shows up in introspection, in the GraphiQL UI Spring serves at /graphiql, and in any IDE plugin that reads the schema.", + "repl": "The field keeps working and nothing breaks, but the deprecation shows up in introspection, in the GraphiQL UI Spring serves at /graphiql, and in any IDE plugin that reads the schema.", + "expect": 1, + "sldIds": [ + 365 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Declarative and easy — but the conns map is per-pod.", + "repl": "Declarative, but the conns map is per-pod.", + "expect": 1, + "sldIds": [ + 368 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A version field lets the client detect an incompatible payload format and renegotiate; the type field dispatches to the right protobuf message; and — critically for what comes next — a monotonic seq number per connection is what makes reconnection and replay possible.", + "repl": "A version field lets the client detect an incompatible payload format and renegotiate; the type field dispatches to the right protobuf message; and a monotonic seq number per connection is what makes reconnection and replay possible, which the failure-handling material relies on.", + "expect": 1, + "sldIds": [ + 372 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Each successful step accumulates its result into the context map (remember that — it's the whole point of the data-dependency slide) and bumps the step index; @Transactional makes the save durable after every step, atomic with the work record.", + "repl": "Each successful step accumulates its result into the context map (the data-dependency pattern depends on it) and bumps the step index; @Transactional makes the save durable after every step, atomic with the work record.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "On the left, your client — a REST handler in a Spring Boot app — calls WorkflowClient.start(...) to kick off a workflow.", + "repl": "On the left, your client, a REST handler in a Spring Boot app, calls WorkflowClient.start(...) to kick off a workflow.", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "What's deliberately absent: any stack trace, exception class name or SQL — those leak internals and are an attack surface.", + "repl": "What the body leaves out: any stack trace, exception class name or SQL; those leak internals and are an attack surface.", + "expect": 1, + "sldIds": [ + 389 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Ports: the domain declares plain Java interfaces — a repository and an event publisher — and depends on nothing else (no Spring imports anywhere in the domain package).", + "repl": "Ports: the domain declares plain Java interfaces (a repository and an event publisher) and depends on nothing else (no Spring imports anywhere in the domain package).", + "expect": 1, + "sldIds": [ + 405 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Constructor injection is the key Spring-friendly choice — fields can be final, the bean is immutable and trivially unit-testable with mocks, and there's no @Autowired anywhere in the domain core (Spring's only mark is @Service which can be replaced with a @Configuration if you want zero framework annotations in the domain).", + "repl": "Constructor injection is the key Spring-friendly choice: fields can be final, the bean is immutable and unit-testable with mocks, and there's no @Autowired anywhere in the domain core (Spring's only mark is @Service, which a @Configuration can replace if you want zero framework annotations in the domain).", + "expect": 1, + "sldIds": [ + 405 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with Spring Boot + Apache Camel + Drools, then circle back to east/west where the most decisions actually happen.", + "repl": "We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with Spring Boot + Apache Camel + Drools, then circle back to east/west, where most routing decisions happen.", + "expect": 1, + "sldIds": [ + 427 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "This is the structure used in the official Camel + Drools content-based-routing examples — the Camel DSL is identical across JVM frameworks, so a Quarkus implementation and a Spring Boot one differ only in dependency choice and DI style. camel-spring-boot-starter auto-configures the CamelContext and automatically registers any @Component RouteBuilder beans — no manual XML, no Java config.", + "repl": "This is the structure used in the official Camel + Drools content-based-routing examples. The Camel DSL is identical across JVM frameworks, so a Quarkus implementation and a Spring Boot one differ only in dependency choice and DI style. camel-spring-boot-starter auto-configures the CamelContext and registers any @Component RouteBuilder beans: no manual XML, no Java config.", + "expect": 1, + "sldIds": [ + 447 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The point isn't the syntax; it's that this file is owned by domain experts — the same people who decide what 'VIP' or 'high-value' actually mean — and can be redeployed without a developer touching Camel or Spring Boot code.", + "repl": "The point isn't the syntax; it's that this file is owned by domain experts (the people who decide what 'VIP' or 'high-value' means) and can be redeployed without a developer touching Camel or Spring Boot code.", + "expect": 1, + "sldIds": [ + 448 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The proxy earns its keep: by path, by header (X-Tenant: acme), by percentage (1% canary), or by feature flag.", + "repl": "The proxy routes by path, by header (X-Tenant: acme), by percentage (1% canary), or by feature flag.", + "expect": 1, + "sldIds": [ + 463 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "NOT a destination — every Liquibase / Flyway change now needs both apps to agree.", + "repl": "It is not a destination: every Liquibase / Flyway change now needs both apps to agree.", + "expect": 1, + "sldIds": [ + 464 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The pattern is more powerful than path-based proxying: you can roll out the new service to one tenant at a time, observe real-traffic behaviour, then widen.", + "repl": "The pattern goes further than path-based proxying: you can roll out the new service to one tenant at a time, observe real-traffic behaviour, then widen.", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The data split is the genuinely hard part; doing it with multiple Spring DataSource beans is much cheaper than coordinating across services.", + "repl": "The data split is the hard part; doing it with multiple Spring DataSource beans is much cheaper than coordinating across services.", + "expect": 1, + "sldIds": [ + 468 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most powerful for vendor / 3rd-party / legacy wrappers.", + "repl": "Best suited to vendor / 3rd-party / legacy wrappers.", + "expect": 1, + "sldIds": [ + 468 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "SpEL: key = \"#order.id + ':' + #user\", condition = \"#qty > 100\" — powerful but harder to debug.", + "repl": "SpEL: key = \"#order.id + ':' + #user\", condition = \"#qty > 100\": flexible but harder to debug.", + "expect": 1, + "sldIds": [ + 474 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When data is written far more often than it's read — audit logs, event records, telemetry — caching every write is wasteful.", + "repl": "When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful.", + "expect": 1, + "sldIds": [ + 477 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Refresh-ahead pre-empts this: a @Scheduled task watches the cache (or maintains a Redis sorted set of 'hot' keys) and refreshes their values from the DB BEFORE the TTL fires.", + "repl": "Refresh-ahead pre-empts this: a @Scheduled task watches the cache (or maintains a Redis sorted set of 'hot' keys) and refreshes their values from the DB before the TTL fires.", + "expect": 1, + "sldIds": [ + 481 + ], + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "@Scheduled runs every 5s: removes keys not accessed in 5 minutes (removeRangeByScore), then for each remaining hot key checks its TTL via getExpire, refreshes if TTL is below REFRESH_BEFORE_S.", + "repl": "@Scheduled runs every 5s: removes keys not accessed in 5 minutes (removeRangeByScore), then for each remaining hot key checks its TTL via getExpire and refreshes it when the TTL drops under REFRESH_BEFORE_S.", + "expect": 1, + "sldIds": [ + 482 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "then four appendices go deep on protocol internals, versioning, WebSockets and saga mechanics.", + "repl": "then the appendices go deep on protocols, versioning, WebSockets, sagas, errors, caching, failure modes and API testing, and run Example 05 on minikube and OpenShift Local.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people there are four deep-dive appendices at the end (protocols, versioning, WebSockets, sagas) they can jump to.", + "repl": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "The four appendices that follow are deep-dive backup material — pull them up on demand during Q&A, or work through them directly if you're running the full-length session.", + "repl": "The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session.", + "expect": 1, + "sldIds": [ + 350 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Choose: invalidate (this code) or set the new value directly (write-through, two slides later).", + "repl": "Choose: invalidate (this code) or set the new value directly (write-through).", + "expect": 1, + "sldIds": [ + 472 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "If you want the managed alternative, the next few slides show Temporal and Spring State Machine — same problem, different point on the build-vs-buy spectrum.", + "repl": "Temporal, in this appendix, solves the same problem at a different point on the build-vs-buy spectrum; Example 17 runs the hand-rolled version with JdbcClient.", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "positional", + "_amended": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "; the next few slides show it.", + "repl": "; the Temporal slides in this appendix show it.", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "GA in Spring Boot 3.x and stronger in 4.0)", + "repl": "GA since the Spring Boot 3 line and supported on Spring Boot 4.1.1)", + "expect": 1, + "sldIds": [ + 459 + ], + "cat": "version" + }, + { + "op": "replace", + "scope": "slide", + "find": "kafka.send(\"order.created\", order.id(),", + "repl": "kafka.send(\"order.placed\", order.id(),", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "code" + }, + { + "op": "replace", + "scope": "slide", + "find": "@KafkaListener(topics = \"order.created\", groupId = \"order-summary\")", + "repl": "@KafkaListener(topics = \"order.placed\", groupId = \"order-summary\")", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "code" + }, + { + "op": "set_code", + "sldId": 523, + "shape_id": 8, + "lines": [ + "{", + " \"name\": \"Create order\",", + " \"request\": {", + " \"method\": \"POST\",", + " \"header\": [{ \"key\": \"Idempotency-Key\", \"value\": \"{{$guid}}\" }],", + " \"url\": \"{{base_url}}/orders\",", + " \"body\": { \"mode\": \"raw\", \"raw\": \"{\\\"sku\\\":\\\"A-1\\\",\\\"qty\\\":2}\" }", + " },", + " \"event\": [{", + " \"listen\": \"test\",", + " \"script\": { \"exec\": [", + " \"pm.test('201 Created', () => pm.response.to.have.status(201));\",", + " \"pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));\",", + " \"const body = pm.response.json();\",", + " \"pm.test('returns an id', () => pm.expect(body).to.have.property('id'));\",", + " \"pm.collectionVariables.set('order_id', body.id);\"", + " ] }", + " }]", + "}" + ], + "cat": "code", + "_why": "_docs/28 Anatomy of a collection (adds the response-time assertion)" + }, + { + "op": "replace_picture", + "sldId": 524, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-black-box.png", + "cat": "stack", + "_why": "the old figure shows five backends; the site figure shows all six" + }, + { + "op": "set_code", + "sldId": 525, + "shape_id": 8, + "lines": [ + "# Spring Boot — start the service under test, then run the same collection", + "./mvnw spring-boot:run & # http://localhost:8080", + "newman run orders.postman_collection.json -e local.postman_environment.json" + ], + "cat": "code", + "_why": "_docs/28 codetab 'One collection, six backends', this deck's language" + }, + { + "op": "set_code", + "sldId": 526, + "shape_id": 8, + "lines": [ + "# install once (Node.js):", + "# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1", + "newman run orders.postman_collection.json \\", + " --environment local.postman_environment.json \\", + " --reporters cli,junit,htmlextra \\", + " --reporter-junit-export results/newman.xml \\", + " --timeout-request 5000 \\", + " --bail", + "# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure", + "", + "# data-driven: one iteration per CSV row; the columns become {{variables}}", + "newman run orders.postman_collection.json -e local.postman_environment.json \\", + " --iteration-data cases.csv" + ], + "cat": "code", + "_why": "_docs/28 Running Newman + Data-driven tests; pinned install; no comment after a line continuation" + }, + { + "op": "set_code", + "sldId": 528, + "shape_id": 8, + "lines": [ + "# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js)", + "jobs:", + " api-tests:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@v7", + " - run: docker compose up -d --build --wait # order, payment, inventory, ...", + " - run: ./scripts/wait-for-ready.sh # poll /readyz before testing", + " - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1", + " - run: |", + " newman run orders.postman_collection.json \\", + " -e ci.postman_environment.json \\", + " --reporters cli,junit \\", + " --reporter-junit-export results/newman.xml", + " - uses: actions/upload-artifact@v7 # keep the report even on failure", + " if: always()", + " with: { name: newman-results, path: results/ }" + ], + "cat": "code", + "_why": "_docs/28 Newman in CI on Docker Engine; actions at v7 as in .github/workflows" + }, + { + "op": "replace_picture", + "sldId": 529, + "shape_id": 6, + "png": "lgtm-presentation/edits/png/27-ci-gate.png", + "cat": "stack", + "_why": "the old figure shows the old container engine; site figure 27-ci-gate" + }, + { + "op": "replace_picture", + "sldId": 271, + "shape_id": 177, + "png": "lgtm-presentation/edits/png/02-pick-protocol-spring.png", + "cat": "stack", + "_slide": 16, + "_why": "framework labels of another language (Python in Go, Quarkus in .NET and Spring)" + }, + { + "op": "replace_picture", + "sldId": 275, + "shape_id": 225, + "png": "lgtm-presentation/edits/png/02-mesh-handles-wire-spring.png", + "cat": "stack", + "_slide": 20, + "_why": "app box named another language's framework" + }, + { + "op": "replace_picture", + "sldId": 278, + "shape_id": 259, + "png": "lgtm-presentation/edits/png/03-composition.png", + "cat": "stack", + "_slide": 23, + "_why": "gateway named Strawberry (Go) or SmallRye GraphQL (.NET, Spring)" + }, + { + "op": "replace_picture", + "sldId": 283, + "shape_id": 315, + "png": "lgtm-presentation/edits/png/04-cqrs.png", + "cat": "voice", + "_slide": 28, + "_why": "image text had a positional reference ('the next slide') and 'genuinely'" + }, + { + "op": "replace_picture", + "sldId": 285, + "shape_id": 338, + "png": "lgtm-presentation/edits/png/04-log-tailing.png", + "cat": "positional", + "_slide": 30, + "_why": "image text had '(next slide)'" + }, + { + "op": "replace_picture", + "sldId": 299, + "shape_id": 496, + "png": "lgtm-presentation/edits/png/06-scale-on-lag.png", + "cat": "stack", + "_slide": 44, + "_why": "HTTP pods named FastAPI (Go) or Quarkus (.NET, Spring)" + }, + { + "op": "replace_picture", + "sldId": 346, + "shape_id": 1009, + "png": "lgtm-presentation/edits/png/13-antipatterns-grid.png", + "cat": "voice", + "_slide": 91, + "_why": "box text broken mid-word (layout defect)" + }, + { + "op": "replace_picture", + "sldId": 432, + "shape_id": 1947, + "png": "lgtm-presentation/edits/png/22-l7-journey.png", + "cat": "voice", + "_slide": 177, + "_why": "subtitle 'each layer earns its place' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 449, + "shape_id": 2145, + "png": "lgtm-presentation/edits/png/22-hop-cost-drools.png", + "cat": "voice", + "_slide": 194, + "_why": "'every hop must earn its place' (ban tier); rule-engine row per language" + }, + { + "op": "replace_picture", + "sldId": 451, + "shape_id": 2166, + "png": "lgtm-presentation/edits/png/22-east-west.png", + "cat": "voice", + "_slide": 196, + "_why": "caption 'for free' (ban tier)" + }, + { + "op": "replace_picture", + "sldId": 464, + "shape_id": 2306, + "png": "lgtm-presentation/edits/png/24-shared-database.png", + "cat": "positional", + "_slide": 209, + "_why": "'(decomposed DB above)'" + }, + { + "op": "replace_picture", + "sldId": 487, + "shape_id": 2568, + "png": "lgtm-presentation/edits/png/25-failure-taxonomy.png", + "cat": "voice", + "_slide": 232, + "_why": "'the rest of this appendix' (ban tier)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# Istio: retries, timeout and outlier detection for inventory-service", + "repl": "# Istio: bounded retries and timeout for inventory-service", + "expect": 1, + "sldIds": [ + 276 + ], + "cat": "code", + "_why": "the manifest has no outlier detection; matches _docs/02 comment" + }, + { + "op": "set_code", + "sldId": 300, + "shape_id": 509, + "lines": [ + "apiVersion: keda.sh/v1alpha1", + "kind: ScaledObject", + "metadata: { name: payment-consumer }", + "spec:", + " scaleTargetRef: { name: payment-consumer }", + " minReplicaCount: 0", + " maxReplicaCount: 20", + " triggers:", + " - type: kafka", + " metadata: { bootstrapServers: \"cndp-kafka-bootstrap:9092\", topic: order.placed,", + " consumerGroup: payment, lagThreshold: \"100\" }", + "---", + "# HTTP: scale the Spring Boot service on concurrent requests (KEDA http-add-on)", + "apiVersion: http.keda.sh/v1alpha1", + "kind: HTTPScaledObject", + "metadata: { name: order-service }", + "spec:", + " hosts: [orders.example.com]", + " scaleTargetRef: { name: order-service, service: order-service, port: 8080 }", + " replicas: { min: 0, max: 30 }" + ], + "cat": "code", + "_why": "ScaledObject needs metadata.name and the kafka scaler needs bootstrapServers; HTTPScaledObject needs apiVersion, name, hosts and a full scaleTargetRef (KEDA v2.21 / http-add-on)", + "pt": 11.0, + "_fit": "slide 45: 20 code lines, 85 columns; 12.0 pt overflows the box" + }, + { + "op": "replace", + "scope": "slide", + "find": "/livez", + "repl": "/healthz", + "expect": 1, + "sldIds": [ + 424 + ], + "cat": "code", + "_why": "examples/21-graceful-shutdown: liveness endpoint is /healthz in all six languages" + }, + { + "op": "replace", + "scope": "slide", + "find": "# must NOT fail during the drain", + "repl": "# keeps answering during the drain", + "expect": 1, + "sldIds": [ + 424 + ], + "cat": "code", + "_why": "comment, no ALL-CAPS emphasis" + }, + { + "op": "set_notes", + "sldId": 424, + "paragraphs": [ + "The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover.", + "Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages.", + "The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number." + ], + "cat": "notes", + "_why": "notes named /livez (/healthz/live on .NET); examples use /readyz and /healthz" + }, + { + "op": "replace", + "scope": "slide", + "find": "ssl crt /etc/haproxy/certs/ ", + "repl": "ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 ", + "expect": 1, + "sldIds": [ + 434 + ], + "cat": "code", + "_why": "gRPC needs HTTP/2 on the TLS listener (ALPN h2)" + }, + { + "op": "replace", + "scope": "slide", + "find": "# TLS termination here", + "repl": "# TLS here; h2 for gRPC", + "expect": 1, + "sldIds": [ + 434 + ], + "cat": "code", + "_why": "comment matches the ALPN change" + }, + { + "op": "replace", + "scope": "slide", + "find": "listen 443 ssl http2;", + "repl": "listen 443 ssl; http2 on;", + "expect": 1, + "sldIds": [ + 435 + ], + "cat": "code", + "_why": "nginx 1.25.1+ deprecates the http2 listen parameter; http2 is its own directive" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-request-id: \"%REQ(x-request-id)%\"", + "repl": "x-client-ip: \"%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%\"", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "code", + "_why": "setting x-request-id to itself is a no-op; Envoy generates it" + }, + { + "op": "replace", + "scope": "slide", + "find": "# propagate or create", + "repl": "# caller address", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "code", + "_why": "comment matches the new header" + }, + { + "op": "replace", + "scope": "slide", + "find": "x-tenant-id: \"%DOWNSTREAM_PEER_CERT_V_END%\"", + "repl": "x-peer-identity: \"%DOWNSTREAM_PEER_URI_SAN%\"", + "expect": 1, + "sldIds": [ + 444 + ], + "cat": "code", + "_why": "DOWNSTREAM_PEER_CERT_V_END is the certificate expiry, not an identity; the URI SAN carries the SPIFFE ID" + }, + { + "op": "set_notes", + "sldId": 444, + "paragraphs": [ + "Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders.", + "The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none.", + "The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved." + ], + "cat": "notes", + "_why": "notes described the old headers (tenant id from the cert expiry operator)" + }, + { + "op": "set_code", + "sldId": 513, + "shape_id": 8, + "lines": [ + "{", + " \"$schema\": \"https://flagd.dev/schema/v0/flags.json\",", + " \"flags\": {", + " \"new-checkout\": {", + " \"state\": \"ENABLED\",", + " \"variants\": { \"on\": true, \"off\": false },", + " \"defaultVariant\": \"off\",", + " \"targeting\": {", + " \"if\": [", + " { \"==\": [ {\"var\": \"plan\"}, \"enterprise\" ] }, \"on\",", + " { \"fractional\": [ {\"var\": \"targetingKey\"},", + " [\"on\", 25], [\"off\", 75] ] }", + " ]", + " }", + " }", + " }", + "}" + ], + "cat": "code", + "_why": "examples/27-feature-flags/flags/flags.json carries the $schema line" + }, + { + "op": "set_notes", + "sldId": 256, + "paragraphs": [ + "Welcome and framing. This is a working architect's tour of designing cloud-native APIs on Spring Boot 4.1.1. We build one realistic system (five services and a gateway) and re-examine it section by section; the appendices go deep on protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, migration, caching, failure modes, feature flags and API testing, and run Example 05 on minikube and OpenShift Local. The whole deck is a reference the audience can keep.", + "The running stack: Java 25 with Spring Boot 4.1.1 on Spring Framework 7.0.9 (Spring Web MVC, gRPC on grpc-java, Spring for GraphQL, Spring for Apache Kafka with Kafka Streams, Spring WebSocket, Jackson 3), on Kubernetes with Kafka, Istio and KEDA, traced by the OpenTelemetry Java agent. The platform around the code (Strimzi, Apicurio, OpenMetadata, the LGTM stack) is the same whatever the language; Spring Boot is how these services are written." + ], + "cat": "version", + "_slide": 1 + }, + { + "op": "replace", + "scope": "both", + "find": "the Spring 4.0 idiomatic style", + "repl": "the idiomatic Spring style", + "expect": 1, + "sldIds": [ + 272 + ], + "cat": "version", + "_slide": 17 + }, + { + "op": "set_text", + "sldId": 273, + "shape_id": 202, + "paragraphs": [ + "protobuf + java · Spring Boot 4 + grpc-java" + ], + "cat": "stack", + "_slide": 18 + }, + { + "op": "set_code", + "sldId": 273, + "shape_id": 203, + "lines": [ + "// inventory.proto — the contract, registered in Apicurio", + "syntax = \"proto3\";", + "service Inventory { rpc ReserveStock (ReserveRequest) returns (ReserveReply); }", + "message ReserveRequest { string sku = 1; int32 quantity = 2; }", + "message ReserveReply { bool reserved = 1; int32 remaining = 2; }", + "// inventory-service (excerpt): grpc-java server; protobuf-maven-plugin generates the stubs", + "@SpringBootApplication", + "public class InventoryServiceApplication extends InventoryGrpc.InventoryImplBase", + " implements CommandLineRunner {", + " @Override public void run(String... args) throws Exception {", + " server = ServerBuilder.forPort(grpcPort).addService(this).build().start();", + " server.awaitTermination();", + " }", + " @Override public void reserveStock(ReserveRequest req, StreamObserver obs) {", + " int remaining = stock.get(req.getSku()) - req.getQuantity();", + " obs.onNext(ReserveReply.newBuilder().setReserved(remaining >= 0)", + " .setRemaining(remaining >= 0 ? remaining : stock.get(req.getSku())).build());", + " obs.onCompleted();", + " }", + "}" + ], + "cat": "code", + "_slide": 18, + "_why": "examples/02 uses plain grpc-java (grpc-netty-shaded, ServerBuilder), not grpc-spring-boot-starter", + "pt": 11.0, + "_fit": "slide 18: 20 code lines, 94 columns; 11.5 pt overflows the box" + }, + { + "op": "set_notes", + "sldId": 273, + "paragraphs": [ + "gRPC for the internal paths. The .proto is the contract, and it lives in the registry like OpenAPI. The protobuf-maven-plugin runs protoc at build time; the service extends the generated InventoryImplBase and fills in the one method, never touching the wire format.", + "The example starts a plain grpc-java server from a CommandLineRunner inside the Spring Boot application, so no gRPC starter sits between the code and the library. The field numbers (=1, =2) are the wire contract; Appendix B explains why they can never be reused.", + "gRPC is binary and multiplexed over HTTP/2, so it is fast and supports streaming: the right default for chatty service-to-service calls where you control both ends. Load-test it with ghz." + ], + "cat": "stack", + "_slide": 18 + }, + { + "op": "set_code", + "sldId": 279, + "shape_id": 272, + "lines": [ + "@Controller // spring-boot-starter-graphql", + "public class OrderGraphQLController {", + " private final OrderClient orderClient;", + " private final InventoryGrpcClient inventory;", + " public OrderGraphQLController(OrderClient o, InventoryGrpcClient i) {", + " this.orderClient = o; this.inventory = i;", + " }", + " @QueryMapping // REST call to order-service", + " public Order order(@Argument String id) {", + " return orderClient.get(id);", + " }", + " @SchemaMapping(typeName = \"Order\", field = \"stock\") // resolved on demand", + " public Integer stock(Order order) { // via gRPC", + " return inventory.remaining(order.sku());", + " }", + " // @BatchMapping(\"stock\") batches N orders into one gRPC call: no N+1", + "}" + ], + "cat": "code", + "_slide": 24, + "_why": "agree with the site codetab (inventory.remaining)" + }, + { + "op": "replace", + "scope": "both", + "find": "the outbox/Debezium combination from the next two slides", + "repl": "the outbox and Debezium combination shown in the outbox write", + "expect": 1, + "sldIds": [ + 284 + ], + "cat": "positional", + "_slide": 29 + }, + { + "op": "set_text", + "sldId": 287, + "shape_id": 361, + "paragraphs": [ + "java · Spring Boot 4 + JdbcClient + Debezium CDC" + ], + "cat": "stack", + "_slide": 32 + }, + { + "op": "set_code", + "sldId": 287, + "shape_id": 362, + "lines": [ + "// order-service OrderController.java (excerpt): order row and outbox row, one transaction", + "@PostMapping(\"/orders\")", + "@ResponseStatus(HttpStatus.CREATED)", + "@Transactional // single local transaction", + "public Map createOrder(@RequestBody Map body) {", + " String orderId = UUID.randomUUID().toString();", + " // sku, quantity and status come from the body (elided)", + " jdbc.sql(\"INSERT INTO orders (id, sku, quantity, status) VALUES (?, ?, ?, ?)\")", + " .param(orderId).param(sku).param(quantity).param(status)", + " .update(); // state change", + " String payloadJson = objectMapper.writeValueAsString(payload); // Jackson 3", + " jdbc.sql(\"INSERT INTO outbox (aggregate_id, event_type, payload) VALUES (?, ?, ?::jsonb)\")", + " .param(orderId).param(\"order.placed\").param(payloadJson)", + " .update(); // same transaction: outbox row", + " return Map.of(\"id\", orderId, \"sku\", sku, \"quantity\", quantity, \"status\", status);", + "}", + "// No Kafka call on the request path: Debezium tails the WAL and publishes order.placed." + ], + "cat": "code", + "_slide": 32, + "_why": "examples/04 uses JdbcClient and Jackson 3, not Spring Data JPA" + }, + { + "op": "set_notes", + "sldId": 287, + "paragraphs": [ + "The whole trick is the @Transactional method: the insert of the order row and the insert of the outbox row happen in one local transaction, through Spring's JdbcClient. There is no Kafka call inside the request path: if the broker is down, the order still commits and the event ships later when Debezium tails the write-ahead log and relays the outbox row in commit order. This decouples request latency and availability from Kafka's.", + "There is no Spring-specific outbox starter: the outbox is a small table (aggregate id, event type, JSON payload, created_at), the payload is serialized with Jackson 3's JsonMapper, and a standard Debezium Postgres connector handles the CDC. The Debezium Outbox Event Router single-message transform unwraps the row into the event envelope on the connector side. The same commit-together discipline reappears in the saga appendix." + ], + "cat": "code", + "_slide": 32 + }, + { + "op": "replace", + "scope": "slide", + "find": "\"order-placed\"", + "repl": "\"order.placed\"", + "expect": 2, + "sldIds": [ + 291 + ], + "cat": "code", + "_slide": 36 + }, + { + "op": "replace", + "scope": "notes", + "find": "For reactive style, swap @KafkaListener for a Reactor-Kafka receiver flux.", + "repl": "Reactor Kafka is discontinued, so the examples use the imperative @KafkaListener.", + "expect": 1, + "sldIds": [ + 291 + ], + "cat": "stack", + "_slide": 36 + }, + { + "op": "set_code", + "sldId": 298, + "shape_id": 485, + "lines": [ + "// stream-processor RevenueTopology.java (excerpt): order.placed -> revenue.by-merchant", + "@Configuration", + "@EnableKafkaStreams", + "public class RevenueTopology {", + " @Bean", + " public KStream revenueStream(StreamsBuilder builder) {", + " JacksonJsonSerde accSerde = new JacksonJsonSerde<>(Acc.class).noTypeInfo();", + " var revenue = builder.stream(ORDERS, Consumed.with(Serdes.String(), Serdes.String()))", + " .map((key, json) -> parse(json)) // re-key by merchant_id", + " .groupByKey(Grouped.with(Serdes.String(), Serdes.String()))", + " .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(windowSeconds)))", + " .aggregate(() -> new Acc(0, BigDecimal.ZERO), // local, changelog-backed", + " (merchant, amount, acc) -> add(merchant, acc, amount),", + " Materialized.with(Serdes.String(), accSerde))", + " .toStream()", + " .map((window, acc) -> KeyValue.pair(window.key(), toJson(window, acc)));", + " revenue.to(REVENUE, Produced.with(Serdes.String(), Serdes.String()));", + " return revenue;", + " }", + "}" + ], + "cat": "code", + "_slide": 43, + "_why": "examples/06: String serdes, BigDecimal Acc, Spring Kafka 4 JacksonJsonSerde (Jackson 3), sink revenue.by-merchant" + }, + { + "op": "set_notes", + "sldId": 298, + "paragraphs": [ + "Kafka Streams is the JVM-native stream processor; on Spring Boot you get it through spring-kafka with @EnableKafkaStreams, which auto-configures a StreamsBuilderFactoryBean from spring.kafka.streams.* properties and injects the StreamsBuilder. The topology is a regular @Bean.", + "The example re-keys each order.placed event by merchant, groups by key and aggregates into a tumbling window (window.seconds, 10 seconds by default). The accumulator, a count and a BigDecimal total, is serialized with Spring Kafka 4's JacksonJsonSerde on Jackson 3. The Materialized state store is local state backed by a Kafka changelog topic, so if the pod dies the state rebuilds by replaying the changelog: fault-tolerant local state with no external database in the hot path.", + "Each update to a (merchant, window) row is mapped to a JSON record with the window start, merchant_id, order_count and the revenue rounded HALF_UP, and written to revenue.by-merchant, a stream other services consume. A total that is not a number is skipped instead of killing the stream thread. If you would rather express this as routing than as a topology, Spring Integration is the alternative; Flink is the heavyweight option." + ], + "cat": "code", + "_slide": 43 + }, + { + "op": "set_text", + "sldId": 319, + "shape_id": 714, + "paragraphs": [ + "Auto-instrument with the OpenTelemetry Java agent" + ], + "cat": "stack", + "_slide": 64 + }, + { + "op": "set_text", + "sldId": 319, + "shape_id": 716, + "paragraphs": [ + "java · Spring Boot 4 + OpenTelemetry Java agent" + ], + "cat": "stack", + "_slide": 64 + }, + { + "op": "set_code", + "sldId": 319, + "shape_id": 717, + "lines": [ + "# Containerfile — attach the OpenTelemetry Java agent (Spring Boot 4 needs no starter)", + "ENV JAVA_TOOL_OPTIONS=\"-javaagent:/deployments/opentelemetry-javaagent.jar\"", + "# environment: OTEL_SERVICE_NAME=order-service", + "# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318", + "# OTEL_TRACES_SAMPLER=parentbased_traceidratio, OTEL_TRACES_SAMPLER_ARG=1.0", + "// The agent instruments Spring MVC, RestClient, gRPC, JDBC and the Kafka clients,", + "// and puts trace_id/span_id in the logback MDC. Custom business spans use the", + "// OpenTelemetry API annotation (io.opentelemetry.instrumentation.annotations):", + "@Service", + "public class OrderService {", + " private final InventoryGrpcClient inventory;", + " public OrderService(InventoryGrpcClient i) { this.inventory = i; }", + " @WithSpan(\"reserve_stock\") // custom span", + " public ReserveReply reserve(String sku, int qty) {", + " return inventory.reserveStock(sku, qty);", + " }", + " // trace_id flows: REST -> gRPC -> Kafka -> consumer", + "}" + ], + "cat": "code", + "_slide": 64, + "_why": "site 11 tab + examples/11: OTel Java agent 2.32.0 replaces Micrometer Tracing", + "pt": 11.0, + "_fit": "slide 64: 18 code lines, 88 columns; 11.5 pt overflows the box" + }, + { + "op": "set_text", + "sldId": 319, + "shape_id": 718, + "paragraphs": [ + "The agent exports OTLP: traces to Tempo, metrics to Mimir, logs to Loki, all keyed by trace_id." + ], + "cat": "stack", + "_slide": 64 + }, + { + "op": "set_notes", + "sldId": 319, + "paragraphs": [ + "The payoff line is how little code this takes. The examples attach the OpenTelemetry Java agent 2.32.0 with JAVA_TOOL_OPTIONS in the Containerfile; Spring Boot 4 needs no tracing starter. The Containerfile downloads the agent jar from Maven Central and checks it against a pinned SHA-256.", + "Every Spring MVC endpoint, RestClient call, gRPC invocation, JDBC query and Kafka producer and consumer is instrumented: spans are created and W3C trace context is propagated with zero code, and the agent writes trace_id and span_id into the logback MDC. Configuration is environment only: the service name, the OTLP endpoint and the sampler.", + "You hand-write spans only for business operations worth measuring, and even that is declarative: @WithSpan from the OpenTelemetry instrumentation annotations. The trace_id flows REST to gRPC to Kafka to the consumer with no manual plumbing, because the agent injects and extracts context at each hop. Instrument once, correlate everything." + ], + "cat": "stack", + "_slide": 64 + }, + { + "op": "set_text", + "sldId": 321, + "shape_id": 740, + "paragraphs": [ + "java · Spring Boot 4 + OpenTelemetry API" + ], + "cat": "stack", + "_slide": 66 + }, + { + "op": "set_code", + "sldId": 321, + "shape_id": 741, + "lines": [ + "// OpenTelemetry Baggage rides the same context propagation as the trace;", + "// the Java agent carries the W3C baggage header over REST, gRPC and Kafka.", + "@Component", + "public class TenantBaggageFilter extends OncePerRequestFilter {", + " @Override", + " protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,", + " FilterChain chain) throws ServletException, IOException {", + " try (Scope s = Baggage.current().toBuilder()", + " .put(\"tenant.id\", req.getHeader(\"X-Tenant\"))", + " .build()", + " .makeCurrent()) {", + " chain.doFilter(req, res); // downstream calls carry tenant.id", + " }", + " }", + "}", + "// read it downstream, in a consumer", + "@KafkaListener(topics = \"order.placed\")", + "public void handle(OrderEvent ev) {", + " String tenant = Baggage.current().getEntryValue(\"tenant.id\");", + " Span.current().setAttribute(\"tenant.id\", tenant); // baggage is not an attribute", + "}" + ], + "cat": "code", + "_slide": 66, + "_why": "site 11 baggage tab (OpenTelemetry Baggage API); Micrometer Tracing is not used by the examples" + }, + { + "op": "set_text", + "sldId": 321, + "shape_id": 742, + "paragraphs": [ + "Set baggage once at the edge; every downstream hop carries it, and a span attribute makes it queryable." + ], + "cat": "stack", + "_slide": 66 + }, + { + "op": "set_notes", + "sldId": 321, + "paragraphs": [ + "Baggage in practice. OpenTelemetry Baggage rides the same context propagation as the trace: the Java agent's default propagators are tracecontext and baggage, so the W3C baggage header travels on every REST, gRPC and Kafka hop with no extra code.", + "The OncePerRequestFilter puts tenant.id into the current baggage at the edge, and the try-with-resources scope restores the previous context when the request ends. A @KafkaListener downstream reads it with Baggage.current().getEntryValue(...). Baggage is not recorded on spans automatically, so the consumer copies it onto its span as an attribute, which is what makes traces filterable by tenant.", + "Caution: baggage goes on the wire to every hop, including third parties you call. No secrets, no PII." + ], + "cat": "stack", + "_slide": 66 + }, + { + "op": "replace", + "scope": "slide", + "find": "s.setStatus(\"DONE\")", + "repl": "s.setStatus(\"COMPLETED\")", + "expect": 1, + "sldIds": [ + 377 + ], + "cat": "code", + "_slide": 122 + }, + { + "op": "replace", + "scope": "slide", + "find": "s.setStatus(\"FAILED\");", + "repl": "s.setStatus(\"COMPENSATED\");", + "expect": 1, + "sldIds": [ + 379 + ], + "cat": "code", + "_slide": 124 + }, + { + "op": "replace", + "scope": "slide", + "find": "new Saga(new Saga.Options()); //", + "repl": "new Saga(new Saga.Options.Builder().build()); //", + "expect": 1, + "sldIds": [ + 383 + ], + "cat": "code", + "_slide": 128 + }, + { + "op": "set_text", + "sldId": 391, + "shape_id": 1507, + "paragraphs": [ + "java · grpc-java + StatusProto" + ], + "cat": "stack", + "_slide": 136 + }, + { + "op": "replace", + "scope": "notes", + "find": "RestClient (Spring 6+)", + "repl": "RestClient", + "expect": 1, + "sldIds": [ + 457 + ], + "cat": "version", + "_slide": 202 + }, + { + "op": "replace", + "scope": "slide", + "find": "RestClient (Spring 6+) —", + "repl": "RestClient —", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "version", + "_slide": 210 + }, + { + "op": "replace", + "scope": "slide", + "find": "forward via Spring 6 RestClient.", + "repl": "forward via RestClient.", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "version", + "_slide": 210 + }, + { + "op": "replace", + "scope": "notes", + "find": "via Spring 6's RestClient", + "repl": "via RestClient", + "expect": 1, + "sldIds": [ + 465 + ], + "cat": "version", + "_slide": 210 + }, + { + "op": "replace", + "scope": "notes", + "find": "cache invalidation is OUTSIDE the transaction", + "repl": "cache invalidation is outside the transaction", + "expect": 1, + "sldIds": [ + 472 + ], + "cat": "voice", + "_slide": 217 + }, + { + "op": "set_code", + "sldId": 474, + "shape_id": 2421, + "lines": [ + "// Spring Cache with Redis backing (Spring Boot 4, Jackson 3).", + "@Configuration @EnableCaching", + "public class CacheConfig {", + " @Bean", + " public RedisCacheManager cacheManager(RedisConnectionFactory cf) {", + " return RedisCacheManager.builder(cf)", + " .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()", + " .entryTtl(Duration.ofSeconds(60))", + " .serializeValuesWith(SerializationPair.fromSerializer(", + " RedisSerializer.json()))) // Jackson 3: hits come back as Product", + " .build();", + " }", + "}", + "@Service", + "public class ProductService {", + " private final ProductRepository repo;", + " public ProductService(ProductRepository repo) { this.repo = repo; }", + " @Cacheable(value = \"products\", key = \"#id\") // cache-then-load", + " public Product findById(String id) {", + " return repo.findById(id) // method body = loader", + " .orElseThrow(() -> new ResponseStatusException(NOT_FOUND));", + " }", + " @CacheEvict(value = \"products\", key = \"#id\") @Transactional", + " public Product update(String id, Product body) { /* load, apply, save */ }", + "}" + ], + "cat": "code", + "_slide": 219, + "_why": "GenericJackson2JsonRedisSerializer (Jackson 2) -> RedisSerializer.json() (Jackson 3), as the site tab", + "pt": 9.0, + "line_spacing": 1.0, + "_fit": "slide 219: 25 code lines, 87 columns; 9.5 pt overflows the box" + }, + { + "op": "replace", + "scope": "notes", + "find": "RedisCacheManager configures Redis as backing store with 60s default TTL.", + "repl": "RedisCacheManager configures Redis as backing store with a 60s default TTL and Jackson 3 JSON values (RedisSerializer.json()).", + "expect": 1, + "sldIds": [ + 474 + ], + "cat": "stack", + "_slide": 219 + }, + { + "op": "set_code", + "sldId": 478, + "shape_id": 2469, + "lines": [ + "// Append-heavy data: events, audit logs, telemetry. Writes hit the DB only.", + "@RestController", + "@RequestMapping(\"/events\")", + "public class EventController {", + " private final EventRepository repo;", + " public EventController(EventRepository repo) { this.repo = repo; }", + "", + " @PostMapping", + " @Transactional", + " public Event record(@RequestBody Event body) {", + " return repo.save(body); // DB only, cache untouched", + " }", + "", + " @GetMapping(\"/{id}\")", + " @Cacheable(value = \"events\", key = \"#id\") // read side populates lazily", + " public Event get(@PathVariable String id) {", + " return repo.findById(id)", + " .orElseThrow(() -> new ResponseStatusException(NOT_FOUND));", + " }", + "}" + ], + "cat": "code", + "_slide": 223, + "_why": "agree with the site tab (single write path)" + }, + { + "op": "set_notes", + "sldId": 478, + "paragraphs": [ + "One write path and a @Cacheable read. The write just calls repo.save(): the cache is not touched. The read side is @Cacheable, which populates lazily on the first read of each id.", + "The pattern pays off when most written records are rarely or never read again: security event logs, telemetry, audit entries. If a stale cached copy could exist for a rewritten id, delete that key after the save. Compare with a product catalog, where every read is a probable cache hit; there @CachePut write-through makes more sense." + ], + "cat": "code", + "_slide": 223 + }, + { + "op": "replace", + "scope": "notes", + "find": "For orchestration in this stack we feature Temporal (Java SDK + Spring Boot) for durable execution as the primary approach, with Spring State Machine as a lighter-weight alternative for state-driven flows. Appendix D shows both, side by side, against the saga from the data section.", + "repl": "For orchestration, Appendix D sets two approaches against the saga from the data section: the hand-rolled, database-backed orchestrator that Example 17 runs, and Temporal (Java SDK + Spring Boot) for durable execution when the saga outgrows it.", + "expect": 1, + "sldIds": [ + 301 + ], + "cat": "stack", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": "Temporal Java SDK docs (docs.temporal.io/dev-guide/java), Spring State Machine, Spring for GraphQL", + "repl": "Temporal Java SDK docs (docs.temporal.io/dev-guide/java), Spring for GraphQL", + "expect": 1, + "sldIds": [ + 349 + ], + "cat": "stack", + "_amend": "S12 decision 6" + }, + { + "op": "replace_picture", + "sldId": 376, + "shape_id": 1333, + "png": "lgtm-presentation/edits/png/17-saga-state.png", + "cat": "stack", + "_slide": 121, + "_why": "figure named 'Temporal / State Machine'; the site figure 17-saga-state", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "notes", + "find": "Spring State Machine is a lighter in-process alternative we'll show side by side.", + "repl": "Example 17 runs the first option, and this appendix sets its code beside Temporal.", + "expect": 1, + "sldIds": [ + 376 + ], + "cat": "stack", + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "notes", + "find": "Temporal does the same automatically through its workflow input/output passing; Spring State Machine does it via the extended state context.", + "repl": "Temporal does the same through its workflow input/output passing, and Example 17 stores each step's result in the saga's JSONB context under the step name.", + "expect": 1, + "sldIds": [ + 380 + ], + "cat": "stack", + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 381, + "shape_id": 1391, + "paragraphs": [ + "Two ways to keep a saga durable" + ], + "cat": "stack", + "_slide": 126, + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 381, + "shape_id": 1392, + "cat": "stack", + "_slide": 126, + "paragraphs": [ + "Two open-source paths, both shown in this appendix with a diagram and code.", + "The hand-rolled orchestrator (Example 17): one sagas row per instance (status, step_index, JSONB context) and a saga_log, written with JdbcClient inside a TransactionTemplate. No extra dependency, no extra cluster.", + "Temporal: a durable execution engine (Temporal Server MIT, Java SDK Apache 2.0). Workflows and activities are plain Java; the Server persists every event and replays it on recovery. Most power, a separate cluster to run.", + "Decision rule: keep the hand-rolled orchestrator while the saga is short and lives in one service; reach for Temporal when it is long, shared across services, or needs timers, retries and child workflows as first-class features.", + "Spring Statemachine is not on the list: its repository is archived upstream and 4.0.x was its last open-source line." + ], + "_amend": "S12 decision 6" + }, + { + "op": "set_notes", + "sldId": 381, + "cat": "stack", + "_slide": 126, + "paragraphs": [ + "Frame the choice as owning the transitions against buying durable execution. Both are open source and both survive a pod crash mid-flow.", + "The hand-rolled orchestrator is what Example 17 runs: a sagas table with one row per saga holding status, step_index and a JSONB context, and a saga_log table that records every execute, failed and compensate action. Each step runs in its own transaction that locks the row, runs the step and commits the new context and step index together. On startup the service resumes every saga still marked RUNNING. Nothing new to operate.", + "Temporal: founded by the original Cadence team, MIT-licensed Server and Apache-2.0 Java SDK, actively maintained. It adds durable execution, deterministic replay, automatic retries with backoff, timers, a Saga compensation utility, child workflows, and a CLI and UI to inspect runs. The cost is operating a Temporal cluster, which runs on your own Kubernetes with no SaaS required.", + "Spring Statemachine is archived upstream: github.com/spring-projects/spring-statemachine is a read-only archive whose README says the project is no longer maintained (last release 4.0.2, June 2026), and the spring.io announcement of 21 April 2025 made 4.0.x the last open-source line, with later releases for commercial Tanzu Spring customers only. That conflicts with the open-source-only rule, so the deck does not offer it.", + "The decision rule to leave with the audience: the question is how much engine this saga needs." + ], + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 384, + "shape_id": 1425, + "paragraphs": [ + "The hand-rolled orchestrator: one row, one log, no extra cluster" + ], + "cat": "stack", + "_slide": 129, + "_amend": "S12 decision 6" + }, + { + "op": "replace_picture", + "sldId": 384, + "shape_id": 1426, + "png": "lgtm-presentation/edits/png/17-sagas-architecture.png", + "cat": "stack", + "_slide": 129, + "_why": "Spring Statemachine figure replaced by examples/17-sagas/architecture.svg", + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 384, + "shape_id": 1427, + "cat": "stack", + "_slide": 129, + "paragraphs": [ + "A sagas row locked FOR UPDATE, a saga_log of every action, and a resume pass at startup: Example 17." + ], + "_amend": "S12 decision 6" + }, + { + "op": "set_notes", + "sldId": 384, + "cat": "stack", + "_slide": 129, + "paragraphs": [ + "Walk the picture: the saga-orchestrator service of Example 17. POST /sagas creates a sagas row (status RUNNING, step_index 0, the order in a JSONB context) and runs the steps in order: charge_payment, reserve_stock, book_shipping. Postgres holds two tables: sagas, one row per saga, and saga_log, one row per execute, failed or compensate action.", + "Each step runs in its own transaction. The orchestrator locks the row with SELECT ... FOR UPDATE, returns at once unless the status is RUNNING (a repeated call is a no-op), runs the step, writes its output into the context and the log, and moves step_index forward, all in one commit. When the index passes the last step the status becomes COMPLETED.", + "When a step throws, the same transaction logs the failure and sets COMPENSATING; the compensation pass then walks the completed steps in reverse (release_stock, then refund_payment) and ends in COMPENSATED. The dashed path in the picture is that unwind.", + "Recovery is a StartupRunner: on boot it advances every saga still marked RUNNING. Everything runs in-process in the Spring Boot app, on the Postgres you already operate." + ], + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 385, + "shape_id": 1436, + "paragraphs": [ + "The hand-rolled orchestrator: one step per transaction" + ], + "cat": "code", + "_slide": 130, + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 385, + "shape_id": 1438, + "paragraphs": [ + "java · Spring Boot 4 + JdbcClient" + ], + "cat": "code", + "_slide": 130, + "_amend": "S12 decision 6" + }, + { + "op": "set_code", + "sldId": 385, + "shape_id": 1439, + "lines": [ + "// SagaService.java excerpt (examples/17-sagas); helper names stand for its JdbcClient calls", + "private void advance(String sagaId) {", + " String[] statusHolder = new String[1];", + " txTemplate.executeWithoutResult(status -> { // lock, run one step, commit", + " var row = lockRow(sagaId); // SELECT ... FROM sagas WHERE id = :id FOR UPDATE", + " if (row == null || !\"RUNNING\".equals(row[0])) return; // finished: no-op", + " int stepIndex = (int) row[1]; var context = fromJson((String) row[2]);", + " if (stepIndex >= STEPS.size()) { markStatus(sagaId, \"COMPLETED\"); return; }", + " SagaStep step = STEPS.get(stepIndex); // (name, compensation)", + " try {", + " Map result = stepExecutor.execute(step.name(), context);", + " context.put(step.name(), result); // output flows forward", + " logStep(sagaId, step.name(), \"execute\", result);", + " saveProgress(sagaId, stepIndex + 1, context); // step_index + JSONB context", + " statusHolder[0] = \"RUNNING\";", + " } catch (Exception e) {", + " markStatus(sagaId, \"COMPENSATING\"); statusHolder[0] = \"COMPENSATING\";", + " }", + " });", + " if (\"RUNNING\".equals(statusHolder[0])) advance(sagaId); // next step, new transaction", + " else if (\"COMPENSATING\".equals(statusHolder[0])) compensate(sagaId);", + "}" + ], + "cat": "code", + "_slide": 130, + "_src": "examples/17-sagas/spring-boot/saga-orchestrator/src/main/java/com/example/saga/service/SagaService.java", + "_amend": "S12 decision 6" + }, + { + "op": "set_text", + "sldId": 385, + "shape_id": 1440, + "cat": "code", + "_slide": 130, + "paragraphs": [ + "The row lock, the step's output and the new state commit together; a failed step hands off to compensate()." + ], + "_amend": "S12 decision 6" + }, + { + "op": "set_notes", + "sldId": 385, + "cat": "code", + "_slide": 130, + "paragraphs": [ + "The excerpt is advance() from SagaService in Example 17, condensed where it talks to Postgres: lockRow, saveProgress, logStep and markStatus stand for its JdbcClient calls, and the failed-step log row is left out.", + "STEPS is a list of SagaStep records pairing each forward step with its compensation (charge_payment with refund_payment, reserve_stock with release_stock, book_shipping with cancel_shipping), so the unwind order is data. advance() opens a TransactionTemplate, locks the saga row with SELECT ... FOR UPDATE and returns at once unless the status is RUNNING, so a redelivered request or a second worker finds a finished saga and does nothing.", + "Inside the transaction the step runs, its result goes into the JSONB context under the step's name (this is how book_shipping can read what reserve_stock produced), the log row is written, and step_index moves forward. The lock, the log and the new state commit together, the same rule as the outbox.", + "After the commit, advance() calls itself for the next step in a new transaction. A crash rolls back at most the step in flight, which runs again on resume, so every step must be idempotent. A thrown exception marks the saga COMPENSATING, and compensate() runs the completed steps' compensations in reverse before setting COMPENSATED.", + "Compared with Temporal: no extra server, but no built-in retries, timers or replay; the retry and resume logic is yours. The step executor in the example simulates the three services." + ], + "_amend": "S12 decision 6" + }, + { + "op": "replace", + "scope": "both", + "find": " and run Example 05 on minikube and OpenShift Local.", + "repl": " and run Example 05 on minikube.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Tell people the deep-dive appendices A to S (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube, OpenShift Local) are there to jump to.", + "repl": "Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "new", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'I need to get data from SFTP to Kafka to S3 with these transforms in the middle'", + "repl": "'I need to get data from SFTP to Kafka to an object store with these transforms in the middle'", + "expect": 1, + "sldIds": [ + 294 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "KMS for keys, CloudTrail / audit logging, region", + "repl": "KMS for keys, audit logging, region", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "'someone left an S3 bucket open'", + "repl": "'someone left a storage bucket open to the internet'", + "expect": 1, + "sldIds": [ + 328 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Cons: requires the storage service to validate tokens (S3, Azure Blob, GCS all do); ", + "repl": "Cons: requires the storage side to validate tokens; ", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "The canonical implementations: S3 pre-signed URLs (15-minute upload tokens for a specific bucket+key+method), Azure SAS tokens (Shared Access Signatures with per-resource scoping), GCS signed URLs, AWS STS AssumeRole with a tight scoping policy.", + "repl": "Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape.", + "expect": 1, + "sldIds": [ + 332 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD)", + "repl": "validates JWTs from a modern IdP (Keycloak, Dex)", + "expect": 1, + "sldIds": [ + 334 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Edge (IAP): Google IAP, Cloudflare Access, Pomerium, AWS Verified Access — ", + "repl": "Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — ", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Pomerium is open source; Google IAP / Cloudflare Access / AWS Verified Access are managed services.", + "repl": "Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too.", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "they authenticate against your IdP (Keycloak, Auth0, Azure AD, Google Workspace)", + "repl": "they authenticate against your IdP (Keycloak, or Dex federating an existing directory)", + "expect": 1, + "sldIds": [ + 336 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "into a secure object store (S3, Azure Blob, GCS) and publishes", + "repl": "into a secure object store and publishes", + "expect": 1, + "sldIds": [ + 341 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "Valet key requires storage-side validation — S3, Azure Blob, GCS all support it natively, so the cost is usually just understanding the SDK.", + "repl": "Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry.", + "expect": 1, + "sldIds": [ + 343 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "; SAS (Shared Access Signature, Azure's scoped token)", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "SAS is Azure's valet-key token; ", + "repl": "", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "(Kong, Apigee, Istio gateway, ", + "repl": "(Kong, Istio gateway, ", + "expect": 1, + "sldIds": [ + 431 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "AWS Builders' Library (timeouts, retries, backoff with jitter)", + "repl": "the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling)", + "expect": 1, + "sldIds": [ + 507 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace", + "scope": "both", + "find": "the AWS Builders' Library has excellent practical articles on timeouts and backoff-with-jitter;", + "repl": "the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol;", + "expect": 1, + "sldIds": [ + 507 + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 866, + "paragraphs": [ + "Valet key — scoped, time-bound, op-restricted" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 333, + "shape_id": 868, + "paragraphs": [ + "java · Spring Boot 4 + javax.crypto (HmacSHA256)" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 333, + "shape_id": 869, + "lines": [ + "// ValetKeyService.java (excerpt), examples/12-security/spring-boot: HMAC-SHA256 valet key", + "@Service", + "public class ValetKeyService {", + " private static final int TTL_SECONDS = 300; // time-bound", + " private final byte[] secret; // VALET_SECRET, injected through @Value", + " public Map mint(String resource, String operation) {", + " long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS;", + " String payload = resource + \":\" + operation + \":\" + expires; // one object, one op", + " Map result = new LinkedHashMap<>();", + " result.put(\"resource\", resource);", + " result.put(\"operation\", operation);", + " result.put(\"expires\", expires);", + " result.put(\"token\", hmac(payload)); // HmacSHA256, hex-encoded", + " return result;", + " }", + " public boolean verify(String resource, String operation, long expires, String token) {", + " if (System.currentTimeMillis() / 1000 > expires) return false; // expired", + " String expected = hmac(resource + \":\" + operation + \":\" + expires);", + " return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time", + " }", + "}", + "// OrderController maps POST /valet-key to mint() and GET /verify-valet to verify()" + ], + "cat": "code", + "_src": "examples/12-security/spring-boot/order-service/src/main/java/com/example/orderservice/ValetKeyService.java", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 333, + "paragraphs": [ + "The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (javax.crypto.Mac with HmacSHA256). POST /valet-key returns the resource, the operation, the expiry and the token.", + "The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with MessageDigest.isEqual, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403.", + "The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 342, + "shape_id": 968, + "paragraphs": [ + "java · Spring Boot 4 + Spring Kafka" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_code", + "sldId": 342, + "shape_id": 969, + "lines": [ + "// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key", + "@Service", + "public class ClaimPublisher {", + " private final ObjectStore store; // any encrypted object store", + " private final KafkaTemplate> kafka;", + " private final ValetKeyService valet; // examples/12-security", + " // constructor injection omitted", + " public void publish(String orderId, byte[] body) {", + " String key = \"payloads/\" + orderId;", + " store.put(key, body, true); // encrypted at rest, ACL'd", + " Map claim = new LinkedHashMap<>(valet.mint(key, \"GET\"));", + " claim.put(\"order_id\", orderId);", + " claim.put(\"uri\", \"https://objects.internal/\" + key);", + " kafka.send(\"order.documents\", orderId, claim); // only the claim on the topic", + " }", + "}", + "// consumer: read the claim, fetch the body out of band", + "@KafkaListener(topics = \"order.documents\", groupId = \"documents\")", + "public void onClaim(Map claim) {", + " byte[] body = store.get((String) claim.get(\"uri\"), (String) claim.get(\"token\"));", + " handle(body); // PII never touched the topic", + "}" + ], + "cat": "code", + "_src": "_docs/12-security.md claim-check codetab (site-fixes.md); ticket from examples/12-security", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 342, + "paragraphs": [ + "Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's ValetKeyService.mint (300 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with KafkaTemplate. The PII is never on Kafka's wire.", + "The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give.", + "Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 332, + "shape_id": 856, + "png": "lgtm-presentation/edits/png/12-valet-key.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "labels named S3, Azure SAS, GCS, STS (tools/vendor_figures.py)" + }, + { + "op": "replace_picture", + "sldId": 335, + "shape_id": 889, + "png": "lgtm-presentation/edits/png/12-zero-trust.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "examples named Google IAP, Cloudflare Access, AWS Verified Access" + }, + { + "op": "replace_picture", + "sldId": 431, + "shape_id": 1936, + "png": "lgtm-presentation/edits/png/22-l7-layers.png", + "cat": "stack", + "_vendor": "S12 decision 7", + "_why": "API gateway row named Apigee; site figure as in the other decks" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 30, + "paragraphs": [ + "07 · Workflows & Jobs", + "08 · API Management", + "09 · API Registry", + "10 · API Metadata", + "11 · Observability", + "12 · Security", + "13 · Anti-Patterns", + "Appendices A–D · Protocols, Versioning, WebSockets, Sagas", + "Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing", + "Appendices K–L · Monolith → Microservices, Caching Patterns", + "Appendices M–O · Failure modes, Feature Flags, API Testing", + "Appendix R · Example 05 on minikube", + "Glossary of acronyms" + ], + "cat": "new", + "_why": "agenda gains Appendices R-S (C++: R)" + }, + { + "op": "set_notes", + "sldId": 257, + "paragraphs": [ + "The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns.", + "The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck.", + "Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 520, + "as": "r0", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 3, + "paragraphs": [ + "R" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 4, + "paragraphs": [ + "Example 05 on minikube" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r0", + "shape_id": 5, + "paragraphs": [ + "The same services and the same verify.sh on a single-node Kubernetes cluster" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r0", + "paragraphs": [ + "Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod.", + "The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 531, + "as": "r1", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r1", + "text": "248", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 5, + "paragraphs": [ + "From compose to a cluster" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 6, + "paragraphs": [ + "Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress.", + "Scope: Example 05 (event-driven fan-out), three services, all six languages, one node.", + "One Kafka broker, one Postgres instance, no service mesh: a local topology, not production.", + "The contract carries over: Service names, the Secret, environment variables, the health endpoint.", + "The same verify.sh runs against compose and the cluster: ten checks per language." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r1", + "shape_id": 7, + "paragraphs": [ + "Site: Appendix R · Code: examples/05-event-driven/k8s/" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r1", + "paragraphs": [ + "What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it.", + "What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged.", + "The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 531, + "as": "r2", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r2", + "text": "249", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 5, + "paragraphs": [ + "The minikube runtime" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 6, + "paragraphs": [ + "Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5.", + "8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services.", + "NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation.", + "No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop.", + "Every command passes --context cndp-k8s; setup-profile.sh restores the prior context." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r2", + "shape_id": 7, + "paragraphs": [ + "minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r2", + "paragraphs": [ + "minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version.", + "Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1.", + "The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 529, + "as": "r3", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r3", + "text": "250", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 5, + "paragraphs": [ + "One node, two namespaces" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r3", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-topology.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r3", + "shape_id": 7, + "paragraphs": [ + "The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r3", + "paragraphs": [ + "The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret.", + "From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 531, + "as": "r4", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r4", + "text": "251", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 5, + "paragraphs": [ + "Compose to Kubernetes" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 6, + "paragraphs": [ + "kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1).", + "postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw.", + "Credential → Secret cndp-postgres-app, read through secretKeyRef.", + "build: → docker build, then minikube image load; imagePullPolicy: Never.", + "healthcheck → startup and readiness probes on the order-service /healthz.", + "ports: 8080:8080 → NodePort 30080." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r4", + "shape_id": 7, + "paragraphs": [ + "The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r4", + "paragraphs": [ + "Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts.", + "The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r5", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r5", + "text": "252", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 5, + "paragraphs": [ + "One overlay per language" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 6, + "paragraphs": [ + "yaml · kustomize" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r5", + "shape_id": 8, + "lines": [ + "# k8s/overlays/spring-boot/kustomization.yaml (excerpt)", + "images:", + " - name: order-service", + " newName: localhost/cndp-05/spring-boot-order-service", + " newTag: v1", + " # shipping-consumer and notification-consumer map the same way", + "# appended to each Deployment's environment by a JSON patch", + "- name: SPRING_DATASOURCE_URL", + " value: \"jdbc:postgresql://cndp-postgres-rw:5432/appdb\"", + "- name: SPRING_DATASOURCE_USERNAME", + " value: \"$(PG_USER)\"", + "- name: SPRING_DATASOURCE_PASSWORD", + " value: \"$(PG_PASSWORD)\"", + "- name: SPRING_KAFKA_BOOTSTRAP_SERVERS", + " value: \"cndp-kafka-bootstrap:9092\"" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r5", + "shape_id": 9, + "paragraphs": [ + "base/ is language-neutral; each overlay maps the images and appends the variables its language reads." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r5", + "paragraphs": [ + "The Spring Boot overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service.", + "Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 529, + "as": "r6", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r6", + "text": "253", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 5, + "paragraphs": [ + "Images built on the host" + ], + "cat": "new" + }, + { + "op": "replace_picture", + "sldId": "$r6", + "shape_id": 6, + "png": "lgtm-presentation/edits/png/31-minikube-image-flow.png", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r6", + "shape_id": 7, + "paragraphs": [ + "docker build on the host, minikube image load into the node; pods never pull" + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r6", + "paragraphs": [ + "The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load.", + "The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 525, + "as": "r7", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r7", + "text": "254", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 5, + "paragraphs": [ + "Run it" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 6, + "paragraphs": [ + "bash · minikube" + ], + "cat": "new" + }, + { + "op": "set_code", + "sldId": "$r7", + "shape_id": 8, + "lines": [ + "# from examples/05-event-driven/, with no other profile or container running", + "k8s/setup-profile.sh # create cndp-k8s; check the published ports", + "k8s/build-images.sh spring-boot# docker build, then minikube image load", + "k8s/deploy.sh spring-boot # Strimzi, Kafka, Postgres, LGTM, then the overlay", + "RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh", + "", + "# all six languages, a fresh namespace for each", + "OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh", + "", + "# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile", + "k8s/teardown.sh --operator", + "minikube stop -p cndp-k8s" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r7", + "shape_id": 9, + "paragraphs": [ + "With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r7", + "paragraphs": [ + "The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Spring Boot images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay.", + "verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0.", + "What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results that follow. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images." + ], + "cat": "new" + }, + { + "op": "clone_slide", + "template": 531, + "as": "r8", + "cat": "new" + }, + { + "op": "set_page_number", + "sldId": "$r8", + "text": "255", + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 4, + "paragraphs": [ + "APPENDIX R · MINIKUBE" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 5, + "paragraphs": [ + "Recorded results" + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 6, + "paragraphs": [ + "10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0).", + "Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s.", + "Tempo received traces from order-service, shipping-consumer and notification-consumer in every language.", + "shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset.", + "Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them." + ], + "cat": "new" + }, + { + "op": "set_text", + "sldId": "$r8", + "shape_id": 7, + "paragraphs": [ + "Times cover the image build and load, the deploy, the checks and the namespace teardown." + ], + "cat": "new" + }, + { + "op": "set_notes", + "sldId": "$r8", + "paragraphs": [ + "The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language.", + "The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster." + ], + "cat": "new" + }, + { + "op": "resize_shape", + "sldId": 257, + "shape_id": 30, + "y": 1589592, + "cy": 4688780, + "cat": "fit", + "_fit": "slide 2: text overflows its frame by overflow-bottom 13.4 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 269, + "shape_id": 155, + "cx": 2595849, + "keep": "right", + "cat": "fit", + "_fit": "slide 14: chip 'java · Spring Boot 4 + Actuator' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 272, + "shape_id": 189, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 17: chip 'java · Spring Boot 4 + Spring Web' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 272, + "shape_id": 190, + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 17: 25 code lines, 81 columns; 11.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 273, + "shape_id": 202, + "cx": 3510250, + "keep": "right", + "cat": "fit", + "_fit": "slide 18: chip 'protobuf + java · Spring Boot 4 + grpc-java' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 279, + "shape_id": 271, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 24: chip 'java · Spring for GraphQL' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 284, + "shape_id": 327, + "cx": 4272250, + "keep": "right", + "cat": "fit", + "_fit": "slide 29: chip 'java · Spring Boot 4 + Spring Data JPA + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 287, + "shape_id": 361, + "cx": 3891250, + "keep": "right", + "cat": "fit", + "_fit": "slide 32: chip 'java · Spring Boot 4 + JdbcClient + Debezium CDC' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 291, + "shape_id": 406, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 36: chip 'java · Spring Boot 4 + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 291, + "shape_id": 407, + "pt": 10.5, + "cat": "fit", + "_fit": "slide 36: 21 code lines, 80 columns; 11.5 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 298, + "shape_id": 484, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 43: chip 'java · Spring Kafka Streams' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 310, + "shape_id": 620, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 55: chip 'xml · Maven + Apicurio' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 319, + "shape_id": 716, + "cx": 3815050, + "keep": "right", + "cat": "fit", + "_fit": "slide 64: chip 'java · Spring Boot 4 + OpenTelemetry Java agent' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 321, + "shape_id": 740, + "cx": 3281650, + "keep": "right", + "cat": "fit", + "_fit": "slide 66: chip 'java · Spring Boot 4 + OpenTelemetry API' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 324, + "shape_id": 774, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 69: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 328, + "shape_id": 815, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 73: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 329, + "shape_id": 825, + "cy": 4597340, + "cat": "fit", + "_fit": "slide 74: text overflows its frame by overflow-bottom 6.2 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 331, + "shape_id": 846, + "cy": 4607500, + "cat": "fit", + "_fit": "slide 76: text overflows its frame by overflow-bottom 7.0 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 333, + "shape_id": 868, + "cx": 3891250, + "keep": "right", + "cat": "fit", + "_fit": "slide 78: chip 'java · Spring Boot 4 + javax.crypto (HmacSHA256)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 338, + "shape_id": 922, + "cx": 3357849, + "keep": "right", + "cat": "fit", + "_fit": "slide 83: chip 'yaml · OPA Gatekeeper (language-neutral)' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 340, + "shape_id": 945, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 85: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 342, + "shape_id": 968, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 87: chip 'java · Spring Boot 4 + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 361, + "shape_id": 1164, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 106: chip 'java · Spring Boot 4 + Spring Web' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 365, + "shape_id": 1212, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 110: chip 'graphql + java · Spring for GraphQL' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 368, + "shape_id": 1245, + "cx": 4196050, + "keep": "right", + "cat": "fit", + "_fit": "slide 113: chip 'java · Spring Boot 4 + spring-boot-starter-websocket' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 368, + "shape_id": 1246, + "pt": 9.5, + "cat": "fit", + "_fit": "slide 113: 23 code lines, 85 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 372, + "shape_id": 1290, + "cx": 2824450, + "keep": "right", + "cat": "fit", + "_fit": "slide 117: chip 'protobuf + java · Spring WebSocket' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 372, + "shape_id": 1291, + "pt": 10.5, + "cat": "fit", + "_fit": "slide 117: 21 code lines, 87 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 377, + "shape_id": 1345, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 122: chip 'java · Spring Data JPA' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 379, + "shape_id": 1369, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 124: chip 'java · Spring Data JPA' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 383, + "shape_id": 1414, + "cx": 3662650, + "keep": "right", + "cat": "fit", + "_fit": "slide 128: chip 'java · io.temporal:temporal-sdk + Spring Boot' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 385, + "shape_id": 1438, + "cx": 2748250, + "keep": "right", + "cat": "fit", + "_fit": "slide 130: chip 'java · Spring Boot 4 + JdbcClient' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 389, + "shape_id": 1483, + "cx": 3662650, + "keep": "right", + "cat": "fit", + "_fit": "slide 134: chip 'java · Spring Boot 4 + Spring 7 ProblemDetail' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 391, + "shape_id": 1507, + "cx": 2519650, + "keep": "right", + "cat": "fit", + "_fit": "slide 136: chip 'java · grpc-java + StatusProto' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 393, + "shape_id": 1531, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 138: chip 'java · Spring Boot 4 + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 393, + "shape_id": 1532, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 138: 24 code lines, 90 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 395, + "shape_id": 1555, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 140: chip 'java · Spring for GraphQL' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 405, + "shape_id": 1662, + "cx": 3129250, + "keep": "right", + "cat": "fit", + "_fit": "slide 150: chip 'java · ports & adapters in Spring Boot' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 405, + "shape_id": 1664, + "cy": 436820, + "cat": "fit", + "_fit": "slide 150: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 424, + "shape_id": 1865, + "cy": 436820, + "cat": "fit", + "_fit": "slide 169: text overflows its frame by overflow-bottom 2.6 pt; free space around the frame" + }, + { + "op": "fit_font", + "sldId": 435, + "shape_id": 1983, + "pt": 11.0, + "cat": "fit", + "_fit": "slide 180: 20 code lines, 70 columns; 13.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 436, + "shape_id": 1995, + "cx": 2291050, + "keep": "right", + "cat": "fit", + "_fit": "slide 181: chip 'yaml · Istio VirtualService' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 441, + "shape_id": 2051, + "pt": 10.0, + "cat": "fit", + "_fit": "slide 186: 22 code lines, 73 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 442, + "shape_id": 2063, + "cx": 2138650, + "keep": "right", + "cat": "fit", + "_fit": "slide 187: chip 'yaml · Istio header-based' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 447, + "shape_id": 2120, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 192: chip 'java · Spring Boot 4 + Apache Camel' wraps; one line at 10.0 pt" + }, + { + "op": "fit_font", + "sldId": 448, + "shape_id": 2134, + "pt": 9.0, + "cat": "fit", + "_fit": "slide 193: 24 code lines, 70 columns; 11.0 pt overflows the box" + }, + { + "op": "resize_shape", + "sldId": 453, + "shape_id": 2188, + "cx": 1910050, + "keep": "right", + "cat": "fit", + "_fit": "slide 198: chip 'yaml · Istio east/west' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 465, + "shape_id": 2318, + "cx": 3738850, + "keep": "right", + "cat": "fit", + "_fit": "slide 210: chip 'java · Spring Boot 4 + Spring Web + RestClient' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 467, + "shape_id": 2342, + "cx": 5415250, + "keep": "right", + "cat": "fit", + "_fit": "slide 212: chip 'java · Spring Boot 4 + RestClient + Spring Data Redis + Spring Kafka' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 468, + "shape_id": 2354, + "cy": 4568130, + "cat": "fit", + "_fit": "slide 213: text overflows its frame by overflow-bottom 3.9 pt; free space around the frame" + }, + { + "op": "resize_shape", + "sldId": 472, + "shape_id": 2396, + "cx": 4653250, + "keep": "right", + "cat": "fit", + "_fit": "slide 217: chip 'java · Spring Boot 4 + Spring Data JPA + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 474, + "shape_id": 2420, + "cx": 4424650, + "keep": "right", + "cat": "fit", + "_fit": "slide 219: chip 'java · Spring Boot 4 + Spring Cache + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 476, + "shape_id": 2444, + "cx": 5491450, + "keep": "right", + "cat": "fit", + "_fit": "slide 221: chip 'java · Spring Cache + Spring Data Redis + @TransactionalEventListener' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 478, + "shape_id": 2468, + "cx": 4424650, + "keep": "right", + "cat": "fit", + "_fit": "slide 223: chip 'java · Spring Boot 4 + Spring Cache + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 480, + "shape_id": 2492, + "cx": 5567650, + "keep": "right", + "cat": "fit", + "_fit": "slide 225: chip 'java · Spring Boot 4 + @Scheduled + ShedLock + Spring Data Redis + JPA' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 482, + "shape_id": 2516, + "cx": 5110450, + "keep": "right", + "cat": "fit", + "_fit": "slide 227: chip 'java · Spring Boot 4 + @Scheduled + ShedLock + Spring Data Redis' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 496, + "shape_id": 2667, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 241: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 498, + "shape_id": 2690, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 243: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 500, + "shape_id": 2713, + "cx": 2900650, + "keep": "right", + "cat": "fit", + "_fit": "slide 245: chip 'java · Spring Boot 4 + Resilience4j' wraps; one line at 10.0 pt" + }, + { + "op": "resize_shape", + "sldId": 504, + "shape_id": 2756, + "cx": 2595849, + "keep": "right", + "cat": "fit", + "_fit": "slide 249: chip 'java · Spring Boot 4 + Actuator' wraps; one line at 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 269, + "shape_id": 156, + "cuts": [ + { + "from": 7, + "to": 7, + "first": "" + }, + { + "from": 22, + "to": 22, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 14: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 11.0 pt" + }, + { + "op": "cut_code", + "sldId": 284, + "shape_id": 328, + "cuts": [ + { + "from": 1, + "to": 1, + "first": "// Write side — command handler" + }, + { + "from": 4, + "to": 5, + "first": " private final OrderRepository writes;", + "last": " private final KafkaTemplate kafka;", + "ellipsis": " // ... final fields: OrderRepository writes, KafkaTemplate kafka" + }, + { + "from": 14, + "to": 14, + "first": "// Read side — query handler reads from a DIFFERENT, denormalised store" + }, + { + "from": 24, + "to": 24, + "first": "// Projection — consumes events, upserts the read model" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 29: 31 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 8.5 pt" + }, + { + "op": "cut_code", + "sldId": 338, + "shape_id": 923, + "cuts": [ + { + "from": 7, + "to": 13, + "first": " crd:", + "last": " allowedRegistries: { type: array, items: { type: string } }", + "ellipsis": " # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 83: 33 code lines do not fit the box at 9 pt; excerpt of 27 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 340, + "shape_id": 946, + "cuts": [ + { + "from": 5, + "to": 5, + "first": "" + }, + { + "from": 6, + "to": 9, + "first": " public OrderService(PaymentClient payments, BulkheadRegistry registry) {", + "last": " }", + "ellipsis": " // ... constructor injects PaymentClient and the BulkheadRegistry" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 22, + "to": 22, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 85: 31 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt, was 8.0 pt" + }, + { + "op": "cut_code", + "sldId": 377, + "shape_id": 1346, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 11, + "to": 11, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 122: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 11.0 pt" + }, + { + "op": "cut_code", + "sldId": 383, + "shape_id": 1415, + "cuts": [ + { + "from": 4, + "to": 7, + "first": "@ActivityInterface", + "last": "public interface ShippingActivity { String book(SagaInput in, String reservationId); }", + "ellipsis": "// ... InventoryActivity.reserve and ShippingActivity.book are declared the same way" + }, + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 12, + "to": 12, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 128: 31 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.0 pt" + }, + { + "op": "cut_code", + "sldId": 389, + "shape_id": 1484, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 134: 26 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 11.0 pt" + }, + { + "op": "cut_code", + "sldId": 395, + "shape_id": 1556, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// Spring for GraphQL: implement DataFetcherExceptionResolver to map exceptions", + "last": "// the rest of the data still returns." + }, + { + "from": 15, + "to": 18, + "first": " .extensions(Map.of(", + "last": " \"traceId\", Span.current().getSpanContext().getTraceId()))", + "ellipsis": " // ... .extensions(Map.of(code, retryable, traceId))" + }, + { + "from": 24, + "to": 24, + "first": "" + }, + { + "from": 29, + "to": 32, + "first": " try { return inventory.remaining(order.sku()); }", + "last": " }", + "ellipsis": " // ... calls inventory; on Unavailable throws InventoryUnavailable (nulls the field)" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 140: 34 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 405, + "shape_id": 1663, + "cuts": [ + { + "from": 4, + "to": 4, + "first": "" + }, + { + "from": 9, + "to": 11, + "first": " public PlaceOrder(OrderRepository r, EventPublisher e) {", + "last": " }", + "ellipsis": " // ... constructor injection: no framework leak" + }, + { + "from": 19, + "to": 19, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 150: 29 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 423, + "shape_id": 1851, + "cuts": [ + { + "from": 13, + "to": 13, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 168: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 453, + "shape_id": 2189, + "cuts": [ + { + "from": 7, + "to": 8, + "first": " connectionPool:", + "last": " http: { http2MaxRequests: 1000, maxRequestsPerConnection: 10 }", + "ellipsis": " # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10" + }, + { + "from": 13, + "to": 14, + "first": " loadBalancer:", + "last": " localityLbSetting: { enabled: true } # prefer same zone", + "ellipsis": " # ... loadBalancer: localityLbSetting enabled (prefer same zone)" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 198: 27 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 465, + "shape_id": 2319, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 10, + "to": 10, + "first": "" + }, + { + "from": 11, + "to": 15, + "first": " public OrderRouter(RestClient.Builder builder) {", + "last": " }", + "ellipsis": " // ... the constructor builds the monolith, orders and orders-eu RestClients" + }, + { + "from": 16, + "to": 16, + "first": "" + } + ], + "pt": 9.0, + "line_spacing": 1.0, + "cat": "fit", + "_fit": "slide 210: 32 code lines do not fit the box at 9 pt; excerpt of 25 lines at 9.0 pt (line spacing 1.0), was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 467, + "shape_id": 2343, + "cuts": [ + { + "from": 1, + "to": 5, + "first": "// Spring REST that wraps a legacy /orders API and adds:", + "last": "" + }, + { + "from": 9, + "to": 21, + "first": " private final RestClient legacy;", + "last": "", + "ellipsis": " // ... fields and constructor: RestClient legacy, RedisTemplate cache, KafkaTemplate kafka, TTL 60 s" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 212: 41 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "cut_code", + "sldId": 472, + "shape_id": 2397, + "cuts": [ + { + "from": 2, + "to": 2, + "first": "" + }, + { + "from": 6, + "to": 13, + "first": " private static final Duration TTL = Duration.ofSeconds(60);", + "last": " }", + "ellipsis": " // ... TTL 60 s; ProductRepository repo and RedisTemplate cache injected" + }, + { + "from": 14, + "to": 14, + "first": "" + }, + { + "from": 25, + "to": 25, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 217: 34 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 10.0 pt" + }, + { + "op": "cut_code", + "sldId": 476, + "shape_id": 2445, + "cuts": [ + { + "from": 3, + "to": 3, + "first": "" + }, + { + "from": 8, + "to": 8, + "first": "" + }, + { + "from": 17, + "to": 17, + "first": "" + }, + { + "from": 21, + "to": 33, + "first": " private final ProductRepository repo;", + "last": "", + "ellipsis": " // ... fields repo, cache, events; @Transactional update() saves, then publishes ProductUpdatedEvent" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 221: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "cut_code", + "sldId": 480, + "shape_id": 2493, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// @EnableScheduling on a @Configuration, then @Scheduled methods run on a", + "last": "" + }, + { + "from": 7, + "to": 15, + "first": " private static final int BATCH = 100;", + "last": "", + "ellipsis": " // ... BATCH = 100; RedisTemplate cache and MetricRepository repo injected" + }, + { + "from": 23, + "to": 23, + "first": "" + }, + { + "from": 30, + "to": 33, + "first": " List rows = ids.stream()", + "last": " .toList();", + "ellipsis": " // ... rows = the ids mapped through loadFromCache, nulls dropped" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 225: 39 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.5 pt" + }, + { + "op": "cut_code", + "sldId": 482, + "shape_id": 2517, + "cuts": [ + { + "from": 1, + "to": 3, + "first": "// A @Scheduled task refreshes a hot-keys sorted-set before TTL.", + "last": "" + }, + { + "from": 6, + "to": 17, + "first": " private static final Duration TTL = Duration.ofSeconds(60);", + "last": "", + "ellipsis": " // ... TTL 60 s, REFRESH_BEFORE_S 10; repo, cache and hot (RedisTemplate) injected" + }, + { + "from": 20, + "to": 24, + "first": " Product cached = cache.opsForValue().get(\"product:\" + id);", + "last": " return p;", + "ellipsis": " // ... then cache-aside on product:{id} with TTL" + }, + { + "from": 26, + "to": 26, + "first": "" + } + ], + "pt": 9.0, + "cat": "fit", + "_fit": "slide 227: 43 code lines do not fit the box at 9 pt; excerpt of 24 lines at 9.0 pt, was 9.0 pt" + }, + { + "op": "fit_font", + "sldId": 343, + "shape_id": 979, + "pt": 15, + "cat": "fit", + "_fit": "'Choosing the right pattern': eight two-line bullets run 35 pt past the frame into the page number and logo; the frame cannot grow" + }, + { + "op": "fit_font", + "sldId": 349, + "shape_id": 1040, + "pt": 14, + "space_before": 6, + "cat": "fit", + "_fit": "references: the reference lines run past the frame into the page number; the frame cannot grow" + }, + { + "op": "replace", + "scope": "slide", + "find": "Glossary of acronyms (2 of 2) — platform, security, observability", + "repl": "Glossary of acronyms (2 of 2) — platform, security, telemetry", + "expect": 1, + "sldIds": [ + 352 + ], + "cat": "fit", + "_fit": "title wraps to two lines and collides with the eyebrow; 'observability' -> 'telemetry' keeps it on one line" + }, + { + "op": "fit_font", + "sldId": 483, + "shape_id": 2528, + "pt": 16, + "cat": "fit", + "_fit": "caching pitfalls: six long bullets run 15 pt past the frame; the frame cannot grow (page number below)" + }, + { + "op": "replace", + "scope": "slide", + "find": "Temporal: your saga is plain Java, the Server makes it crash-proof", + "repl": "Temporal: a plain-Java saga that the Server makes durable", + "expect": 1, + "sldIds": [ + 382 + ], + "cat": "fit", + "_fit": "title wraps to two lines" + }, + { + "op": "replace", + "scope": "slide", + "find": "The hand-rolled orchestrator: one row, one log, no extra cluster", + "repl": "A hand-rolled orchestrator: one row, one log, no cluster", + "expect": 1, + "sldIds": [ + 384 + ], + "cat": "fit", + "_fit": "title wraps to two lines" + } + ] +} diff --git a/lgtm-presentation/edits/K8s-Environment-Access-Policies.json b/lgtm-presentation/edits/K8s-Environment-Access-Policies.json new file mode 100644 index 00000000..bc2c7410 --- /dev/null +++ b/lgtm-presentation/edits/K8s-Environment-Access-Policies.json @@ -0,0 +1,292 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "notes", + "find": "This deck covers environment access governance for Kubernetes — the policies, guardrails, and automation that separate a cluster where developers can safely experiment from one where a single misconfigured kubectl command wipes production.", + "repl": "The subject is environment access governance for Kubernetes: the policies, guardrails, and automation that separate a cluster where developers can safely experiment from one where a single misconfigured kubectl command wipes production.", + "expect": 1, + "sldIds": [ + 256 + ], + "cat": "voice", + "_slide": 1 + }, + { + "op": "replace", + "scope": "slide", + "find": "The fix is a layered policy model — RBAC, NetworkPolicy, admission control, and GitOps — applied differently per environment.", + "repl": "The fix is a layered policy model (RBAC, NetworkPolicy, admission control, and GitOps) applied differently per environment.", + "expect": 1, + "sldIds": [ + 257 + ], + "cat": "voice", + "_slide": 2 + }, + { + "op": "replace", + "scope": "notes", + "find": "The fix is not one tool but a layered policy model — RBAC, NetworkPolicy, admission control, and GitOps — applied with increasing strictness as you move from dev through test to production.", + "repl": "The fix is not one tool but a layered policy model (RBAC, NetworkPolicy, admission control, and GitOps) applied with increasing strictness from dev through test to production.", + "expect": 1, + "sldIds": [ + 257 + ], + "cat": "voice", + "_slide": 2 + }, + { + "op": "replace", + "scope": "slide", + "find": "Three environments, three risk profiles", + "repl": "Risk profiles by environment", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "voice", + "_slide": 3 + }, + { + "op": "replace", + "scope": "notes", + "find": "This gradient drives every policy decision in the rest of the deck.", + "repl": "This gradient drives every policy decision in the model.", + "expect": 1, + "sldIds": [ + 258 + ], + "cat": "voice", + "_slide": 3 + }, + { + "op": "replace", + "scope": "notes", + "find": "Emphasize that RBAC is the foundation — it controls who can act — but it is not sufficient alone.", + "repl": "RBAC is the foundation because it controls who can act, but it is not sufficient alone.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "voice", + "_slide": 4 + }, + { + "op": "replace", + "scope": "notes", + "find": "The next slide maps these verbs to specific environments.", + "repl": "The environment-specific RBAC matrix maps these verbs to each environment.", + "expect": 1, + "sldIds": [ + 259 + ], + "cat": "positional", + "_slide": 4 + }, + { + "op": "replace", + "scope": "slide", + "find": "CI validates (kubeval, conftest)", + "repl": "CI validates (kubeconform, conftest)", + "expect": 1, + "sldIds": [ + 264 + ], + "cat": "stack", + "_slide": 9 + }, + { + "op": "replace", + "scope": "notes", + "find": "CI validates — kubeval checks YAML schema, conftest runs OPA policies, a staging deployment proves the change does not break.", + "repl": "CI validates: kubeconform v0.8.0 checks the manifests against the Kubernetes and CRD schemas, conftest runs OPA policies, and a staging deployment proves the change does not break.", + "expect": 1, + "sldIds": [ + 264 + ], + "cat": "stack", + "_slide": 9 + }, + { + "op": "replace", + "scope": "notes", + "find": "The API gateway layer — Kong, Ambassador, Envoy Gateway — handles authentication, rate limiting, and API key validation.", + "repl": "The API gateway layer (Kong, Ambassador, Envoy Gateway) handles authentication, rate limiting, and API key validation.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice", + "_slide": 10 + }, + { + "op": "replace", + "scope": "notes", + "find": "The service mesh layer — Istio with Envoy sidecars — provides mTLS encryption, AuthorizationPolicy for identity-based access control, circuit breaking, and retry budgets.", + "repl": "The service mesh layer (Istio with Envoy sidecars) provides mTLS encryption, AuthorizationPolicy for identity-based access control, circuit breaking, and retry budgets.", + "expect": 1, + "sldIds": [ + 265 + ], + "cat": "voice", + "_slide": 10 + }, + { + "op": "replace", + "scope": "notes", + "find": "This is particularly powerful in production where you want to guarantee that services can only communicate along defined paths, regardless of what the application code attempts.", + "repl": "In production this guarantees that services communicate only along defined paths, regardless of what the application code attempts.", + "expect": 1, + "sldIds": [ + 266 + ], + "cat": "voice", + "_slide": 11 + }, + { + "op": "replace", + "scope": "slide", + "find": "Schema validation (kubeval)", + "repl": "Schema validation (kubeconform)", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "stack", + "_slide": 14 + }, + { + "op": "replace", + "scope": "notes", + "find": "Every policy change — NetworkPolicy, RBAC Role, OPA Constraint, ResourceQuota — flows through the same validation pipeline before reaching any cluster.", + "repl": "Every policy change (NetworkPolicy, RBAC Role, OPA Constraint, ResourceQuota) flows through the same validation pipeline before reaching any cluster.", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "voice", + "_slide": 14 + }, + { + "op": "replace", + "scope": "notes", + "find": "schema validation with kubeval or kubeconform catches invalid YAML and wrong API versions.", + "repl": "schema validation with kubeconform v0.8.0 catches invalid YAML, unknown fields and wrong API versions, including custom resources through the CRDs-catalog schemas.", + "expect": 1, + "sldIds": [ + 269 + ], + "cat": "stack", + "_slide": 14 + }, + { + "op": "replace", + "scope": "notes", + "find": "OPA decision logs record every admission decision — allow or deny — with the policy that matched and the object that triggered it.", + "repl": "OPA decision logs record every admission decision, allow or deny, with the policy that matched and the object that triggered it.", + "expect": 1, + "sldIds": [ + 270 + ], + "cat": "voice", + "_slide": 15 + }, + { + "op": "replace", + "scope": "slide", + "find": "ArgoCD — argo-cd.readthedocs.io · Flux — fluxcd.io", + "repl": "ArgoCD: argo-cd.readthedocs.io · Flux: fluxcd.io", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "voice", + "_slide": 18 + }, + { + "op": "replace", + "scope": "notes", + "find": "Pod Security Standards define the three levels — privileged, baseline, restricted — that replace the deprecated PodSecurityPolicy.", + "repl": "Pod Security Standards define three levels (privileged, baseline, restricted) and replace PodSecurityPolicy, which was removed in Kubernetes 1.25.", + "expect": 1, + "sldIds": [ + 273 + ], + "cat": "voice", + "_slide": 18 + }, + { + "op": "replace_picture", + "sldId": 258, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-policy-env-rbac.png", + "cat": "stack", + "_slide": 3, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + }, + { + "op": "replace_picture", + "sldId": 262, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-policy-namespace-isolation.png", + "cat": "stack", + "_slide": 7, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + }, + { + "op": "replace_picture", + "sldId": 263, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-12-policy-as-code.png", + "cat": "stack", + "_slide": 8, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + }, + { + "op": "replace_picture", + "sldId": 264, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-policy-gitops-flow.png", + "cat": "stack", + "_slide": 9, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + }, + { + "op": "replace_picture", + "sldId": 265, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-22-l7-layers.png", + "cat": "stack", + "_slide": 10, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + }, + { + "op": "replace_picture", + "sldId": 267, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-22-traffic-steering.png", + "cat": "stack", + "_slide": 12, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + }, + { + "op": "replace_picture", + "sldId": 268, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-policy-namespace-isolation.png", + "cat": "stack", + "_slide": 13, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + }, + { + "op": "replace_picture", + "sldId": 269, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/k8s-policy-ci-pipeline.png", + "cat": "stack", + "_slide": 14, + "_why": "re-rendered from the current assets/diagrams source: kubeconform instead of kubeval, arrows as ASCII, S11 label wording" + } + ] +} diff --git a/lgtm-presentation/edits/Network-Policy-Process-Advisory.fix1.json b/lgtm-presentation/edits/Network-Policy-Process-Advisory.fix1.json new file mode 100644 index 00000000..22e3ceed --- /dev/null +++ b/lgtm-presentation/edits/Network-Policy-Process-Advisory.fix1.json @@ -0,0 +1,181 @@ +[ + { + "op": "resize_shape", + "sldId": 257, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 258, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 259, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 260, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 261, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 262, + "shape_id": 7, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 263, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 264, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 265, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 266, + "shape_id": 7, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 267, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 268, + "shape_id": 7, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 269, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 270, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 271, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 272, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 273, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 274, + "shape_id": 6, + "cx": 914400 + }, + { + "op": "resize_shape", + "sldId": 275, + "shape_id": 5, + "cx": 914400 + }, + { + "op": "set_geometry", + "sldId": 262, + "shape": "Picture 4", + "x": 0.62, + "y": 3.3, + "w": 12.09, + "h": 3.05 + }, + { + "op": "replace_picture", + "sldId": 262, + "shape": "Picture 4", + "png": "lgtm-presentation/edits/png/net-advisory-tier-evaluation.png" + }, + { + "op": "set_geometry", + "sldId": 266, + "shape": "Picture 4", + "x": 0.62, + "y": 3.3, + "w": 12.09, + "h": 3.05 + }, + { + "op": "replace_picture", + "sldId": 266, + "shape": "Picture 4", + "png": "lgtm-presentation/edits/png/net-advisory-ci-pipeline.png" + }, + { + "op": "set_geometry", + "sldId": 268, + "shape": "Picture 4", + "x": 0.62, + "y": 3.3, + "w": 12.09, + "h": 3.05 + }, + { + "op": "replace_picture", + "sldId": 268, + "shape": "Picture 4", + "png": "lgtm-presentation/edits/png/net-advisory-team-ownership.png" + }, + { + "op": "replace", + "scope": "slide", + "sldIds": [ + 262 + ], + "find": "Only evaluated if the Admin tier passed the traffic.", + "repl": "Evaluated unless an Admin rule accepted or denied it.", + "expect": 1 + }, + { + "op": "replace", + "scope": "notes", + "sldIds": [ + 262 + ], + "find": "it catches traffic that the Admin tier passed and no NetworkPolicy matched.", + "repl": "it decides traffic that no Admin rule accepted or denied and that no NetworkPolicy covers.", + "expect": 1 + } +] \ No newline at end of file diff --git a/lgtm-presentation/edits/Network-Policy-Process-Advisory.json b/lgtm-presentation/edits/Network-Policy-Process-Advisory.json new file mode 100644 index 00000000..ddc7f8de --- /dev/null +++ b/lgtm-presentation/edits/Network-Policy-Process-Advisory.json @@ -0,0 +1,597 @@ +{ + "ops": [ + { + "op": "set_text", + "sldId": 256, + "shape_id": 3, + "paragraphs": [ + "Network Policy Process", + "Advisory — Shifting from perimeter to in-cluster segmentation." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 256, + "paragraphs": [ + "Welcome the audience and set context. This deck presents a recommended approach for operationalizing in-cluster network segmentation on Kubernetes, with upstream APIs and open-source tools. The starting point is common: perimeter and east-west controls run through the firewall team on a traditional appliance, there is no in-cluster network policy, and developers have never written a firewall rule. The goal is not to pick tools or assign owners; it is to describe a process and a maturity path that the organization can adopt at its own pace. We will walk through where organizations typically start, how to handle the developer skill gap, the CI/CD flow for policy changes, repo structure, day-two operations, the firewall team's evolving role, and what maturity looks like. Ask the audience: do you have a documented process for who authors, reviews, and approves network policies in your clusters today? The answer usually reveals the gap this deck fills." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 4, + "paragraphs": [ + "Perimeter and east-west controls run through the firewall team on a traditional appliance today.", + "The organization wants to shift network segmentation left into the cluster (NetworkPolicy, ClusterNetworkPolicy, egress rules) but has not defined the workflow.", + "Developers are not security specialists. Most have never written a firewall rule and don't inherently know what traffic their applications require.", + "The goal: a recommended process for operationalizing in-cluster network segmentation, not a tool selection exercise or an ownership assignment." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 257, + "shape_id": 5, + "paragraphs": [ + "A recommended approach, built on upstream Kubernetes and open-source tools." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 257, + "paragraphs": [ + "Frame the current state without judgment. The perimeter firewall handles everything: north-south and east-west traffic controls run through the firewall team on the perimeter appliance. There is no Kubernetes NetworkPolicy in the cluster. Developers are not security specialists; most have never written a firewall rule and do not inherently know what traffic their applications require. This is not unusual. Most organizations start in exactly this position. The shift to in-cluster segmentation is not about replacing the firewall team or the perimeter; it is about extending the organization's security posture into a layer the perimeter cannot see. The perimeter firewall has no visibility into pod-to-pod traffic inside the cluster. A compromised pod behind the perimeter can reach every other pod unless the cluster itself enforces segmentation. That is the gap this process fills." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 258, + "shape_id": 3, + "paragraphs": [ + "The four-phase maturity path" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 258, + "shape_id": 4, + "paragraphs": [ + "Phase 1 — Observe: understand what traffic flows today from the CNI's flow logs (Hubble or Calico). No enforcement yet.", + "Phase 2 — Baseline: apply default-deny in every namespace, generate policies from the observed flows, clean them up.", + "Phase 3 — Guardrails: introduce cluster-scoped ClusterNetworkPolicy (Admin and Baseline tiers) owned by the platform team.", + "Phase 4 — Developer ownership: app teams author and own their namespace-scoped NetworkPolicies via PR.", + "The timeline for each phase depends on the organization's complexity, team capacity, and risk tolerance. Each phase builds on the previous." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 258, + "paragraphs": [ + "Walk through the four phases. The most important point is that these are sequential and the timeline depends on the organization's complexity and risk tolerance. Phase 1 is observation: reading the CNI's flow logs to discover what traffic flows before writing any policy. Phase 2 is baseline: applying default-deny and cleaning up policies generated from the observed flows. Phase 3 is guardrails: the platform team introduces cluster-scoped ClusterNetworkPolicy rules in the Admin and Baseline tiers that developers cannot override. Phase 4 is developer ownership: app teams author their own namespace-scoped NetworkPolicies via pull request. The most common failure is skipping Phase 1 and jumping straight to enforcement; teams that did this caused outages from denied traffic they did not know existed. The observation phase pays for itself by revealing undocumented dependencies and unauthorized connections. Stress that each phase builds on the previous: you cannot do Phase 3 well without Phase 2's baseline, and Phase 2 is reckless without Phase 1's visibility." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 259, + "shape_id": 3, + "paragraphs": [ + "Observe the flows" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 259, + "shape_id": 4, + "paragraphs": [ + "The CNI records flows between pods, namespaces, and external destinations: Hubble with Cilium, flow logs (Goldmane, Whisker) with Calico.", + "Inspektor Gadget's advise_networkpolicy gadget records the TCP and UDP traffic of chosen namespaces. Together they give the network baseline, the source of truth for what traffic is expected.", + "The firewall team and platform team review the flows together and identify which services talk to each other, which make outbound calls, and which cross namespace boundaries.", + "What field teams typically see: organizations are surprised by the volume of east-west traffic that was invisible behind the perimeter." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 259, + "paragraphs": [ + "Explain what observation means without a commercial console. The CNI already sees every flow. With Cilium, Hubble shows them (hubble observe, or the Hubble UI); with Calico 3.30 and later, Felix sends flow logs to Goldmane and the Whisker console shows them. Inspektor Gadget, a CNCF Sandbox project, adds the advise_networkpolicy gadget: it records the TCP and UDP traffic of the namespaces you choose and later turns it into NetworkPolicy YAML. At this stage, nothing is denied. The goal is pure visibility. The firewall team and platform team review the observed flows together. They identify which services talk to each other and which should not, which services make outbound calls to external endpoints, which connections cross namespace boundaries, and which traffic patterns were previously invisible behind the perimeter. What field teams typically see: organizations are surprised by the volume of east-west traffic they did not know existed. A frontend directly querying a database it should go through an API for. A monitoring agent making outbound calls to a SaaS endpoint. Discovery is the highest-value step." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 260, + "shape_id": 3, + "paragraphs": [ + "Baseline: default-deny + generated policies" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 260, + "shape_id": 4, + "paragraphs": [ + "Apply default-deny NetworkPolicy in every namespace — the single highest-impact security measure.", + "Generate NetworkPolicy YAML from the observed flows: advise_networkpolicy writes one policy per workload, with rules from the observed peers.", + "Generated policies capture what traffic did flow, not what should flow. The platform team reviews, removes unauthorized connections, and applies cleaned-up policies alongside default-deny.", + "The first default-deny rollout reveals undocumented dependencies — monitoring agents, legacy batch jobs, SaaS sidecars. Discovering these in dev/test is the point." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 260, + "shape_id": 5, + "paragraphs": [ + "Generated policies are a starting point, not the final state." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 260, + "paragraphs": [ + "Walk through the baseline phase. The first action is applying default-deny NetworkPolicy in every namespace: podSelector empty, policyTypes Ingress and Egress. This is the single highest-impact security measure. Simultaneously, generate NetworkPolicy YAML from the observed flows. Inspektor Gadget's advise_networkpolicy writes one policy per workload, with ingress and egress rules based on the peers it saw; it can also write CiliumNetworkPolicy. On Calico, a StagedNetworkPolicy shows in the flow logs what a policy would deny before it is enforced. These generated policies capture what traffic did flow, not what traffic should flow. The platform team reviews them, removes connections that should not have existed, and applies the cleaned-up policies alongside the default-deny. This audit step finds the connections nobody documented. The first default-deny rollout in a non-prod namespace immediately reveals undocumented dependencies: a monitoring agent scraping every pod, a legacy batch job connecting to an external database, a sidecar phoning home to a SaaS endpoint. Discovering these in dev or test is the point." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 261, + "shape_id": 4, + "paragraphs": [ + "Phase 3: the platform team introduces cluster-scoped guardrails with ClusterNetworkPolicy, in its Admin and Baseline tiers. These are the policies developers cannot override.", + "Phase 4: app teams begin authoring their own namespace-scoped NetworkPolicies via pull request, with CI validation and platform team review.", + "The transition from Phase 3 to Phase 4 is gradual — developers start by reviewing generated baselines, then modify them, then eventually author from scratch.", + "The platform team controls the guardrails. Developers control their application connectivity. Even if a developer writes no policies at all, the Baseline tier ensures default-deny." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 261, + "shape_id": 5, + "paragraphs": [ + "The Baseline tier addresses the developer skill gap directly: safety by default." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 261, + "paragraphs": [ + "Explain the transition from platform-owned guardrails to developer ownership. In Phase 3, the platform team introduces ClusterNetworkPolicy rules in the Admin and Baseline tiers: cluster-scoped rules that developers cannot override. They enforce non-negotiable boundaries: deny cross-environment traffic, accept monitoring, restrict control-plane access. In Phase 4, app teams begin authoring their own namespace-scoped NetworkPolicies. The transition is gradual: developers start by reviewing generated baselines, then modify them, then eventually author from scratch as they build familiarity. The key architectural point: even if a developer writes no NetworkPolicies at all, the Baseline-tier default-deny keeps the namespace closed. This directly addresses the developer skill gap: the platform provides safety by default, and developers add precision as they learn. This is a fundamentally different model from asking every developer to be a firewall engineer." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 262, + "shape_id": 4, + "paragraphs": [ + "Tier 1 — ClusterNetworkPolicy, Admin tier: cluster-scoped, priority 0-1000, Accept/Deny/Pass. Cannot be overridden by tenants.", + "Tier 2 — NetworkPolicy (NP): namespace-scoped, owned by app teams. Only evaluated if the Admin tier passed the traffic.", + "Tier 3 — ClusterNetworkPolicy, Baseline tier: cluster-scoped safety net. Overridable by NetworkPolicy.", + "Admin Accept/Deny are final — NP and the Baseline tier are skipped. Admin Pass hands evaluation to the next tier." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 262, + "shape_id": 6, + "paragraphs": [ + "Platform team controls tiers 1 and 3. App teams control tier 2." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 262, + "paragraphs": [ + "This is the most important conceptual slide. A CNI that implements the upstream ClusterNetworkPolicy API evaluates traffic against three tiers of policy. Tier 1 is the Admin tier: cluster-scoped, priority zero to one thousand, with Accept, Deny, or Pass actions. If the traffic matches an Admin Accept or Deny rule, tiers 2 and 3 are skipped entirely. If it matches a Pass rule, evaluation hands to tier 2. Tier 2 is standard Kubernetes NetworkPolicy: namespace-scoped, owned by app teams. Tier 3 is the Baseline tier, the safety net: it catches traffic that the Admin tier passed and no NetworkPolicy matched. Point to the diagram showing the evaluation flow. The value proposition: the platform team controls tiers 1 and 3, the guardrails. Developers control tier 2, their application's policies. This separation of concerns is what makes the model scalable." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 262, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/net-advisory-tier-evaluation.png", + "cat": "stack", + "_why": "vendor-neutral labels (tools/vendor_figures.py)", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 263, + "shape_id": 2, + "paragraphs": [ + "ADMIN · BASELINE" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 263, + "shape_id": 3, + "paragraphs": [ + "ClusterNetworkPolicy: Admin and Baseline tiers" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 263, + "shape_id": 4, + "paragraphs": [ + "The Admin tier enforces non-negotiable rules: deny traffic to the control plane, accept monitoring agents scraping all pods, deny cross-environment traffic (dev → prod).", + "A Pass rule explicitly delegates to the NetworkPolicy tier — the platform team says 'app teams, this one is yours to decide.'", + "The Baseline tier is the safety net. Recommended: a default-deny Baseline policy. If no Admin rule and no NP allows the traffic, the Baseline tier denies it.", + "Critical: an Admin-tier policy with an empty namespace selector ({}) matches all namespaces including kube-system and the CNI's own. Use workload-specific label selectors." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 263, + "shape_id": 5, + "paragraphs": [ + "An overly broad Admin-tier policy can lock the cluster. Use workload-specific labels." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 263, + "paragraphs": [ + "Dive deeper into the two cluster tiers. The Admin tier enforces non-negotiable rules: deny traffic to the control plane from application namespaces, accept monitoring agents scraping all pods, deny cross-environment traffic like dev to prod. A Pass rule explicitly delegates the decision to the NetworkPolicy tier: this is how the platform team says app teams, this one is yours to decide. The Baseline tier is the safety net, configured as default-deny. If no Admin rule and no NetworkPolicy allows the traffic, the Baseline tier denies it, so a namespace with no NetworkPolicies still has a default-deny posture without the app team creating it. Critical caution: an Admin-tier policy with an empty namespace selector matches all namespaces, including kube-system and the CNI's own namespaces. This can lock the cluster. Label application namespaces and use that label as the namespace selector.", + "Status, as of network-policy-api v0.2.0 (April 2026): ClusterNetworkPolicy is policy.networking.k8s.io/v1alpha2, an alpha API. It merged the earlier AdminNetworkPolicy and BaselineAdminNetworkPolicy (v1alpha1), which upstream now lists as previous APIs, and renamed Allow to Accept. Calico enforces it from 3.32 and no longer enforces the old pair; Cilium 1.20 enforces it behind the k8sClusterNetworkPolicy.enabled Helm value. Check the release notes before each upgrade." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 264, + "shape_id": 4, + "paragraphs": [ + "Generated baselines: developers review generated policies, not author from scratch. Reviewing a concrete policy is dramatically easier than writing one from zero.", + "Platform-authored starter templates: web-facing service, internal API, batch job, database archetypes. Developers pick the template, customize selectors, submit a PR.", + "The Baseline tier as safety net: a developer who deploys without any NetworkPolicy gets default-deny automatically. Missing policies become connectivity issues, not security gaps.", + "Golden-path provisioning: new namespaces come with default-deny (a Kyverno generate rule), egress rules, starter templates, and RBAC bindings pre-installed." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 264, + "paragraphs": [ + "Walk through the four approaches for bridging the developer skill gap. First, generated baselines invert the skill requirement: developers review a policy generated from observed flows, they do not author one from scratch. Reviewing a concrete policy (does this look right, should this connection exist) is dramatically easier than writing one from zero. Second, platform-authored starter templates for common workload archetypes: web-facing, internal API, batch job, database. Developers pick the template, customize the namespace and label selectors, and submit a PR. Third, the Baseline tier as a safety net converts missing policies from a security gap into a connectivity issue that surfaces immediately in testing. Fourth, golden-path namespace provisioning includes default-deny, egress rules, starter templates, and RBAC bindings automatically; Kyverno's add-network-policy sample is a generate rule that creates the default-deny NetworkPolicy in every new namespace. The developer starts with a secured namespace and never sees the everything-is-open starting point that leads to procrastination." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 266, + "shape_id": 4, + "paragraphs": [ + "Schema check: kubeconform and kubectl apply --dry-run=client validate YAML and API version.", + "Policy lint: conftest / kyverno test / gator verify enforce organizational rules (no allow-all, required labels); trivy config scans the manifests.", + "Baseline diff: compare the change with the policy generated from observed flows; flag traffic outside the baseline.", + "Review: CODEOWNERS enforces — app teams author NP (platform reviews), platform authors ClusterNetworkPolicy (firewall reviews)." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 266, + "paragraphs": [ + "Walk through the policy CI/CD pipeline. Every policy change (NetworkPolicy, ClusterNetworkPolicy, egress rules) flows through the same pipeline. Schema check with kubeconform and kubectl apply --dry-run=client catches invalid YAML and wrong API versions. Policy lint with conftest, kyverno test or Gatekeeper's gator verify catches organizational policy violations like allow-all rules or missing labels, and trivy config scans the manifests (its KSV-0038 check flags network policies without pod or namespace selectors). The baseline diff compares the change with the policy generated from observed flows and flags rules that would allow traffic outside the baseline. Platform or security team reviews the PR; CODEOWNERS enforces who reviews based on the file path. Merge triggers an Argo CD sync to staging, smoke tests validate connectivity, then sync to production. Post-deploy validation reads the Hubble or Calico flow logs, and Falco alerts on unexpected egress at runtime. Point to the diagram. The key principle: the CI pipeline is the only path to production. No exceptions, no emergency kubectl apply, no just this once." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 266, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/net-advisory-ci-pipeline.png", + "cat": "stack", + "_why": "vendor-neutral labels (tools/vendor_figures.py)", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 267, + "shape_id": 4, + "paragraphs": [ + "platform-baselines/ — ClusterNetworkPolicy (Admin and Baseline tiers) and egress rules. Platform team authors, firewall/security team reviews. Cluster-wide scope.", + "onboarding/ — starter NetworkPolicy templates per workload archetype. Platform team maintains, app teams consume.", + "app-policies/ — per-namespace NetworkPolicy. App teams author, platform team reviews. Kustomize overlays for dev/test/prod.", + "CODEOWNERS enforces review requirements by path. The same PR flow applies regardless of target environment." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 267, + "paragraphs": [ + "Describe the three-layer repository model. The platform-baselines directory contains the ClusterNetworkPolicy resources of the Admin and Baseline tiers and the egress rules: cluster-scoped policies authored by the platform team and reviewed by the firewall and security team. The onboarding directory contains starter NetworkPolicy templates per workload archetype, maintained by the platform team and consumed by app teams as starting points. The app-policies directory contains per-namespace NetworkPolicies authored by app teams, with base and overlay directories for Kustomize-based environment promotion. CODEOWNERS enforces review requirements by path: platform-baselines requires firewall team review, app-policies requires platform team review. This separation provides clear ownership boundaries. The same PR that modifies a policy flows through the same CI pipeline regardless of whether it targets dev, test, or production; only the Kustomize overlay differs." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 268, + "shape_id": 4, + "paragraphs": [ + "Firewall team: authors egress rules, reviews the Admin and Baseline tiers, owns the perimeter firewall (north-south and compliance).", + "Platform team: authors ClusterNetworkPolicy guardrails and starter templates, reviews app NetworkPolicies, owns cluster security posture.", + "App teams: author namespace-scoped NetworkPolicy, consume templates and generated baselines, own application connectivity.", + "The firewall team moves from 'approve every rule' to 'set the boundaries and review the exceptions.'" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 268, + "paragraphs": [ + "Describe the three-team ownership model and point to the diagram. The firewall team authors the egress rules for external destinations, reviews all cluster-scoped policies (the Admin and Baseline tiers) because they have cluster-wide blast radius, and continues to own the perimeter for north-south traffic, TLS inspection, and compliance logging. The platform team authors the ClusterNetworkPolicy guardrails, maintains starter templates, reviews app team NetworkPolicies, and owns the overall cluster security posture. App teams author namespace-scoped NetworkPolicy for their services, consume starter templates and generated baselines, and own their application's connectivity. The critical framing: the firewall team moves from approve every rule to set the boundaries and review the exceptions. This is faster for developers, safer for the organization, and a better use of the firewall team's expertise." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "replace_picture", + "sldId": 268, + "shape_id": 5, + "png": "lgtm-presentation/edits/png/net-advisory-team-ownership.png", + "cat": "stack", + "_why": "vendor-neutral labels (tools/vendor_figures.py)", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 269, + "shape_id": 4, + "paragraphs": [ + "Step 1: compare with the observed baseline — was this connection ever in the generated policies in Git?", + "Step 2: read the flow logs (hubble observe --verdict DROPPED, or Calico's flow logs) and match the dropped flow to the policies that select the pod: which tier (Admin, NP, or Baseline) denied it tells you who updates the policy.", + "Step 3: cilium sysdump or calicoctl cluster diags collects policy state, CNI configuration, and logs for offline analysis or escalation.", + "Target: denied-flow triage should take under 30 minutes. If it takes longer, the observability tooling is not configured correctly." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 269, + "shape_id": 5, + "paragraphs": [ + "The flow logs and the tier that matched say who owns the fix." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 269, + "paragraphs": [ + "Walk through the denied-flow triage process. When a developer reports my service cannot reach X, the first step is comparing with the observed baseline: was this connection ever in the generated policies kept in Git? If not, it was likely never policy-allowed, and the decision is whether to add a policy or investigate why the new connection exists. The second step is the flow logs: hubble observe with --verdict DROPPED on Cilium, or the Calico flow logs in Whisker, show the dropped flow with source pod, destination and port. Matching it to the policies that select the pod tells you which tier denied the traffic: Admin, NetworkPolicy, or Baseline. If the Admin tier denied it, the platform team needs to act. If a NetworkPolicy denied it, the app team updates their policy. If the Baseline tier denied it, no one wrote a policy for this connection at all. The third step is a diagnostics bundle, cilium sysdump or calicoctl cluster diags, for offline analysis, an upstream issue, or escalation to your support provider. The target is under 30 minutes for triage. If it regularly takes longer, the observability tooling is not configured correctly." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 270, + "shape_id": 4, + "paragraphs": [ + "Break-glass: platform team creates a temporary Admin-tier Pass rule at high priority, time-boxed with an expiration annotation, logged via incident ticket. Proper policy change follows within 24 hours via normal PR.", + "Argo CD drift detection: if someone manually applies a policy via kubectl apply instead of the PR pipeline, Argo CD detects the drift and alerts or reverts it (self-heal).", + "Baseline drift: new flows outside the observed baseline show up in the flow logs and Falco alerts — catches both policy drift and application drift.", + "All break-glass events are reviewed in the quarterly policy review." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 270, + "paragraphs": [ + "Explain the break-glass process and drift detection. When a denied flow is blocking a production incident and the normal PR process is too slow, a platform team member creates a temporary Admin-tier ClusterNetworkPolicy with a Pass rule at high priority. This is time-boxed with an expiration annotation and logged via an incident ticket. If the Baseline tier is default-deny and no NetworkPolicy covers the target, a temporary NetworkPolicy is needed as well. Within 24 hours, the proper policy change goes through the normal PR and CI pipeline, and the temporary rule is removed. All break-glass events are reviewed in the quarterly policy review. For drift detection: Argo CD reports sync status for every policy manifest; if someone manually applies a policy, Argo CD detects the drift and alerts, or reverts it when self-heal is on. Baseline drift catches new traffic flows outside the observed baseline, whether from policy changes or application changes. The critical lesson: define the break-glass process before you enforce anything in production. The first denied-flow incident without a break-glass process causes panic and a rollback of all policies." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 271, + "shape_id": 4, + "paragraphs": [ + "What stays: north-south edge controls (the perimeter firewall for TLS inspection, DDoS, compliance logging), egress rule authorship (approved CIDRs, DNS rules), Admin and Baseline tier review (cluster-wide blast radius).", + "What shifts: east-west traffic segmentation moves to NetworkPolicy authored by app teams. The firewall team does not approve every pod-to-pod rule — that does not scale.", + "The new model: the firewall team's expertise is applied at a new layer. Position this as elevation, not replacement.", + "Teams that presented in-cluster policy as a replacement met resistance. Teams that positioned it as the firewall team's expertise at a new layer got buy-in." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 271, + "paragraphs": [ + "Frame the firewall team's evolving role as elevation, not replacement. What stays with the firewall team: north-south edge controls on the perimeter firewall for TLS inspection, DDoS mitigation, and compliance logging. Egress rule authorship: the firewall team catalogs approved external destinations and authors the egress rules, which is their domain expertise. Admin and Baseline tier review: the firewall team reviews all cluster-scoped policies because they have cluster-wide blast radius. What shifts to the platform and app teams: east-west traffic segmentation is handled by NetworkPolicy authored by app teams. The firewall team does not need to approve every pod-to-pod rule; that does not scale. The key framing: teams that presented in-cluster policy as a replacement for the firewall team met resistance. Teams that positioned it as the firewall team's expertise applied at a new layer got buy-in." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 272, + "shape_id": 2, + "paragraphs": [ + "EGRESS" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 272, + "shape_id": 4, + "paragraphs": [ + "Admin-tier ClusterNetworkPolicy egress rules with networks peers (CIDR, standard channel) restrict outbound traffic cluster-wide.", + "DNS-based rules: CNP domainNames peers are experimental and Accept-only; Cilium's toFQDNs (CiliumNetworkPolicy) gives DNS-based egress today.", + "The firewall team's domain: they catalog approved external endpoints and author the egress rules per environment.", + "Combine with NetworkPolicy: NetworkPolicy for intra-cluster segmentation, cluster egress rules for external traffic control. They complement each other." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 272, + "paragraphs": [ + "Explain egress control with upstream APIs. A ClusterNetworkPolicy in the Admin tier can carry egress rules whose peers are networks, CIDR ranges, which is part of the standard channel; deny everything outside the approved ranges and accept the approved ones. DNS names are harder: the domainNames peer of ClusterNetworkPolicy is in the experimental channel and only valid in Accept rules, and Calico's open-source edition has no DNS-based policy. On Cilium, a CiliumNetworkPolicy with toFQDNs gives DNS-based egress today, through Cilium's DNS proxy, which matters for external services that change IP addresses without notice. The firewall team's domain: they catalog approved external endpoints and author the egress rules. This extends what they already do at the perimeter, controlling which destinations workloads can reach, to the cluster level, where the perimeter has no visibility. Combine the two: NetworkPolicy for intra-cluster segmentation, cluster egress rules for external traffic control. Caveat: DNS-based rules depend on the DNS answers the cluster sees, so test them against the real resolvers." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 273, + "shape_id": 4, + "paragraphs": [ + "Default-deny in every namespace — no namespace without coverage.", + "Baselines reviewed — new flows outside them raise alerts.", + "Admin-tier guardrails enforced — cross-environment traffic blocked, monitoring allowed.", + "Baseline-tier default-deny — the cluster-wide safety net catches unmatched traffic.", + "App teams authoring policies via PR — not kubectl apply.", + "CI validates before merge — schema, lint, baseline diff, dry-run on every PR.", + "Egress rules per environment — outbound traffic restricted to approved destinations.", + "Denied-flow triage under 30 minutes. Quarterly policy review cadence." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 273, + "paragraphs": [ + "Walk through the maturity checklist. Default-deny in every namespace: no namespace without coverage. Baselines reviewed: every production namespace has a reviewed baseline in Git, and new flows outside it raise alerts. Admin-tier guardrails enforced: cross-environment traffic blocked, monitoring allowed, control-plane access restricted. Baseline-tier default-deny: the cluster-wide safety net catches any traffic not explicitly allowed. App teams authoring policies via PR, not kubectl apply. CI validates before merge: schema checks, policy linting, the baseline diff, and dry-run apply on every PR. Egress rules restrict outbound traffic to approved destinations. Firewall team reviews, does not block: turnaround on Admin and Baseline tier reviews is prompt. Denied-flow triage under 30 minutes. Quarterly policy review cadence covering break-glass events, stale policies, baseline refreshes, and RBAC audits. The maturity timeline is agreed upon by the organization; there is no universal pace." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 274, + "shape_id": 3, + "paragraphs": [ + "What teams wish they'd done" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 274, + "shape_id": 4, + "paragraphs": [ + "1. 'We should have started with observation, not enforcement.' Jumping to default-deny without observing the flows caused outages from denied traffic they didn't know existed.", + "2. 'We over-engineered the templates.' Start with 3-5 archetypes, not twenty. Refine based on actual usage.", + "3. 'We underestimated the egress work.' Cataloging every external endpoint is the most time-consuming part. Start this inventory in Phase 1.", + "4. 'We should have involved the firewall team earlier.' Position as elevation, not replacement.", + "5. 'We needed a break-glass process on day one.' The first denied-flow incident without break-glass caused panic and a rollback of all policies." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 274, + "shape_id": 5, + "paragraphs": [ + "These lessons come from real engagements." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 274, + "paragraphs": [ + "Walk through the top five lessons from real engagements. First, teams that jumped to default-deny without observing the flows caused outages from denied traffic they did not know existed. The observation phase pays for itself. Second, the first set of starter templates should be simple: three to five archetypes, not twenty. Start broad and refine based on actual PR volume. Third, cataloging every external endpoint every service needs to reach is the most time-consuming part of the shift. Start this inventory in Phase 1 alongside the flow observation. Fourth, teams that presented in-cluster policy as a replacement for the firewall team met resistance; teams that positioned it as the firewall team's expertise applied at a new layer got buy-in. Fifth, the first denied-flow incident in production without a break-glass process caused panic and a rollback of all policies. Define break-glass before you enforce. These lessons are from real engagements: they are patterns, not hypotheticals." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_text", + "sldId": 275, + "shape_id": 4, + "paragraphs": [ + "Network Policy API (ClusterNetworkPolicy) — network-policy-api.sigs.k8s.io", + "network-policy-api v0.2.0 release — github.com/kubernetes-sigs/network-policy-api/releases/tag/v0.2.0", + "Calico ClusterNetworkPolicy (v3.32 release notes) — github.com/projectcalico/calico, release-notes/v3.32.0-release-notes.md", + "Cilium network policy — docs.cilium.io/en/stable/network/kubernetes/policy/", + "Inspektor Gadget advise_networkpolicy — inspektor-gadget.io/docs/latest/gadgets/advise_networkpolicy", + "Kubernetes Network Policies — kubernetes.io/docs/concepts/services-networking/network-policies/", + "Companion: Kubernetes Environment Access Policies (k8s-environment-policies.md)" + ], + "cat": "stack", + "_vendor": "S12 decision 7" + }, + { + "op": "set_notes", + "sldId": 275, + "paragraphs": [ + "Point the audience to authoritative sources. The Network Policy API site covers ClusterNetworkPolicy, its Admin and Baseline tiers and the list of implementations. The v0.2.0 release notes record the move from AdminNetworkPolicy and BaselineAdminNetworkPolicy to ClusterNetworkPolicy and the Allow-to-Accept rename. The Calico 3.32 release notes and the Cilium network policy page say how each CNI enforces it. The Inspektor Gadget page covers generating NetworkPolicy from observed traffic. The Kubernetes Network Policies documentation covers the standard NetworkPolicy API that app teams will author. The companion document, Kubernetes Environment Access Policies, covers the broader governance framework including RBAC, admission control, GitOps, and audit that surrounds the network policy process described in this deck." + ], + "cat": "stack", + "_vendor": "S12 decision 7" + } + ] +} diff --git a/lgtm-presentation/edits/_shared.json b/lgtm-presentation/edits/_shared.json new file mode 100644 index 00000000..8dbf15a3 --- /dev/null +++ b/lgtm-presentation/edits/_shared.json @@ -0,0 +1,468 @@ +{ + "ops": [ + { + "op": "replace", + "scope": "both", + "find": "Postman collections as an automated gate — on plain P\u006fdman", + "repl": "Postman collections as an automated gate on Docker Engine", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "The arc: why Newman, the anatomy of a collection, one collection against five backends, running it and shaping the output, data-driven cases, the CI gate on P\u006fdman, and where Newman sits in the test pyramid.", + "repl": "The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "This is a quietly powerful idea: the collection is the API specification made executable.", + "repl": "The collection is the API specification made executable.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "One collection, five backends", + "repl": "One collection, six backends", + "expect": 1, + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "That is the whole point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, or C++, and run the exact same collection against it.", + "repl": "That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it.", + "expect": 1, + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "In CI you would start the service with p\u006fdman compose rather than a foreground command, but locally a background process and a newman run is the fastest loop.", + "repl": "In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "Bring the stack up on P\u006fdman, wait for readiness, run the collection — a failed assertion fails the build.", + "repl": "Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "The services come up on P\u006fdman exactly as they do on a laptop - no managed cloud - and Newman runs against them.", + "repl": "The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "The gate on P\u006fdman", + "repl": "The gate on Docker Engine", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "The full gate: a pull request builds the image, p\u006fdman compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge - pass merges, fail blocks.", + "repl": "The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "The important property is parity: the stack in CI is the same stack you run on your laptop, on the same P\u006fdman, so a green run in CI means the contract holds against the real services, not a mocked approximation.", + "repl": "The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "the API/contract tier — above unit tests, below a thin end-to-end layer", + "repl": "the API/contract tier: on top of unit tests, under a thin end-to-end layer", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Newman is not a replacement for unit tests - it is the layer above them.", + "repl": "Newman does not replace unit tests; it is the layer on top of them.", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "and reserve a thin layer of full end-to-end tests for the few journeys that genuinely need the whole system.", + "repl": "and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "And test the failure paths as deliberately as the happy path - a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice - which ties this appendix back to the error-format, security, idempotency, and rate-limiting material.", + "repl": "Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "One collection is the conformance suite for all five implementations: Newman tests over HTTP, as a black box.", + "repl": "One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box.", + "expect": 1, + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "Gate CI on plain P\u006fdman so the build fails on a broken contract; keep Newman in the API/contract tier.", + "repl": "Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "Because Newman tests over HTTP, one collection is the conformance suite for all five implementations.", + "repl": "Because Newman tests over HTTP, one collection is the conformance suite for all six implementations.", + "expect": 1, + "cat": "stack" + }, + { + "op": "replace", + "scope": "both", + "find": "Wire it into CI on plain P\u006fdman so the build fails on a broken contract, and keep Newman in its lane - the API/contract tier, above unit tests and below a thin end-to-end layer.", + "repl": "Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer.", + "expect": 1, + "cat": "engine" + }, + { + "op": "replace", + "scope": "both", + "find": "AI tooling — Claude, Claude Code, frontier models — turns intent into code at a rate that wasn't possible before.", + "repl": "AI tooling (Claude, Claude Code, frontier models) turns intent into code at a rate that was not possible before.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Chaining calls inside services quietly rebuilds the monolith.", + "repl": "Chaining calls inside services rebuilds the monolith over the network.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Log tailing — the database commit log IS the event stream", + "repl": "Log tailing — the database commit log is the event stream", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Dual-write is a lie — use the outbox", + "repl": "Dual-write is unsafe — use the outbox", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Sourcing: events ARE the state.", + "repl": "Sourcing: the events are the state.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A coordinator that commands is easy to follow; events that trigger each other decouple but hide the flow.", + "repl": "A coordinator that commands keeps the flow in one place; events that trigger each other decouple but hide the flow.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Easy to reason about, change and audit", + "repl": "One place to reason about, change and audit", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD), and translates to whatever the legacy expects — it speaks BOTH languages.", + "repl": "Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Auth0, Azure AD), and translates to whatever the legacy expects; it speaks both languages.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Most production systems use ALL of these, in combinations — not as a checklist, but as deliberate decisions per concern.", + "repl": "Most production systems combine all of these, not as a checklist but as a decision per concern.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Match each interaction to its style — REST, gRPC, GraphQL or async — and compose at the edge.", + "repl": "Match each interaction to its style (REST, gRPC, GraphQL or async) and compose at the edge.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue — where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe).", + "repl": "Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue, where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe).", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service — a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD — translates between models; note: ACL also means Access Control List in security); Saga (not an acronym — a sequence of local transactions with compensating actions).", + "repl": "Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service: a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD, which translates between models; ACL also means Access Control List in security); Saga (not an acronym: a sequence of local transactions with compensating actions).", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); SAS (Shared Access Signature — Azure's scoped token); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials).", + "repl": "Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); SAS (Shared Access Signature, Azure's scoped token); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials).", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Three paradigms, three mental models", + "repl": "Three paradigms and their mental models", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A fact that many consumers react to, decoupled in time → none of the above; emit an event.", + "repl": "A fact that many consumers react to, decoupled in time → none of these; emit an event.", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "Each makes a different kind of L7 decision.", + "repl": "Each makes a different type of L7 decision.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Internal traffic is where most L7 decisions actually happen.", + "repl": "Most L7 decisions happen on internal traffic.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Four layers, north/south at the top, east/west at the bottom — each making a different kind of L7 decision.", + "repl": "Four layers, north/south at the top, east/west at the bottom, each making a different type of L7 decision.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A request's L7 journey", + "repl": "A request's L7 path", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "One HTTP request is really a chain of L7 decisions made by four independent components.", + "repl": "One HTTP request is a chain of L7 decisions made by four independent components.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "TLS passthrough — don't terminate — when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only.", + "repl": "TLS passthrough (no termination) when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When you actually need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod.", + "repl": "When you need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Cookie-based is almost always the right choice over IP-hash — it's robust to NAT and lets clients explicitly drop the cookie.", + "repl": "Cookie-based is almost always the right choice over IP-hash: it survives NAT and lets clients explicitly drop the cookie.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Pushing those rules into HAProxy ACLs or VirtualService matches doesn't scale — they're owned by domain experts, change often, and depend on the payload, not just the envelope.", + "repl": "Pushing those rules into HAProxy ACLs or VirtualService matches does not scale: domain experts own them, they change often, and they depend on the payload, not just the envelope.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Prefer exact / prefix matches; use regex only when truly needed.", + "repl": "Prefer exact / prefix matches; use regex only when needed.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Sticky sessions silently breaking elasticity — the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone.", + "repl": "Sticky sessions break elasticity without any error: the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Useful intermediate state, NOT a destination.", + "repl": "Useful intermediate state, not a destination.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "When NOT to cache, and what to watch for", + "repl": "When not to cache, and what to watch for", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "The goal is NOT zero failures — that's impossible.", + "repl": "The goal is not zero failures; that is impossible.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Nodes are alive and healthy — they simply cannot reach each other.", + "repl": "Nodes are alive and healthy; they cannot reach each other.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "A partition where BOTH sides think they are the leader and both accept writes.", + "repl": "A partition where both sides think they are the leader and both accept writes.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Istio mesh policies + graceful shutdown + observability — the platform layer underneath all of the above.", + "repl": "Istio mesh policies + graceful shutdown + observability: the platform layer underneath all of these patterns.", + "expect": 1, + "cat": "positional" + }, + { + "op": "replace", + "scope": "both", + "find": "The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, consistent with the rest of the book.", + "repl": "The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, like every other example in the book.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Feature flag covers four genuinely different tools, and conflating them is the root of most flag messes.", + "repl": "Feature flag covers four different tools, and conflating them is the root of most flag messes.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Default true = fail open: only a deliberate flip disables it.", + "repl": "Default true = fail open: only an operator flipping the flag disables it.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "It is the deliberate, human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; the kill switch is flipped on purpose.", + "repl": "It is the human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; an operator flips the kill switch by hand.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Note the default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only a deliberate flip removes it.", + "repl": "The default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only an explicit flip removes it.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Test BOTH sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag.", + "repl": "Test both sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag.", + "expect": 1, + "cat": "voice" + }, + { + "op": "replace", + "scope": "both", + "find": "Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them deliberately.", + "repl": "Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them explicitly.", + "expect": 1, + "cat": "voice" + } + ] +} diff --git a/lgtm-presentation/edits/png/01-factors-mapped-go.png b/lgtm-presentation/edits/png/01-factors-mapped-go.png new file mode 100644 index 00000000..7ca30978 Binary files /dev/null and b/lgtm-presentation/edits/png/01-factors-mapped-go.png differ diff --git a/lgtm-presentation/edits/png/01-factors-mapped-py.png b/lgtm-presentation/edits/png/01-factors-mapped-py.png new file mode 100644 index 00000000..ad4655ba Binary files /dev/null and b/lgtm-presentation/edits/png/01-factors-mapped-py.png differ diff --git a/lgtm-presentation/edits/png/01b-windows-to-k8s-bridge-dn.png b/lgtm-presentation/edits/png/01b-windows-to-k8s-bridge-dn.png new file mode 100644 index 00000000..290fb56f Binary files /dev/null and b/lgtm-presentation/edits/png/01b-windows-to-k8s-bridge-dn.png differ diff --git a/lgtm-presentation/edits/png/02-mesh-handles-wire-dotnet.png b/lgtm-presentation/edits/png/02-mesh-handles-wire-dotnet.png new file mode 100644 index 00000000..1390624b Binary files /dev/null and b/lgtm-presentation/edits/png/02-mesh-handles-wire-dotnet.png differ diff --git a/lgtm-presentation/edits/png/02-mesh-handles-wire-go.png b/lgtm-presentation/edits/png/02-mesh-handles-wire-go.png new file mode 100644 index 00000000..9650e9ba Binary files /dev/null and b/lgtm-presentation/edits/png/02-mesh-handles-wire-go.png differ diff --git a/lgtm-presentation/edits/png/02-mesh-handles-wire-spring.png b/lgtm-presentation/edits/png/02-mesh-handles-wire-spring.png new file mode 100644 index 00000000..d373476e Binary files /dev/null and b/lgtm-presentation/edits/png/02-mesh-handles-wire-spring.png differ diff --git a/lgtm-presentation/edits/png/02-pick-protocol-dotnet.png b/lgtm-presentation/edits/png/02-pick-protocol-dotnet.png new file mode 100644 index 00000000..ba263729 Binary files /dev/null and b/lgtm-presentation/edits/png/02-pick-protocol-dotnet.png differ diff --git a/lgtm-presentation/edits/png/02-pick-protocol-go.png b/lgtm-presentation/edits/png/02-pick-protocol-go.png new file mode 100644 index 00000000..8193e44f Binary files /dev/null and b/lgtm-presentation/edits/png/02-pick-protocol-go.png differ diff --git a/lgtm-presentation/edits/png/02-pick-protocol-spring.png b/lgtm-presentation/edits/png/02-pick-protocol-spring.png new file mode 100644 index 00000000..751b4e6d Binary files /dev/null and b/lgtm-presentation/edits/png/02-pick-protocol-spring.png differ diff --git a/lgtm-presentation/edits/png/03-composition.png b/lgtm-presentation/edits/png/03-composition.png new file mode 100644 index 00000000..93556d0b Binary files /dev/null and b/lgtm-presentation/edits/png/03-composition.png differ diff --git a/lgtm-presentation/edits/png/04-cqrs.png b/lgtm-presentation/edits/png/04-cqrs.png new file mode 100644 index 00000000..6995d71d Binary files /dev/null and b/lgtm-presentation/edits/png/04-cqrs.png differ diff --git a/lgtm-presentation/edits/png/04-dual-write-outbox-cpp.png b/lgtm-presentation/edits/png/04-dual-write-outbox-cpp.png new file mode 100644 index 00000000..46fa4d4e Binary files /dev/null and b/lgtm-presentation/edits/png/04-dual-write-outbox-cpp.png differ diff --git a/lgtm-presentation/edits/png/04-dual-write-outbox.png b/lgtm-presentation/edits/png/04-dual-write-outbox.png new file mode 100644 index 00000000..91de208b Binary files /dev/null and b/lgtm-presentation/edits/png/04-dual-write-outbox.png differ diff --git a/lgtm-presentation/edits/png/04-log-tailing.png b/lgtm-presentation/edits/png/04-log-tailing.png new file mode 100644 index 00000000..3597417f Binary files /dev/null and b/lgtm-presentation/edits/png/04-log-tailing.png differ diff --git a/lgtm-presentation/edits/png/05-sourcing-vs-streaming.png b/lgtm-presentation/edits/png/05-sourcing-vs-streaming.png new file mode 100644 index 00000000..8b8df4bc Binary files /dev/null and b/lgtm-presentation/edits/png/05-sourcing-vs-streaming.png differ diff --git a/lgtm-presentation/edits/png/05-tools-landscape.png b/lgtm-presentation/edits/png/05-tools-landscape.png new file mode 100644 index 00000000..d643ed14 Binary files /dev/null and b/lgtm-presentation/edits/png/05-tools-landscape.png differ diff --git a/lgtm-presentation/edits/png/06-derive-streams.png b/lgtm-presentation/edits/png/06-derive-streams.png new file mode 100644 index 00000000..f03c78ce Binary files /dev/null and b/lgtm-presentation/edits/png/06-derive-streams.png differ diff --git a/lgtm-presentation/edits/png/06-scale-on-lag.png b/lgtm-presentation/edits/png/06-scale-on-lag.png new file mode 100644 index 00000000..6f98f239 Binary files /dev/null and b/lgtm-presentation/edits/png/06-scale-on-lag.png differ diff --git a/lgtm-presentation/edits/png/12-valet-key.png b/lgtm-presentation/edits/png/12-valet-key.png new file mode 100644 index 00000000..d74cef27 Binary files /dev/null and b/lgtm-presentation/edits/png/12-valet-key.png differ diff --git a/lgtm-presentation/edits/png/12-zero-trust.png b/lgtm-presentation/edits/png/12-zero-trust.png new file mode 100644 index 00000000..d43e8e49 Binary files /dev/null and b/lgtm-presentation/edits/png/12-zero-trust.png differ diff --git a/lgtm-presentation/edits/png/13-antipatterns-grid.png b/lgtm-presentation/edits/png/13-antipatterns-grid.png new file mode 100644 index 00000000..f8971b20 Binary files /dev/null and b/lgtm-presentation/edits/png/13-antipatterns-grid.png differ diff --git a/lgtm-presentation/edits/png/17-saga-state.png b/lgtm-presentation/edits/png/17-saga-state.png new file mode 100644 index 00000000..b1e8647d Binary files /dev/null and b/lgtm-presentation/edits/png/17-saga-state.png differ diff --git a/lgtm-presentation/edits/png/17-sagas-architecture.png b/lgtm-presentation/edits/png/17-sagas-architecture.png new file mode 100644 index 00000000..99251204 Binary files /dev/null and b/lgtm-presentation/edits/png/17-sagas-architecture.png differ diff --git a/lgtm-presentation/edits/png/20-coupling-quadrant.png b/lgtm-presentation/edits/png/20-coupling-quadrant.png new file mode 100644 index 00000000..cb151b7c Binary files /dev/null and b/lgtm-presentation/edits/png/20-coupling-quadrant.png differ diff --git a/lgtm-presentation/edits/png/22-business-rule-routing-cpp.png b/lgtm-presentation/edits/png/22-business-rule-routing-cpp.png new file mode 100644 index 00000000..fe8f5423 Binary files /dev/null and b/lgtm-presentation/edits/png/22-business-rule-routing-cpp.png differ diff --git a/lgtm-presentation/edits/png/22-business-rule-routing-dotnet.png b/lgtm-presentation/edits/png/22-business-rule-routing-dotnet.png new file mode 100644 index 00000000..75d7f59b Binary files /dev/null and b/lgtm-presentation/edits/png/22-business-rule-routing-dotnet.png differ diff --git a/lgtm-presentation/edits/png/22-business-rule-routing-go.png b/lgtm-presentation/edits/png/22-business-rule-routing-go.png new file mode 100644 index 00000000..d5d1d505 Binary files /dev/null and b/lgtm-presentation/edits/png/22-business-rule-routing-go.png differ diff --git a/lgtm-presentation/edits/png/22-east-west.png b/lgtm-presentation/edits/png/22-east-west.png new file mode 100644 index 00000000..f763e36a Binary files /dev/null and b/lgtm-presentation/edits/png/22-east-west.png differ diff --git a/lgtm-presentation/edits/png/22-hop-cost-cpp.png b/lgtm-presentation/edits/png/22-hop-cost-cpp.png new file mode 100644 index 00000000..a669e062 Binary files /dev/null and b/lgtm-presentation/edits/png/22-hop-cost-cpp.png differ diff --git a/lgtm-presentation/edits/png/22-hop-cost-drools.png b/lgtm-presentation/edits/png/22-hop-cost-drools.png new file mode 100644 index 00000000..88030362 Binary files /dev/null and b/lgtm-presentation/edits/png/22-hop-cost-drools.png differ diff --git a/lgtm-presentation/edits/png/22-hop-cost-go.png b/lgtm-presentation/edits/png/22-hop-cost-go.png new file mode 100644 index 00000000..3ae738bf Binary files /dev/null and b/lgtm-presentation/edits/png/22-hop-cost-go.png differ diff --git a/lgtm-presentation/edits/png/22-hop-cost-nrules.png b/lgtm-presentation/edits/png/22-hop-cost-nrules.png new file mode 100644 index 00000000..32d2f7c1 Binary files /dev/null and b/lgtm-presentation/edits/png/22-hop-cost-nrules.png differ diff --git a/lgtm-presentation/edits/png/22-hop-cost.png b/lgtm-presentation/edits/png/22-hop-cost.png new file mode 100644 index 00000000..f926b039 Binary files /dev/null and b/lgtm-presentation/edits/png/22-hop-cost.png differ diff --git a/lgtm-presentation/edits/png/22-l7-journey.png b/lgtm-presentation/edits/png/22-l7-journey.png new file mode 100644 index 00000000..5867300b Binary files /dev/null and b/lgtm-presentation/edits/png/22-l7-journey.png differ diff --git a/lgtm-presentation/edits/png/22-l7-layers.png b/lgtm-presentation/edits/png/22-l7-layers.png new file mode 100644 index 00000000..9a07509b Binary files /dev/null and b/lgtm-presentation/edits/png/22-l7-layers.png differ diff --git a/lgtm-presentation/edits/png/24-shared-database.png b/lgtm-presentation/edits/png/24-shared-database.png new file mode 100644 index 00000000..c44371f5 Binary files /dev/null and b/lgtm-presentation/edits/png/24-shared-database.png differ diff --git a/lgtm-presentation/edits/png/25-circuit-breaker.png b/lgtm-presentation/edits/png/25-circuit-breaker.png new file mode 100644 index 00000000..690400cb Binary files /dev/null and b/lgtm-presentation/edits/png/25-circuit-breaker.png differ diff --git a/lgtm-presentation/edits/png/25-failure-taxonomy.png b/lgtm-presentation/edits/png/25-failure-taxonomy.png new file mode 100644 index 00000000..06840f44 Binary files /dev/null and b/lgtm-presentation/edits/png/25-failure-taxonomy.png differ diff --git a/lgtm-presentation/edits/png/25-gray-failure.png b/lgtm-presentation/edits/png/25-gray-failure.png new file mode 100644 index 00000000..74edb050 Binary files /dev/null and b/lgtm-presentation/edits/png/25-gray-failure.png differ diff --git a/lgtm-presentation/edits/png/27-black-box.png b/lgtm-presentation/edits/png/27-black-box.png new file mode 100644 index 00000000..165a507c Binary files /dev/null and b/lgtm-presentation/edits/png/27-black-box.png differ diff --git a/lgtm-presentation/edits/png/27-ci-gate.png b/lgtm-presentation/edits/png/27-ci-gate.png new file mode 100644 index 00000000..4d2d90c6 Binary files /dev/null and b/lgtm-presentation/edits/png/27-ci-gate.png differ diff --git a/lgtm-presentation/edits/png/29-container-layers.png b/lgtm-presentation/edits/png/29-container-layers.png new file mode 100644 index 00000000..3985b702 Binary files /dev/null and b/lgtm-presentation/edits/png/29-container-layers.png differ diff --git a/lgtm-presentation/edits/png/29-dotnet-compilation.png b/lgtm-presentation/edits/png/29-dotnet-compilation.png new file mode 100644 index 00000000..1c653ccd Binary files /dev/null and b/lgtm-presentation/edits/png/29-dotnet-compilation.png differ diff --git a/lgtm-presentation/edits/png/29-kestrel-pipeline.png b/lgtm-presentation/edits/png/29-kestrel-pipeline.png new file mode 100644 index 00000000..78a69808 Binary files /dev/null and b/lgtm-presentation/edits/png/29-kestrel-pipeline.png differ diff --git a/lgtm-presentation/edits/png/31-minikube-image-flow.png b/lgtm-presentation/edits/png/31-minikube-image-flow.png new file mode 100644 index 00000000..132e7b9b Binary files /dev/null and b/lgtm-presentation/edits/png/31-minikube-image-flow.png differ diff --git a/lgtm-presentation/edits/png/31-minikube-topology.png b/lgtm-presentation/edits/png/31-minikube-topology.png new file mode 100644 index 00000000..58677e02 Binary files /dev/null and b/lgtm-presentation/edits/png/31-minikube-topology.png differ diff --git a/lgtm-presentation/edits/png/63-appD-stateless-dn.png b/lgtm-presentation/edits/png/63-appD-stateless-dn.png new file mode 100644 index 00000000..ec73e5fc Binary files /dev/null and b/lgtm-presentation/edits/png/63-appD-stateless-dn.png differ diff --git a/lgtm-presentation/edits/png/70-appJ-pinned-libraries-dn.png b/lgtm-presentation/edits/png/70-appJ-pinned-libraries-dn.png new file mode 100644 index 00000000..27988b32 Binary files /dev/null and b/lgtm-presentation/edits/png/70-appJ-pinned-libraries-dn.png differ diff --git a/lgtm-presentation/edits/png/71-appJ-pinned-platform-dn.png b/lgtm-presentation/edits/png/71-appJ-pinned-platform-dn.png new file mode 100644 index 00000000..ee4607e2 Binary files /dev/null and b/lgtm-presentation/edits/png/71-appJ-pinned-platform-dn.png differ diff --git a/lgtm-presentation/edits/png/72-appJ-migration-reference-dn.png b/lgtm-presentation/edits/png/72-appJ-migration-reference-dn.png new file mode 100644 index 00000000..48b4a293 Binary files /dev/null and b/lgtm-presentation/edits/png/72-appJ-migration-reference-dn.png differ diff --git a/lgtm-presentation/edits/png/k8s-12-policy-as-code.png b/lgtm-presentation/edits/png/k8s-12-policy-as-code.png new file mode 100644 index 00000000..d0fbd44f Binary files /dev/null and b/lgtm-presentation/edits/png/k8s-12-policy-as-code.png differ diff --git a/lgtm-presentation/edits/png/k8s-22-l7-layers.png b/lgtm-presentation/edits/png/k8s-22-l7-layers.png new file mode 100644 index 00000000..238bdd41 Binary files /dev/null and b/lgtm-presentation/edits/png/k8s-22-l7-layers.png differ diff --git a/lgtm-presentation/edits/png/k8s-22-traffic-steering.png b/lgtm-presentation/edits/png/k8s-22-traffic-steering.png new file mode 100644 index 00000000..4374b0ea Binary files /dev/null and b/lgtm-presentation/edits/png/k8s-22-traffic-steering.png differ diff --git a/lgtm-presentation/edits/png/k8s-policy-ci-pipeline.png b/lgtm-presentation/edits/png/k8s-policy-ci-pipeline.png new file mode 100644 index 00000000..79a283b1 Binary files /dev/null and b/lgtm-presentation/edits/png/k8s-policy-ci-pipeline.png differ diff --git a/lgtm-presentation/edits/png/k8s-policy-env-rbac.png b/lgtm-presentation/edits/png/k8s-policy-env-rbac.png new file mode 100644 index 00000000..56c4558a Binary files /dev/null and b/lgtm-presentation/edits/png/k8s-policy-env-rbac.png differ diff --git a/lgtm-presentation/edits/png/k8s-policy-gitops-flow.png b/lgtm-presentation/edits/png/k8s-policy-gitops-flow.png new file mode 100644 index 00000000..098610a5 Binary files /dev/null and b/lgtm-presentation/edits/png/k8s-policy-gitops-flow.png differ diff --git a/lgtm-presentation/edits/png/k8s-policy-namespace-isolation.png b/lgtm-presentation/edits/png/k8s-policy-namespace-isolation.png new file mode 100644 index 00000000..7fcb4435 Binary files /dev/null and b/lgtm-presentation/edits/png/k8s-policy-namespace-isolation.png differ diff --git a/lgtm-presentation/edits/png/net-advisory-ci-pipeline.png b/lgtm-presentation/edits/png/net-advisory-ci-pipeline.png new file mode 100644 index 00000000..9eba2696 Binary files /dev/null and b/lgtm-presentation/edits/png/net-advisory-ci-pipeline.png differ diff --git a/lgtm-presentation/edits/png/net-advisory-team-ownership.png b/lgtm-presentation/edits/png/net-advisory-team-ownership.png new file mode 100644 index 00000000..b822836f Binary files /dev/null and b/lgtm-presentation/edits/png/net-advisory-team-ownership.png differ diff --git a/lgtm-presentation/edits/png/net-advisory-tier-evaluation.png b/lgtm-presentation/edits/png/net-advisory-tier-evaluation.png new file mode 100644 index 00000000..7ca41184 Binary files /dev/null and b/lgtm-presentation/edits/png/net-advisory-tier-evaluation.png differ diff --git a/lgtm-presentation/edits/png/src/01-factors-mapped-go.svg b/lgtm-presentation/edits/png/src/01-factors-mapped-go.svg new file mode 100644 index 00000000..be32cdbd --- /dev/null +++ b/lgtm-presentation/edits/png/src/01-factors-mapped-go.svg @@ -0,0 +1,49 @@ + + + +Build & ship +codebase · dependencies · build/release/run · parity + +Built once, run anywhere +one repo · go.mod + go.sum · one image per release + +§1 + +Configure +config + +Config from the environment +os.Getenv · per-profile · secrets injected + +§1 + +Run on the platform +stateless · concurrency · disposability · port binding + +Disposable, stateless, scaled +KEDA scale-out & to-zero · fast start/stop · behind Istio + +§1·2·6 + +Attach resources +backing services + +Backing services as URLs +Postgres · Kafka (Strimzi) · Apicurio — swappable + +§4·5 + +Operate +logs · admin processes + +Signals & admin off the hot path +OpenTelemetry → LGTM · admin as Jobs/CronJobs + +§7·11 + + + + + +most factors are now platform defaults you adopt; a few are discipline you still write + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/01-factors-mapped-py.svg b/lgtm-presentation/edits/png/src/01-factors-mapped-py.svg new file mode 100644 index 00000000..7f09b721 --- /dev/null +++ b/lgtm-presentation/edits/png/src/01-factors-mapped-py.svg @@ -0,0 +1,49 @@ + + + +Build & ship +codebase · dependencies · build/release/run · parity + +Built once, run anywhere +one repo · pip + requirements.txt · one image per release + +§1 + +Configure +config + +Config from the environment +pydantic-settings · per-profile · secrets injected + +§1 + +Run on the platform +stateless · concurrency · disposability · port binding + +Disposable, stateless, scaled +KEDA scale-out & to-zero · fast start/stop · behind Istio + +§1·2·6 + +Attach resources +backing services + +Backing services as URLs +Postgres · Kafka (Strimzi) · Apicurio — swappable + +§4·5 + +Operate +logs · admin processes + +Signals & admin off the hot path +OpenTelemetry → LGTM · admin as Jobs/CronJobs + +§7·11 + + + + + +most factors are now platform defaults you adopt; a few are discipline you still write + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/01b-windows-to-k8s-bridge-dn.svg b/lgtm-presentation/edits/png/src/01b-windows-to-k8s-bridge-dn.svg new file mode 100644 index 00000000..d454d86b --- /dev/null +++ b/lgtm-presentation/edits/png/src/01b-windows-to-k8s-bridge-dn.svg @@ -0,0 +1,75 @@ + + + +From the Windows world to Kubernetes +what you knew, and what it becomes +Windows / .NET 4.x era +Kubernetes / .NET 10 era + + + +IIS app pool +Kubernetes Pod (ReplicaSet) + + + +App pool recycle +Graceful shutdown + rolling deploy + + + +WCF service + DataContract +gRPC + .proto (or ASP.NET Core + OpenAPI) + + + +WCF bindings (BasicHttp / NetTcp / MSMQ) +REST + gRPC + Kafka, picked per call + + + +COM+ / MSDTC distributed transactions +Saga + outbox (no two-phase commit) + + + +MSMQ / SQL Server Service Broker +Kafka topic + consumer group (Confluent.Kafka) + + + +Windows Service +IHostedService / BackgroundService in a Pod + + + +App.config / web.config +appsettings.json + env vars + ConfigMap / Secret + + + +Windows performance counters +OpenTelemetry metrics → Prometheus / Mimir + + + +Windows Event Log +Structured logs (Microsoft.Extensions.Logging) → Loki + + + +Active Directory + Windows Auth +mTLS via Istio + SPIFFE identity + + + +.NET Remoting +gRPC over HTTP/2 + + + +BizTalk orchestration +Temporal workflows / Stateless state machines +The architectural shift: stop owning the host (IIS, MSDTC, MSMQ). Kubernetes owns the platform; you own the code. +Most of these are patterns you already used; the platform now runs them instead of IIS. + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/02-mesh-handles-wire-dotnet.svg b/lgtm-presentation/edits/png/src/02-mesh-handles-wire-dotnet.svg new file mode 100644 index 00000000..6e180503 --- /dev/null +++ b/lgtm-presentation/edits/png/src/02-mesh-handles-wire-dotnet.svg @@ -0,0 +1,36 @@ + + + +Pod + +Pod + +app +ASP.NET Core / gRPC server +plain localhost + +istio-proxy +Envoy sidecar +mTLS · retries +timeouts · traces + +app +ASP.NET Core / gRPC server +plain localhost + +istio-proxy +Envoy sidecar +mTLS · retries +timeouts · traces + +istiod control plane · policy, identity (SPIFFE), telemetry config + + + +mTLS +mTLS + + +encrypted, authenticated +your service speaks plain HTTP/gRPC to localhost; the sidecar secures and observes the wire + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/02-mesh-handles-wire-go.svg b/lgtm-presentation/edits/png/src/02-mesh-handles-wire-go.svg new file mode 100644 index 00000000..81dc602c --- /dev/null +++ b/lgtm-presentation/edits/png/src/02-mesh-handles-wire-go.svg @@ -0,0 +1,36 @@ + + + +Pod + +Pod + +app +net/http / grpc-go server +plain localhost + +istio-proxy +Envoy sidecar +mTLS · retries +timeouts · traces + +app +net/http / grpc-go server +plain localhost + +istio-proxy +Envoy sidecar +mTLS · retries +timeouts · traces + +istiod control plane · policy, identity (SPIFFE), telemetry config + + + +mTLS +mTLS + + +encrypted, authenticated +your service speaks plain HTTP/gRPC to localhost; the sidecar secures and observes the wire + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/02-mesh-handles-wire-spring.svg b/lgtm-presentation/edits/png/src/02-mesh-handles-wire-spring.svg new file mode 100644 index 00000000..835892cd --- /dev/null +++ b/lgtm-presentation/edits/png/src/02-mesh-handles-wire-spring.svg @@ -0,0 +1,36 @@ + + + +Pod + +Pod + +app +Spring Boot / gRPC server +plain localhost + +istio-proxy +Envoy sidecar +mTLS · retries +timeouts · traces + +app +Spring Boot / gRPC server +plain localhost + +istio-proxy +Envoy sidecar +mTLS · retries +timeouts · traces + +istiod control plane · policy, identity (SPIFFE), telemetry config + + + +mTLS +mTLS + + +encrypted, authenticated +your service speaks plain HTTP/gRPC to localhost; the sidecar secures and observes the wire + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/02-pick-protocol-dotnet.svg b/lgtm-presentation/edits/png/src/02-pick-protocol-dotnet.svg new file mode 100644 index 00000000..67a2e587 --- /dev/null +++ b/lgtm-presentation/edits/png/src/02-pick-protocol-dotnet.svg @@ -0,0 +1,36 @@ + + + +REST / HTTP +public & edge surface +wide reach, cacheable +resource-shaped +ASP.NET Core minimal APIs + +sync + +gRPC +internal service-to-service +low latency, streaming +schema-first (proto) +Grpc.AspNetCore + protobuf + +sync + +GraphQL +client-driven reads +compose many sources +one round-trip +HotChocolate + +sync + +Async / events +fire-and-forget facts +decoupled in time +fan-out to many +Kafka / Pulsar + +async +pick the protocol for the interaction, not the org chart — most systems use all four; the skill is matching each call to the right style + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/02-pick-protocol-go.svg b/lgtm-presentation/edits/png/src/02-pick-protocol-go.svg new file mode 100644 index 00000000..6d6f3aa4 --- /dev/null +++ b/lgtm-presentation/edits/png/src/02-pick-protocol-go.svg @@ -0,0 +1,36 @@ + + + +REST / HTTP +public & edge surface +wide reach, cacheable +resource-shaped +net/http ServeMux + +sync + +gRPC +internal service-to-service +low latency, streaming +schema-first (proto) +grpc-go + protobuf + +sync + +GraphQL +client-driven reads +compose many sources +one round-trip +gqlgen + +sync + +Async / events +fire-and-forget facts +decoupled in time +fan-out to many +Kafka / Pulsar + +async +pick the protocol for the interaction, not the org chart — most systems use all four; the skill is matching each call to the right style + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/02-pick-protocol-spring.svg b/lgtm-presentation/edits/png/src/02-pick-protocol-spring.svg new file mode 100644 index 00000000..4196f033 --- /dev/null +++ b/lgtm-presentation/edits/png/src/02-pick-protocol-spring.svg @@ -0,0 +1,36 @@ + + + +REST / HTTP +public & edge surface +wide reach, cacheable +resource-shaped +Spring Web MVC + +sync + +gRPC +internal service-to-service +low latency, streaming +schema-first (proto) +grpc-java + protobuf + +sync + +GraphQL +client-driven reads +compose many sources +one round-trip +Spring for GraphQL + +sync + +Async / events +fire-and-forget facts +decoupled in time +fan-out to many +Kafka / Pulsar + +async +pick the protocol for the interaction, not the org chart — most systems use all four; the skill is matching each call to the right style + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/04-dual-write-outbox.svg b/lgtm-presentation/edits/png/src/04-dual-write-outbox.svg new file mode 100644 index 00000000..58a97e3d --- /dev/null +++ b/lgtm-presentation/edits/png/src/04-dual-write-outbox.svg @@ -0,0 +1,26 @@ + + + + + + +Dual-write is unsafe — use the transactional outbox +one local transaction writes state and an outbox row; CDC relays it to Kafka + +order-service +1 transaction:INSERT orderINSERT outbox row + +PostgreSQL +orders tableoutbox tableWAL / logicaldecoding + +Debezium +CDC connectortails the WALno dual write + +Kafka +order.placed + + + +exactly the committed facts become events — atomically, in order +The DB commit is the single source of truth; Debezium reads it from the write-ahead log, so a committed change is not lost when the service crashes. + diff --git a/lgtm-presentation/edits/png/src/12-valet-key.svg b/lgtm-presentation/edits/png/src/12-valet-key.svg new file mode 100644 index 00000000..962fcc2d --- /dev/null +++ b/lgtm-presentation/edits/png/src/12-valet-key.svg @@ -0,0 +1,32 @@ + + + +Client + +Application +mints a valet key: +scoped, time-bound, +operation-restricted + +Resource (object store, file) +client uploads / downloads +directly — no app hop + +Storage / KMS +validates the token, +permits the named op, +rejects everything else + +1. request key +1. request key + +2. signed token +2. signed token + +3. PUT / GET with token +3. PUT / GET with token + +4. validate +4. validate +the app never proxies the bytes — no long-lived credentials at the client (Example 12: a ticket the service signs and verifies itself) + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/12-zero-trust.svg b/lgtm-presentation/edits/png/src/12-zero-trust.svg new file mode 100644 index 00000000..bb7a98ce --- /dev/null +++ b/lgtm-presentation/edits/png/src/12-zero-trust.svg @@ -0,0 +1,35 @@ + + + +VPN + +trusted intranet + +any service + +User / device + +IAP / Auth proxy +who? where? device? +MFA, posture checks + +Policy engine +per-request decision +(OPA / Cedar) + +Service +accepts only requests +carrying a valid claim + + + + + + +repeat +repeat +Old model (perimeter trust) +in once → trusted forever → lateral movement is trivial +Zero-trust model +mTLS + SPIFFE identity inside the cluster · IAP at the edge · AuthorizationPolicy per workload — a breach in one service buys nothing + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/22-business-rule-routing-cpp.svg b/lgtm-presentation/edits/png/src/22-business-rule-routing-cpp.svg new file mode 100644 index 00000000..8a94b015 --- /dev/null +++ b/lgtm-presentation/edits/png/src/22-business-rule-routing-cpp.svg @@ -0,0 +1,37 @@ + + + +inputs +REST · WebSocket · Kafka + +Drogon HttpController + +Task<> Orders::route(HttpRequestPtr req, auto cb) { + auto order = parse<OrderIn>(req); // front + auto ctx = co_await enrich(order); // context + auto dests = router.evaluate(order, ctx); // rules + for (const auto& dest : dests) // dispatch + produce_to(*producer_, topic, id, body); // rdkafka++ + +sol2 + Lua +embedded Lua VM +rules.lua, sandboxed +(loaded at startup) + +service A +kafka:orders + +service B +http:payments + +DLQ / audit +kafka:errors + + +evaluate +evaluate + + + +when 'route by header' isn't enough — when the decision is a business rule — push it into a rule engine inside the service + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/22-business-rule-routing-dotnet.svg b/lgtm-presentation/edits/png/src/22-business-rule-routing-dotnet.svg new file mode 100644 index 00000000..d5c516b1 --- /dev/null +++ b/lgtm-presentation/edits/png/src/22-business-rule-routing-dotnet.svg @@ -0,0 +1,37 @@ + + + +inputs +REST · WebSocket · Kafka + +OrderRouter (ASP.NET Core) + +public async Task Route(OrderInbound msg) { +var fact = new OrderFact(id, cents, customer); // context +var session = rules.CreateSession(); +session.Insert(fact); session.Fire(); // rules +foreach (var dest in fact.WhereTo.Split(',')) // dispatch + await producer.ProduceAsync(topic, message); // Confluent.Kafka + +NRules +Rete engine for .NET +rules in C# (NRules.Fluent) +(forward chaining) + +service A +kafka:orders + +service B +http:payments + +DLQ / audit +kafka:errors + + +evaluate +evaluate + + + +when 'route by header' isn't enough — when the decision is a business rule — push it into a rule engine inside the service + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/22-business-rule-routing-go.svg b/lgtm-presentation/edits/png/src/22-business-rule-routing-go.svg new file mode 100644 index 00000000..bb9b5155 --- /dev/null +++ b/lgtm-presentation/edits/png/src/22-business-rule-routing-go.svg @@ -0,0 +1,37 @@ + + + +inputs +REST · WebSocket · Kafka + +net/http handler + +func (s *Server) route(w http.ResponseWriter, r *http.Request) { +rc := enrich(ctx, order) // context +dests := rt.evaluate(ctx, order, rc) // rules +for _, dest := range dests { // dispatch + cl.Produce(ctx, record(dest), done) // franz-go +} + +gopher-lua +Lua VM in pure Go +rules.lua, sandboxed +(pooled LState) + +service A +kafka:orders + +service B +http:payments + +DLQ / audit +kafka:errors + + +evaluate +evaluate + + + +when 'route by header' isn't enough — when the decision is a business rule — push it into a rule engine inside the service + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/22-hop-cost-cpp.svg b/lgtm-presentation/edits/png/src/22-hop-cost-cpp.svg new file mode 100644 index 00000000..5dadd649 --- /dev/null +++ b/lgtm-presentation/edits/png/src/22-hop-cost-cpp.svg @@ -0,0 +1,44 @@ + + + +TLS termination + +0.5 – 2 ms + +L7 path / header routing + +< 1 ms + +Auth / JWT validation + +1 – 5 ms + +WAF inspection + +2 – 5 ms + +Mesh sidecar (mTLS + retry) + +0.5 – 2 ms / hop + +Rate-limit lookup (Redis) + +1 – 3 ms + +Body / payload inspection + +5 – 50 ms + +In-app rule eval (sol2 / Lua) + +< 1 ms (cached) + +rule of thumb + +stay under ~10 ms total +L7 on hot paths + +every hop must +pay for itself +the budget is finite — two layers can be cheap; four overlapping layers are where p99 latency grows + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/22-hop-cost-drools.svg b/lgtm-presentation/edits/png/src/22-hop-cost-drools.svg new file mode 100644 index 00000000..400b28cf --- /dev/null +++ b/lgtm-presentation/edits/png/src/22-hop-cost-drools.svg @@ -0,0 +1,44 @@ + + + +TLS termination + +0.5 – 2 ms + +L7 path / header routing + +< 1 ms + +Auth / JWT validation + +1 – 5 ms + +WAF inspection + +2 – 5 ms + +Mesh sidecar (mTLS + retry) + +0.5 – 2 ms / hop + +Rate-limit lookup (Redis) + +1 – 3 ms + +Body / payload inspection + +5 – 50 ms + +In-app rule eval (Drools) + +< 1 ms (cached) + +rule of thumb + +stay under ~10 ms total +L7 on hot paths + +every hop must +pay for itself +the budget is finite — two layers can be cheap; four overlapping layers are where p99 latency grows + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/22-hop-cost-go.svg b/lgtm-presentation/edits/png/src/22-hop-cost-go.svg new file mode 100644 index 00000000..400030a6 --- /dev/null +++ b/lgtm-presentation/edits/png/src/22-hop-cost-go.svg @@ -0,0 +1,44 @@ + + + +TLS termination + +0.5 – 2 ms + +L7 path / header routing + +< 1 ms + +Auth / JWT validation + +1 – 5 ms + +WAF inspection + +2 – 5 ms + +Mesh sidecar (mTLS + retry) + +0.5 – 2 ms / hop + +Rate-limit lookup (Redis) + +1 – 3 ms + +Body / payload inspection + +5 – 50 ms + +In-app rule eval (gopher-lua) + +< 1 ms (cached) + +rule of thumb + +stay under ~10 ms total +L7 on hot paths + +every hop must +pay for itself +the budget is finite — two layers can be cheap; four overlapping layers are where p99 latency grows + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/22-hop-cost-nrules.svg b/lgtm-presentation/edits/png/src/22-hop-cost-nrules.svg new file mode 100644 index 00000000..0ad26c7c --- /dev/null +++ b/lgtm-presentation/edits/png/src/22-hop-cost-nrules.svg @@ -0,0 +1,44 @@ + + + +TLS termination + +0.5 – 2 ms + +L7 path / header routing + +< 1 ms + +Auth / JWT validation + +1 – 5 ms + +WAF inspection + +2 – 5 ms + +Mesh sidecar (mTLS + retry) + +0.5 – 2 ms / hop + +Rate-limit lookup (Redis) + +1 – 3 ms + +Body / payload inspection + +5 – 50 ms + +In-app rule eval (NRules) + +< 1 ms (cached) + +rule of thumb + +stay under ~10 ms total +L7 on hot paths + +every hop must +pay for itself +the budget is finite — two layers can be cheap; four overlapping layers are where p99 latency grows + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/63-appD-stateless-dn.svg b/lgtm-presentation/edits/png/src/63-appD-stateless-dn.svg new file mode 100644 index 00000000..80a98482 --- /dev/null +++ b/lgtm-presentation/edits/png/src/63-appD-stateless-dn.svg @@ -0,0 +1,60 @@ + + + +Stateless for in-process sagas in .NET +lighter-weight: states + triggers + transitions, persisted in your own DB + +Created + +PayOk + +Paid + +ReserveOk + +Reserved + +ShipOk + +Shipped + +Delivered + +Done + +on StepFail + +Compensating + +Failed + + +StateMachine<TState, TTrigger> +states · triggers · transitions +guards (predicate on edge) +entry / exit actions +OnTransitioned listeners + +Stateless package (Apache 2.0) +tiny dependency, no extra runtime + +Persist state yourself +serialize CurrentState → saga row + +EF Core · Dapper · Marten + +one DbContext transaction per step +scheduled sweeper for in-flight sagas + +Driving the machine +await sm.FireAsync( + Trigger.PayOk, payload); + +// guards + actions run on transition +// persister saves new CurrentState + +need durable timers and retries: +Temporal (separate server) +in-process · no extra cluster to run · open source (Apache 2.0) +When the saga is small, in-process, and you do not want a separate Temporal cluster. + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/70-appJ-pinned-libraries-dn.svg b/lgtm-presentation/edits/png/src/70-appJ-pinned-libraries-dn.svg new file mode 100644 index 00000000..c68d723f --- /dev/null +++ b/lgtm-presentation/edits/png/src/70-appJ-pinned-libraries-dn.svg @@ -0,0 +1,167 @@ + + +The pinned .NET 10 toolkit +every library in your csproj: purpose, license, NuGet package +Concern +Library +Purpose +License +NuGet package + + +Runtime +.NET 10 (10.0.12) +LTS through Nov 2028; cross-platform +MIT +(built in) +Hosting +ASP.NET Core 10 +Web host, minimal APIs, controllers, DI, config +MIT +(built in) + +REST +Microsoft.AspNetCore.OpenApi +OpenAPI 3 spec generation from endpoints +MIT +Microsoft.AspNetCore.OpenApi +REST +FluentValidation +Expressive request validation +Apache 2.0 +FluentValidation + +Versioning +Asp.Versioning +Media-type / header / URI versioning +MIT +Asp.Versioning.Http +gRPC +Grpc.AspNetCore +Server + Grpc.Tools build-time codegen +Apache 2.0 +Grpc.AspNetCore + +gRPC +Grpc.StatusProto +google.rpc.Status rich errors (ToRpcException) +Apache 2.0 +Grpc.StatusProto +GraphQL +HotChocolate +Code-first server, DataLoader, federation +MIT +HotChocolate.AspNetCore + +Data · ORM +Entity Framework Core +Full-fat ORM; LINQ; migrations +MIT +Microsoft.EntityFrameworkCore +Data · micro +Dapper +Lightweight micro-ORM +Apache 2.0 +Dapper + +Event store +Marten +Document + event sourcing on Postgres +MIT +Marten +Messaging +Confluent.Kafka 2.16.0 +Kafka client: producers, consumer groups, explicit offset commits +Apache 2.0 +Confluent.Kafka + +Streaming +Streamiz.Kafka.Net 1.8.2 +.NET port of Kafka Streams +MIT +Streamiz.Kafka.Net +Workflow +Temporalio +Temporal .NET SDK — durable execution +MIT +Temporalio + +Workflow +Stateless +In-process state machine library +Apache 2.0 +Stateless +Rules +NRules +Rete-algorithm rules engine +MIT +NRules.Fluent + +Rules · DSL +NRules.Language +External DSL for non-developer rule editing +MIT +NRules.Language +WebSockets +Microsoft.AspNetCore.SignalR +Hub abstraction; auto-reconnect; MessagePack +MIT +(built in) + +WebSockets +SignalR.StackExchangeRedis +Open-source Redis backplane (no managed SignalR service) +MIT +Microsoft.AspNetCore.SignalR.StackExchangeRedis +UI · server +Blazor Server +BFF + WebSocket UI; SignalR-based +MIT +(built in) + +UI · client +Blazor WebAssembly +.NET in the browser; static-file hosted +MIT +(built in) +Resilience +Polly + M.E.Resilience +Retry, circuit breaker, hedging, timeout, bulkhead +BSD-3 / MIT +Microsoft.Extensions.Resilience + +Health +AspNetCore.HealthChecks +Postgres / Redis / Kafka / SQL Server probes (community) +Apache 2.0 +AspNetCore.HealthChecks.* +Observability +OpenTelemetry .NET +Tracing, metrics, logs; ActivitySource is in the BCL +Apache 2.0 +OpenTelemetry.Extensions.Hosting + +Reverse proxy +YARP +In-app reverse proxy / gateway library +MIT +Yarp.ReverseProxy +Mediator +Wolverine +Command / handler dispatch (MediatR OSS heir) +MIT +WolverineFx + +Scheduling +Quartz.NET +Cron / cluster-aware job scheduling +Apache 2.0 +Quartz +Scheduling +DistributedLock +Distributed mutex over Postgres / Redis / SQL Server +MIT +DistributedLock.* +Every entry is MIT, Apache 2.0 or BSD-3 · every entry runs on plain Kubernetes · no cloud-only dependencies +Kafka through Confluent.Kafka directly; stream processing on Streamiz.Kafka.Net. + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/71-appJ-pinned-platform-dn.svg b/lgtm-presentation/edits/png/src/71-appJ-pinned-platform-dn.svg new file mode 100644 index 00000000..1fc88ca8 --- /dev/null +++ b/lgtm-presentation/edits/png/src/71-appJ-pinned-platform-dn.svg @@ -0,0 +1,84 @@ + + +The pinned cloud-native platform stack +the cluster-side open-source components your .NET Pods rely on +Concern +Component +Purpose +License + + +Container orchestration +Kubernetes +Pods, Services, Deployments, ConfigMaps, Secrets +Apache 2.0 +Service mesh +Istio / Linkerd +mTLS, retries, traffic shaping, observability +Apache 2.0 + +Autoscaling +KEDA +Event- and lag-driven scaling (incl. scale to zero) +Apache 2.0 +Messaging +Apache Kafka / Strimzi +Distributed log + Kubernetes-native operator +Apache 2.0 + +Schema registry +Apicurio Registry +OpenAPI / .proto / Avro / JSON Schema, versioned +Apache 2.0 +Data catalog / lineage +OpenMetadata +Asset catalog, lineage, SLOs, ownership +Apache 2.0 + +Tracing backend +Grafana Tempo +Trace storage + query, OTLP ingest +AGPL-3.0 +Metrics backend +Grafana Mimir / Prometheus +Long-term metrics, OTLP + Prom remote-write +AGPL / Apache + +Logs backend +Grafana Loki +Label-indexed log store, low cardinality cost +AGPL-3.0 +Dashboards / alerts +Grafana +Unified panel for traces, metrics, logs +AGPL-3.0 + +CDC +Debezium +Postgres / SQL Server WAL tailing → Kafka +Apache 2.0 +Relational DB +PostgreSQL +Primary OLTP store; CDC source +PostgreSQL + +Key-value / cache +Redis (or Valkey) +SignalR backplane, distributed locks, caching +AGPL-3.0 (Redis 8) / BSD (Valkey) +Workflow server +Temporal Server +Durable-execution server (paired with .NET SDK) +MIT + +Secrets +Kubernetes Secrets + external-secrets-operator +Secrets sync from your vault of choice +Apache 2.0 +Observability pipeline +OpenTelemetry Collector +OTLP ingest, processing, fan-out +Apache 2.0 +These components serve a Spring Boot or Python stack the same way. +Standard CNCF-friendly stack; .NET 10 is the runtime in the application Pods. + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/72-appJ-migration-reference-dn.svg b/lgtm-presentation/edits/png/src/72-appJ-migration-reference-dn.svg new file mode 100644 index 00000000..256b3f2e --- /dev/null +++ b/lgtm-presentation/edits/png/src/72-appJ-migration-reference-dn.svg @@ -0,0 +1,111 @@ + + +Migration reference +concrete patterns from Windows / .NET Framework 4.x to Kubernetes / .NET 10 +Concern +Coming from (Windows / .NET 4.x) +Going to (.NET 10 / Kubernetes) +What changes most + + +Hosting +IIS app pool, web.config +ASP.NET Core 10 in container; appsettings.json + ConfigMap +Process is now stateless; cluster injects config; image is the unit +Lifecycle +App pool recycle on memory threshold +Kubernetes rolling deploy + graceful shutdown +SIGTERM is your shutdown signal; HostOptions.ShutdownTimeout is your budget + +Service shape +WCF [ServiceContract] / [OperationContract] +ASP.NET Core minimal API, Grpc.AspNetCore service, HotChocolate type +Pick the protocol per call site, not per binding +Contracts +DataContract / .svc / WSDL +Microsoft.AspNetCore.OpenApi; Grpc.Tools; HotChocolate SDL +Contracts live in Apicurio; consumers code against the contract + +Wire +NetTcpBinding (binary), BasicHttpBinding (SOAP) +gRPC over HTTP/2 (binary); REST / JSON +Binary on internal paths; REST at the edge +Distributed txn +COM+ / MSDTC two-phase commit +Saga + outbox (EF Core + Debezium) +No two-phase commit across services; compensation replaces rollback + +Messaging +MSMQ, SQL Server Service Broker +Apache Kafka (Strimzi) + Confluent.Kafka +Replayable log + consumer groups + dead-letter routing +Background work +Windows Service hosting a queue listener +BackgroundService in a Pod, scaled by KEDA +Long-running concerns ride the same Pod lifecycle as your web host + +Scheduled work +Windows Scheduled Tasks +Kubernetes CronJob / KEDA Cron / Quartz.NET +Pick by scope: cluster-wide → CronJob; in-app → Quartz.NET +Config +web.config + Web.Release.config xforms +appsettings.json + env vars + ConfigMap +Config flows via the platform; the image never embeds env + +Auth +Windows Auth, AD, NTLM, Kerberos +OIDC + mTLS via Istio + SPIFFE +Identity is workload-bound, not machine-bound +Telemetry +Windows Perf Counters + ETW + Event Log +OpenTelemetry .NET → OTLP → Tempo / Mimir / Loki +One model for traces, metrics, logs across all languages + +Logs +log4net, NLog, EventLog +Microsoft.Extensions.Logging + Serilog → Loki +Structured JSON; never the local filesystem +Caching +ASP.NET Cache, AppFabric +Redis (open source) via StackExchange.Redis +Distributed cache lives outside the Pod + +Sessions +ASP.NET Session State +Stateless services + Redis (or no sessions, use JWT) +12-factor disposability; do not pin users to Pods +.NET Remoting +Remoting, MarshalByRefObject +gRPC over HTTP/2 / HTTP API +Wire boundary is explicit; serialization is contractual + +Distributed transactions UI +BizTalk orchestration +Temporal .NET workflows / Stateless (lightweight) +Orchestration is plain C#; the engine handles durability +Routing logic +BizTalk pipelines + content-based routing +NRules + Confluent.Kafka producers +EIP patterns in code; rules in their own .csproj + +UI · LOB +ASP.NET Web Forms / MVC + jQuery +Blazor Server (SignalR) or Blazor WebAssembly (browser .NET) +Same C# both sides of the wire; no managed cloud service required +Reverse proxy +ARR (IIS Application Request Routing) +YARP (in-app) / Istio Gateway (mesh edge) +Code-level routing or declarative platform routing + +Resilience +Hand-coded try/retry loops, Polly v6 standalone +M.E.Resilience pipelines (built on Polly) +Composable: retry + circuit breaker + timeout + hedging +Health probes +ASP.NET app warmup, monitoring agents +M.E.Diagnostics.HealthChecks → /healthz/live + ready +Kubernetes is the monitoring agent +Almost every row keeps the shape of the pattern; what changes is who owns the host and the substrate. +The last column is usually 'the platform owns more, your code owns less'. + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/k8s-12-policy-as-code.svg b/lgtm-presentation/edits/png/src/k8s-12-policy-as-code.svg new file mode 100644 index 00000000..6769bb7d --- /dev/null +++ b/lgtm-presentation/edits/png/src/k8s-12-policy-as-code.svg @@ -0,0 +1,34 @@ + + + +Developer + +Git / CI +conftest, kyverno +pre-merge gate + +kubectl apply +or ArgoCD / Flux + +Admission controller +OPA Gatekeeper / Kyverno + +evaluates ConstraintTemplates +against incoming objects + +API server +accepts or rejects +based on policy + +Policy (Rego / Kyverno YAML) +"images must be signed" +"no privileged containers" + + + + + +loaded +loaded +policy lives in Git, reviewed like code, applied by the cluster — no human runs a checklist (OPA/Gatekeeper, Kyverno, Conftest, Kubescape) + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/k8s-22-l7-layers.svg b/lgtm-presentation/edits/png/src/k8s-22-l7-layers.svg new file mode 100644 index 00000000..9edeaf55 --- /dev/null +++ b/lgtm-presentation/edits/png/src/k8s-22-l7-layers.svg @@ -0,0 +1,36 @@ + + + +North / south — a request is a chain of L7 decisions + +East / west — most L7 decisions happen here, between services, in the mesh + +Client + +Edge / ingress +TLS · host/path + +API gateway +auth · rate limit + +Mesh sidecar +mTLS · retries + +Service +in-app rules + +Service A + +mesh sidecars +mTLS · outlier · locality + +Service B + + + + + + +the mesh is the same L7 routing, applied to every internal call — not just at the edge +each layer makes a distinct decision, and each emits a trace span — the path is observable end to end + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/k8s-22-traffic-steering.svg b/lgtm-presentation/edits/png/src/k8s-22-traffic-steering.svg new file mode 100644 index 00000000..b6164537 --- /dev/null +++ b/lgtm-presentation/edits/png/src/k8s-22-traffic-steering.svg @@ -0,0 +1,28 @@ + + + +Canary +90% v1, 10% v2 +watch metrics; ramp + +Blue / Green +all v1 -> all v2 +flip atomically; fast rollback + +A / B test +cohort -> variant +stable per user; measure + +Header-based +x-internal:1 -> v2 +dark launches, betas + +Geo / latency +EU users -> EU pods +lower RTT, data residency + +Shadow +copy traffic to v2 +test under load, no user impact +each pattern is a header/weight rule away — the value is in the deploy and rollback discipline around them + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/k8s-policy-ci-pipeline.svg b/lgtm-presentation/edits/png/src/k8s-policy-ci-pipeline.svg new file mode 100644 index 00000000..dae2f7d7 --- /dev/null +++ b/lgtm-presentation/edits/png/src/k8s-policy-ci-pipeline.svg @@ -0,0 +1,31 @@ + + + +Schema Check +kubeconform v0.8.0 + +Policy Lint +conftest / kyverno + +Dry-Run +kubectl --dry-run + +Stage Deploy +ArgoCD sync + +Smoke Test +curl / hey / newman + +Approve +PR review gate + +Prod + + + + + + +CI/CD validation pipeline — every policy change goes through all stages +Fail at any stage: PR blocked. No exceptions, no bypass. + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/k8s-policy-env-rbac.svg b/lgtm-presentation/edits/png/src/k8s-policy-env-rbac.svg new file mode 100644 index 00000000..c248d2ad --- /dev/null +++ b/lgtm-presentation/edits/png/src/k8s-policy-env-rbac.svg @@ -0,0 +1,42 @@ + + + +Dev + +Test / Staging + +Production + +Kubernetes API Server + +Developers +Full CRUD in own namespace + +NetworkPolicies +create, update, delete + +Deployments / Pods +create, update, delete + +Developers +Read-only manual access + +CI/CD Pipeline +deploy via automation + +NetworkPolicies +get, list, watch only + +Developers +Read-only (view) + +ArgoCD / Flux +GitOps-only deploy path + +All changes +PR -> CI -> review -> sync + + + +Freedom <-> Restriction: access decreases as environment risk increases + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/k8s-policy-gitops-flow.svg b/lgtm-presentation/edits/png/src/k8s-policy-gitops-flow.svg new file mode 100644 index 00000000..d15e52e9 --- /dev/null +++ b/lgtm-presentation/edits/png/src/k8s-policy-gitops-flow.svg @@ -0,0 +1,38 @@ + + + +Developer +opens PR + +Git Repo +env manifests + +CI Lint +kubeconform +conftest + +Staging +deploy + test + +Approval +peer review + +ArgoCD/Flux +sync to cluster + +Prod Cluster +desired state applied + + + + + + + + +drift detection +drift detection + +GitOps pipeline: the only path to production +No human runs kubectl apply in production — every change is a Git commit + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/k8s-policy-namespace-isolation.svg b/lgtm-presentation/edits/png/src/k8s-policy-namespace-isolation.svg new file mode 100644 index 00000000..c77bfd47 --- /dev/null +++ b/lgtm-presentation/edits/png/src/k8s-policy-namespace-isolation.svg @@ -0,0 +1,41 @@ + + + +Namespace: order-prod + +RBAC +RoleBinding -> view only + +NetworkPolicy +default-deny + allow list + +ResourceQuota +CPU, memory, pod limits + +LimitRange +per-container defaults + +order-service +port 8080 + +inventory-service +port 8080 + +payment-service +port 8080 + +PostgreSQL +port 5432 + +Cross-namespace traffic +DENIED by default + +allowed +allowed + + +allowed +allowed + +Four controls stack to make each namespace a blast-radius boundary + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/net-advisory-ci-pipeline.svg b/lgtm-presentation/edits/png/src/net-advisory-ci-pipeline.svg new file mode 100644 index 00000000..1009d064 --- /dev/null +++ b/lgtm-presentation/edits/png/src/net-advisory-ci-pipeline.svg @@ -0,0 +1,46 @@ + + + +Developer +authors policy YAML + +PR opened +policy repo + +Schema Check +kubeconform + +Policy Lint +conftest / kyverno + +Baseline Diff +vs observed flows + +Review +platform + security + +GitOps Sync +Argo CD → staging + +Smoke Test +connectivity checks + +Prod Sync +Argo CD → prod + +Post-deploy +flow logs + Falco + + + + + + +merge + + + +Network policy CI/CD pipeline — every change goes through all stages +CODEOWNERS enforces: platform-baselines → firewall review, app-policies → platform review +Fail at any stage → PR blocked. No exceptions, no bypass. + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/net-advisory-team-ownership.svg b/lgtm-presentation/edits/png/src/net-advisory-team-ownership.svg new file mode 100644 index 00000000..714dc6b1 --- /dev/null +++ b/lgtm-presentation/edits/png/src/net-advisory-team-ownership.svg @@ -0,0 +1,54 @@ + + + +Firewall / Security Team + +Platform Team + +App Teams + +Egress rules +external destination rules + +Admin / Baseline review +cluster-scoped policy approval + +Perimeter firewall +north-south, compliance logs + +CNP Admin tier +cluster guardrails, Accept/Deny/Pass + +CNP Baseline tier +default-deny safety net + +Starter templates +per workload archetype + +NP review +app team policy approval + +NetworkPolicy +namespace-scoped, per-service + +Observed baselines +review generated policies + +PR submission +policy changes via Git + +reviews +reviews + +reviews +reviews + +templates +templates +Policy ownership model — who authors, who reviews, who approves +Gatekeeper → architect +Keeps north-south edge +Controls cluster posture +Owns application connectivity +Developers review, not author from scratch + \ No newline at end of file diff --git a/lgtm-presentation/edits/png/src/net-advisory-tier-evaluation.svg b/lgtm-presentation/edits/png/src/net-advisory-tier-evaluation.svg new file mode 100644 index 00000000..3d5f765d --- /dev/null +++ b/lgtm-presentation/edits/png/src/net-advisory-tier-evaluation.svg @@ -0,0 +1,51 @@ + + + +Tier 1 — CNP Admin tier + +Tier 2 — NetworkPolicy + +Tier 3 — CNP Baseline tier + +Admin tier evaluates +cluster-scoped, priority 0-1000 + +Accept +traffic flows immediately + +Deny +traffic blocked immediately + +Pass +hand to tier 2 + +NP evaluates +namespace-scoped, app-owned + +Match +apply NP rules + +No NP exists +hand to tier 3 + +Baseline tier evaluates +cluster-scoped, safety net + +Accept +traffic flows + +Deny +traffic blocked + +pass +pass + +no match +no match +ClusterNetworkPolicy three-tier evaluation +Platform team controls tiers 1 and 3 (guardrails). +App teams control tier 2 (their policies). +The Baseline tier catches unmatched traffic. +Admin Accept/Deny skip NP and Baseline. +Baseline is overridable by NetworkPolicy. + \ No newline at end of file diff --git a/lgtm-presentation/k8s-environment-policies.docx b/lgtm-presentation/k8s-environment-policies.docx index 68ffdca6..6c311e06 100644 Binary files a/lgtm-presentation/k8s-environment-policies.docx and b/lgtm-presentation/k8s-environment-policies.docx differ diff --git a/lgtm-presentation/k8s-environment-policies.md b/lgtm-presentation/k8s-environment-policies.md index 7602610d..d3879a94 100644 --- a/lgtm-presentation/k8s-environment-policies.md +++ b/lgtm-presentation/k8s-environment-policies.md @@ -23,7 +23,7 @@ and GitOps — applied differently per environment. --- -## 2. Three environments, three risk profiles +## 2. Risk profiles by environment Each environment serves a different purpose and demands a different level of control: @@ -240,7 +240,7 @@ In production, no human runs `kubectl apply`. Every change follows a GitOps pipe 1. **Developer opens a PR** against the environment repo (or a `kustomize` overlay for the prod branch). -2. **CI validates** — `kubeval` checks schema, `conftest` runs OPA policies, a +2. **CI validates** — `kubeconform` checks schema, `conftest` runs OPA policies, a staging deployment proves the change doesn't break. 3. **Peer review + approval** — at least one reviewer from the platform or SRE team. 4. **Merge triggers deployment** — ArgoCD or Flux detects the merge, syncs the @@ -385,7 +385,7 @@ same validation pipeline before reaching any cluster: | Stage | Tool | What it catches | |-------|------|----------------| -| **Schema validation** | kubeval, kubeconform | Invalid YAML, unknown fields, wrong API versions | +| **Schema validation** | kubeconform v0.8.0 | Invalid YAML, unknown fields, wrong API versions | | **Policy lint** | conftest, kyverno CLI | Violations of organizational policies (no privileged, trusted registry, etc.) | | **Dry-run apply** | `kubectl apply --dry-run=server` | API server rejects (quota exceeded, admission denial) | | **Staging deploy** | ArgoCD sync to staging | Runtime failures, misconfigured selectors, broken connectivity | diff --git a/lgtm-presentation/network-policy-process-advisory.docx b/lgtm-presentation/network-policy-process-advisory.docx index e04bf5bf..9f236c7f 100644 Binary files a/lgtm-presentation/network-policy-process-advisory.docx and b/lgtm-presentation/network-policy-process-advisory.docx differ diff --git a/lgtm-presentation/network-policy-process-advisory.md b/lgtm-presentation/network-policy-process-advisory.md index c37726b5..dde84e93 100644 --- a/lgtm-presentation/network-policy-process-advisory.md +++ b/lgtm-presentation/network-policy-process-advisory.md @@ -1,58 +1,63 @@ -# Network Policy Process — Red Hat Advisory +# Network Policy Process — Advisory A recommended approach for operationalizing in-cluster network segmentation -on OpenShift, from perimeter-only to developer-owned NetworkPolicies. +on Kubernetes, from perimeter-only to developer-owned NetworkPolicies. --- ## What this document is -This is Red Hat's recommended approach to building a network policy process +This is a recommended approach to building a network policy process for an organization that: - Runs perimeter and east-west controls through a firewall team on a - traditional appliance (Palo Alto, Fortinet, etc.) today. -- Wants to shift network segmentation left into the OpenShift cluster using - Kubernetes NetworkPolicy, AdminNetworkPolicy, and EgressFirewall. + traditional perimeter firewall appliance today. +- Wants to shift network segmentation left into the Kubernetes cluster using + NetworkPolicy, ClusterNetworkPolicy, and egress rules. - Has developers who are not security specialists — most have never written a firewall rule and do not inherently know what traffic their applications require. **What this document is not:** a tool selection exercise or an ownership assignment. It describes a process and a maturity path. Organizations apply -it with the teams and tools they already have. +it with the teams and tools they already have. The tools named are upstream +open-source options: a CNI that enforces NetworkPolicy and ClusterNetworkPolicy +(Calico or Cilium), Kyverno or OPA Gatekeeper for admission policy, Trivy for +manifest scanning, Falco for runtime detection, and Argo CD for GitOps. --- -## 1. Where customers typically start +## 1. Where organizations typically start Most organizations move through a four-phase maturity path. Trying to skip phases — jumping straight to developer-authored micro-segmentation — is the most common cause of stalled adoption. The timeline for each phase depends on the organization's complexity, team -capacity, and risk tolerance. The phases below are sequential — each builds -on the previous — but the pace is agreed upon by the organization. +capacity, and risk tolerance. The four phases are sequential, each building +on the previous, but the pace is agreed upon by the organization. ### Phase 1 — Observe -Before writing any policy, understand what traffic actually flows today. +Before writing any policy, understand what traffic flows today. -Red Hat Advanced Cluster Security (ACS) automatically discovers network -flows between deployments, namespaces, and external destinations. It builds -a **network baseline** — a map of every connection the cluster has seen — and -renders it in the **Network Graph**. This baseline becomes the source of truth -for what traffic is expected. +The CNI records the network flows between pods, namespaces, and external +destinations: Hubble with Cilium (`hubble observe`), or Calico's flow logs +(Goldmane, viewed in the Whisker console). Inspektor Gadget's +`advise_networkpolicy` gadget records the TCP and UDP traffic of chosen +namespaces. Together they give a **network baseline**, a map of every +connection the cluster has seen, which becomes the source of truth for what +traffic is expected. At this stage, nothing is denied. The goal is visibility. The firewall team -and platform team review the ACS network graph together and identify: +and platform team review the observed flows together and identify: - Which services talk to each other (and which shouldn't). - Which services make outbound calls to external endpoints. - Which connections cross namespace boundaries. - Which traffic patterns were previously invisible behind the perimeter. -**What Red Hat typically sees:** customers are surprised by the volume of +**What field teams typically see:** organizations are surprised by the volume of east-west traffic they did not know existed — service-to-service connections that were never documented because the perimeter firewall was the only control plane. @@ -76,17 +81,19 @@ spec: - Egress ``` -Simultaneously, use ACS to **auto-generate NetworkPolicy YAML** from the -observed baseline. ACS generates a single policy per deployment (named -`stackrox-generated-`), with ingress rules based on observed -source pods and namespace selectors. These generated policies are a starting +Simultaneously, **generate NetworkPolicy YAML** from the observed baseline. +Inspektor Gadget's `advise_networkpolicy` writes one policy per workload, with +ingress and egress rules based on the observed peers (Kubernetes NetworkPolicy, +or CiliumNetworkPolicy on Cilium). These generated policies are a starting point — they capture what traffic *did* flow, not what traffic *should* flow. +On Calico, a StagedNetworkPolicy shows in the flow logs what a policy would +deny before it is enforced. The platform team reviews the generated policies, removes any connections that should not have existed (the audit value of this step is significant), and applies the cleaned-up policies alongside the default-deny. -**What Red Hat typically sees:** the first default-deny rollout in a non-prod +**What field teams typically see:** the first default-deny rollout in a non-prod namespace immediately reveals undocumented dependencies — a monitoring agent that scrapes every pod, a legacy batch job that connects to an external database, a sidecar that phones home to a SaaS endpoint. Discovering these @@ -95,8 +102,8 @@ in dev or test is the point. ### Phase 3 — Guardrails With baseline policies in place, the platform team introduces cluster-scoped -guardrails using OpenShift's **AdminNetworkPolicy (ANP)** and -**BaselineAdminNetworkPolicy (BANP)**. +guardrails using the upstream **ClusterNetworkPolicy (CNP)** API, in its +**Admin** and **Baseline** tiers. These are the policies the platform team owns — they cannot be overridden by developers, and they establish the security boundaries that all applications @@ -106,69 +113,76 @@ inherit. App teams begin authoring and owning their own namespace-scoped NetworkPolicies. They do this via pull request, with CI validation and -platform team review. The process section later in this document describes +platform team review. The CI/CD flow section describes this workflow in detail. --- ## 2. The three-tier policy model -OpenShift's OVN-Kubernetes network plugin evaluates traffic against three +A CNI that implements ClusterNetworkPolicy evaluates traffic against three tiers of policy, in order. Understanding this model is critical — it determines who controls what and where overrides are possible. -### Tier 1 — AdminNetworkPolicy (ANP) +### Tier 1 — ClusterNetworkPolicy, Admin tier - **Scope:** cluster-wide (not namespace-scoped) - **Controlled by:** platform / infrastructure team -- **Priority:** 0 (highest) to 99 (lowest) -- **Actions:** Allow, Deny, or **Pass** (hand evaluation to the next tier) -- **Override:** cannot be overridden by NetworkPolicy or BANP - -ANP is where the platform team enforces non-negotiable rules: deny traffic -to the control plane from application namespaces, allow monitoring agents -to scrape all pods, deny cross-environment traffic (dev → prod). A **Pass** +- **Priority:** 0 (highest) to 1000 (lowest) +- **Actions:** Accept, Deny, or **Pass** (hand evaluation to the next tier) +- **Override:** cannot be overridden by NetworkPolicy or the Baseline tier +- **Status:** `policy.networking.k8s.io/v1alpha2` (alpha, network-policy-api v0.2.0); enforced by Calico 3.32 and later, and by Cilium 1.20 when enabled + +The Admin tier is where the platform team enforces non-negotiable rules: deny +traffic to the control plane from application namespaces, accept monitoring +agents scraping all pods, deny cross-environment traffic (dev → prod). A **Pass** rule explicitly delegates the decision to the next tier (NetworkPolicy), which is how the platform team says "app teams, this one is yours to decide." -**Critical caution:** an ANP with an empty namespace selector (`{}`) matches -*all* namespaces, including `openshift-*` and `kube-system`. Use workload- -specific label selectors. An overly broad ANP can lock the cluster. +**Critical caution:** an Admin-tier policy with an empty namespace selector +(`{}`) matches *all* namespaces, including `kube-system` and the CNI's own +namespaces. Use workload-specific label selectors. An overly broad Admin-tier +policy can lock the cluster. ### Tier 2 — NetworkPolicy (NP) - **Scope:** namespace-scoped - **Controlled by:** app teams (developers) -- **Override:** can be overridden by ANP (which takes precedence) +- **Override:** can be overridden by the Admin tier (which takes precedence) This is the standard Kubernetes NetworkPolicy API. App teams use it to define which pods in their namespace accept traffic from which sources, on -which ports. Traffic that matches an ANP Allow or Deny rule never reaches -this tier — it is only evaluated if the ANP passed the traffic through. +which ports. Traffic that matches an Admin-tier Accept or Deny rule never reaches +this tier; it is evaluated unless an Admin-tier rule accepted or denied the traffic. -### Tier 3 — BaselineAdminNetworkPolicy (BANP) +### Tier 3 — ClusterNetworkPolicy, Baseline tier -- **Scope:** cluster-wide (singleton — only one BANP exists per cluster) +- **Scope:** cluster-wide (one default-deny policy is the recommended shape) - **Controlled by:** platform / infrastructure team -- **Actions:** Allow or Deny (no Pass) +- **Actions:** Deny for the safety net, Accept for cluster-wide defaults such as DNS - **Override:** *can* be overridden by NetworkPolicy -BANP is the safety net. It catches traffic that was passed by the ANP and -not matched by any NetworkPolicy. The recommended use is **default-deny at -the BANP tier** — if no ANP rule and no NetworkPolicy allows it, BANP denies -it. This means that a namespace with no NetworkPolicies at all still has a +The Baseline tier is the safety net. It decides traffic that no Admin-tier rule +accepted or denied and no NetworkPolicy covers. The recommended use is **default-deny at +the Baseline tier**: if no Admin-tier rule and no NetworkPolicy allows it, the +Baseline tier denies it. This means that a namespace with no NetworkPolicies at all still has a default-deny posture, without requiring the app team to create the default-deny policy themselves. +The earlier releases of the API split these two tiers into +AdminNetworkPolicy and BaselineAdminNetworkPolicy (`v1alpha1`). Upstream now +lists them as previous APIs, and Calico 3.32 no longer enforces them, so new +policies use ClusterNetworkPolicy. + ### How the tiers compose -*Diagram: advisory-tier-evaluation — OVN-Kubernetes three-tier policy evaluation* +*Diagram: advisory-tier-evaluation — ClusterNetworkPolicy three-tier evaluation* ![Three-tier policy evaluation](../assets/diagrams/advisory-tier-evaluation.svg) -**The value proposition for the customer:** the platform team controls tiers +**The value proposition for the organization:** the platform team controls tiers 1 and 3 (the guardrails). Developers control tier 2 (their application's -policies). Even if a developer writes no NetworkPolicies at all, the BANP +policies). Even if a developer writes no NetworkPolicies at all, the Baseline default-deny ensures the namespace is not wide open. This directly addresses the developer skill gap — the platform provides safety by default. @@ -179,11 +193,12 @@ the developer skill gap — the platform provides safety by default. Developers are not firewall engineers. Expecting them to author network policies from scratch — knowing which ports their services listen on, which upstream services call them, and which external endpoints they need — is -unrealistic at the start. Red Hat recommends four complementary approaches: +unrealistic at the start. Four complementary approaches close the gap: -### a) ACS-generated baselines — developers review, not author +### a) Generated baselines — developers review, not author -ACS observes actual traffic for a configurable window, then generates +Inspektor Gadget's `advise_networkpolicy` observes traffic for a chosen window, +then generates NetworkPolicy YAML that matches the observed flows. The developer's job is to *review* the generated policy — "does this look right? should this connection exist?" — not to author it from scratch. This inverts the skill @@ -213,9 +228,9 @@ namespace and label selectors, and submit a PR. The template handles the hard parts (deny-all baseline, correct policyTypes, CIDR ranges for external services). -### c) BANP as a safety net +### c) The Baseline tier as a safety net -With a BANP configured to deny traffic that no other policy allows, a +With a Baseline-tier policy that denies traffic no other policy allows, a developer who deploys a new service without any NetworkPolicy gets default-deny behavior automatically. Their service cannot send or receive traffic until they (or the platform) create a policy. This is strict but @@ -227,8 +242,8 @@ connectivity issue that surfaces immediately in testing. When a new namespace is provisioned (via GitOps, a self-service portal, or an operator), the provisioning process automatically includes: -- A default-deny NetworkPolicy -- An EgressFirewall allowing only approved external destinations +- A default-deny NetworkPolicy (a Kyverno generate rule can create it for every new namespace) +- Egress rules allowing only approved external destinations - Starter NetworkPolicy templates in a `policies/` directory - RBAC bindings giving the app team edit access to NetworkPolicy resources @@ -254,28 +269,28 @@ The pipeline stages: 1. **Developer** authors or modifies NetworkPolicy YAML and opens a PR against the policy repo. 2. **CI validates automatically:** - - Schema check — `oc apply --dry-run=client` (valid YAML, correct API version) - - Policy lint — conftest / kyverno CLI (organizational rules: no allow-all, - required labels, port range limits) - - ACS build-time — validate against known baselines and flag policies that - would allow traffic outside the baseline - - Dry-run apply — `oc apply --dry-run=server` against a staging cluster + - Schema check — `kubeconform` and `kubectl apply --dry-run=client` (valid YAML, correct API version) + - Policy lint — conftest / `kyverno test` / `gator verify` (organizational rules: no allow-all, + required labels, port range limits); `trivy config` scans the manifests + - Baseline diff — compare the change with the policy generated from observed flows + and flag rules that would allow traffic outside the baseline + - Dry-run apply — `kubectl apply --dry-run=server` against a staging cluster (catches admission denials, quota issues) 3. **Platform / security team reviews the PR** — CODEOWNERS enforces: app team authors NetworkPolicies (platform reviews), platform authors - ANP/BANP/EgressFirewall (firewall/security reviews). -4. **Merge triggers GitOps sync** — OpenShift GitOps (ArgoCD) syncs to + ClusterNetworkPolicies and egress rules (firewall/security reviews). +4. **Merge triggers GitOps sync** — Argo CD syncs to the staging cluster, smoke tests validate connectivity, then syncs to production. -5. **Post-deploy validation** — ACS network graph confirms no anomalous - flows, OVN-Kubernetes flow logs confirm deny counts are expected. +5. **Post-deploy validation** — Hubble or Calico flow logs confirm no unexpected + flows and the expected deny counts; Falco alerts on unexpected egress at runtime. -### What ACS build-time tools add +### What generated baselines add -Starting with ACS 4.4, build-time network policy tools can generate and -validate policies locally or in a CI pipeline. This closes the loop between -"what traffic does my app need" (observed in ACS) and "what policy should I -write" (generated and validated in CI). The developer never needs to author +Inspektor Gadget's `advise_networkpolicy` gadget records the traffic of the +selected namespaces and writes the NetworkPolicy that matches it. This closes the loop between +"what traffic does my app need" (observed by the CNI) and "what policy should I +write" (generated, then validated in CI). The developer never needs to author a policy from scratch — they generate one from the baseline, review it, and submit the PR. @@ -283,7 +298,7 @@ submit the PR. ## 5. Repo structure -Red Hat recommends a three-layer repository model that separates platform- +A three-layer repository model separates platform- owned policies from application-owned policies, with clear ownership and review boundaries. @@ -291,14 +306,14 @@ review boundaries. cluster-policies/ │ ├── platform-baselines/ # Platform team owns -│ ├── anp/ +│ ├── admin-tier/ │ │ ├── deny-cross-env.yaml # Block dev ↔ prod traffic -│ │ ├── allow-monitoring.yaml # Prometheus/OTel can scrape all pods +│ │ ├── accept-monitoring.yaml # Prometheus/OTel can scrape all pods │ │ ├── deny-control-plane.yaml # Apps cannot reach API server directly │ │ └── pass-app-traffic.yaml # Delegate app-to-app decisions to NP tier -│ ├── banp/ +│ ├── baseline-tier/ │ │ └── default-deny-baseline.yaml # Safety net: deny anything not explicitly allowed -│ ├── egressfirewall/ +│ ├── egress/ │ │ ├── prod-egress.yaml # Production: allow only approved external CIDRs/DNS │ │ ├── test-egress.yaml # Test: same as prod (mirrors topology) │ │ └── dev-egress.yaml # Dev: broader access, still no direct internet @@ -348,7 +363,7 @@ cluster-policies/ | Layer | Authors | Reviewers | Scope | |-------|---------|-----------|-------| -| `platform-baselines/` (ANP, BANP, EgressFirewall) | Platform team | Firewall / security team | Cluster-wide | +| `platform-baselines/` (Admin tier, Baseline tier, egress rules) | Platform team | Firewall / security team | Cluster-wide | | `onboarding/` (templates) | Platform team | Platform team | Reusable templates | | `app-policies/` (NetworkPolicy) | App teams | Platform team | Per-namespace | @@ -367,22 +382,24 @@ policy flows through the same CI pipeline regardless of target environment. When a developer reports "my service can't reach X," the triage path is: -1. **ACS anomalous flow alerts** — check the ACS network graph for the - deployment. If the connection appears as an anomalous flow (outside the - baseline), it was likely never policy-allowed. Decide whether to add +1. **Compare with the baseline** — check whether the connection is in the + observed baseline (the generated policies in Git). If it is not, it was + likely never policy-allowed. Decide whether to add a policy or investigate why the new connection exists. -2. **OVN-Kubernetes flow logs** — OVN-K logs denied packets with source pod, - destination, port, and the ACL rule that denied them. These logs - identify *which tier* denied the traffic (ANP, NP, or BANP), which +2. **Flow logs** — `hubble observe --verdict DROPPED` (Cilium) or the Calico + flow logs show the dropped flow with source pod, destination and port. + Match it with the policies that select the pod to find *which tier* + denied the traffic (Admin, NP, or Baseline), which tells you who needs to update the policy. -3. **`oc adm must-gather`** — collects network policy state, OVN-K - configuration, and flow logs for offline analysis. Use this for - escalation to Red Hat support. +3. **Diagnostics bundle** — `cilium sysdump` or `calicoctl cluster diags` + collects network policy state, CNI configuration, and logs for offline + analysis. Use it for an upstream issue or for escalation to your support + provider. **Target:** denied-flow triage should take under 30 minutes. If it -regularly takes longer, the observability tooling (ACS, flow logs) is not +regularly takes longer, the observability tooling (flow logs, baselines) is not configured correctly. ### Break-glass process @@ -390,10 +407,10 @@ configured correctly. When a denied flow is blocking a production incident and the normal PR process is too slow: -1. A platform team member creates a **temporary ANP with a Pass rule** at a +1. A platform team member creates a **temporary Admin-tier policy with a Pass rule** at a high priority (low number), handing the traffic decision to the - NetworkPolicy tier. If no NetworkPolicy exists for the target, the BANP - (if configured as default-deny) will still deny — so the break-glass + NetworkPolicy tier. If no NetworkPolicy exists for the target, the Baseline tier + (if configured as default-deny) will still deny, so the break-glass may need a temporary NetworkPolicy as well. 2. The temporary rule is **time-boxed** (label it with an expiration date @@ -407,13 +424,13 @@ process is too slow: ### Drift detection -- **OpenShift GitOps (ArgoCD)** reports sync status for every policy - manifest. If someone manually applies a policy via `oc apply` instead - of the PR pipeline, ArgoCD detects the drift and either alerts or - auto-reverts (depending on configuration). +- **Argo CD** reports sync status for every policy + manifest. If someone manually applies a policy via `kubectl apply` instead + of the PR pipeline, Argo CD detects the drift and either alerts or + auto-reverts (self-heal, depending on configuration). -- **ACS baseline drift** — if new traffic flows appear that are not in - the ACS network baseline, ACS flags them as anomalous. This catches +- **Baseline drift** — if new traffic flows appear that are not in + the observed baseline, the flow logs and Falco flag them. This catches both policy drift (someone added a rule manually) and application drift (a new service started making unexpected connections). @@ -422,8 +439,8 @@ process is too slow: Maintain a dashboard or periodic report showing: - Namespaces with default-deny in place vs. those without. -- Namespaces with ACS baselines active vs. those still in observation. -- ANP/BANP rules in effect and their last-modified dates. +- Namespaces with reviewed baselines vs. those still in observation. +- Admin-tier and Baseline-tier rules in effect and their last-modified dates. - Break-glass events in the current quarter. - Denied-flow triage times (mean, p95). @@ -432,22 +449,22 @@ Maintain a dashboard or periodic report showing: ## 7. The firewall team's evolving role The shift to in-cluster network policy does not eliminate the firewall team. -It changes their scope and elevates their role from gatekeeper to architect. +It changes their scope and moves their role from gatekeeper to architect. ### What stays with the firewall team -- **North-south edge controls** — the perimeter firewall (Palo Alto) +- **North-south edge controls** — the perimeter firewall continues to manage traffic entering and leaving the data center or cloud VPC. This is where TLS inspection, DDoS mitigation, and compliance logging (PCI, HIPAA) remain. -- **EgressFirewall authorship** — the firewall team authors and reviews - EgressFirewall CRDs that control which external destinations cluster +- **Egress rule authorship** — the firewall team authors and reviews + the egress rules that control which external destinations cluster workloads can reach. This is their domain expertise (approved CIDRs, DNS-based rules, compliance-required blocks). -- **ANP / BANP review** — the firewall team reviews all cluster-scoped - network policies (ANP, BANP) because these have cluster-wide blast +- **Admin and Baseline tier review** — the firewall team reviews all cluster-scoped + network policies (the Admin and Baseline tiers) because these have cluster-wide blast radius. The platform team authors them; the firewall team approves. ### What shifts to the platform and app teams @@ -482,34 +499,34 @@ After sustained effort over a maturity period agreed upon by the organization, a mature network policy practice looks like this: - [ ] **Default-deny in every namespace** — no namespace exists without a - default-deny NetworkPolicy or BANP coverage. -- [ ] **ACS baselines active** — every production namespace has a locked - baseline. New flows trigger anomalous-flow alerts. -- [ ] **ANP guardrails enforced** — cross-environment traffic blocked, + default-deny NetworkPolicy or Baseline-tier coverage. +- [ ] **Baselines reviewed** — every production namespace has a reviewed + baseline in Git. New flows outside it raise alerts. +- [ ] **Admin-tier guardrails enforced** — cross-environment traffic blocked, monitoring access allowed, control-plane access restricted. -- [ ] **BANP default-deny** — the cluster-wide safety net catches any - traffic not explicitly allowed by ANP or NetworkPolicy. +- [ ] **Baseline-tier default-deny** — the cluster-wide safety net catches any + traffic not explicitly allowed by the Admin tier or NetworkPolicy. - [ ] **App teams authoring policies via PR** — developers submit - NetworkPolicy changes through the CI/CD pipeline, not `oc apply`. -- [ ] **CI validates before merge** — schema checks, policy linting, ACS - build-time validation, and dry-run apply run on every PR. -- [ ] **EgressFirewall per project** — outbound traffic is restricted to + NetworkPolicy changes through the CI/CD pipeline, not `kubectl apply`. +- [ ] **CI validates before merge** — schema checks, policy linting, baseline + diff, and dry-run apply run on every PR. +- [ ] **Egress rules per namespace** — outbound traffic is restricted to approved external destinations in every namespace. -- [ ] **Firewall team reviews, does not block** — turnaround on ANP/BANP +- [ ] **Firewall team reviews, does not block** — turnaround on Admin and Baseline tier reviews is under 48 hours. -- [ ] **Denied-flow triage under 30 minutes** — ACS alerts and OVN-K flow - logs provide immediate root-cause. +- [ ] **Denied-flow triage under 30 minutes** — flow logs and Falco alerts + provide immediate root-cause. - [ ] **Quarterly policy review** — all break-glass events reviewed, - stale policies removed, ACS baselines refreshed, RBAC bindings + stale policies removed, baselines refreshed, RBAC bindings audited. -### What customers wish they'd done differently +### What organizations wish they'd done differently -These are the top five lessons Red Hat hears from customers who have -completed this journey: +These are the five lessons heard most often from organizations that have +completed this transition: -1. **"We should have started with observation, not enforcement."** Customers - who jumped to default-deny without first running ACS in observation mode +1. **"We should have started with observation, not enforcement."** Teams + that jumped to default-deny without first observing the flows caused outages from denied traffic they did not know existed. The observe phase pays for itself. @@ -517,14 +534,14 @@ completed this journey: should be simple — three to five archetypes, not twenty. Start broad and refine based on actual PR volume and developer feedback. -3. **"We underestimated the EgressFirewall work."** Cataloging every +3. **"We underestimated the egress work."** Cataloging every external endpoint every service needs to reach is the most time-consuming part of the shift. Start this inventory in Phase 1 - alongside the ACS observation. + alongside the flow observation. -4. **"We should have involved the firewall team earlier."** Customers who +4. **"We should have involved the firewall team earlier."** Teams that presented in-cluster policy as a replacement for the firewall team met - resistance. Customers who positioned it as "the firewall team's + resistance. Teams that positioned it as "the firewall team's expertise applied at a new layer" got buy-in. 5. **"We needed a break-glass process on day one."** The first denied-flow @@ -543,11 +560,11 @@ full stack: | Governance layer | This document | Companion document | |-----------------|---------------|-------------------| -| Network segmentation | NetworkPolicy, ANP, BANP, EgressFirewall — the focus here | Default-deny baseline, environment-specific allow rules | +| Network segmentation | NetworkPolicy, ClusterNetworkPolicy (Admin and Baseline tiers), egress rules: the focus here | Default-deny baseline, environment-specific allow rules | | RBAC | Who can create/modify network policies | Full RBAC matrix by environment (dev/test/prod) | | Admission control | Policy linting in CI (conftest, kyverno) | OPA Gatekeeper ConstraintTemplates | -| GitOps | ArgoCD sync and drift detection for policies | Full GitOps-gated production change pipeline | -| Audit | ACS baselines, OVN-K flow logs, break-glass tracking | API server audit logs, OPA decision logs, Git history | +| GitOps | Argo CD sync and drift detection for policies | Full GitOps-gated production change pipeline | +| Audit | Flow logs, Falco alerts, break-glass tracking | API server audit logs, OPA decision logs, Git history | | Namespace isolation | NetworkPolicy as one of four controls | RBAC + NetworkPolicy + ResourceQuota + LimitRange | The network policy process described here is one vertical slice of that @@ -559,11 +576,13 @@ network segmentation leaves east-west traffic uncontrolled. ## References -- [OpenShift AdminNetworkPolicy documentation](https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/network_security/admin-network-policy) -- [BaselineAdminNetworkPolicy documentation](https://docs.openshift.com/container-platform/4.16/networking/network_security/AdminNetworkPolicy/ovn-k-banp.html) -- [Using AdminNetworkPolicy API to secure OpenShift cluster networking](https://www.redhat.com/en/blog/using-adminnetworkpolicy-api-to-secure-openshift-cluster-networking) -- [OpenShift EgressFirewall documentation](https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/network_security/egress-firewall) -- [ACS — Managing network policies](https://docs.redhat.com/en/documentation/red_hat_advanced_cluster_security_for_kubernetes/3.69/html/operating/manage-network-policies) +- [Kubernetes Network Policy API: ClusterNetworkPolicy](https://network-policy-api.sigs.k8s.io/) +- [network-policy-api v0.2.0 release notes](https://github.com/kubernetes-sigs/network-policy-api/releases/tag/v0.2.0) +- [Network Policy API implementations](https://network-policy-api.sigs.k8s.io/implementations/) +- [Calico v3.32 release notes: ClusterNetworkPolicy](https://github.com/projectcalico/calico/blob/release-v3.32/release-notes/v3.32.0-release-notes.md) +- [Cilium: Kubernetes network policy](https://docs.cilium.io/en/stable/network/kubernetes/policy/) +- [Inspektor Gadget: advise_networkpolicy](https://inspektor-gadget.io/docs/latest/gadgets/advise_networkpolicy) +- [Kyverno policy: add-network-policy](https://kyverno.io/policies/best-practices/add-network-policy/add-network-policy/) - [Kubernetes Network Policies](https://kubernetes.io/docs/concepts/services-networking/network-policies/) - [CNCF Cloud Native Security Whitepaper](https://github.com/cncf/tag-security/tree/main/security-whitepaper) - [Kubernetes RBAC Good Practices](https://kubernetes.io/docs/concepts/security/rbac-good-practices/) diff --git a/lgtm-presentation/text/Designing-Cloud-Native-APIs-Cpp.md b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Cpp.md new file mode 100644 index 00000000..167d30f2 --- /dev/null +++ b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Cpp.md @@ -0,0 +1,4351 @@ +# Designing-Cloud-Native-APIs-Cpp + +Source: Designing-Cloud-Native-APIs-Cpp.pptx · 281 slides + +## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native APIs (sldId 256) + +From principles to a running system on Kubernetes — communication, composition, data, events, and the platform that governs them. + +Robert Sedor + +Chief Architect, Application Development + +> Title slide. Same Red Hat brand frame as the other decks in the series. Talk track: this is the C++ entry in the Designing Cloud-Native APIs series. Like its siblings (Python/FastAPI, Java/Quarkus, Spring Boot, .NET 10, Go), it carries the same five-service running example end to end, and every line of code is idiomatic C++23. +> +> Toolchain: GCC 15 (gcc-toolset-15 on the ubi10/ubi:10.2 builder image), CMake 4.4.4 and Ninja 1.13.2 from the upstream release binaries, Conan 2.33.0 with a lockfile per project; libstdc++ is linked statically into a ubi10/ubi-minimal runtime image. Libraries: Drogon for REST and WebSocket, grpc++, librdkafka's C++ API (rdkafka++) for Kafka, libpq directly for Postgres, sw/redis++, cppgraphqlgen, OpenTelemetry C++ with spdlog. The examples run on Docker Engine with docker compose; Example 05 also runs on Kubernetes (Appendix R). + +## [002] AGENDA · Agenda (sldId 257) + +- Setting up · Patterns in the age of AI +- 01 · Cloud-Native Principles +- 02 · Communications +- 03 · Composition +- 04 · Data +- 05 · Event-Driven +- 06 · Stream Processing +- 07 · Workflows & Jobs +- 08 · API Management +- 09 · API Registry +- 10 · API Metadata +- 11 · Observability +- 12 · Security +- 13 · Anti-Patterns +- Appendices A–D · Protocols, Versioning, WebSockets, Sagas +- Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing +- Appendices K–L · Monolith → Microservices, Caching Patterns +- Appendices M–O · Failure modes, Feature Flags, API Testing +- Appendix R · Example 05 on minikube +- Glossary of acronyms + +p. 1 + +> The agenda: the same scope as the other decks in the series. A short setup, thirteen sections, the deep-dive appendices A to O, Appendix R and a glossary. Every section is shown in idiomatic C++23; the architecture and the running example are the same as in the other languages. +> +> Appendix R runs Example 05 on minikube with the same verify.sh as compose. +> +> Tell the audience which sections you will cover given the session length. + +## [003] OVERVIEW · What we'll build, and the order we'll build it (sldId 258) + +- Foundations — cloud-native principles and the canonical system we carry throughout +- Communications — REST, gRPC, GraphQL, and synchronous vs. asynchronous +- Composition & Data — gateways, federation, database-per-service, outbox, sagas +- Event-driven & Stream processing — the log, schemas, derived streams, KEDA scaling +- Workflows & Jobs — orchestration (Temporal as a service), choreography, batch and scheduled work +- The API platform — gateway, registry (Apicurio), metadata (OpenMetadata), observability +- Anti-patterns — and the heuristics that keep you out of them + +p. 2 + +> Overview. Same ordering as the Python/Quarkus/Spring/.NET decks. Note one C++-specific framing already visible here: Temporal as an external service rather than an in-process SDK — there is no first-class C++ Temporal SDK, so we'll integrate via gRPC activity workers. We'll call this out explicitly when we get to Workflows. + +## [004] SETTING UP · Why patterns matter more in the age of AI (sldId 259) + +- AI tooling (Claude, Claude Code, frontier models) turns intent into code at a rate that was not possible before. The bottleneck shifted: it's no longer typing, it's knowing what to ask for and what to accept. +- Patterns are your vocabulary at three layers: design patterns (factory, strategy, observer), architecture patterns (hexagonal, microservices, event-driven), system design patterns (sagas, caching, strangler fig — the scope of this deck). +- Without that vocabulary you can't specify precisely, evaluate what comes back, tell good code from plausible-looking code, or notice when an anti-pattern is dressed as a solution. The AI is fluent; you still need to be literate. +- All architecture is the least-worst decision — every choice trades something away. +- All management is risk management — you manage the distribution of failures, not their existence. +- There is no such thing as a free lunch — every pattern has a cost; ask whether you can afford it. +- There is no problem which cannot be made worse — the wrong fix amplifies the original pain. + +p. 3 + +> Patterns matter more under AI tooling. C++ raises the stakes here: AI tools can produce plausible-looking C++ that mismanages ownership, leaks resources, or relies on UB. Knowing the patterns, and the C++ idioms that implement them (RAII, value semantics, PMR arenas, ports & adapters), is what lets you accept or reject what a model proposes. The four maxims at the bottom carry across every deck in the series. + +## [005] SETTING UP · Three layers of patterns literacy (sldId 260) + +p. 4 + +> The three-layer model. Design patterns (Gang of Four etc.) are the code-level vocabulary; architecture patterns describe how applications fit together; system design patterns span services, data, and runtime — and they're the scope of this deck. C++ adds patterns at the bottom layer that managed-runtime languages do not have: RAII, value semantics, type erasure, CRTP. They are the vocabulary for arguing about ownership and lifetime with an AI tool. + +## [006] THE RUNNING EXAMPLE · One system, carried end to end (sldId 261) + +We will design every concern against these five services and a gateway — the same example, re-examined from each angle. + +p. 5 + +> The canonical system. Five C++ services + a GraphQL gateway. Order, inventory, payment, shipping are full request/response services; notification is Kafka-consumer-only. Protocols differ per service by design: order-service exposes REST at the edge and calls others over gRPC; inventory/payment/shipping speak gRPC + GraphQL + Kafka; the gateway federates reads. For the C++ deck specifically: every backend service is a Drogon + gRPC C++ binary, the GraphQL gateway is shown both ways — as a cppgraphqlgen server in front of REST+gRPC, and as the simpler alternative of a Node/Apollo gateway over the same C++ backends. Apicurio (schema registry) and OpenMetadata (metadata) remain external seams, not in-process, the same posture every deck in the series takes. + +## [007] Cloud-Native Principles (sldId 262) + +01 + +Properties first, tools second. + +> Section 01 divider: Cloud-Native +> +> Principles. Properties first, tools second. + +## [008] CLOUD-NATIVE PRINCIPLES · Cloud-native is a set of runtime properties (sldId 263) + +- Disposable — a process you can stop at any moment without data loss. +- Observable — every request emits correlated traces, metrics, logs. +- Resilient — failures are bounded; no single fault sinks the system. +- Elastic — capacity follows load, all the way to zero. +- Automatable — every change goes through code, not a console. +- Loosely coupled — services share contracts, not internals. +- If your API isn't disposable, observable, resilient, elastic, automatable and loosely coupled, the platform can't help it. + +p. 6 + +> Cloud-native is properties, not tools. The same six properties show up in the CNCF definition, the 12-factor manifesto, Bilgin Ibryam's microservices patterns, and every major cloud vendor's framework. For C++ specifically: 'disposable' means the binary starts fast and exits cleanly under SIGTERM (RAII reverse-order teardown from main() — we'll come back to this in App. H). 'Observable' means the OpenTelemetry C++ SDK with the Batch span processor — never Simple, which collapses throughput. Elastic-to-zero is real in C++: a Drogon binary cold-starts in tens of milliseconds, which is what makes KEDA scale-to-zero workable. + +## [009] CLOUD-NATIVE PRINCIPLES · The platform makes the properties the default (sldId 264) + +- Kubernetes gives you the disposable process model: liveness/readiness probes, rolling deploys, self-healing. +- KEDA (and the http-add-on) make elasticity real — scale on lag or RPS, all the way to zero. +- Istio supplies resilience and security at the wire: timeouts, retries, circuit breaking, mTLS. +- OpenTelemetry + Tempo / Mimir / Prometheus / Grafana / Kiali make every request observable. +- Your job is to write services that lean on these — not to reimplement them per service. + +p. 7 + +> The platform is doing most of the work. For a C++ service this means: no in-process service discovery (Kubernetes DNS), no in-process load balancing (the mesh), no in-process circuit breaker by default (Istio DestinationRule), no in-process retries beyond a single safe attempt (the mesh handles bounded retries). The temptation in C++ is to write everything yourself because you can — but every wheel you reinvent is a wheel you now have to test, monitor, and patch. The discipline is the same as in every deck in the series: lean on the platform. + +## [010] CLOUD-NATIVE PRINCIPLES · Design-first: the contract precedes the code (sldId 265) + +- Agree the interface before implementing it — OpenAPI for REST, .proto for gRPC, AsyncAPI for Kafka topics, SDL for GraphQL. +- The contract is the source of truth — code generators read it; humans and AI tooling produce against it. Versioning lives at the contract layer, gated by Apicurio in CI. +- Contract-first tooling in C++ is uneven across the four protocols: decide which one carries codegen and which one stays at the documentation/registry layer. +- gRPC — strongest path: protoc + grpc_cpp_plugin generates full C++ server + client stubs from .proto. The cleanest codegen of the four. +- GraphQL — strong: cppgraphqlgen generates server stubs and types from .graphql SDL. Apollo Federation over C++ REST/gRPC backends is the simpler alternative we'll revisit in §3. +- OpenAPI — workable: write the YAML first, scaffold C++ stubs with OpenAPI Generator (stable C++ server generators: cpp-pistache-server, cpp-httplib-server; none targets Drogon). Drogon does not auto-generate OpenAPI from handlers the way FastAPI does — the YAML is hand-maintained or template-generated, then versioned in Apicurio. +- AsyncAPI — documentation only: no maintained C++ generator. Register topic schemas in Apicurio; the actual codegen comes from the payload format (Protobuf via protoc, Avro via Avro-C++). +- Contract-first is non-negotiable under AI tooling — even where C++ codegen isn't strong, the YAML/SDL/proto is what defines 'done' for a model to produce against. + +p. 8 + +> Design-first in C++, protocol by protocol. The four protocols differ sharply in how much code the contract generates. gRPC and GraphQL have first-class C++ generators: point protoc or cppgraphqlgen at the contract and you get stubs. OpenAPI is workable but doesn't have the FastAPI-style 'free' generation: the contract lives separately from the Drogon handlers and the YAML is either hand-written, scaffolded by openapi-generator-cli, or documented after the fact. AsyncAPI is the weakest — there's no actively-maintained C++ template in the AsyncAPI generator project, so it serves as registry-level documentation and the actual schema work happens via the payload format (Protobuf, Avro). None of this lets you off the hook on contract-first — the discipline is what makes AI tooling tractable, even when the codegen pipeline is partial. + +## [011] CLOUD-NATIVE PRINCIPLES · The twelve-factor app, revisited (sldId 266) + +- The twelve-factor app (2011) is the original recipe for software that's disposable, scalable and portable. +- Most factors are now platform concerns: the platform gives you build/release/run separation, port binding, log streaming, processes, concurrency. +- A few factors remain yours: codebase, dependencies, config, backing services, dev/prod parity, admin processes. +- For C++ specifically: dependencies pinned by Conan + a lockfile; config parsed once in main() into a const struct; backing services as URLs; logs to stdout (not a file). +- Treat it as a pre-flight check: will this survive the platform? Each factor maps to a place in our architecture. + +p. 9 + +> 12-factor isn't dated; it's been absorbed by the platform. Worth re-reading every couple of years. For C++ the highest-yield factors to internalize are III (Config), IV (Backing services), VI (Processes: be stateless), IX (Disposability: fast startup, graceful shutdown), and XI (Logs: stdout only, never spdlog's basic_logger_mt, which opens a file and hits EROFS on a read-only rootfs). Those last three are where C++ services trip most often, and we'll spend appendix time on them. + +## [012] CLOUD-NATIVE PRINCIPLES · What the twelve factors are (sldId 267) + +The original 2011 checklist — most are now platform concerns; only a few are yours to write. + +p. 10 + +> The 12 factors at a glance. Read the original at 12factor.net — Adam Wiggins wrote it in 2011 and the model has held up remarkably well. The grouping that follows sorts them by what each asks of you in a C++ service on Kubernetes. + +## [013] CLOUD-NATIVE PRINCIPLES · Twelve factors, mapped to our system (sldId 268) + +Most factors are now platform defaults you adopt; a few are discipline you still write. + +p. 11 + +> Mapping the 12 factors to our C++ system. Build & ship → one repo, Conan + CMake, one image per release. Configure → parsed once in main() into a const struct, constinit for the unavoidable globals; no scattered getenv calls. Run on the platform → stateless, fast startup (RAII composition root), behind Istio. Attach resources → Postgres via libpq, Kafka (Strimzi) via librdkafka (rdkafka++), Apicurio for schemas. Operate → OpenTelemetry C++ SDK feeding the LGTM stack, admin work as Jobs/CronJobs. Note Apicurio stays a seam — same posture as every other deck in the series. + +## [014] CLOUD-NATIVE PRINCIPLES · A 12-factor Drogon service, ready for the platform (sldId 269) + +[chip] c++ · Drogon + +```c++ +// main.cpp (excerpt), examples/01-cloud-native-principles/cpp; conan.lock pins every library +using Callback = std::function; +const char* env(const char* name, const char* fallback) { // config from env, factor III + const char* v = std::getenv(name); return v ? v : fallback; // unset: the default +} +int main() { + g_version = env("SERVICE_VERSION", "1.0.0"); + g_pool = std::make_unique( + env("PG_CONNINFO", "postgresql://appuser:apppass@postgres:5432/appdb"), + std::atoi(env("PG_POOL_SIZE", "4"))); + app().registerHandler("/healthz", [](const HttpRequestPtr&, Callback&& cb) { + Json::Value j; j["status"] = "ok"; cb(HttpResponse::newHttpJsonResponse(j)); // liveness + }, {Get}); + app().registerHandler("/readyz", [](const HttpRequestPtr&, Callback&& cb) { + const bool db_ok = db_ready(); // SELECT 1 via the libpq pool (retry elided) + Json::Value j; j["status"] = db_ok ? "ready" : "down"; + auto resp = HttpResponse::newHttpJsonResponse(j); + if (!db_ok) resp->setStatusCode(k503ServiceUnavailable); // down: out of rotation + cb(resp); + }, {Get}); + app().addListener("0.0.0.0", 8080).run(); // factor VII: port binding +} +``` + +Probes, env-config and an explicit version are the price of admission for a Kubernetes Deployment. + +p. 12 + +> A 12-factor Drogon service, from Example 01. Configuration comes from the environment (factor III): a small env() helper returns the variable or a default, so an unset variable never turns into a null pointer, and main() reads PG_CONNINFO, PG_POOL_SIZE and SERVICE_VERSION once at start-up. +> +> /healthz answers while the process is up (liveness); /readyz runs SELECT 1 through the libpq pool and answers 200 with status ready, or 503 with status down (readiness; the probe reads the status code, not the body), so the probe that takes a Pod out of rotation is not the probe that restarts it. Handlers are lambdas with the request and callback types spelled out, which Drogon's handler binding needs. +> +> Port binding (factor VII) is explicit in main(): the binary is self-contained, with no external web server in front of it. conan.lock pins every library, and the Containerfile builds with GCC 15 on UBI 10. + +## [015] Communications (sldId 270) + +02 + +Match the interaction style to the call. + +> Section 02 divider: Communications. Match the interaction style to the call. + +## [016] COMMUNICATIONS · Pick the protocol for the interaction (sldId 271) + +REST for reach, gRPC for internal speed, GraphQL for client-driven reads, async for decoupled facts. + +p. 13 + +> The protocol matrix. The point of this slide is that 'pick a protocol' is a per-edge decision, not a per-service decision — one C++ service can speak REST at the edge, gRPC east-west, and publish to Kafka, all from the same binary. C++ specifics: Drogon at the REST edge (or cpp-httplib for minimal cases); grpc::Server for the gRPC surface; librdkafka's C++ API (rdkafka++) for the producer and consumer; cppgraphqlgen for native GraphQL or, as the simpler default covered in §3, a Node/Apollo gateway in front of the REST + gRPC backends with no native C++ GraphQL. + +## [017] COMMUNICATIONS · REST at the edge with Drogon (sldId 272) + +[chip] c++ · Drogon + +```c++ +// orders_controller.h — Drogon controller; OpenAPI YAML is the contract +struct OrderIn { std::string sku; int quantity; }; +struct Order { std::string id, sku, status; int quantity; }; + +class Orders : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(Orders::place, "/orders", Post); + ADD_METHOD_TO(Orders::list, "/orders?after={1}&limit={2}", Get); + METHOD_LIST_END +}; +// orders_controller.cc — body validated against the YAML, 201 on create +Task<> Orders::place(HttpRequestPtr req, auto cb) { + auto in = co_await parse_validated(req); // throws → 400 + Order o = co_await orders.create(in); + co_await events.publish("order.placed", o); // emit the fact + auto r = HttpResponse::newHttpJsonResponse(to_json(o)); + r->setStatusCode(k201Created); cb(r); +} +Task<> Orders::list(HttpRequestPtr r, auto cb, std::string a, int n) { + cb(HttpResponse::newHttpJsonResponse(to_json( // cursor, not offset + co_await orders.page(a, n)))); +} +``` + +Resource-shaped URLs, validated contracts, correct status codes, cursor pagination — the edge surface other teams depend on. + +p. 14 + +> REST at the edge in Drogon. Two patterns to call out: (1) HttpController is the idiomatic Drogon route registration — the METHOD_LIST macros declare paths and verbs in one place, and Drogon's coroutine integration lets handlers be Task<> functions that co_await on async DB / Kafka calls without callback nesting. (2) parse_validated is your seam to the OpenAPI YAML — either hand-roll it against the schema, or generate it via openapi-generator-cli and link in. Either way, Drogon handlers stay clean and the contract stays in version control. cpp-httplib is the leaner alternative for sidecars or small admin services where Drogon's machinery is more than you need. + +## [018] COMMUNICATIONS · gRPC for internal service-to-service calls (sldId 273) + +[chip] c++ · gRPC + +```c++ +// inventory.proto — the contract, registered in Apicurio +syntax = "proto3"; +service Inventory { + rpc ReserveStock (ReserveRequest) returns (ReserveReply); +} +message ReserveRequest { string sku = 1; int32 quantity = 2; } +message ReserveReply { bool reserved = 1; int32 remaining = 2; } + +// server.cc — stubs generated by protoc + grpc_cpp_plugin +class InventoryServicer final : public Inventory::Service { + public: + explicit InventoryServicer(Stock& s) : stock_(s) {} // DI by ref + grpc::Status ReserveStock(grpc::ServerContext* ctx, + const ReserveRequest* req, + ReserveReply* reply) override { + auto deadline = ctx->deadline(); // budget-aware + auto [ok, remaining] = stock_.reserve(req->sku(), req->quantity()); + reply->set_reserved(ok); + reply->set_remaining(remaining); + return grpc::Status::OK; + } + private: Stock& stock_; +}; +``` + +Schema-first, binary, multiplexed over HTTP/2 — the right default for chatty internal paths. Test with ghz. + +p. 15 + +> gRPC internal-east-west. Three idioms: (1) sync API: we use the blocking Service base class, not the callback or async API; for most read/reserve paths the sync API is simpler and the performance gap is small. (2) deadline propagation — ServerContext::deadline() gives you the inbound budget; you pass the remainder to the DB statement_timeout and any outbound ClientContext. Blown budget surfaces as DEADLINE_EXCEEDED, never as a hung handler. (3) DI by reference — Stock& injected, owned in main(), no singletons. CMake snippet for codegen: protobuf_generate(LANGUAGE cpp) + protobuf_generate(LANGUAGE grpc PLUGIN protoc-gen-grpc=$). + +## [019] COMMUNICATIONS · Synchronous coupling vs. asynchronous facts (sldId 274) + +A synchronous call shares fate; an event shares a fact. Reach for async wherever immediacy isn't required. + +p. 16 + +> Sync vs async is a coupling decision, not a perf decision. A synchronous gRPC call binds the caller's fate to the callee's availability; a Kafka event publishes a fact and walks away. C++ implications: synchronous code uses gRPC ClientContext with a deadline; asynchronous code uses a process-scoped librdkafka producer (rdkafka++, one per binary, not per request). The producer is bounded in queue, retries and memory, consistent with the 'bound everything' rule. When in doubt: async. Sync is the special case, not the default. + +## [020] COMMUNICATIONS · The mesh handles the wire, not your code (sldId 275) + +Istio sidecars give you mTLS, retries, timeouts and telemetry — uniformly, without per-service code. + +p. 17 + +> The Istio sidecar handles wire concerns: mTLS, bounded retries with backoff, timeouts, outlier detection, hop-level telemetry. The C++ temptation is to write this yourself because you can — but every wheel you reinvent in-process is a wheel you have to test, monitor, patch, and document. The sidecar runs as Envoy alongside your C++ pod, and your binary stays clean. Two C++-specific notes: (1) gRPC retries from the application side should be limited to a single safe attempt — let Istio do bounded retries with backoff. (2) Don't double-instrument with both Envoy spans and your application OTel spans on the same hop — pick one boundary. + +## [021] COMMUNICATIONS · Resilience as configuration, not per-call code (sldId 276) + +[chip] yaml · istio + +```yaml +# Istio: bounded timeout + retries for inventory-service +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: inventory } +spec: + hosts: [inventory] + http: + - timeout: 2s + retries: + attempts: 2 # bounded — never an unbounded retry storm + perTryTimeout: 800ms + retryOn: connect-failure,unavailable + route: + - destination: { host: inventory } + +--- +# DestinationRule (separate doc): outlierDetection ejects bad pods, +# circuit-breaker thresholds on pending requests, max connections. +# All resilience as configuration — no per-service C++ code. +``` + +Bounded retries with backoff live in the mesh. Unbounded client retries are how an incident becomes an outage. + +p. 18 + +> Resilience as YAML, not as scattered try/catch in C++. The VirtualService bounds timeouts and retries at the mesh layer; the DestinationRule adds outlier detection so a single bad pod gets ejected from rotation. Both live in Git, both are versioned, both apply uniformly to every pod in the service — no per-binary configuration. What stays in your C++ code: one safe retry where the call is idempotent (for example, with an idempotency key); deadline propagation via gRPC ClientContext::set_deadline; circuit breaking is the mesh's job, not yours. Cap the retry budget at the inbound deadline and never retry past it (cpp-statelessness Doc 07, patterncatalyst.github.io/cpp-statelessness/docs/07-state-externalization/). + +## [022] Composition (sldId 277) + +03 + +Aggregate at the edge, not inside services. + +> Section 03 divider: Composition. Aggregate at the edge, not inside services. + +## [023] COMPOSITION · One query, many services (sldId 278) + +The gateway presents one schema and fans out to REST and gRPC back-ends, then joins and shapes the result. + +p. 19 + +> Federation at the edge. The gateway exposes a single GraphQL schema; resolvers fan out to the owning services via REST or gRPC. Two ways to implement this gateway in our stack: (1) Native C++: cppgraphqlgen as a Drogon service that owns the schema and resolves fields against C++ REST/gRPC backends. Strongest type safety, more setup. (2) Apollo (Node.js) Federation: keep the C++ services exposing REST + gRPC only; the GraphQL concern lives in a Node gateway service. Less code in C++, less to maintain across teams. Most shops with mixed-language fleets end up here. Both appear in this section, native C++ first. + +## [024] COMPOSITION · A composing resolver in cppgraphqlgen (sldId 279) + +[chip] c++ · cppgraphqlgen + +```c++ +// schema.graphql is the contract — schemagen produces these types: +// type Query { order(id: ID!): Order } +// type Order { id: ID! sku: String! status: String! stock: Int! } +class OrderResolver : public object::Order { + public: + OrderResolver(Order o, InventoryClient& inv) : o_(std::move(o)), inv_(inv) {} + service::AwaitableScalar getId() const { co_return o_.id; } + service::AwaitableScalar getSku() const { co_return o_.sku; } + service::AwaitableScalar getStatus() const { co_return o_.status; } + service::AwaitableScalar getStock() const { // resolved on demand + auto reply = co_await inv_.ReserveStock_view(o_.sku); // gRPC + co_return reply.remaining(); + } + private: Order o_; InventoryClient& inv_; +}; +class QueryResolver : public object::Query { + public: + service::AwaitableObject> + getOrder(std::string id) const { + Order o = co_await orders_.get(id); // REST to order-service + co_return std::make_shared(std::move(o), inv_); + } + private: OrdersClient& orders_; InventoryClient& inv_; +}; +``` + +The gateway owns aggregation; each field resolves against the owning service. Simpler alternative: an Apollo (Node) gateway over the same C++ REST + gRPC backends — no native GraphQL in the C++ services. + +p. 20 + +> Native C++ GraphQL via cppgraphqlgen. The pattern: schemagen reads schema.graphql at build time and produces Object base classes (object::Order, object::Query); you subclass them and implement the getX methods. Coroutine-based via service::AwaitableScalar / AwaitableObject. Each field is resolved against the owning service — getStock here calls inventory via gRPC, getOrder calls order-service via REST. Dependencies are injected by reference (OrdersClient&, InventoryClient&) from a composition root in main(), as in cpp-statelessness Doc 04 (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/). The trade-off: cppgraphqlgen is well maintained, but the C++ resolver code is verbose compared to Strawberry or Apollo. Many production C++ shops choose the Apollo gateway path instead — keep C++ services on REST/gRPC, write the gateway in Node where the GraphQL ecosystem is mature, and accept the extra service in the deployment topology. Either path is defensible; the choice depends on your team's polyglot tolerance. + +## [025] COMPOSITION · Three ways to compose — and where each belongs (sldId 280) + +Push aggregation to a gateway or BFF. Chaining calls inside services rebuilds the monolith over the network. + +p. 21 + +> Three composition patterns. (1) Gateway federation: one schema, many backends, edge does the join. Apollo gateway or cppgraphqlgen on Drogon, as compared in the federation discussion. (2) BFF (backend-for-frontend): per-client gateway tuned for one consumer (web BFF vs mobile BFF). Same C++/Node trade-off as federation. (3) Service-to-service chaining: order-service calls inventory-service which calls payment- service. This is the anti-pattern. In C++ it's especially tempting because the call sites look cheap (gRPC client, stack allocation), but you're building a synchronous fate-shared fan-out that fails together. Push the aggregation out to the edge; let services own their own domain and emit events for what others care about. Rule of thumb: a service should never need more than one other service to satisfy its own command — if it does, aggregation belongs at the gateway, not inside the service. + +## [026] Data (sldId 281) + +04 + +Own your data; share it through contracts. + +> Section 04 divider: Data. Own your data; share it through contracts. + +## [027] DATA · A database per service (sldId 282) + +Each service owns its schema and storage engine; no other service touches its tables directly. + +p. 22 + +> Database-per-service is the boundary contract for data. In C++ this means: each service opens its own libpq connection pool against its own schema, and the only external surface is the API. Cross-service joins happen at the gateway (composition) or via events — never by reaching into another service's tables. Each service checks connections out through an RAII guard that invalidates a broken connection instead of returning it to the pool (ScopedConn in cpp-statelessness Doc 07, patterncatalyst.github.io/cpp-statelessness/docs/07-state-externalization/). Storage engine choice follows access pattern: PostgreSQL is the default; document stores, time-series, search indexes are per-service decisions. Apicurio holds the public schemas (API contracts), not the internal database DDL. + +## [028] DATA · CQRS — Command Query Responsibility Segregation (sldId 283) + +Separate the write model from the read model. They have different shapes, scales and even different stores. + +p. 23 + +> CQRS as a pattern: the write model optimises for invariants and transactions; the read model optimises for query shape. They live in different stores when needed (Postgres for writes, Elasticsearch or a denormalised Postgres view for reads). Projections, built by consuming events from the write side, keep the read store in sync. Don't apply CQRS by default; reach for it when read and write loads, shapes, or consistency needs diverge enough that a single model becomes a compromise. The C++ implementation is mechanical once you accept the pattern. + +## [029] DATA · CQRS in practice (sldId 284) + +[chip] c++ · libpq + kafka + +```c++ +// Write side — command handler (one txn, outbox row, no broker call) +Task<> Orders::place(HttpRequestPtr req, auto cb) { + auto in = co_await parse_validated(req); + PgTxn txn = co_await pg_.begin(); // one local txn + Order o = co_await txn.exec(insert_order_sql, in); + co_await txn.exec(insert_outbox_sql, + "order.placed", o.id, to_json(o)); // same txn + co_await txn.commit(); // CDC publishes + cb(HttpResponse::newHttpJsonResponse(to_json(o))); +} +// Read side — queries a different, denormalised store +Task<> Orders::summary(HttpRequestPtr req, auto cb, std::string oid) { + auto row = co_await read_pg_.fetch_opt( + "SELECT * FROM order_summary WHERE order_id = $1", oid); + if (!row) throw grpc::Status{NOT_FOUND, "unknown order"}; + cb(HttpResponse::newHttpJsonResponse(to_json(*row))); +} +// Projection — consumes events, upserts the read model (idempotent) +std::unique_ptr msg(consumer->consume(100)); // rdkafka++ +if (msg->err() == RdKafka::ERR_NO_ERROR) { + read_pg_.exec(upsert_order_summary_sql, deserialize(payload_of(*msg))); + consumer->commitSync(msg.get()); // after the upsert +} +``` + +Write side, read side, projection — three flows, one consistent story. At-least-once delivery + idempotent upsert. + +p. 24 + +> Three flows in one slide. Write side: a single libpq transaction writes the row and an outbox row, then commits — CDC publishes the fact, never the app code. Read side: queries a different store (here a denormalised order_summary table; could be Elasticsearch, Redis, anything). Projection: a librdkafka consumer applies events to keep the read model fresh and commits offsets after the upsert succeeds; at-least-once delivery with an idempotent ON CONFLICT DO UPDATE is the standard pattern. PgTxn stands for a small RAII wrapper over a libpq transaction (BEGIN on construction, ROLLBACK in the destructor unless commit() ran); Example 04 writes the same BEGIN, COMMIT and ROLLBACK with libpq calls directly. The consumer is process-scoped; the exception path on the read side uses grpc::Status which we'd translate at the handler boundary. + +## [030] DATA · Log tailing — the database commit log is the event stream (sldId 285) + +Debezium tails the WAL and publishes every committed row as a Kafka event. No dual-write. + +p. 25 + +> Log tailing (CDC) is the answer to the dual-write problem. Debezium runs as a Kafka Connect task, tails the Postgres WAL via logical replication, and publishes every committed row to Kafka. From the C++ service's perspective: you commit to Postgres and you're done. The broker may be down for hours — the events still arrive, in commit order, once CDC catches up. This is the pattern that makes 'exactly-once effect' achievable from at-least-once delivery: the database commit is the source of truth, and the Kafka event is its replication. + +## [031] DATA · Dual-write is unsafe — use the outbox (sldId 286) + +Write state and the outbox row in one transaction; CDC relays the committed fact to Kafka. + +p. 26 + +> Dual-write is the classic distributed-systems bug: write to DB, then write to Kafka. Either can fail without the other, leaving an inconsistency you can't reconcile after the fact. The outbox pattern is the fix: write state and an outbox row in one local transaction; let a separate process (Debezium, or a custom relay) read the outbox and publish to Kafka. C++ implementation is unchanged from any other language — the transaction discipline is the interesting part. Pair with idempotent consumers (ON CONFLICT DO NOTHING / DO UPDATE) and you get exactly-once effect from at-least-once delivery. + +## [032] DATA · The outbox write — one transaction, no lost events (sldId 287) + +[chip] c++ · libpq + +```c++ +// order-service (excerpt): order row + outbox row in one libpq transaction +PGresult* res = PQexec(conn, "BEGIN"); // single local txn +if (PQresultStatus(res) != PGRES_COMMAND_OK) { /* 500, nothing written */ } +PQclear(res); +const char* params_order[4] = {id.c_str(), sku.c_str(), quantity_s.c_str(), status.c_str()}; +res = PQexecParams(conn, + "INSERT INTO orders (id, sku, quantity, status) VALUES ($1, $2, $3, $4)", + 4, nullptr, params_order, nullptr, nullptr, 0); // the state change +// on any error: PQexec(conn, "ROLLBACK") and answer 500 +const char* params_outbox[3] = {id.c_str(), "order.placed", payload_str.c_str()}; +res = PQexecParams(conn, + "INSERT INTO outbox (aggregate_id, event_type, payload) VALUES ($1, $2, $3::jsonb)", + 3, nullptr, params_outbox, nullptr, nullptr, 0); // atomic with the state change +res = PQexec(conn, "COMMIT"); // Debezium publishes the row +// No Kafka call on the request path: if the broker is down the order +// still commits, and the event is delivered when CDC catches up. +``` + +The database commit is the source of truth. The outbox + CDC turns it into a guaranteed, ordered event. + +p. 27 + +> The outbox write in the C++ example uses libpq directly. Three things to call out: (1) BEGIN, the orders insert, the outbox insert and COMMIT run on one connection; any failure rolls back and answers 500, so the order and its event are written together or not at all. (2) No Kafka call inside the request path: calling a producer from the handler would reintroduce the dual-write the outbox removes. (3) Debezium runs as Kafka Connect and publishes the committed outbox rows; the order-service has no Kafka-publish code. The cost is one extra process and a small outbox table; the benefit is an event for every committed order with no distributed-transaction machinery. + +## [033] DATA · Consistency without distributed transactions: the saga (sldId 288) + +A chain of local transactions; failures trigger compensating actions, never a cross-service rollback. + +p. 28 + +> Sagas are how you get cross-service consistency without two-phase commit. Each step is a local transaction in one service; if a later step fails, earlier steps are compensated (refunded, released, marked-cancelled — semantically reversed, not rolled back). In C++ we implement the saga itself with boost::sml as a finite state machine — strongly typed, header-only, ~200 lines for an order-fulfillment saga. The FSM state is persisted to Postgres in the same transaction as each step, so crash recovery is automatic: on restart, load the state, see which step we were on, and resume. Appendix D goes into the boost::sml implementation in detail. + +## [034] Event-Driven (sldId 289) + +05 + +Emit facts; let consumers decide. + +> Section 05 divider: Event-Driven. Emit facts; let consumers decide. + +## [035] EVENT-DRIVEN · The event backbone — one fact, many reactions (sldId 290) + +order.placed fans out to consumers the producer never names. Add a consumer without touching the producer. + +p. 29 + +> Event-driven coupling is the loosest coupling you can achieve while still cooperating. order-service publishes order.placed; inventory-service, payment-service, notification-service, analytics, audit, and three more services you haven't built yet can all consume it. The producer doesn't know they exist. Concretely for C++: a single RdKafka::Producer instance, process-scoped, owned in main(), shared by reference. Topic partitioning is done by message key (usually the aggregate ID — order.id) so all events for one entity land in the same partition and stay ordered. Consumers commit offsets after their side effects succeed. + +## [036] EVENT-DRIVEN · Producing and consuming with librdkafka (sldId 291) + +[chip] c++ · rdkafka++ + +```c++ +// Producer (excerpt): process-scoped, built once in main() (librdkafka C++ API, rdkafka++) +std::unique_ptr conf(RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL)); +conf->set("bootstrap.servers", cfg.kafka_bootstrap, err); +conf->set("acks", "all", err); // wait for ISR ack: durable +conf->set("dr_cb", &delivery_report, err); // DeliveryReportCb logs failures +std::unique_ptr producer(RdKafka::Producer::create(conf.get(), err)); +void publish_order_placed(const Order& o) { + auto value = serialize(o); + producer->produce("order.placed", RdKafka::Topic::PARTITION_UA, + RdKafka::Producer::RK_MSG_COPY, value.data(), value.size(), + o.id.data(), o.id.size(), 0, nullptr); // key = order id + producer->poll(0); // serve delivery reports +} +std::unique_ptr consumer( + RdKafka::KafkaConsumer::create(cconf.get(), err)); +consumer->subscribe({"order.placed"}); // notification-group, enable.auto.commit=false +while (running) { + std::unique_ptr msg(consumer->consume(1000)); + if (msg->err() != RdKafka::ERR_NO_ERROR) continue; // timeout / partition EOF + send_email(deserialize(payload_of(*msg))); // idempotent handler + consumer->commitSync(msg.get()); // after the side-effect +} +``` + +Commit offsets after the side-effect, and make handlers idempotent — at-least-once delivery is the contract. + +p. 30 + +> librdkafka's C++ API (rdkafka++) is the C++ binding that ships with librdkafka, the C client that most Kafka clients build on; the examples pin librdkafka 2.14.2 from ConanCenter. Three patterns: (1) the producer is process-scoped: one instance per binary, owned in main(), shared by reference, never one per request; poll() serves the delivery reports. (2) acks=all waits for every in-sync replica before the delivery report; add enable.idempotence=true when a producer retry must never write the record twice. +> +> (3) Commit after the side-effect succeeds: with enable.auto.commit=false and commitSync after the handler, the offset never advances past work that did not finish, and a restart re-delivers instead of losing events. Handlers are idempotent because at-least-once delivery means a record can arrive twice. The loop exits on a shutdown flag, which gives a graceful drain instead of an abrupt termination. + +## [037] EVENT-DRIVEN · Schemas are the contract — the registry enforces them (sldId 292) + +Apicurio holds versioned schemas; compatibility is checked at publish time so breaking changes are rejected early. + +p. 31 + +> Schema registry (Apicurio) is non-optional for production event-driven systems. Without it, a producer can ship a breaking schema change and silently corrupt consumers across the fleet. With it, breaking changes are rejected at publish time. For C++: protobuf is the natural schema choice — schemas live in .proto files, register to Apicurio at deploy time, codegen via protoc gives you typed structs both sides. Avro is also supported (Avro-C++ for serialisation, Apicurio for the registry). JSON Schema works but loses the binary efficiency. Compatibility mode (BACKWARD, FORWARD, FULL) is set per topic — BACKWARD is the safe default: old consumers can read new messages. + +## [038] EVENT-DRIVEN · Event sourcing vs. event streaming (sldId 293) + +Two patterns, often confused. Sourcing: the events are the state. Streaming: events are the transport. + +p. 32 + +> These two patterns get conflated constantly. Event sourcing means events are the state: you don't store the current order; you store the sequence of events that produced it, and you derive current state by replay. Event streaming means events are how state changes are transported — the order row in Postgres is the truth; Kafka is the wire. Most production systems use event streaming (with Postgres + outbox + CDC, as we've shown). Event sourcing is a much bigger commitment: your storage is the event log, your queries are replays or projections, and snapshotting becomes a first-class concern. Adopt event sourcing where the audit log and the temporal queries are the primary use case (financial ledgers, regulated workflows, debugging-heavy domains). + +## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) + +Different jobs, often together: substrate (Kafka, Pulsar), compute (Flink), movement (NiFi, Debezium). + +p. 33 + +> Three layers of the event-driven stack: (1) substrate — Kafka (Strimzi on Kubernetes) and Pulsar are the durable log. Pick Kafka by default; reach for Pulsar when geo-replication or tenant isolation is a first-class need. (2) compute — Flink for stateful stream processing, windowed aggregations, and complex event processing. (3) movement — NiFi for routing and transformation between systems; Debezium for database CDC. For C++ services specifically: we're producers and consumers on the substrate. Compute lives in Flink (Java/Scala) — the Stream Processing section frames this as 'stream processing as a pipeline'. Movement is operational tooling, not application code. None of these have native C++ alternatives we'd recommend over the JVM-based incumbents. + +## [040] EVENT-DRIVEN · The log is the source of truth (sldId 295) + +Consumers track their own offset; replay is just rewinding. Kafka (Strimzi) and Pulsar are logs, not queues. + +p. 34 + +> The mental model shift: Kafka is not a queue. A queue is a one-shot delivery mechanism where consuming the message destroys it. Kafka is an append-only log; the message stays until the retention policy says otherwise, and every consumer (group) tracks its own position. Implications for C++ services: (1) Reprocessing is just seeking to an earlier offset. A bug in your handler? Fix it, reset the offset, replay. (2) New consumers can join with auto.offset.reset=earliest and catch up from the beginning of retained history. (3) Retention is a real configuration concern — a topic with retention=7days is forgetful; a compacted topic remembers the latest value per key forever. Choose retention per topic. + +## [041] Stream Processing (sldId 296) + +06 + +Streams in, derived streams out. + +> Section 06 divider: Stream +> +> Processing. Streams in, derived streams out. + +## [042] STREAM PROCESSING · Derive new streams from streams (sldId 297) + +A streaming app is a topology of sources, stateful processors and sinks — joins, windows and aggregates. + +p. 35 + +> Stream processing is the second layer of the event-driven story: not just transport, but transformation. The topology is sources → stateful processors → sinks; the processors hold windowed state and emit derived streams. For C++ specifically, heavyweight stream processing belongs in Flink (JVM). What's tractable in-process for a C++ service is windowed aggregation over librdkafka's C++ API — small state, bounded windows, no joins across topics. The in-process pattern fits small, bounded state; production-grade joins, CEP and large state belong in Flink, with C++ services as producers and consumers. + +## [043] STREAM PROCESSING · In-process windowed aggregation (sldId 298) + +[chip] c++ · rdkafka++ + +```c++ +// stream-processor (excerpt, simplified): order.placed -> per-merchant tumbling +// windows -> revenue.by-merchant. Bounded state fits in-process; joins: Flink. +std::unordered_map merchants_; // open windows + +void aggregate(const std::string& merchant, int64_t ts, const cndp::Decimal& amount) { + auto it = merchants_.try_emplace(merchant, + cndp::MerchantWindows(window_seconds_)).first; // WINDOW_SECONDS + auto update = it->second.apply(ts, amount); // nullopt: late record, dropped + if (!update) return; + send("revenue.by-merchant", merchant, // running total for the window + cndp::revenue_json(merchant, *update), 0); +} + +bool commit_batch() { // after each batch of up to 500 + producer_->flush(30000); // outputs delivery-reported first + if (reports_.failed() > 0) return false; // else exit, replay from last commit + consumer_->commitSync(); // then commit the input offsets + return true; +} +// The example re-keys order.placed through a repartition topic first, so each +// merchant's records land on one partition and one instance. +``` + +In-process is fine for small, bounded windowed state; outputs are delivered before offsets commit. For joins, CEP or large state, deploy Flink. + +p. 36 + +> The in-process stream-processing pattern from the C++ example. Three things to call out: (1) state is bounded by design: one MerchantWindows per merchant holds only the open tumbling windows, and a window closes when stream time passes its end, so a late record is dropped. (2) Every update is a running total keyed by merchant and window start; consumers of revenue.by-merchant keep the latest record per window. (3) Outputs first, offsets second: after each batch the producer flushes, and the input offsets are committed only when every output was delivery-reported; a failed delivery stops the process so the batch replays. +> +> The example also re-keys order.placed through a repartition topic so each merchant lands on one partition, and it sums money as exact fixed-point decimals. When this pattern stops scaling (joins across topics, long retention, exactly-once aggregation), deploy a Flink job and keep the C++ service as a producer and consumer. + +## [044] STREAM PROCESSING · KEDA scales workers on the signal that matters (sldId 299) + +Scale Kafka workers on consumer lag and HTTP services on request rate — both down to zero when idle. + +p. 37 + +> KEDA (Kubernetes Event-Driven Autoscaler) is the right answer for both async workers and synchronous HTTP services. For Kafka consumers, the signal is consumer lag — when order.placed lag exceeds the threshold, scale up; when it drops back under, scale down, potentially to zero. For HTTP services (Drogon), the signal is concurrent requests, measured by the KEDA http-add-on which fronts the service. Why this matters for C++: a Drogon binary cold-starts in tens of milliseconds, which makes scale-to-zero viable; the latency of the first request after a cold start is well within typical HTTP timeout budgets. This is one of the real wins of choosing C++ for cloud-native services. + +## [045] STREAM PROCESSING · Scale on lag (events) and on RPS (HTTP) (sldId 300) + +[chip] yaml · KEDA + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: { name: payment-consumer } +spec: + scaleTargetRef: { name: payment-consumer } + minReplicaCount: 0 + maxReplicaCount: 20 + triggers: + - type: kafka + metadata: { bootstrapServers: "cndp-kafka-bootstrap:9092", topic: order.placed, + consumerGroup: payment, lagThreshold: "100" } +--- +# HTTP: scale the Drogon service on concurrent requests (KEDA http-add-on) +apiVersion: http.keda.sh/v1alpha1 +kind: HTTPScaledObject +metadata: { name: order-service } +spec: + hosts: [orders.example.com] + scaleTargetRef: { name: order-service, service: order-service, port: 8080 } + replicas: { min: 0, max: 30 } +``` + +One mechanism, two signals: queue depth for async workers, concurrency for synchronous APIs. + +p. 38 + +> Two KEDA objects, one mechanism. The Kafka ScaledObject scales the payment-consumer deployment based on consumer-group lag — 100 events of lag per replica is a reasonable default, tune to your workload. The HTTPScaledObject (from the KEDA http-add-on) puts an interceptor in front of the service that measures concurrent requests and scales pods to match. Both go to zero when idle — for a C++ binary this is realistic because cold start is fast. For Python or JVM services the scale-to-zero math is harder because the first-request latency dominates. Worth checking your specific Drogon image's cold-start time in your environment before relying on min=0 in production. + +## [046] Workflows and Jobs (sldId 301) + +07 + +Not every unit of work is a request. + +> Section 07 divider: Workflows +> +> and Jobs. Not every unit of work is a request. + +## [047] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 302) + +A coordinator that commands keeps the flow in one place; events that trigger each other decouple but hide the flow. + +p. 39 + +> Two ways to coordinate multi-step business processes across services. Orchestration has a central coordinator that commands each step; choreography has services react to each other's events with no central authority. C++-specific framing — orchestration in production normally rides on Temporal or Cadence. There is no first-class Temporal C++ SDK; in our deck, C++ services participate in a Temporal workflow as activities, invoked via gRPC from a Temporal worker written in Go, Java, or TypeScript. The workflow code itself lives in that polyglot worker; the C++ service exposes the activity's grpc::Service. This is the integration pattern we'll lean on whenever a workflow engine appears in the architecture. + +## [048] WORKFLOWS AND JOBS · Choosing between the two (sldId 303) + +Orchestration + +- One coordinator owns the saga and its state +- Flow is explicit and centrally observable +- One place to reason about, change and audit +- Risk: the orchestrator becomes a god-service +- Good for complex, long-lived business processes +- C++ integrates as Activities, invoked over gRPC from a Temporal worker + +Choreography + +- Each service reacts to the previous event +- Maximally decoupled; no central bottleneck +- Flow is emergent — harder to see end-to-end +- Risk: nobody owns the overall process +- Good for simple, additive event reactions +- C++ subscribes to topics with rdkafka++; in-process saga via boost::sml + +p. 40 + +> When to pick which. Orchestration excels when there's clear ownership of a multi-step business process and the steps are complex or long-lived (order fulfillment, claims processing, multi-day approvals). The orchestrator becomes a real service, but the trade is explicit ownership and end-to-end visibility. Choreography excels when steps are simple and the additive list of consumers grows over time — analytics, audit, notifications fanning out from order.placed. C++-specific note (italic rows): orchestration means C++ services are Activities behind a Temporal worker in another language; choreography means C++ services own boost::sml state machines and react to Kafka topics directly. Both are workable; the choice is about your process complexity and your team's polyglot tolerance. + +## [049] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 304) + +Long, batch or periodic work belongs in Jobs/CronJobs or a scaled queue worker — never on the request path. + +p. 41 + +> Three Kubernetes-native patterns for non-request work. (1) Job: a one-shot batch — run a data migration, a backfill, a nightly export. The Job spec runs the same C++ binary you'd serve from, with a different argv. (2) CronJob: a scheduled Job — nightly reconciliation, weekly cleanup. (3) Queue worker: a long-running deployment that consumes from Kafka or a queue, scaled by KEDA on backlog. C++-specific note: the same Drogon binary can serve HTTP, consume Kafka, run as a Job, or run as a CronJob — pick the argv-driven mode in your image. Or split into separate binaries per CMake target if you prefer; both patterns work. The point: never put a 30-second operation on the synchronous request path. + +## [050] API Management (sldId 305) + +08 + +Treat the API surface as a platform. + +> Section 08 divider: API +> +> Management. Treat the API surface as a platform. + +## [051] API MANAGEMENT · Four cooperating planes (sldId 306) + +Gateway and mesh at runtime; registry for contracts; metadata for discovery; observability for signals. + +p. 42 + +> API management is four planes that have to cooperate. (1) Runtime: gateway at the edge, service mesh east-west. (2) Contract: schema registry (Apicurio) holds versioned OpenAPI/proto/AsyncAPI/SDL contracts. (3) Discovery & lineage: metadata catalogue (OpenMetadata) tracks who owns what, who depends on what, where signals come from. (4) Observability: OpenTelemetry feeding the LGTM stack stitches it all together. For C++ specifically, all four planes are external to the service binary. Your C++ code speaks Drogon/gRPC/Kafka; the platform handles the rest. This is the cleanest possible API-management story — the language is just a producer of contracts and signals. + +## [052] API MANAGEMENT · Versioning and evolution are part of management (sldId 307) + +- Evolve additively — new optional fields and endpoints; never repurpose or remove without a new version. +- Carry a major version in the path (/v2) or media type; deprecate with headers and a sunset date. +- The registry's compatibility rules (BACKWARD / FORWARD / FULL) enforce this mechanically at publish time. +- Run old and new side by side; migrate consumers off the old contract before you retire it. +- Per-protocol mechanics: proto3 fields are optional by default (additive is safe); OpenAPI requires explicit deprecation markers; GraphQL SDL evolves by deprecating fields, not bumping versions. +- Lifecycle: design → register → publish → consume → deprecate → retire — each step has an owner and a gate. + +p. 43 + +> Versioning at the contract layer is the discipline that lets the rest of the API platform do its job. Five rules, one principle: evolve additively. C++-specific notes: protobuf field numbers are forever — adding a new field is safe, removing or repurposing one is a hard break that protoc-generated stubs can't paper over. OpenAPI in C++ is hand-maintained or scaffolded (see §1 design-first), so the discipline of deprecation markers and major-version paths lands on you, not on auto-generation. cppgraphqlgen regenerates from the SDL; deprecated fields stay in the generated code with a @deprecated annotation surfaced in introspection. The registry (Apicurio) gates breaking changes at publish time — BACKWARD compatibility is the right default for most topics. + +## [053] API Registry (sldId 308) + +09 + +One versioned home for every contract. + +> Section 09 divider: API Registry. One versioned home for every contract. + +## [054] API REGISTRY · Apicurio: contracts, versioned and gated (sldId 309) + +OpenAPI, AsyncAPI, proto and Avro/JSON schemas live in one registry with compatibility rules and CI gating. + +p. 44 + +> Apicurio is the central registry for every contract in the system: OpenAPI YAML for REST, .proto for gRPC, AsyncAPI for Kafka topics, Avro/JSON Schema for event payloads. One versioned, governable home. C++-specific framing: of these contract types, .proto and Avro are the ones with strong C++ codegen (protoc, Avro-C++). OpenAPI and AsyncAPI in C++ are documentation-first: Apicurio still holds them with compatibility rules, but the codegen pipeline is partial or absent (see design-first in §1). Apicurio's value to a C++ shop is the same as to any other: gate-keeping breaking changes before they ship. + +## [055] API REGISTRY · Gate breaking changes in CI (sldId 310) + +[chip] c++ · Apicurio + +{% raw %} +```c++ +// publish_schema.cc — invoked from CI after the build, before the merge. +// Apicurio answers 400 when a version breaks the artifact's rule (e.g. BACKWARD). +#include +#include +#include +int publish(const std::string& group, const std::string& artifact_id, + const std::string& schema) { + httplib::Client cli{"http://apicurio.registry.svc"}; + cli.set_read_timeout(10); + auto path = "/apis/registry/v3/groups/" + group + + "/artifacts/" + artifact_id + "/versions"; + nlohmann::json body = {{"content", {{"content", schema}, // v3 CreateVersion + {"contentType", "application/json"}}}}; + auto res = cli.Post(path, body.dump(), "application/json"); + if (!res) return 2; // unreachable: retry or page on-call + if (res->status == 400) { // RuleViolationException + spdlog::error("incompatible schema change, blocking the merge: {}", res->body); + return 1; + } + if (res->status >= 300) { spdlog::error("{}", res->body); return 2; } + return 0; // 200: compatible, version created +} +``` +{% endraw %} + +The CI job fails the build on an incompatible change — the contract is enforced before code ships, not after. + +p. 45 + +> Small, single-purpose binary you run as part of CI. cpp-httplib (header-only, MIT, github.com/yhirose/cpp-httplib) is the lean HTTP client; libcurl (curl license, curl.se) is the heavier alternative when you need richer auth or HTTP/2 multiplexing. Exit codes are meaningful: 0 = registered OK, 1 = incompatible (fail the merge), 2 = transient (retry or page). The CI step is `publish_schema build/schema.json` and the merge is blocked iff the registry rejects. The schema text travels as the content field of the v3 CreateVersion body with its content type (application/json for Avro and JSON Schema, application/x-yaml for OpenAPI or AsyncAPI, application/x-protobuf for a .proto); Apicurio stores it as given. Compatibility rules are configured once per artifact, not per push. + +## [056] API Metadata (sldId 311) + +10 + +If they can't find it, it doesn't exist. + +> Section 10 divider: API Metadata. If they can't find it, it doesn't exist. + +## [057] API METADATA · Discovery, lineage and ownership (sldId 312) + +OpenMetadata ingests from services, the registry and Kafka to build a searchable catalog with lineage. + +p. 46 + +> OpenMetadata is the discovery layer. It crawls the registry (Apicurio), the broker (Kafka topic list and schemas), the databases (table schemas), and the services (OpenAPI specs fetched from /openapi.json or equivalent endpoints) to build one searchable catalog with ownership, lineage, and quality signals. For a C++ shop the ingestion story is identical to any other language — OpenMetadata doesn't care that your service is C++; it cares that the service exposes its OpenAPI spec at a known URL. The C++ team's job is to publish that spec (the Apicurio-registered YAML) at a discovery endpoint and tag the service with its owning team and tier. + +## [058] API METADATA · What the catalog gives every consumer (sldId 313) + +- Discovery — search across APIs, topics and tables; find the owning team and how to get access. +- Trust — published SLOs, data-quality test results, freshness, and a tier/criticality rating. +- Lineage — column- and topic-level lineage answers 'if I change this, who breaks?' before you change it. +- Ownership & governance — tags, classifications (PII), and accountable owners attached to every asset. +- Ingestion runs as a scheduled connector against OpenAPI specs, the registry, Kafka and Postgres. +- For C++ services: publish the OpenAPI YAML at a known endpoint (e.g. /openapi.json), tag the service with team + tier in OpenMetadata — the ingestion connector does the rest. + +p. 47 + +> Four concrete things a catalog gives you, plus the discipline they depend on: ownership and freshness are enforced at ingest time, not just collected. The bold final bullet is the only C++-specific thing here — the integration is mechanical, but the discipline of keeping the OpenAPI YAML accurate (since C++ doesn't auto-generate it the way FastAPI does) becomes more visible at this layer. If the YAML is stale, the catalog is stale, and consumers find out the hard way. + +## [059] Observability (sldId 314) + +11 + +Instrument once; correlate everything. + +> Section 11 divider: Observability. Instrument once; correlate everything. + +## [060] OBSERVABILITY · Three signals, one correlated story (sldId 315) + +OpenTelemetry emits traces, metrics and logs; the LGTM-style stack stores them and Grafana joins them. + +p. 48 + +> The classic three pillars: traces (what happened in this request), metrics (aggregates and trends), logs (the detail). OpenTelemetry is the single instrumentation API that emits all three; the LGTM stack (Loki / Grafana / Tempo / Mimir) stores them; Grafana correlates across by trace_id. For C++ this means the OpenTelemetry C++ SDK (opentelemetry-cpp, Apache 2.0, actively released), with the OTLP exporter pointing at your Collector. The Collector handles fan-out to Tempo / Mimir / Loki and the per-backend formatting; your C++ binary just speaks OTLP. Critical config: use the Batch span processor, never Simple in production, because Simple flushes synchronously and collapses throughput. cpp-statelessness Doc 04 shows the BatchSpanProcessor setup (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/). + +## [061] OBSERVABILITY · The signals in depth — traces, metrics, logs, baggage, profiles (sldId 316) + +Three classic pillars plus two that matter more every year. OpenTelemetry emits all five; one trace_id correlates them. + +p. 49 + +> Three classic signals plus two that are now first-class in OpenTelemetry. Baggage carries request-scoped context (tenant_id, user_tier) along the trace; profiles tell you which function is burning CPU or holding locks, continuously, in production. For C++ all five are supported by the OpenTelemetry C++ SDK and OTLP. Profiling specifically is where C++ shines — eBPF-based profilers like Parca or Pyroscope work natively against C++ binaries with DWARF symbols and don't need a runtime agent. Most teams turn on profiling in C++ services much earlier than they would in JVM or Python because the data is so much more directly actionable. + +## [062] OBSERVABILITY · What each signal answers — and when to reach for it (sldId 317) + +- Traces — 'where did the time go in this request, which hop failed?' Reach for it when debugging latency or a specific failed request. Stored in Tempo / Jaeger. +- Metrics — 'what is the aggregate rate / error / latency trend, is it getting worse?' RED for request-driven services; USE for resources. Stored in Prometheus / Mimir. +- Logs — 'what exactly happened in this component at this instant?' Structured (JSON via spdlog), keyed by trace_id. Stored in Loki / ELK. +- Baggage — 'carry a value (tenant_id, user_tier) along the whole request so downstream services and their telemetry can use it.' Set once at the edge, readable everywhere. +- Profiles — 'which function is burning CPU / allocating memory / holding locks, continuously, in production?' Pyroscope / Parca with DWARF symbols on the C++ binary. +- Discipline: traces + metrics + logs always; add baggage when downstream needs request-scoped context; add profiling when you have CPU/memory mysteries a trace can't resolve. + +p. 50 + +> One question per signal. The trick is knowing which signal to reach for first. Latency for one request → trace. Latency trend across all requests → metric. The exact string that came back from this DB call → log. 'Why is this slow for this tenant?' → baggage filter on the trace. 'Why is steady-state CPU 30% higher than last week?' → continuous profile diff. C++ specifically: spdlog with the stdout sink is the standard log path; the spdlog sink in opentelemetry-cpp-contrib bridges into the OTel pipeline. Never spdlog::basic_logger_mt: that's a file sink which dies on a read-only root filesystem (the 'logs to stdout, not a file' rule of cpp-statelessness Doc 08, patterncatalyst.github.io/cpp-statelessness/docs/08-ephemeral-filesystem/). + +## [063] OBSERVABILITY · Auto-injection vs. instrumentation — three distinct things (sldId 318) + +- Manual instrumentation — you write the spans. tracer->StartSpan(...), span->SetAttribute(...), span->AddEvent(...). Needed for business operations the libraries can't know about ('reserve_stock', 'score_fraud'). +- Auto-instrumentation — a library wraps known frameworks for you. For C++: opentelemetry-cpp-contrib ships log bridges (spdlog, glog, Boost.Log, log4cxx) and nginx and Apache httpd modules; gRPC, Drogon, librdkafka and libpq spans are written by hand. +- Auto-injection — the OpenTelemetry Operator mutates pods at admission to inject an agent. Native auto-injection support for C++ is more limited than for JVM or Python; in practice you ship the OTel SDK statically linked into the binary and configure via env vars. +- How they layer for C++: link the OTel SDK into your binary → add manual middleware spans for Drogon/Kafka boundaries → add manual business spans on top. Less framework auto-coverage than Python; more deterministic. +- Trade-off: manual costs effort but produces precise, low-noise traces. Auto in C++ covers fewer frameworks than in Python/JVM, so most C++ services run mostly-manual with a thin instrumentation library. + +p. 51 + +> Where C++ OTel auto-instrumentation stands. Python and JVM have far more ready-made auto-instrumentation libraries; for C++, opentelemetry-cpp-contrib covers logging bridges and the nginx and httpd modules, while gRPC, Drogon, librdkafka, libpq and most C++ frameworks need the boundary spans written by hand (opentelemetry-cpp's examples/grpc shows the context propagation). This is more work but produces cleaner traces: fewer spurious framework spans, and every span you see exists for a reason. Auto-injection: the OTel Operator's auto-injection (where Kubernetes mutates the pod to add a sidecar agent) is JVM/Python/Node-first; C++ services typically link the SDK in and configure with OTEL_EXPORTER_OTLP_ENDPOINT and friends from env vars. Cleaner deploy story either way. + +## [064] OBSERVABILITY · Auto-instrument Drogon with OpenTelemetry (sldId 319) + +[chip] c++ · OpenTelemetry + +```c++ +// otel_init.cc — initialise once in main(), before serving traffic +#include +#include +#include + +namespace otel = opentelemetry; +void init_tracing(std::string_view service_name) { + auto exporter = otel::exporter::otlp::OtlpHttpExporterFactory::Create({}); + auto processor = otel::sdk::trace::BatchSpanProcessorFactory::Create( // batch, not simple + std::move(exporter), { /* max_queue_size, schedule_delay */ }); + auto provider = otel::sdk::trace::TracerProviderFactory::Create(std::move(processor)); + otel::trace::Provider::SetTracerProvider(std::move(provider)); +} + +// Drogon middleware — one span per request, attributes per route +Task<> trace_middleware(HttpRequestPtr req, auto next) { + auto tracer = otel::trace::Provider::GetTracerProvider()->GetTracer("order-service"); + auto span = tracer->StartSpan(req->getPath()); + auto scope = tracer->WithActiveSpan(span); + span->SetAttribute("http.method", req->getMethodString()); + co_await next(req); // child spans nest naturally + span->End(); +} +``` + +Init once in main(); add boundary middleware for HTTP / gRPC / Kafka; manual spans for business operations. trace_id flows automatically downstream. + +p. 52 + +> Two parts, both essential. init_tracing() goes in main() before any handlers register; it sets the global TracerProvider with the Batch span processor (never Simple, which flushes synchronously and crushes throughput). The Drogon middleware creates one parent span per request and registers it as the active span; any child spans you start inside the handler nest naturally because OTel's Context API tracks active spans per-thread. Outbound calls do not pick up the context on their own: inject it with the W3C trace-context propagator into a carrier (the gRPC ClientContext metadata, the Kafka message headers). opentelemetry-cpp's examples/grpc shows the carrier; opentelemetry-cpp-contrib has no gRPC interceptor. + +## [065] OBSERVABILITY · How correlation works — context propagation (sldId 320) + +The trace context rides in-band with the request (W3C traceparent). The same trace_id at every hop is what stitches the story. + +p. 53 + +> Trace propagation in one mental model: every protocol header carries a traceparent. HTTP request headers (W3C traceparent), gRPC metadata (grpc-trace-bin in some setups, but most use the standard W3C header), Kafka record headers (the OTel Kafka extension adds them automatically when configured). Each receiver extracts the parent context and creates its own span as a child. C++ specifically: the OTel context propagators (HttpTraceContext, B3) are configured once via SetGlobalPropagator at startup; from then on, your Drogon middleware extracts inbound context from request headers and the gRPC OTel interceptor injects outbound context into ClientContext metadata. The work is wiring once; the runtime behaviour is automatic. + +## [066] OBSERVABILITY · Baggage in practice — carry request context downstream (sldId 321) + +[chip] c++ · OpenTelemetry + +```c++ +// Set baggage at the edge — it rides the whole trace, every downstream hop +#include +#include + +namespace otel = opentelemetry; +Task<> baggage_middleware(HttpRequestPtr req, auto next) { + auto bag = otel::baggage::Baggage::GetDefault() + ->Set("tenant_id", req->getHeader("X-Tenant").value_or("unknown")) + ->Set("user_tier", lookup_tier(req)); + auto ctx = otel::baggage::SetBaggage( + otel::context::RuntimeContext::GetCurrent(), bag); + auto token = otel::context::RuntimeContext::Attach(ctx); + co_await next(req); // propagates automatically +} + +// Read baggage anywhere downstream — even in a Kafka consumer +void handle_event(const auto& msg) { + auto bag = otel::baggage::GetBaggage( + otel::context::RuntimeContext::GetCurrent()); + auto tenant = bag->GetValue("tenant_id").value_or("unknown"); + otel::trace::Tracer::GetCurrentSpan() + ->SetAttribute("tenant_id", tenant); // filterable in Tempo +} +``` + +Set once at the edge; read anywhere downstream. Pin to the span as an attribute to filter traces by tenant_id. + +p. 54 + +> Baggage is a separate concern from trace context — they ride together but carry different data. Trace context (traceparent) tells you 'this is part of trace X'; baggage carries named key-value pairs that downstream services can read. Set once at the edge in a middleware (tenant_id from a header, user_tier from a lookup), and it propagates with the active context through Drogon → gRPC → Kafka → downstream consumer. To filter traces by these values, pin baggage onto the current span as an attribute (last three lines) — baggage itself isn't queryable in Tempo, but span attributes are. Watch the cardinality of what you put in baggage — every baggage entry rides every request as a header. Tenant_id is fine; user-agent strings are not. + +## [067] OBSERVABILITY · A single trace stitches the whole request (sldId 322) + +One trace_id propagates from the edge REST call through gRPC, Kafka and the consumer — five services, one story. + +p. 55 + +> This is what 'instrument once, correlate everything' looks like. One client request enters at the edge, hits order-service over REST, fans out via gRPC to inventory and payment, emits order.placed to Kafka, which notification-service consumes. One trace_id binds the whole story. Operational reality: in Tempo / Grafana you click the latency outlier in your dashboard, see the trace spanning all five services, see which hop spent the time, drop into the logs for that exact span (correlated by trace_id), and you're debugging in seconds instead of minutes. This is the payoff for the instrumentation work — it's not free, but every production incident pays it back. + +## [068] OBSERVABILITY · Sampling — head vs. tail (sldId 323) + +You can't store every trace at scale. Head decides at the start (cheap, blind); tail decides at the end (smart, costly). + +p. 56 + +> Sampling is the only reason a trace pipeline scales — full retention at production volume is prohibitively expensive in storage and bandwidth. Two families: head sampling decides at the start of the trace (cheap, but blind, since you don't yet know if this trace will fail or be slow); tail sampling decides at the end, after buffering the whole trace at the Collector (smart: keep all errors, all slow traces, plus a percentage of the rest). + +## [069] OBSERVABILITY · Choosing a sampling strategy (sldId 324) + +- Probabilistic (head) — keep a fixed percentage (e.g. 10%). Simplest; predictable volume; blind to errors. Good baseline for high-traffic, low-stakes services. +- Rate-limiting (head) — keep at most N traces/sec per service. Protects your backend from traffic spikes; good when load is bursty. +- Parent-based (head) — honour the upstream's sampling decision (carried in traceparent flags). Essential: stops a trace being sampled at A but dropped at B, which gives you broken half-traces. +- Tail-based (Collector) — buffer the whole trace, then keep by rule: all errors, all traces over a latency threshold, rare routes, plus a small % of the rest. Gold standard; costs Collector memory + CPU. +- Recommended setup: parent-based head sampling at 1–10% baseline, plus tail sampling at the Collector to guarantee every error and slow trace is kept. +- Don't sample metrics the way you sample traces (metrics aggregate everything). Keep logs in full with retention limits, or sample on a separate policy keyed by trace_id. + +p. 57 + +> Sampling is a per-pipeline configuration decision, but the rules are universal. Five named strategies; the production answer is almost always parent-based head + tail at the Collector. C++ specifics: the OpenTelemetry C++ SDK supports all of these sampler types (AlwaysOnSampler, AlwaysOffSampler, ParentBasedSampler, TraceIdRatioBasedSampler), configured at TracerProvider construction time. Tail sampling lives in the Collector config, not in your C++ code. The metrics-vs-traces note matters: don't get confused and apply head sampling to metrics. Metrics are aggregates over every request; if you sample at 10%, your error-rate metric is off by 10x. Logs are separate again — keep them in full but on a shorter retention than traces, and sample by trace_id so a kept trace keeps its logs. + +## [070] OBSERVABILITY · Prove it with simple tools before you ship (sldId 325) + +- curl / Postman — exercise REST contracts and inspect status codes, headers and pagination. +- hey — quick HTTP load to watch latency, error rate and the KEDA http-add-on scale the Drogon service from zero. +- ghz — load-test gRPC endpoints and confirm timeouts (gRPC ClientContext::set_deadline) and Istio retries behave under pressure. +- GraphQL Playground — explore the composed schema and watch the gateway fan out across services in Tempo. +- Kiali — read the live service-mesh topology, mTLS status and request flow during a load test. +- perf / eBPF (Parca, Pyroscope) — profile the C++ binary continuously; flame graphs against DWARF symbols make hotspots obvious. +- Apicurio UI — visually inspect the registered contracts and their compatibility rules before you push a breaking change. + +p. 58 + +> Six tools that pay for themselves before you ever hit production. The pattern across all of them: exercise the system from outside, watch what the platform reports back, fix the gap between expectation and observation. C++ specific: perf and eBPF-based profilers are far more useful in C++ than in managed runtimes — flame graphs against DWARF symbols show you the actual function names, no interpreter or JIT layer to peel back. Pyroscope's eBPF profiler can attach to a running Drogon binary with zero source changes and produce continuous flame graphs in Grafana. Worth turning on in staging from day one. + +## [071] Security (sldId 326) + +12 + +Defence in depth, in layers. + +> Section 12 divider: Security. Defence in depth, in layers. + +## [072] SECURITY · The 4 C's of cloud-native security (sldId 327) + +Cloud → Cluster → Container → Code. Concentric layers, each layer protects what is inside. + +p. 59 + +> The 4 C's model (Cloud, Cluster, Container, Code) is the CNCF's framing for defence in depth. Each layer protects what's inside it; controls at the wrong layer give false confidence — network ACLs can't save you from SQL injection in your handler. For C++ specifically, the Container and Code layers carry slightly different idioms than managed runtimes: minimal base images get even smaller (no JVM, no Python interpreter — a statically-linked C++ binary in distroless or scratch is realistic), and the Code layer gets help from compiler hardening flags (-fstack-protector-strong, -D_FORTIFY_SOURCE=2, PIE/ASLR) and sanitisers (ASan, UBSan) in CI. The Container and Code controls compound. + +## [073] SECURITY · The 4 C's — what each layer enforces, and who owns it (sldId 328) + +- Cloud — IAM (least privilege), VPC isolation, KMS for keys, audit logging, region & data-residency controls. Owner: platform / cloud admin. +- Cluster — RBAC, NetworkPolicy (default-deny + targeted allows), Pod Security Standards (restricted), admission control (Gatekeeper / Kyverno), secret encryption at rest. Owner: platform / SRE. +- Container — minimal base images (distroless, UBI-minimal; statically linked C++ can run on scratch), non-root, read-only rootfs, image signing (Cosign), vuln scanning (Trivy). Owner: app + platform. +- Code — parameterised queries (libpq with $1, $2, never string concatenation), input validation at the trust boundary, dependency scanning (Conan + OSV-Scanner), no secrets in source, threat modeling. Owner: app team. +- C++-specific code controls: ASan/UBSan in CI; -fstack-protector-strong + -D_FORTIFY_SOURCE=2 + PIE in release builds; std::string_view for non-owning views (no buffer-overrun escape). +- Cross-cutting: observability (who did what), incident response, threat modeling per service, regular dependency updates. A control at the wrong layer is the most common source of false confidence. + +p. 60 + +> The 4 C's in detail with C++-specific notes added at each layer where they matter. Container: a C++ binary compiled with all dependencies statically linked can run on gcr.io/distroless/static-debian12 or even FROM scratch — image sizes in the single-digit MB are routine, attack surface is minimal. Code: the C++ controls (compiler hardening flags, sanitisers in CI) catch a class of bugs that managed runtimes don't have at all but the C++ ecosystem has tooling for. Pin them in your CMake preset and they're free thereafter. The cross-cutting rule is the headline: defence in depth means controls at every layer, not just the one you find convenient. + +## [074] SECURITY · Seven patterns to know (sldId 329) + +- Sidecar — move security concerns out of the app into a companion process. mTLS, JWT, audit, rate-limiting belong there. +- Valet Key — hand the client a short-lived signed token to access a resource directly. Less app proxying, less attack surface. +- Strangler Fig (for security modernisation) — encapsulate legacy auth behind an API gateway, replace pieces over time. +- Zero-Trust / Identity-Aware Proxy — every request authenticated and authorised. Perimeter trust is dead. +- Policy-as-Code — security gates declared in Git, evaluated at admission and at build. No human runs a checklist. +- Bulkhead — isolate resources so one failure (or compromise) cannot starve or cascade into others. +- Claim-Check — send a reference, not the payload. Keeps PII out of message bus history and limits the blast radius if a topic leaks. +- The seven appear in different layers and combine. Each slide that follows shows the pattern, then implementation — platform config where it's mostly YAML, C++ code where it's mostly code. + +p. 61 + +> Seven security patterns, each with a concept and an implementation. They're not a checklist; production systems combine them per concern. The framing for this section: each pattern gets a concept slide (diagram) and an implementation slide (YAML or C++ code). Where it's platform configuration (Sidecar, Policy-as-Code, Zero-Trust mostly), the implementation is YAML and language-neutral. Where it's app code (Valet Key, Bulkhead, Claim-Check), we show idiomatic C++. + +## [075] SECURITY · SIDECAR · Sidecar pattern — push security out of the app (sldId 330) + +mTLS, JWT validation, rate limiting, audit logging — a companion process in the same Pod handles them all. + +p. 62 + +> The sidecar is the security pattern that covers the most ground in a Kubernetes deployment. Istio's istio-proxy (Envoy) sidecar handles mTLS, JWT validation, rate limiting, and audit logging uniformly across every workload — your C++ binary, your Java binary, your Python binary all get the same security guarantees with the same config. Cost: one extra container per Pod (~20-50 MB memory, sub-second startup). For a fleet of C++ services where each binary is itself only a few MB, the sidecar can be larger than the app. That's fine — the security guarantee is what you're paying for. The app never re-implements what the sidecar does. No JWT library in your C++ binary, no mTLS in your code. The sidecar does it; the app reads trusted headers. + +## [076] SECURITY · SIDECAR · Reading the sidecar's trusted headers (sldId 331) + +- The sidecar handles mTLS, JWT validation, policy decisions — the app trusts the headers the sidecar attaches and reads them as plain values. +- X-Forwarded-Client-Cert — the validated client identity from mTLS, includes the SPIFFE ID (spiffe://cluster.local/ns/orders/sa/order-service). The app must reject requests that don't have it. +- JWT claims — the sidecar validates the signature, then passes claims forward as headers. The app reads sub, aud, scope; it never re-validates the signature. +- X-Request-Id — attached by the sidecar for traceability. Log it; propagate it to downstream calls; correlate with audit logs. +- Trust only when the sidecar is in front. Direct-to-Pod traffic (port-forward, debugging) could spoof these headers — use a NetworkPolicy to ensure traffic only enters via the sidecar's mTLS port. +- C++ in Drogon: read req->getHeader("X-Forwarded-Client-Cert") in a guard middleware; reject the request with 401 if missing. jwt-cpp stays unused — the sidecar did it. Examples: Istio Envoy, Linkerd2-proxy, OPA Sidecar. All Apache 2.0. + +p. 63 + +> The trust contract between the app and the sidecar. The sidecar is the auth perimeter; the app reads the conclusions it reached. For C++ this changes what jwt-cpp is for: it is the deck's JWT library, but in a sidecar-fronted deployment its only job is to handle internal token minting or decoding tokens from contexts the sidecar didn't see. The default case, validating tokens from the client, is the sidecar's job, not yours. Always pair the sidecar headers with a NetworkPolicy. If a developer port-forwards or a sidecar fails injection, traffic could reach the Pod without going through the sidecar, and the headers become spoofable. The NetworkPolicy enforces that direct Pod-to-Pod traffic is blocked. + +## [077] SECURITY · VALET KEY · Valet key pattern — short-lived signed token, direct access (sldId 332) + +App mints a scoped, time-bound token; client uses it directly against the resource. App never proxies the bytes. + +p. 64 + +> The valet key pattern keeps bytes out of your service's request path. Instead of uploading a file through the C++ service to object storage, the service hands the client a signed ticket with a short expiry, scoped to one key and one operation; the client uploads directly. Why this matters more in C++: a single Drogon worker thread occupied for 30 seconds of upload time is a thread you can't use for other requests. The valet key pattern eliminates that — the C++ service mints the ticket in microseconds, the upload goes directly to the object store, and the thread is back in the pool immediately. The math compounds in any system handling user-uploaded content. + +## [078] SECURITY · VALET KEY · Valet key — scoped, time-bound, op-restricted (sldId 333) + +[chip] c++ · Drogon + jwt-cpp + +```c++ +// valet.hpp (excerpt), examples/12-security/cpp: the valet key is an HS256 JWT (jwt-cpp) +inline Minted mint(const std::string& secret, const std::string& resource, + const std::string& operation, + std::chrono::seconds ttl = std::chrono::minutes(5)) { // time-bound + const auto now = std::chrono::system_clock::now(); + const auto exp = std::chrono::time_point_cast(now + ttl); + auto token = jwt::create() + .set_type("JWT") + .set_issuer(kIssuer) // "order-service" + .set_issued_at(now) + .set_expires_at(exp) + .set_payload_claim("res", jwt::claim(resource)) // one object + .set_payload_claim("op", jwt::claim(operation)) // one operation + .sign(jwt::algorithm::hs256{secret}); + return {std::move(token), exp.time_since_epoch().count()}; +} +// verify(): HS256 pinned ("none" and RS256 never reach the verifier), then iss, res, op +const auto decoded = jwt::decode(token); +if (decoded.get_algorithm() != "HS256") return false; +jwt::verify().allow_algorithm(jwt::algorithm::hs256{secret}).with_issuer(kIssuer) + .with_claim("res", jwt::claim(resource)).with_claim("op", jwt::claim(operation)) + .verify(decoded); // signature + exp; throws on failure +``` + +The Drogon worker thread is free in microseconds; the upload happens directly to object storage with a signature that cannot be forged or extended. + +p. 65 + +> The handler mints a ticket that authorises one operation (GET or PUT) on one resource for five minutes; after that it is worthless. In Example 12 the ticket is an HS256 JWT signed with jwt-cpp: the res and op claims carry the scope, exp the expiry, iss the issuer. POST /valet-key returns the resource, the operation, the expiry and the token. +> +> The verifier (GET /verify-valet in the example, the storage gateway in a real system) reads the header first and rejects anything but HS256, so alg=none or an RS256 token never reaches the verifier; then jwt::verify checks the signature, the expiry, the issuer and the res and op claims, and the exp must equal the expiry the client presents. Any mismatch is a 403. +> +> The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth. What this saves: the Drogon worker thread is back in the pool before the upload starts. + +## [079] SECURITY · STRANGLER FIG · Strangler Fig for security modernisation (sldId 334) + +- Use case: encapsulate legacy authentication (LDAP-only, HTTP Basic, hardcoded session cookies) behind a modern API gateway, then replace the legacy auth path one service at a time. +- Step 0: introduce the gateway in front of the monolith. Clients hit the gateway, which forwards to the legacy auth. Day-zero behaviour unchanged. +- Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Dex), and translates to whatever the legacy expects; it speaks both languages. +- Step 2: per route, switch the gateway from 'forward legacy headers' to 'forward only the JWT claims to the new service'. Legacy auth is no longer in the path for that route. +- Step 3: when no routes still need legacy auth, remove it. +- Bounded blast radius: each route's auth migration is a config change in the gateway, reversible without a release. +- For C++ services: the new auth path is sidecar-validated (see the sidecar pattern); your Drogon code reads X-Forwarded-Client-Cert and JWT claim headers, never the legacy session cookie. The migration touches the gateway, not the service. + +p. 66 + +> Strangler Fig applied to authentication, not business logic. The pattern is the same as App K (monolith decomposition): proxy, redirect, replace, retire. The key insight: the gateway is the translator. It speaks both the modern protocol (OIDC + JWT) and the legacy protocol (whatever the monolith expects), per route. New C++ services never need to know the legacy auth existed. Why this works incrementally: each route is a config change. If a route's migration breaks, you flip it back in seconds. Compare that to the Big Bang migration where you replace auth everywhere at once and find out at 3am what you forgot. + +## [080] SECURITY · ZERO-TRUST · Zero-trust / Identity-Aware Proxy (sldId 335) + +Every request authenticated and authorised, on every hop. Perimeter trust is dead. SPIFFE inside, IAP at the edge. + +p. 67 + +> Zero-trust is the default posture for any system with users you don't control. The perimeter (VPN, IP allow-list) is no longer the auth boundary — every request, on every hop, is authenticated and authorised. Two layers in this model. At the edge, an Identity-Aware Proxy (Pomerium or OAuth2 Proxy) terminates TLS, validates identity against your IdP (OIDC), applies the route's access policy, and issues a JWT that downstream services see. Inside the cluster, Istio + SPIFFE assigns every workload an X.509 identity, and every service-to-service call is mutually authenticated. For C++ services this is all platform — your Drogon binary reads X-Forwarded-Client-Cert and JWT claims and never touches OIDC or X.509 directly. + +## [081] SECURITY · ZERO-TRUST · Implementing zero-trust (sldId 336) + +- Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — terminate TLS, validate identity (OIDC against your IdP), apply the route's access policy, issue a JWT downstream services see in X-Forwarded-* headers. +- Inside the cluster (mTLS): Istio + SPIFFE assigns every workload an X.509 cert identifying its service account (spiffe://cluster.local/ns/orders/sa/order-service). Every service-to-service call is mutually authenticated. +- Authorization: Istio AuthorizationPolicy declares 'order-service can call payment-service /charge but nothing else, only when carrying a valid customer JWT.' Per-workload, fine-grained, declarative. +- Policy decisions: OPA / Cedar evaluate context (user, time, device, target, action) and return allow/deny. Out-of-process so policy can change without redeploying every service. +- C++ app-side discipline: read X-Forwarded-Client-Cert in a guard middleware; reject calls that arrive without it; log every authorization decision (the audit trail lives in your logs, not just the sidecar's). +- License notes: Istio, SPIFFE/SPIRE, OPA, Cedar — all open source (Apache 2.0). Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too. + +p. 68 + +> Zero-trust is a posture implemented across four layers, not one product. Each layer is open source and well-maintained; this is real, not aspirational. C++-specific note in the fifth bullet: even though the sidecar handles the work, your audit log entries in spdlog should record who made each request (read from JWT claims) and what they were authorised to do. The sidecar's logs are separate; your app's logs are what your team grep through during incident response. Make them informative. + +## [082] SECURITY · POLICY-AS-CODE · Policy-as-Code — security gates in Git (sldId 337) + +OPA Gatekeeper / Kyverno enforce declarative policies at admission. CI runs the same policies pre-merge. + +p. 69 + +> Policy-as-Code is the discipline that makes every other security pattern enforceable. Without it, there's a checklist somewhere, and somebody is supposed to run through it, and they don't, and a Pod ships without a non-root user, and three months later you find out. With it, the cluster's admission controller rejects the Pod before it ever runs, and the CI pipeline rejects the manifest before the merge. For a C++ shop the policy authoring is the same as for any other shop (Rego or Kyverno-flavoured YAML), but the policies often look at C++-specific concerns: image must come from registry.acme.com, base image must be distroless, must run non-root, must specify CPU/memory limits. + +## [083] SECURITY · POLICY-AS-CODE · OPA Gatekeeper ConstraintTemplate (Rego) (sldId 338) + +[chip] yaml · Gatekeeper + +```yaml +# ConstraintTemplate — reusable policy logic in Rego +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: { name: k8srequiresignedimages } +spec: + crd: + spec: + names: { kind: K8sRequireSignedImages } + validation: + openAPIV3Schema: + properties: { allowedRegistries: { type: array, items: { type: string } } } + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiresignedimages + violation[{"msg": msg}] { + container := input.review.object.spec.containers[_] + not startswith(container.image, input.parameters.allowedRegistries[_]) + msg := sprintf("image %v not from an allowed registry", [container.image]) + } + +# A separate Constraint resource binds the template to actual Pods with +# allowedRegistries=["registry.acme.com/", "quay.io/redhat/"]. Same Rego; +# different param values per environment. +``` + +Policy in Git, enforced at admission. CI runs the same policy pre-merge via conftest — feedback loop in seconds, not after deployment. + +p. 70 + +> Two-document pattern, identical to the Istio VirtualService/DestinationRule split we saw in §2. The ConstraintTemplate defines the reusable logic in Rego; the Constraint applies the template to actual resources with specific parameters. What this catches in a C++ deployment: an unsigned image shipped from a developer workstation, a Pod that pulls from Docker Hub instead of the internal registry, a CI bypass. The admission controller refuses the Pod — it never runs. Run the same policy in CI via conftest before the merge: same Rego, evaluated against the proposed manifest. Feedback at PR time is much cheaper than feedback at deploy time. Kyverno is the YAML-only alternative if you want to skip learning Rego; same admission control story. + +## [084] SECURITY · BULKHEAD · Bulkhead — isolate resources, bound the blast radius (sldId 339) + +Per-tenant / per-call / per-priority pools. One tenant's storm cannot starve others. One compromise cannot cascade. + +p. 71 + +> The bulkhead pattern (named for ship hull compartments) isolates resources so a failure or abusive load in one partition can't drain the whole pool. The classic example: a tenant's retry storm or a buggy customer integration shouldn't be able to consume every worker thread in your service and starve everyone else. For C++ specifically: bulkheads can be enforced at multiple levels — thread pool, connection pool, memory arena. PMR per-request arenas bound memory per request (cpp-statelessness Doc 03, patterncatalyst.github.io/cpp-statelessness/docs/03-pmr/); bulkheads bound concurrency per tenant. Both matter, and both are straightforward to implement in C++. + +## [085] SECURITY · BULKHEAD · Per-tenant bulkheads with an atomic counter (sldId 340) + +[chip] c++ · std::atomic + +```c++ +// main.cpp (excerpt), examples/12-security/cpp: one lock-free counter per tenant +struct TenantBulkhead { + static constexpr int CAPACITY = 5; // 5 concurrent per tenant + std::atomic active{0}; + bool try_acquire() { + int current = active.load(); + while (current < CAPACITY) { + if (active.compare_exchange_weak(current, current + 1)) return true; + } + return false; // full: shed, don't queue + } + void release() { active.fetch_sub(1); } +}; +auto& bulkhead = bulkhead_manager.get_or_create(tenant); // POST /orders: per tenant +if (!bulkhead.try_acquire()) { + Json::Value error; + error["detail"] = "tenant capacity exceeded"; + auto resp = HttpResponse::newHttpJsonResponse(error); + resp->setStatusCode(k429TooManyRequests); + callback(resp); + return; +} +``` + +5 concurrent requests per tenant; the sixth gets 429 at once instead of waiting in a queue. + +p. 72 + +> The bulkhead from Example 12: one TenantBulkhead per tenant, created on first use in a map guarded by a mutex. Each holds a lock-free std::atomic with a capacity of five; try_acquire increments it with a compare-and-swap loop only while it is under capacity, so a full tenant is refused without blocking a Drogon worker. +> +> A refused request gets 429 with "tenant capacity exceeded" at once: shed, don't queue, because a queue in front of a saturated tenant becomes the bottleneck. The example releases the slot after the order is stored; in production code an RAII guard makes the release exception-safe. One tenant's storm fills only its own five slots; other tenants keep their capacity. +> +> Per-tenant connection pools at the libpq or gRPC layer are a separate bulkhead: the counter caps concurrency, the pool caps sockets. + +## [086] SECURITY · CLAIM-CHECK · Claim-check — send a reference, not the payload (sldId 341) + +Large or sensitive payload goes to a secure store; the message bus carries only an opaque token to fetch it. + +p. 73 + +> Claim-check keeps the message bus small and the PII contained. Instead of publishing {order: {customer_email, payment_card, address, items: [...]}} to Kafka, the producer writes the payload to encrypted object storage and publishes only {claim_id: 'abc-123', uri: 'https://objects.internal/claims/abc-123'} to Kafka. Two wins: (1) the message bus stays small — Kafka topics with 30-day retention don't become PII archives. (2) Access control on the payload is in object storage, where you can use per-object access policies, KMS keys, audit logs — Kafka topic ACLs are coarser. The cost: an extra fetch hop in every consumer. Worth it when the payload is large or sensitive; overkill for small public facts like order.placed events with just IDs. + +## [087] SECURITY · CLAIM-CHECK · Producer mints claim, consumer fetches payload (sldId 342) + +[chip] c++ · rdkafka++ + jwt-cpp + +{% raw %} +```c++ +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +void publish_with_claim(const std::string& order_id, const std::string& body) { + const auto key = "payloads/" + order_id; + store_.put(key, body, /*encrypt=*/true); // 1. store: encrypted, ACL'd + auto ticket = cndp::valet::mint(VALET_SECRET, key, "GET", std::chrono::minutes(15)); + auto claim = nlohmann::json{{"order_id", order_id}, + {"uri", "https://objects.internal/" + key}, + {"token", ticket.token}, {"expires", ticket.expires}}.dump(); + producer_->produce("order.documents", RdKafka::Topic::PARTITION_UA, + RdKafka::Producer::RK_MSG_COPY, claim.data(), claim.size(), + order_id.data(), order_id.size(), 0, nullptr); // 2. claim only +} +// Consumer: consume the claim, fetch the payload, then handle +std::unique_ptr msg(consumer_->consume(100)); +if (msg->err() == RdKafka::ERR_NO_ERROR) { + auto claim = nlohmann::json::parse(payload_of(*msg)); + auto body = store_.get(claim["uri"].get(), + claim["token"].get()); // the store checks the ticket + handle(body); // PII never touched the topic + consumer_->commitSync(msg.get()); // after handle +} +``` +{% endraw %} + +Payloads sit in encrypted object storage; Kafka carries only the claim and its ticket. PII never crosses the broker. + +p. 74 + +> Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's cndp::valet::mint (15 minutes), and publishes only a small claim (order id, URI and the ticket) to order.documents with rdkafka++. The PII is never on Kafka's wire. +> +> The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give. +> +> Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing. + +## [088] SECURITY · Choosing the right pattern (sldId 343) + +- Sidecar — when you want one security policy uniformly across every workload, regardless of language. Cost: an extra container per Pod. +- Valet Key — when clients need direct access to a large or specific resource (file upload, image download). Cost: storage system must validate tokens (most do). +- Strangler Fig (auth) — when you have legacy authentication you can't change all at once. Cost: a gateway running translation logic during the transition. +- Zero-Trust / IAP — the default model for any system with users outside your immediate control. Cost: mTLS everywhere has real CPU + ops overhead. +- Policy-as-Code — always. The cost of not having it (drift, inconsistency, forgotten reviews) is much higher than the cost of adopting OPA / Kyverno. +- Bulkhead — any multi-tenant system, any system with mixed-priority workloads. Cost: pools are extra moving parts to size and tune. +- Claim-Check — when payloads carry PII or are large enough to bloat the message bus. Cost: an extra fetch hop and the store-and-bus must agree on token semantics. +- Most production systems combine all of these, not as a checklist but as a decision per concern. + +p. 75 + +> Each pattern has a concrete trigger condition. The headline at the bottom: production systems use combinations — sidecar + zero-trust + policy-as-code is the table-stakes baseline, with valet-key + bulkhead + claim-check added wherever the workload calls for them. For C++ specifically, the patterns that have C++ code (valet-key, bulkhead, claim-check) are the ones the dev team owns; the others (sidecar, zero-trust, policy-as-code) are platform team territory. The split tracks ownership, not difficulty. + +## [089] SECURITY · Take-aways and references (sldId 344) + +- Defence in depth across the 4 C's: cloud, cluster, container, code. A control at the wrong layer is false confidence. +- Identity is the new perimeter. mTLS + SPIFFE inside; IAP / OIDC at the edge. Every request authenticated and authorised, on every hop. +- Policy in Git, enforced by the cluster. OPA Gatekeeper or Kyverno at admission; conftest in CI for early feedback. +- PII never crosses your message bus in cleartext. Claim-check or end-to-end encrypt. +- Isolate by tenant, by call, by priority. Bulkheads bound the blast radius of both incidents and outages. +- C++-specific: ASan/UBSan in CI; -fstack-protector-strong + -D_FORTIFY_SOURCE=2 + PIE in release; the sidecar handles auth so jwt-cpp stays out of the hot path; Example 12 mints valet-key tickets with jwt-cpp, and a claim check carries the same ticket. +- References: NIST SP 800-204A; CNCF Cloud Native Security Whitepaper; OWASP API Security Top 10; OpenSSF SLSA; Istio AuthorizationPolicy docs; OPA Gatekeeper library. Open-source pillars all Apache 2.0. + +p. 76 + +> Five take-aways for security, plus one C++-specific line. The headline: security is layered, platform-driven, and Git-tracked. Your C++ code participates by reading trusted headers, implementing the three app-side patterns (valet-key, bulkhead, claim-check), and emitting audit logs. The platform does the rest. + +## [090] Anti-Patterns (sldId 345) + +13 + +Most outages re-couple what you separated. + +> Section 13 divider: Anti-Patterns. Most outages re-couple what you separated. + +## [091] ANTI-PATTERNS · How cloud-native API systems go wrong (sldId 346) + +Each anti-pattern reintroduces the coupling the architecture worked to remove. + +p. 77 + +> Every anti-pattern in this section is the same shape: coupling sneaking back in. The distributed monolith looks like microservices until you try to deploy one without the others. Shared databases look like fast development until two teams change the same table. Synchronous chains look efficient until one slow hop takes the whole flow down. For C++ specifically, the temptation profile is slightly different from Python or JVM. C++ developers reach for in-process shared state readily because it's cheap; the discipline of 'in-process is per-service, never cross-service' has to be enforced explicitly. cpp-statelessness covers the per-service in-process state patterns, PMR arenas and RAII connection pools (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/); the anti-pattern is when that state becomes a shared assumption across services. + +## [092] ANTI-PATTERNS · The heuristics that keep you out of them (sldId 347) + +- If two services must deploy together, they are one service — merge them or fix the contract. +- One writer per dataset; everyone else reads through the API or subscribes to its events. +- Push aggregation to a gateway/BFF; forbid service-to-service call chains deeper than one hop. +- No contract in the registry, no merge — and every retry has a timeout and a bound. +- If you can't trace it, you can't run it: observability is a launch requirement, not a follow-up. +- C++-specific: no shared mutable state across services, ever — not via shared memory, not via mmap'd files, not via a 'shared utility binary'. Each C++ service owns its process space. + +p. 78 + +> Six heuristics — five universal, one C++-specific. The universal ones come straight from the Python deck; the C++-specific one matters because C++ makes shared memory tempting in a way Python or JVM don't. mmap, shared memory segments, even a static library that wraps a 'singleton config': all of these become coupling that the deployment topology hides until something breaks. The shipping rule: every C++ service is a process. Two services that 'happen to share a memory region' are one service with extra steps. If they need to share, use a data store or message bus; pay the deserialisation cost and get the operational benefits. + +## [093] IN CLOSING · The whole talk, in five lines (sldId 348) + +- Cloud-native is a set of properties; the platform exists to make them the default. +- Match each interaction to its style (REST, gRPC, GraphQL or async) and compose at the edge. +- Own your data; share it as contracts and facts, never as shared tables. +- Make the contract a governed artifact: registry to enforce it, metadata to find it, observability to run it. +- Every anti-pattern is coupling sneaking back in. Design to keep things apart. +- C++ in this picture: a clean, fast producer of contracts and signals. Drogon at the edge, gRPC east-west, librdkafka for facts, OpenTelemetry C++ for visibility. RAII makes the discipline cheap to keep. + +p. 79 + +> The five-line summary, with one extra C++-specific line. The point: C++ services are first-class participants in a cloud-native architecture, with their own ecosystem of production-grade libraries (Drogon, gRPC, librdkafka, OpenTelemetry C++, jwt-cpp) that integrate with the same platform every other language plugs into. The language stops being the interesting question; the architecture starts being the interesting question. + +## [094] REFERENCES · The shoulders this stands on (sldId 349) + +- Cloud-Native Patterns and Architecture +- Building Microservices, 2nd ed. — Sam Newman (O'Reilly) +- Designing Data-Intensive Applications, 2nd ed. — Martin Kleppmann & Chris Riccomini (O'Reilly) +- Building Event-Driven Microservices — Adam Bellemare (O'Reilly) +- Cloud Native Patterns — Cornelia Davis (Manning) +- Patterns of Distributed Systems — Unmesh Joshi (Addison-Wesley) +- Architecture Modernization — Nick Tune & Jean-Georges Perrin (Manning) +- Cloud Native Anti-Patterns — Gerald Bachlmayr +- API Design +- Principles of Web API Design — James Higginbotham (Addison-Wesley) +- Patterns for API Design — Olaf Zimmermann et al. (Addison-Wesley) +- API Design Patterns — JJ Geewax (Manning) +- Domain modelling +- Learning Domain-Driven Design — Vlad Khononov (O'Reilly) +- Balancing Coupling in Software Design — Vlad Khononov (Addison-Wesley) + +p. 80 + +> The reference list. These are the books that the architectural patterns in this deck draw from — read them in roughly the order listed if you're building up the model from scratch; cherry-pick if you're filling in a specific gap. Newman and Kleppmann are the table-stakes baseline; Bellemare is the event-driven deep dive; Khononov on coupling is the synthesis that ties API design to domain modelling. For C++-specific references on the implementation side: the C++ Core Guidelines, Sutter & Alexandrescu's C++ Coding Standards, and two Apache-2.0 companion projects: cpp-statelessness (patterncatalyst.github.io/cpp-statelessness/) on stateless C++ services in containers, and the C++ container optimization tutorial (patterncatalyst.github.io/cpp-container-optimization-tutorial/). + +## [095] THANK YOU · Questions? (sldId 350) + +Designing Cloud-Native APIs · C++23 · Drogon · gRPC · Kafka · Kubernetes + +> Closing slide. Use the Q&A as a chance to circle back to the running example — show how the canonical five-service system threads through every section in the deck. If audience questions go deep on a specific section (Data, Event-Driven, Security are common pull points), the appendices have the depth: A Protocols, B Versioning, C Pinning, etc. + +## [096] REFERENCE · Glossary of acronyms (1 of 2) — protocols, data, events (sldId 351) + +- Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface). +- Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue, where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe). +- Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log: the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live); LRU (Least Recently Used: cache eviction policy); SETEX (Redis 'SET with EXpiry'); ORM (Object-Relational Mapping); libpq (C library for PostgreSQL: the C++ driver in this deck); nlohmann::json (the canonical header-only C++ JSON library). +- Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service: a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD, which translates between models; ACL also means Access Control List in security); Saga (not an acronym: a sequence of local transactions with compensating actions). + +p. 81 + +> Glossary 1 of 2 — protocols, data, events. Two C++-specific entries swapped in: libpq (replaces Python's asyncpg) and nlohmann::json (replaces Pydantic for the JSON-handling concern; serialisation and validation are separate concerns in C++ — nlohmann::json does the deserialisation, validation rides on top via something like JSON Schema or hand-rolled checks). + +## [097] REFERENCE · Glossary of acronyms (2 of 2) — platform, security, telemetry (sldId 352) + +- Kubernetes & cloud — K8s (Kubernetes); RBAC (Role-Based Access Control); HPA (Horizontal Pod Autoscaler); KEDA (Kubernetes Event-Driven Autoscaling); PSS (Pod Security Standards); CRD (Custom Resource Definition); IAM (Identity & Access Management); KMS (Key Management Service); VPC (Virtual Private Cloud); CNI (Container Network Interface). +- Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials). +- Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context: per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency); spdlog (the canonical C++ structured logging library, used throughout this deck); DWARF (the C++ binary debug format that makes eBPF profilers actionable). +- C++ build & runtime — Conan (the C++ package manager used in this deck); CMake (the build system generator); RAII (Resource Acquisition Is Initialization: the central C++ ownership idiom); PMR (Polymorphic Memory Resource: the standard arena allocators, since C++17); DI (Dependency Injection); DTO (Data Transfer Object); ABI (Application Binary Interface); LTO (Link-Time Optimization). + +p. 82 + +> Glossary 2 of 2 — platform, security, observability, plus a C++ build & runtime row replacing the Python deck's WSGI/ASGI/GIL/venv row. + +## [098] Protocols in Depth (sldId 353) + +Appendix A + +REST, gRPC and GraphQL: how they differ. + +> Section Appendix A divider: Protocols +> +> in Depth. REST, gRPC and GraphQL: how they differ. + +## [099] APPENDIX A · PROTOCOLS · Three paradigms and their mental models (sldId 354) + +REST models resources, gRPC models procedures, GraphQL models a typed graph — the same intent, three shapes. + +p. 83 + +> Each protocol has its own mental model. REST's primitive is the resource: nouns and verbs. gRPC's primitive is the call: services and methods. GraphQL's primitive is the typed graph: nodes and edges. The same business intent can be modelled in any of them; the shape changes more than the function. For C++ this means three different library shapes too: Drogon's HttpController for REST, grpc::Service for gRPC, cppgraphqlgen's object:: types for GraphQL. Ports and adapters (Appendix F, Example 19) let you write the business logic once and adapt it to all three boundaries, though in practice most C++ services pick one or two protocols and commit. + +## [100] APPENDIX A · PROTOCOLS · Over-fetching, under-fetching and round trips (sldId 355) + +REST trades round trips or bespoke endpoints for simplicity; GraphQL shapes one response; gRPC is a typed call. + +p. 84 + +> The fetching trilemma: over-fetch (REST returns the whole resource when you needed two fields), under-fetch (REST returns too little, you make N more round trips), or bespoke endpoint (REST grows /orders/{id}/with-customer-and-totals, which doesn't scale). GraphQL solves over- and under-fetch by shaping the response client-side. gRPC sidesteps it by being a typed call with a typed response — the caller knows what they're getting. C++ implications: for chatty internal paths, gRPC's typed responses and HTTP/2 multiplexing dramatically outperform multiple REST calls. For client-facing aggregation across services, the GraphQL gateway (cppgraphqlgen or Apollo) handles the shaping. + +## [101] APPENDIX A · PROTOCOLS · The wire: transport, encoding and streaming (sldId 356) + +Reach and caching, or raw speed, or client-shaped responses — choose on the wire properties you need. + +p. 85 + +> The wire-level differences. REST: HTTP/1.1 by default (HTTP/2 sometimes), text JSON, cacheable via standard headers, ubiquitous browser support. gRPC: HTTP/2 always, binary protobuf, bidirectional streaming, no browser support without grpc-web. GraphQL: HTTP/1.1 POST by default (with WebSocket subscriptions for live data), text JSON over the wire, single endpoint. C++ wire considerations: protobuf's binary encoding is meaningfully smaller and faster to parse than JSON — for high-volume internal paths the CPU saving compounds. JSON parse cost in C++ with nlohmann::json is competitive with Java/.NET but slower than protobuf by 5-10x for the same payload. + +## [102] APPENDIX A · PROTOCOLS · Choosing between them in practice (sldId 357) + +- Public or partner-facing, browser or third-party clients, caching matters → REST. C++ at the edge: Drogon. +- Internal service-to-service, low latency, high call volume, streaming → gRPC. C++ east-west: grpc::Service with the sync API. +- Aggregating many back-ends for varied clients that each want different fields → GraphQL at the gateway. C++ option: cppgraphqlgen on Drogon, or front C++ REST/gRPC services with an Apollo (Node) gateway. +- A fact that many consumers react to, decoupled in time → none of these; emit an event. C++ producers: librdkafka (rdkafka++). +- Real systems use all four; standardizing on one wire for everything is itself an anti-pattern. + +p. 86 + +> Picking a protocol is a per-edge decision, not a per-service one. A single C++ service often speaks REST at the public edge (Drogon), gRPC internally (grpc::Service), and publishes events on Kafka (rdkafka++) — all from the same binary. The ports-and-adapters structure of Appendix F keeps the business logic clean of any protocol concerns; each adapter lives at its boundary and translates. + +## [103] APPENDIX A · PROTOCOLS · Common mistakes with each (sldId 358) + +- REST: verbs in URLs (/createOrder), 200-for-everything, offset pagination instead of cursors, ignoring cache headers. +- gRPC: exposing it directly to browsers without grpc-web; treating it as REST-with-protobuf and ignoring streaming. +- GraphQL: the N+1 resolver explosion; no query depth/complexity limits; using it for simple single-resource reads. +- All three: leaking internal database models straight onto the wire, so you can never evolve them. +- Async: using a queue when you needed a log (no replay), or a request when you needed a fact. +- C++-specific traps: parsing JSON repeatedly in a hot path instead of caching the parsed result; copying protobuf messages across boundaries instead of passing const-ref; using grpc::Status as a return value when an exception fits better. Decide which boundary errors cross. + +p. 87 + +> Five universal anti-patterns and one C++-specific row. The C++ traps are all about ownership and copies — the things C++ makes explicit that other languages hide. JSON parsing is expensive (nlohmann::json builds a tree); protobuf messages contain owned strings and submessages that copy by value unless you std::move them; grpc::Status as a return-vs-throw decision is one you should make consciously and apply consistently. + +## [104] Versioning & Evolution (sldId 359) + +Appendix B + +REST, proto breaking changes, GraphQL evolution. + +> Section Appendix B divider: Versioning +> +> & Evolution. REST, proto breaking changes, GraphQL evolution. + +## [105] APPENDIX B · VERSIONING · Four ways to version a REST API (sldId 360) + +URI versioning is common and visible; media-type versioning keeps URLs stable — the more RESTful choice. + +p. 88 + +> Four mechanisms for REST versioning, in roughly decreasing order of how RESTful they are: media-type (Accept header), custom header (X-API-Version), query string (?v=2), and URI (/v2/orders). URI is the most visible and easiest to debug; media-type is the most RESTful because the URL remains the resource and the representation is negotiated. For C++ services the implementation differs by mechanism: URI versioning means two Drogon HttpControllers registered at different paths; media-type versioning is one controller branching on the Accept header. Both are mechanical; pick whichever your consumers find more workable. + +## [106] APPENDIX B · VERSIONING · Media-type versioning with Drogon (sldId 361) + +[chip] c++ · Drogon + +```c++ +// Media-type versioning in Drogon — one stable URL, two representations +constexpr auto V1 = "application/vnd.acme.order+json"; // legacy +constexpr auto V2 = "application/vnd.acme.order.v2+json"; // adds currency + +Task<> Orders::get(HttpRequestPtr req, auto cb, std::string oid) { + Order order = co_await orders_.get(oid); + auto accept = req->getHeader("Accept").value_or(V1); + if (accept == V2) { // negotiate the rep + json body = order.to_json(); + body["currency"] = order.currency; + auto r = HttpResponse::newHttpJsonResponse(body); + r->setContentTypeString(V2); + cb(r); co_return; + } + auto r = HttpResponse::newHttpJsonResponse(order.to_json()); // v1 + r->setContentTypeString(V1); + cb(r); +} +// The URL never changes; clients opt into a representation via Accept. +// New clients ask for V2; old clients keep getting V1. +``` + +The resource URL is stable; the representation is negotiated. Add fields by minting a new media type; old clients see the old shape. + +p. 89 + +> Media-type versioning in idiomatic Drogon. Key things to note: (1) the URL /orders/{oid} never changes — it's the resource, not a version. (2) The Accept header is the version selector; clients opt in. (3) The handler builds the appropriate JSON shape and pins the Content-Type to match what was requested. Compared to URI versioning, this is harder to test from a browser (you can't just type /v2/orders in the URL bar) but cleaner long-term — there's no /v3 endpoint waiting to explode. For partner APIs where the consumers are programs, this is the better choice; for human-explored APIs, URI versioning is often more pragmatic. + +## [107] APPENDIX B · VERSIONING · gRPC / protobuf: what is a breaking change (sldId 362) + +Field numbers are the contract — names and order are not. Never reuse or renumber a field. + +p. 90 + +> protobuf's contract is the field numbers, not the field names. This means renaming a field is safe on the wire (old serialised bytes still parse), but renumbering or reusing a number is catastrophic — an old client that sees tag 4 with new semantics will misinterpret the data silently. For C++ specifically: protoc-generated C++ classes use the field names for accessor methods (set_sku, sku, has_sku) but the numbers for wire encoding. Renaming breaks your build but not other consumers; renumbering compiles cleanly but breaks everyone. The compiler is the wrong tool for catching this — the registry (Apicurio's BACKWARD compatibility rule for proto) is the right one. + +## [108] APPENDIX B · VERSIONING · Evolving a .proto safely (sldId 363) + +[chip] protobuf + +```protobuf +// Evolving a .proto safely — add with new tags, reserve what you remove +syntax = "proto3"; +message Order { + string id = 1; + string sku = 2; + int32 qty = 3; + reserved 4; // tag 4 once held 'notes' — never reuse it + reserved "notes"; + string currency = 5; // safe: brand-new field, new tag number + PaymentState state = 6; // safe: new field; old clients ignore it +} +enum PaymentState { + PENDING = 0; // proto3: 0 must exist as the default + PAID = 1; + REFUNDED = 2; +} + +// breaking (don't): +// qty's type from int32 -> int64 (silent corruption on overflow) +// moving 'sku' to tag 7 (old clients read garbage from tag 2) +// reusing tag 4 for a different field (old data deserialises wrong) +``` + +Add with new tags, reserve what you remove, and never change an existing field's number or type. Apicurio BACKWARD enforces this in CI. + +p. 91 + +> The four rules of safe proto evolution, in one example: (1) add new fields at new tag numbers (currency=5, state=6). (2) Use `reserved` for any tag number and name that's been retired — protoc enforces that you can never accidentally re-use them. (3) Never change a field's type — int32 → int64 looks innocent but old wire data interprets differently. (4) Enum 0 must exist as the default; proto3 doesn't distinguish 'unset' from 'zero', so the zero value is your sentinel. These rules look universal but the failure modes differ in C++: protoc-generated C++ classes will compile fine after a renumber, and you'll only see the corruption when an old peer sends data on the wire — the test suite needs to include cross-version data samples, not just round-trips through fresh code. + +## [109] APPENDIX B · VERSIONING · GraphQL versions by evolution, not by number (sldId 364) + +There is no /v2 in GraphQL: add fields, mark old ones @deprecated, watch usage, then retire. + +p. 92 + +> GraphQL has no version numbers because it doesn't need them. The query selects fields; adding new fields can't break existing queries (they don't ask for them). The migration lifecycle is: add the new field additively, mark the old field @deprecated with a reason, measure usage through field-level metrics, and retire when usage drops to zero. For C++ via cppgraphqlgen: the schemagen tool produces deprecated wrappers automatically from @deprecated annotations in the .graphql file; introspection surfaces the deprecation to client tooling (GraphQL Playground, Apollo Studio); usage metrics need to be wired into the resolver (one OTel span attribute per resolved field is enough). + +## [110] APPENDIX B · VERSIONING · Deprecating a field in cppgraphqlgen (sldId 365) + +[chip] graphql + c++ + +```graphql+c++ +# schema.graphql — deprecate additively, never break +type Order { + id: ID! + total: Float + @deprecated(reason: "Use totalMinor (integer cents) instead.") + totalMinor: Int! # the replacement field, added additively +} + +// schemagen produces deprecated wrappers from @deprecated in the SDL. +// cppgraphqlgen surfaces the deprecation in introspection automatically. +// Wire one OTel span attribute per resolved field to track usage: +class OrderResolver : public object::Order { + public: + service::AwaitableScalar getTotal() const { + auto span = tracer_->StartSpan("resolve.Order.total"); + span->SetAttribute("graphql.deprecated", true); // usage signal + co_return order_.total; + } + service::AwaitableScalar getTotalMinor() const { + co_return order_.total_minor; + } +}; +// When the 'deprecated=true' span attribute drops to zero, +// the field is safe to remove. No /v2 endpoint ever exists. +``` + +Additive change plus @deprecated plus usage metrics replaces versioning entirely. Retirement is data-driven, not date-driven. + +p. 93 + +> Field-level deprecation in cppgraphqlgen. The SDL is the contract; the @deprecated annotation surfaces in introspection and client tooling. The OTel span attribute trick at the bottom is how you make the retirement decision data-driven: every time a deprecated field is resolved, you tag the span. Aggregating in your metrics backend gives you 'how many requests still pull Order.total per day' — when that number drops to zero (or close enough that the remaining callers can be migrated manually), the field is safe to remove. Compare to REST where there's no analogous field-level usage signal — you'd have to log every request, then parse to see which fields the response included. GraphQL makes this ergonomic, which is one of its real wins for evolution. + +## [111] APPENDIX B · VERSIONING · Principles that cut across all three (sldId 366) + +- Additive change is always safe; removal and mutation are where you break people. +- Make compatibility mechanical — the registry's BACKWARD / FORWARD / FULL rules, checked in CI (publish_schema from §9), not by reviewers' memory. +- Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. +- Run old and new in parallel and migrate consumers before retiring — retirement is the only step that can break. +- Version data the same way you version APIs: event schemas in Apicurio follow the identical rules. +- C++ specifics: protoc-generated stubs CRT-link cleanly across compatible proto changes (good); but C++ ABI is fragile, so when you re-link a service, all dependencies must rebuild against the new proto. Pin generated stubs via Conan, regenerate via CMake, never copy generated .pb.cc into Git. + +p. 94 + +> Five universal versioning principles plus a C++-specific row about the C++ ABI / Conan / CMake interplay. The point of the C++ row: generated proto stubs are build output, not source — they should not live in Git; they should be regenerated from the .proto in CI. When you upgrade protobuf itself to a new major version, every consumer rebuilds against the new stubs at the same time. A committed Conan lockfile (conan.lock, as every C++ example here has) is the mechanism that keeps this manageable; cpp-statelessness Doc 11 shows the profiles around it (patterncatalyst.github.io/cpp-statelessness/docs/11-build-tooling/). + +## [112] WebSockets at Scale (sldId 367) + +Appendix C + +Drogon, pod pinning, the backplane, binary framing, reconnection. + +> Section Appendix C divider: WebSockets +> +> at Scale. Drogon, pod pinning, the backplane, binary framing, reconnection. + +## [113] APPENDIX C · WEBSOCKETS · A WebSocket endpoint in Drogon (sldId 368) + +[chip] c++ · Drogon + +```c++ +// Drogon WebSocket endpoint — per-pod state, scaling problem starts here. +class ChatWs : public drogon::WebSocketController { + public: + WS_PATH_LIST_BEGIN + WS_PATH_ADD("/ws/{user_id}"); + WS_PATH_LIST_END + void handleNewConnection(const HttpRequestPtr& req, + const WebSocketConnectionPtr& conn) override { + auto uid = req->getPathParameter("user_id"); + std::lock_guard lk{mu_}; + clients_[uid] = conn; // this pod now owns the user + } + void handleNewMessage(const WebSocketConnectionPtr& conn, + std::string&& msg, + const WebSocketMessageType& t) override { + handle(msg); // full-duplex, long-lived + } + void handleConnectionClosed(const WebSocketConnectionPtr& conn) override { + std::lock_guard lk{mu_}; + erase_if(clients_, [&](auto& kv){ return kv.second == conn; }); + } + private: + std::mutex mu_; + std::unordered_map clients_; // per-pod +}; +``` + +Short to write, but clients_ is per-pod. That single member is the entire scaling problem. + +p. 95 + +> Drogon's WebSocketController is the natural C++ entry point. The code is mechanical (registration, message handler, disconnect cleanup), but the clients_ map is the foundation of the scaling problem. Per-pod state, by definition not shared. Boost.Beast is the alternative if you need lower-level control or want to host WebSocket logic outside Drogon's HttpAppFramework — Beast gives you the raw protocol; you build the connection registry yourself. The Drogon path is simpler for most cases. Either way, the scaling problem and its solutions are the same. + +## [114] APPENDIX C · WEBSOCKETS · Why WebSockets fight Kubernetes scaling (sldId 369) + +A long-lived connection pins to one pod; new pods get no existing connections and scale-down drops them. + +p. 96 + +> Kubernetes is built on the assumption that pods are interchangeable and requests are short. WebSockets violate both. A connection lives for hours; the LB can't migrate it without dropping it. New replicas only receive new connections — existing hotspots stay hot. Scale-down severs whatever connections were on the evicted pods. For C++ this is the same problem as for any language: C++ can handle tens of thousands of concurrent sockets per pod (vs Python's few thousand), which delays the problem but does not solve it. The backplane, graceful drain and resume are the fixes. + +## [115] APPENDIX C · WEBSOCKETS · The scaling problem, stated precisely (sldId 370) + +- Pinning: a connection is bound to one pod; the LB can't move it without dropping it. +- Uneven load: new replicas only receive new connections, so existing hotspots persist. +- Rollouts hurt: every deploy or scale-down severs the connections on the affected pods. +- Cross-pod delivery: to push to user X you must reach whichever pod holds X — which you don't track. +- Connection limits & memory: each socket is held-open state; pods cap out on file descriptors, not CPU. +- C++ angle: each connection in Drogon is cheap (~10-20 KB resident plus the kernel socket); a single C++ pod can hold 30-50k connections — but it still pins, still drops on scale-down. Capacity doesn't solve coupling. + +p. 97 + +> Five precise failure modes, plus a C++-specific note about per-connection cost. The headline: C++'s low per-connection memory footprint changes the scale of the problem (you need fewer pods) but not its nature (you still need a backplane for cross-pod delivery, graceful drain on rollout, and resume on reconnect). Operational reality: a C++ pod at 50k connections is approaching FD limits (ulimit -n = 65536 by default); raise the limit explicitly, monitor it, and don't pack pods past where graceful drain becomes painful. + +## [116] APPENDIX C · WEBSOCKETS · Scaling out: a pub/sub backplane (sldId 371) + +Each pod owns only its own sockets; a Redis or Kafka backplane fans a message out to whichever pod holds the client. + +p. 98 + +> The pub/sub backplane is the architectural fix. Each pod owns only its own sockets and subscribes to a Redis or Kafka channel; when any pod wants to push to user X, it publishes to the channel; every pod sees the message and forwards it to user X if they happen to hold that socket. C++ implementation: sw/redis++ for the Redis backplane (lightweight, fast publish/subscribe), librdkafka (rdkafka++) if you're already running Kafka and want the durability. Redis is simpler and faster for ephemeral fan-out; Kafka is the right choice if missed messages would matter (offline users coming back online and wanting their queue). Either way: the backplane is process-scoped — one subscriber per pod, not per connection. + +## [117] APPENDIX C · WEBSOCKETS · Binary framing with protobuf — fast and versioned (sldId 372) + +[chip] protobuf + c++ + +```protobuf+c++ +// envelope.proto — version the message, not the URL +message Envelope { + uint32 version = 1; // bump for incompatible payload changes + string type = 2; // "order.updated", "ping", ... + bytes payload = 3; // protobuf-encoded body for `type` + uint64 seq = 4; // monotonic per-connection — drives resume +} + +// Server: send compact binary frames instead of JSON text +void send_order_update(WebSocketConnectionPtr conn, + const Order& order, uint64_t seq) { + Envelope env; + env.set_version(2); + env.set_type("order.updated"); + env.set_payload(order.SerializeAsString()); + env.set_seq(seq); + conn->send(env.SerializeAsString(), + WebSocketMessageType::Binary); // binary, not text +} +// Client checks env.version on each frame; unknown major → +// reconnect and renegotiate. seq drives resume after a drop. +``` + +Protobuf frames are smaller and faster than JSON; the versioned envelope and seq number enable evolution and resume. + +p. 99 + +> Binary framing is a meaningful win for high-frequency WebSocket traffic. JSON text frames cost CPU on both ends (parse, stringify) and 2-3x the bytes of the equivalent protobuf. The envelope pattern adds version and seq fields so the client can detect incompatible server upgrades and the server can drive resume after a reconnect. C++ specifics: protobuf in C++ is fast — both serialise and parse run at memory-copy speed for typical message sizes. The cost is in the heap allocations for owned strings; for hot paths a protobuf arena (google::protobuf::Arena::Create(&arena); CreateMessage is no longer public in protobuf 6.33) amortises that cost. Keep the two allocators apart: protobuf messages on their arena or the default allocator, handler internals on the PMR request arena, with the boundary explicit in code review (cpp-statelessness Doc 03, patterncatalyst.github.io/cpp-statelessness/docs/03-pmr/). + +## [118] APPENDIX C · WEBSOCKETS · Failure handling: resume, don't restart (sldId 373) + +Heartbeats detect dead links; the client reconnects with backoff and resumes from the last acknowledged sequence. + +p. 100 + +> WebSocket connections die — TCP RST, NAT timeout, pod restart, network glitch. The client side must reconnect; the question is what state survives across the gap. Heartbeats (ping/pong) detect dead links well before TCP-level timeouts. The seq number from the envelope is what enables resume — the client sends 'last seq I saw' on reconnect; the server replays from there. C++ server-side: keep a per-connection ring buffer of the last N frames (sized to your expected disconnect duration × message rate), discard frames older than the buffer holds. Give each connection its own PMR arena (the per-request arena of cpp-statelessness Doc 03, applied to a connection's lifetime) so its allocations are released on disconnect. Backoff with jitter is the client's job: exponential backoff capped at 30s, plus 0-1s random jitter to avoid thundering herd on server recovery. + +## [119] APPENDIX C · WEBSOCKETS · A cloud-native WebSocket checklist (sldId 374) + +- Externalize fan-out to a backplane (Redis via sw/redis++ or Kafka via rdkafka++); never assume one pod holds the client. +- Scale and trigger on connection count and memory, not CPU; set realistic per-pod connection caps (50k is reachable for a C++ pod; tune to your messages/sec). +- Drain gracefully on shutdown: stop accepting (std::stop_token blocks new), signal clients to reconnect, let exponential backoff + jitter spread the herd. +- Heartbeat to detect dead links; reconnect with exponential backoff + jitter; resume via a per-connection seq number. +- Use binary frames with a versioned envelope; authenticate on connect (sidecar JWT or per-connection token) and re-check on resume. +- Per-connection state in a PMR arena (the cpp-statelessness Doc 03 pattern): arena destructs on disconnect, no leaks across long-lived connections. + +p. 101 + +> Six rules for WebSockets that scale on Kubernetes. The first five carry across any language; the sixth (PMR per-connection arenas) is the C++-specific win. + +## [120] Saga State & Compensation (sldId 375) + +Appendix D + +boost::sml, persistence, compensation, the step-B-needs-A's-data problem. + +> Section Appendix D divider: Saga State +> +> & Compensation. boost::sml, persistence, compensation, the step-B-needs-A's-data problem. + +## [121] APPENDIX D · SAGAS · Where a C++ saga keeps its state (sldId 376) + +Memory won't survive a pod restart — persist every step transition in a DB, an event log, or a workflow engine. + +p. 102 + +> Three options for saga state durability. (1) Database row — one row per saga, row-locked for the duration of advance(); cheapest for short sagas. (2) Event log (Kafka) — append every step transition as an event; the current state is the fold of the log; replay is trivial. (3) Workflow engine — Temporal / Cadence owns the state; your code is just activity implementations. For C++ specifically: option (1) is the default in this deck because it pairs cleanly with the libpq + PgTxn RAII patterns. Option (3) means C++ services are Temporal activities behind a workflow worker written in Go/Java/TS (since C++ has no first-class Temporal SDK). Option (2) requires an event-sourcing commitment across the whole system; reach for it when audit and time-travel queries matter. + +## [122] APPENDIX D · SAGAS · A persisted saga state machine with boost::sml (sldId 377) + +[chip] c++ · boost::sml + +```c++ +// saga_machine.hpp (excerpt): the saga lifecycle as a boost::sml state machine +struct StepSucceeded {}; struct StepFailed {}; struct AllStepsDone {}; struct Compensated {}; +struct Progress { int step_index = 0; int total_steps = 0; }; + +struct Lifecycle { + auto operator()() const { + using namespace sml; + auto all_done = [](Progress& p) { return p.step_index >= p.total_steps; }; + auto advance = [](Progress& p) { ++p.step_index; }; + return make_transition_table( + *state + event / advance = state, + state + event[all_done] = state, + state + event = state, + state + event = state); + } +}; +// main.cpp advance(): one step per transaction, under a row lock (libpq) +// BEGIN; SELECT status, step_index, context FROM sagas WHERE id = $1 FOR UPDATE; +// restore(sm, status); run g_steps[step_index]; context[step.name] = result; +// sm.process_event(StepSucceeded{}); UPDATE sagas ...; COMMIT +``` + +A typed lifecycle in four transitions; status, step_index and context persisted to Postgres on every step. Crash = reload the row and call advance() again. + +p. 103 + +> boost::sml (header-only, Boost Software License) is the natural C++ choice for typed state machines. The example models the saga lifecycle, not the individual steps: RUNNING advances step by step, AllStepsDone moves to COMPLETED when the guard holds, StepFailed moves to COMPENSATING, and Compensated ends in COMPENSATED. The machine owns no I/O: the orchestrator runs a step, reports the outcome as an event, and the machine decides the next status. +> +> Postgres holds status, step_index and context. Each advance() is one transaction under SELECT ... FOR UPDATE, so one saga moves one step at a time and no two workers advance the same saga. restore() rebuilds the machine from the persisted status, which is how a restarted orchestrator resumes a RUNNING saga. + +## [123] APPENDIX D · SAGAS · Compensating transactions unwind a failed saga (sldId 378) + +Each committed step has an inverse; on failure they run in reverse order — there is no cross-service rollback. + +p. 104 + +> Sagas don't roll back — they compensate. Each forward step has a defined inverse (charge_payment → refund_payment, reserve_stock → release_stock, book_shipping → cancel_shipping). When a saga fails midway, compensations run only for the steps that completed, in reverse order. Important property: compensations are semantic, not transactional. A refund is a new transaction in payment-service; it doesn't undo the original charge, it issues a new movement that nets to zero. This is why every compensation needs to be idempotent with an idempotency key — retries are expected and must not double-refund. + +## [124] APPENDIX D · SAGAS · Running compensations in reverse (sldId 379) + +[chip] c++ · boost::sml + +```c++ +// main.cpp (excerpt): COMPENSATING undoes the completed steps, newest first +if (status == "COMPENSATING") { + for (int i : cndp::saga::compensation_order(progress.step_index)) { // n-1 .. 0 + auto& step = g_steps[i]; // charge_payment -> refund_payment, ... + if (!logStep(db.conn, sagaId, step.compensationName, "compensate", + step.compensate(context))) { abandon(); return; } + } + sm.process_event(cndp::saga::Compensated{}); + setState(db.conn, sagaId, cndp::saga::status_of(sm), progress.step_index, nullptr); + exec(db.conn, "COMMIT"); // one transaction for the unwind +} + +// A forward step that throws is logged, then StepFailed moves the saga +// from RUNNING to COMPENSATING; the next advance() runs the block. +catch (const std::exception& e) { + logStep(db.conn, sagaId, step.name, "failed", failResult); + sm.process_event(cndp::saga::StepFailed{}); + setState(db.conn, sagaId, cndp::saga::status_of(sm), progress.step_index, nullptr); +} +``` + +Compensate only completed steps, in reverse, with an idempotency key per (saga, step) so a retry never double-refunds. + +p. 105 + +> Compensation in the example is a separate phase of the same lifecycle. When a step throws, the orchestrator logs the failure and sends StepFailed, which moves the saga to COMPENSATING. The next advance() undoes the completed steps in reverse order (compensation_order returns step_index-1 down to 0), logs each compensation (refund_payment, release_stock, cancel_shipping), then sends Compensated and commits. +> +> The state machine guarantees that only steps that committed are compensated. In a production system each compensation call also carries an idempotency key such as the saga id plus the step name, because a restart between the compensation and the commit repeats it. + +## [125] APPENDIX D · SAGAS · When step B needs data only step A produced (sldId 380) + +Don't make B call A. Persist A's output into the saga context and pass it forward; on resume, read it back. + +p. 106 + +> The classic saga data-dependency trap: B needs the reservation_id that A produced. Naive fix: B calls A to fetch it. This rebuilds the synchronous coupling the saga was meant to break. Correct fix: A's output goes into the saga's context blob (JSON column on the saga row); B reads it from the persisted context. If A re-runs (it shouldn't, but if a bug forces it), it must produce identical output for the same idempotency key — this is what makes the saga deterministic across restarts. + +## [126] APPENDIX D · SAGAS · Carrying context forward — and resuming after a crash (sldId 381) + +[chip] c++ · boost::sml + +```c++ +// Every step's output lands in context[step.name], the sagas.context jsonb column +// (saga-orchestrator/src/main.cpp, jsoncpp Json::Value). +// after reserve_stock: context["reserve_stock"] = {"reservation_id": ..., "sku": ...} + +// A later step reads A's output from the persisted context, never from A: +[](const Json::Value& ctx) { // book_shipping execute() + const auto& reserved = ctx["reserve_stock"]; // B reads A's data + Json::Value r; + r["shipment_id"] = generateId("shp-"); + r["reservation_id"] = reserved["reservation_id"].asString(); + return r; +} + +// advance(), after the step succeeds, in the same transaction as the status: +Json::Value result = step.execute(context); +context[step.name] = result; // carried forward +setState(db.conn, sagaId, status_of(sm), progress.step_index, &context); +// Crash between A and B: the next advance() reloads the row; context +// already holds A's output, so B runs with it and A is never called again. +``` + +B reads A's result from the persisted context, never from A. Resume = reload the row and continue at the next step. + +p. 107 + +> The saga context as a JSON column in Postgres is the simplest persistence model and fits the C++ + libpq + jsoncpp (Drogon's Json::Value) toolchain naturally. Every successful step appends to context[step_name]; every step that needs upstream data reads from context[upstream_step]. Type safety trade-off: the JSON column is untyped at the DB layer, which is fine because the type is enforced in the C++ code that produces and consumes it. The step name (compile-time constant) is the schema key. For larger saga payloads, compress (jsonb_compress) or move detail to a separate object-storage claim and store only the reference in context — the same claim-check pattern from §12. + +## [127] API Error Handling (sldId 382) + +Appendix E + +One discipline, four wire formats. + +> Section Appendix E divider: API Error +> +> Handling. One discipline, four wire formats. + +## [128] APPENDIX E · ERRORS · One error model, four wire formats (sldId 383) + +The same five facts in every error; only the encoding changes per protocol. + +p. 108 + +> Five facts that every error needs to carry, regardless of protocol: (1) a stable machine code (STOCK_UNAVAILABLE), never a string the client has to parse. (2) A safe human message: no SQL, no stack traces, no internal class names. (3) A trace_id, so server-side logs and traces can be looked up. (4) A retryable flag: does the client back off and try again, or fail permanently. (5) Field details: for validation errors, which field failed why. The four protocols encode these differently: RFC 9457 problem+json for REST, google.rpc.Status for gRPC, errors[] with extensions for GraphQL, DLQ + retry topics for Kafka. The facts stay the same; only the encoding changes. + +## [129] APPENDIX E · ERRORS · REST: status, then Problem Details (sldId 384) + +The status code is the category; the problem+json body carries the machine code and specifics. + +p. 109 + +> RFC 9457 (formerly RFC 7807) Problem Details is the IETF standard for REST error bodies. HTTP status code is the broad category (4xx client, 5xx server); the body carries the specifics in a standardised JSON shape. Why use problem+json instead of your-favourite-format: every API gateway, every client SDK, every observability tool already understands it. Standardising on it across a fleet of C++ services means error handling code is shared, not duplicated per service. + +## [130] APPENDIX E · ERRORS · REST errors with Drogon (sldId 385) + +[chip] c++ · Drogon + +```c++ +// One central exception → wire mapping; Drogon middleware catches and shapes. +struct StockError : public std::exception { + std::string detail; + explicit StockError(std::string d) : detail(std::move(d)) {} + const char* what() const noexcept override { return detail.c_str(); } +}; + +// Drogon error-handling middleware — one place: domain → wire +Task<> error_middleware(HttpRequestPtr req, auto next) { + try { co_await next(req); } + catch (const StockError& e) { + json body = { + {"type", "https://errors.acme.io/stock"}, + {"title", "Insufficient stock"}, + {"status", 409}, + {"detail", e.detail}, + {"code", "STOCK_UNAVAILABLE"}, // stable + {"traceId", current_trace_id()}, // correlate + }; + auto r = HttpResponse::newHttpJsonResponse(body); + r->setStatusCode(k409Conflict); + r->setContentTypeString("application/problem+json"); // RFC 9457 + co_yield r; + } +} +``` + +Map every domain error to problem+json in one middleware — stable code, safe message, trace id; never a raw stack trace. + +p. 110 + +> One central middleware translates exceptions to problem+json. The exception type (StockError, ValidationError, NotFoundError) carries the domain detail; the middleware adds the wire shape (status code, problem+json content type, stable machine code, trace id from OTel current span). Discipline: handlers throw typed exceptions; they never format error responses themselves. This keeps the wire shape consistent across the entire service and means a registry-level review only has to look at the middleware to validate the contract. + +## [131] APPENDIX E · ERRORS · gRPC: status codes + rich details (sldId 386) + +A small fixed set of canonical codes, plus structured google.rpc.Status details in the trailers. + +p. 111 + +> gRPC's error model is two layers: (1) one of ~16 canonical codes (OK, CANCELLED, UNAVAILABLE, NOT_FOUND, etc.) — a small enum, well-defined retry semantics, every client library understands them. (2) Optional google.rpc.Status proto with rich details — ErrorInfo (machine code, domain, metadata), RetryInfo (back-off duration), BadRequest (field violations), LocalizedMessage (i18n), and more. Picking the canonical code is the most important decision — UNAVAILABLE means retryable, INVALID_ARGUMENT means don't bother, NOT_FOUND has its own semantics. Get this right and clients behave correctly without reading your docs. + +## [132] APPENDIX E · ERRORS · gRPC errors with rich status details (sldId 387) + +[chip] c++ · gRPC + +```c++ +// gRPC rich status: code + ErrorInfo + RetryInfo. C++ via grpc++ + google::rpc. +#include +#include + +grpc::Status Inventory::ReserveStock(grpc::ServerContext* ctx, + const ReserveRequest* req, + ReserveReply* reply) { + if (inventory_down_) { + google::rpc::Status status; + status.set_code(static_cast(grpc::StatusCode::UNAVAILABLE)); + status.set_message("inventory temporarily down"); + google::rpc::ErrorInfo info; // machine detail + info.set_reason("STOCK_DOWN"); + info.set_domain("inventory"); + google::rpc::RetryInfo retry; // back-off hint + retry.mutable_retry_delay()->set_seconds(2); + status.add_details()->PackFrom(info); + status.add_details()->PackFrom(retry); + return ::grpc::Status( + ::grpc::StatusCode::UNAVAILABLE, status.message(), + status.SerializeAsString()); // in trailer + } + // ... success path ... + return grpc::Status::OK; +} +``` + +Pick the canonical code (UNAVAILABLE = retryable) and attach ErrorInfo + RetryInfo so clients back off correctly. + +p. 112 + +> Rich status in C++ gRPC is more verbose than Python but mechanically identical. The google.rpc.Status proto is serialised into the binary trailer; client SDKs unpack it and surface ErrorInfo + RetryInfo to caller code. Helper utility worth writing once: a make_grpc_error(code, machine_code, message, retry_delay) function that does this boilerplate. Drop it in a shared header used by every C++ gRPC service. The discipline that matters: every error path returns a well-shaped Status, not the default grpc::Status::CANCELLED. + +## [133] APPENDIX E · ERRORS · Kafka: retry topics and a dead-letter queue (sldId 388) + +Commit the offset, route the failed record aside, and keep consuming — a poison record never blocks the partition. + +p. 113 + +> Kafka's failure model is unique: there's no synchronous client to return an error to; you have a stream you must keep consuming. A 'poison' record (malformed, persistent downstream failure) that you keep retrying will block the partition behind it — every subsequent record waits. The standard pattern: bounded in-place retries → delayed-retry topic → DLQ. Always commit the offset for the original record so the head-of-line is free; the retried version lives on a different topic with its own consumer. + +## [134] APPENDIX E · ERRORS · Kafka retry + DLQ with librdkafka (sldId 389) + +[chip] c++ · rdkafka++ + +```c++ +// Bounded in-place retries, then a delayed-retry topic, then a DLQ. +constexpr int MAX_RETRIES = 3; +while (!stop_token.stop_requested()) { + std::unique_ptr msg(consumer->consume(100)); + if (msg->err() != RdKafka::ERR_NO_ERROR) continue; + try { + handle(*msg); + consumer->commitSync(msg.get()); // success + } catch (const PoisonError& e) { + int attempts = read_header(*msg, "retries", 0); + auto value = std::string(payload_of(*msg)); + if (attempts < MAX_RETRIES) { + auto* h = RdKafka::Headers::create(); + h->add("retries", std::to_string(attempts + 1)); + produce_with_headers(*producer, "orders.retry.30s", value, h); // delayed retry + } else { + auto* h = RdKafka::Headers::create(); + h->add("error", "poison"); + h->add("traceId", current_trace_id()); + produce_with_headers(*producer, "orders.DLQ", value, h); // DLQ + } + consumer->commitSync(msg.get()); // free head-of-line + } +} +``` + +Retry a bounded number of times, then DLQ — and always commit, so one poison record can't stall the partition. + +p. 114 + +> Three-tier failure handling: in-place retry attempts in headers, delayed-retry topic (consumed by a separate worker after a delay), DLQ for inspection. The critical discipline: commit after you've routed the failure (to the retry topic or the DLQ), never before. If you crash between the route and the commit you'll re-deliver to the retry topic, which is fine; if you commit before the route and crash you've lost the record. Delayed-retry topic mechanism: a dedicated consumer reads orders.retry.30s, sleeps until 30s after the record's timestamp, then republishes to the original topic. Cheap to implement, gives you exponential backoff across multiple retry topics (retry.30s, retry.5m, retry.30m). + +## [135] APPENDIX E · ERRORS · GraphQL: partial success with errors[] (sldId 390) + +One HTTP 200; the failed field is null and explained in errors[] with a machine code in extensions. + +p. 115 + +> GraphQL's error model is fundamentally different from REST or gRPC: the HTTP layer is always 200 OK (unless the request didn't parse). Failures live in the body's errors[] array, paired with null values in the data field for the failed paths. Other fields still return — partial success is the default. This is a feature, not a bug: a client querying { order { id, total, stock } } where inventory is down gets the order and total but null for stock. The client decides whether the partial result is useful. + +## [136] APPENDIX E · ERRORS · GraphQL field errors with cppgraphqlgen (sldId 391) + +[chip] c++ · cppgraphqlgen + +{% raw %} +```c++ +// In cppgraphqlgen, throw service::schema_exception to null a single field. +// Other fields in the same query still return data. + +class OrderResolver : public object::Order { + public: + service::AwaitableScalar getStock() const { // one resolver + try { + co_return co_await inventory_.remaining(order_.sku); + } catch (const Unavailable& e) { + throw service::schema_exception{ { + service::schema_error{ + "inventory unavailable", // safe message + {{ "path", "order.stock" }}, // the field + {{ "extensions", { + { "code", "UNAVAILABLE" }, // machine + { "retryable", true }, + { "traceId", current_trace_id() }, + }}} + } + } }; + } + } + // getId(), getSku(), getTotal() still return normally — partial response +}; +``` +{% endraw %} + +Throw from the field resolver: cppgraphqlgen nulls just that field and adds it to errors[] with your extensions — the rest of data still returns. + +p. 116 + +> cppgraphqlgen's schema_exception is the mechanism for field-level errors. Each entry in the exception's errors list becomes one entry in the response's errors[] array; the exception's path attribute tells GraphQL which field's data to null. The extensions block is where the five-fact discipline lives: code (machine), retryable (bool), traceId (correlate). Clients have a stable shape to react to. + +## [137] APPENDIX E · ERRORS · How a retry storm forms — and how to stop it (sldId 392) + +Naive retries multiply load on a struggling service; bounded, spaced, circuit-broken retries break the feedback loop. + +p. 117 + +> The retry storm is the classic distributed-systems amplification failure. A downstream service slows down; clients retry; the retries add to the load; the service slows down more; clients retry more aggressively; the service dies; clients all retry now that the service is back; the service dies again. Three controls break the loop: (1) bounded retries — small max (2-3), exponential backoff with jitter. (2) Circuit breaker — after N failures, fail-fast for a window without trying. (3) Retry budget — system-wide cap on retries-per-second per client/service pair. C++ specifics: the Istio sidecar handles (1) and (3) per the §2 resilience-as-config pattern. Circuit breaking is the mesh's job; your C++ code stays clean of retry logic in the common case. If you need in-process retries (a sidecar isn't deployed), no widely adopted C++ retry library exists: write the loop with capped exponential backoff and jitter, as Example 26 does (retry_with_backoff in edge-service). + +## [138] APPENDIX E · ERRORS · Error-handling heuristics across all four (sldId 393) + +- One contract, four encodings: a stable code, a safe message, a trace id, a retryable flag, field details — everywhere. +- The category lives in the protocol: HTTP status, gRPC code, GraphQL errors[] entry, Kafka DLQ vs retry topic. +- Make retryability explicit and honour it: Retry-After, RetryInfo, extensions.retryable — don't make clients guess. +- Never leak internals: no stack traces, SQL, or class names on the wire; map them to a code and log the detail server-side (spdlog with trace_id). +- Bound every retry (timeout, small max, backoff + jitter, circuit breaker, retry budget) and make handlers idempotent. +- C++ specific: a typed exception hierarchy (StockError, ValidationError, NotFoundError) with one centralized middleware per protocol is dramatically cleaner than per-handler error code. RAII unwind via PgTxn destructor cleans up partial state without explicit catch blocks. + +p. 118 + +> Five universal heuristics plus one C++-specific row about exception-driven error handling. + +## [139] DDD & Hexagonal (sldId 394) + +Appendix F + +Bounded contexts, ports & adapters, the architectural language of API design. + +> Section Appendix F divider: DDD & +> +> Hexagonal. Bounded contexts, ports & adapters, the architectural language of API design. + +## [140] APPENDIX F · DDD · Why DDD belongs in an API talk (sldId 395) + +- DDD gives you the language to talk about where service boundaries belong — bounded contexts map directly to deployable services. +- Strategic DDD (subdomains, bounded contexts, context maps) is the macro-architecture decision: what services exist, who talks to whom. +- Tactical DDD (aggregates, entities, value objects, domain events) is the in-service code structure: how each service is organized internally. +- Hexagonal architecture (ports & adapters) is the implementation pattern that keeps the domain pure and the I/O at the edge — a clean fit for C++. +- Without this vocabulary, every architecture discussion devolves into 'where do we put X' arguments that have no resolution. With it, you can name what you disagree about. +- C++ note: a composition root in main() wiring adapters into ports (Example 19) is hexagonal architecture. The DDD framing puts the architectural reasoning in front of the C++ idiom. + +p. 119 + +> DDD has been around since 2003 (Eric Evans' blue book). It earned its place in modern API design by giving us the conceptual tools we needed to decompose monoliths into services without creating the distributed-monolith anti-pattern. For C++ specifically, the hexagonal pattern (which is just well-applied DI + interface segregation) maps directly to how you'd write a testable, refactorable C++ service: domain code depends on abstract interfaces (ports); concrete implementations (REST handler, libpq adapter, Kafka adapter) live at the edge and are wired in at startup. + +## [141] APPENDIX F · DDD · Strategic DDD: subdomains & bounded contexts (sldId 396) + +Core / supporting / generic subdomains, with bounded contexts as the technical boundary inside each. + +p. 120 + +> Strategic DDD has two layers. Subdomains are business: core (your competitive advantage, where most effort goes), supporting (necessary but not differentiating: payments, shipping), generic (commoditised: auth, logging, billing platform). Bounded contexts are technical: a context is one cohesive model with its own ubiquitous language. Two contexts can both have 'Customer' but mean different things; the context boundary is where the translation happens. Practical rule for C++ services: one bounded context = one deployable service = one binary. Multiple contexts in one binary breeds the distributed-monolith inside-the-process. The CMake project structure should reflect this — one top-level directory per context. + +## [142] APPENDIX F · DDD · A bounded context is a service-and-API boundary (sldId 397) + +- Inside the context, one model. Order means one thing; Customer means one thing. The ubiquitous language is consistent. +- At the context boundary, translation. The same business concept (Customer in CRM vs Customer in Billing) carries different attributes and rules — anti-corruption layers translate. +- Bounded contexts become services in cloud-native architectures. The API is the context boundary, made explicit and contractual. +- The contract published to other contexts is the Published Language; the API documentation is the language specification. +- C++ structure: one CMake project per context; the wire contracts (.proto / OpenAPI YAML) are the Published Language; internal headers stay private to the binary. + +p. 121 + +> This is where DDD stops being theoretical and starts being deployable. A bounded context in a cloud-native system is the unit of independent deployability — one binary, one database, one team, one contract. The C++-specific framing: CMake project boundaries should match context boundaries. Public include directories are the published language; private headers are internal model details that other contexts don't see. Shared libraries that span contexts are a smell — they're either a misidentified context (merge them) or they're a generic subdomain that should be its own service or its own well-versioned library. + +## [143] APPENDIX F · DDD · Context mapping: how contexts integrate (sldId 398) + +Customer/Supplier, Conformist, Anti-Corruption Layer, Open Host Service, Published Language — the named patterns. + +p. 122 + +> Context maps name the relationships between bounded contexts. The vocabulary lets a team say 'we're the Conformist to Billing's model' or 'we need an ACL because their model leaks legacy concerns we don't want'. The most useful patterns in practice: Customer/Supplier (who serves whom; the supplier owns the contract), Anti-Corruption Layer (the protective translation buffer when you must integrate with a model you don't control), Open Host Service + Published Language (a stable, well-documented protocol any consumer can adopt). Most of our §1-§13 architectural patterns implement these context-mapping patterns: the OHS is the registered API; the ACL is the gateway translation layer; the Customer/Supplier relationship is enforced by the SLA on the supplier's contract. + +## [144] APPENDIX F · DDD · Open-Host Service & Published Language (sldId 399) + +- Open-Host Service (OHS) — a context publishes a stable, well-documented protocol that any other context can integrate with, without bespoke per-consumer agreements. +- Published Language — the explicit, versioned vocabulary of that protocol. In cloud-native terms: the OpenAPI YAML, the .proto file, the AsyncAPI document, the GraphQL SDL. +- Critical property: the published language is the published language for all consumers. There is no per-consumer dialect; if a consumer needs a different shape, an Anti-Corruption Layer translates on their side, not yours. +- Versioning lives in the Published Language (App B's discipline). Deprecation is announced through the language; removal happens only when usage drops to zero. +- Apicurio (§9) is the registry where Published Languages live; OpenMetadata (§10) is the catalog where consumers discover them. +- C++ specific: the public headers exposed by your service are not the Published Language. The wire contracts (YAML, .proto, SDL) are. C++ ABI stability is a private internal concern; wire-level compatibility is what consumers depend on. + +p. 123 + +> OHS and Published Language are the DDD names for what App A-E spent slides on: contracts as governed artifacts. The framing is helpful because it forces the question 'who is this API published to?' — your own internal team, your partner teams, third-party developers? The audience changes the discipline. The C++-specific bullet: C++ ABI is a header-and-link concern that only matters between binaries that link together. The wire contract is what matters between services. Don't conflate them — a Drogon service that exposes its OpenAPI publicly can freely refactor its internal C++ headers without affecting consumers. + +## [145] APPENDIX F · HEXAGONAL · Hexagonal architecture: ports & adapters (sldId 400) + +Domain in the centre. Ports are interfaces the domain declares. Adapters at the edge implement them. + +p. 124 + +> Hexagonal (Alistair Cockburn, 2005) — the same idea as ports & adapters, sometimes also called 'clean architecture'. The shape of the diagram is incidental; the property is what matters: domain code depends only on interfaces it declares (ports), never on concrete I/O implementations. The concrete implementations (adapters) sit at the edge and are wired in at startup. Why this matters for C++ specifically: testing without mocking frameworks (just provide a test adapter implementing the port); swapping technology choices without touching domain code (libpq for Postgres → libmariadb for MySQL is an adapter swap, not a rewrite); compile-time dependency reversal via abstract base classes or concepts. + +## [146] APPENDIX F · HEXAGONAL · Ports & adapters in C++ (sldId 401) + +[chip] c++ · hexagonal + +```c++ +// domain/ports.hpp — the domain declares the interfaces it needs (no framework) +struct OrderRepository { // outbound port + virtual ~OrderRepository() = default; + virtual void save(const Order& order) = 0; + virtual std::optional find_by_id(const std::string& id) = 0; +}; +struct EventPublisher { // outbound port + virtual ~EventPublisher() = default; + virtual void publish(const OrderPlaced& event) = 0; +}; +// domain/service.hpp — the use case: pure logic, depends only on ports +class PlaceOrderUseCase { + public: + PlaceOrderUseCase(OrderRepository& repo, EventPublisher& publisher) + : repo_(repo), publisher_(publisher) {} + Order execute(const PlaceOrderCmd& cmd) { + if (cmd.quantity <= 0) throw std::invalid_argument("quantity must be positive"); + Order order{generate_uuid(), cmd.sku, cmd.quantity, "placed"}; // aggregate + repo_.save(order); + publisher_.publish(OrderPlaced{order.id, order.sku, order.quantity}); + return order; + } + private: OrderRepository& repo_; EventPublisher& publisher_; +}; // adapters/: rest_adapter.cpp (Drogon), pg_repository.hpp (libpq), log_publisher +``` + +The domain depends only on ports it declares; REST, CLI, Postgres and log adapters are wired in at startup. Swap or add one without touching PlaceOrderUseCase. + +p. 125 + +> Hexagonal in idiomatic C++. Two design choices to call out: (1) ports are abstract base classes with virtual functions, not concepts. Concepts are great for compile-time polymorphism in templates, but ports need runtime polymorphism (the composition root selects which adapter at startup). (2) Dependency injection by reference, not shared_ptr — the composition root in main() owns the lifetime; PlaceOrderUseCase just uses the dependencies it was given. cpp-statelessness Doc 04 describes the same composition-root idiom (patterncatalyst.github.io/cpp-statelessness/docs/04-process-scoped-state/). Testing: write a TestOrderRepository that records save() calls in a std::vector. PlaceOrderUseCase doesn't know it's not the real one. No mocking framework needed. + +## [147] APPENDIX F · DDD · Aggregates and domain events (sldId 402) + +- An aggregate is a consistency boundary inside a context: a small cluster of entities + value objects that change together, accessed via a root. +- Order with OrderLines is an aggregate; the Order is the aggregate root; you never reach into OrderLines except through the root. This is what makes the boundary defensible. +- Transactions stay within one aggregate; cross-aggregate consistency happens through domain events (asynchronously, eventually). +- Domain events name what happened at the business level: OrderPlaced, PaymentRefunded, StockReserved — not 'row inserted'. Same name in the C++ struct, the wire protobuf, and the conversation in the team meeting. +- C++: aggregate roots are value-semantic structs with private invariant-enforcing constructors and factory functions; you can copy them, move them, store them in containers. Aggregates as references-with-shared-state is an anti-pattern. + +p. 126 + +> Aggregates are the tactical-DDD pattern that maps the consistency-boundary discipline into code. The rule of thumb: one transaction = one aggregate. If you find yourself needing to modify two aggregates atomically, either (a) they should be one aggregate, or (b) the consistency should be eventual via a domain event. C++ aggregates as value types is non-negotiable in idiomatic code: structs with private constructors enforcing invariants, factory functions to create them, value semantics throughout. A PMR request arena makes this affordable: you pay arena cost per request instead of a heap allocation per aggregate (cpp-statelessness Doc 03, patterncatalyst.github.io/cpp-statelessness/docs/03-pmr/). + +## [148] APPENDIX F · DDD · How DDD maps to our API stack (sldId 403) + +- Bounded contexts → independently deployed services. One context per CMake project, one binary per service. +- Open-Host Services → published REST / gRPC / GraphQL APIs. Apicurio holds the Published Language; OpenMetadata makes it discoverable. +- Anti-Corruption Layers → API gateways (Apollo for GraphQL aggregation, Drogon services exposing a clean facade over messier internals). +- Domain events → Kafka topics. The event name in the topic matches the domain event name in C++. rdkafka++ producers per service. +- Aggregate roots → resource URLs in REST, top-level types in GraphQL, root messages in .proto. They name the thing the consumer interacts with. +- Hexagonal ports & adapters → a composition root in main() plus adapters (Example 19). Domain code is testable without any infrastructure spinning up. + +p. 127 + +> The full DDD-to-stack mapping. Each row is something we've seen elsewhere in this deck — DDD names what we were already doing and gives us a vocabulary for arguing about it. The headline: DDD isn't extra work. It's a label for the disciplined parts of the work you should be doing anyway. Once you have the labels, the architectural discussions get shorter and the implementation discipline gets clearer. + +## [149] Balancing Coupling (sldId 404) + +Appendix G + +Strength, distance, volatility — balance them, don't eliminate them. + +> Section Appendix G divider: Balancing +> +> Coupling. Strength, distance, volatility — balance them, don't eliminate them. + +## [150] APPENDIX G · COUPLING · Coupling is necessary — balance it, don't eliminate it (sldId 405) + +- Two services that never interact are not coupled, and they are not cooperating either. Useful systems require coupling somewhere. +- The question is not 'how do I have no coupling' but 'where do I put the coupling I need, and how strong is it?' +- Coupling is local. Two services can be tightly coupled in one dimension (shared schema) and loosely in another (independent deploys). +- Optimal coupling depends on volatility: things that change together at the same rate should be coupled; things that change at different rates should be decoupled. +- Khononov's framing (Balancing Coupling, Addison-Wesley) is the synthesis: three dimensions of coupling, balanced against the distance between the coupled things. +- C++ specific: in-process coupling (one binary) is the strongest; cross-process same-host coupling is next; cross-host network coupling is weakest. Architectural decisions about coupling are also decisions about deployment topology. + +p. 128 + +> Khononov's book is the best synthesis of coupling thinking I've seen. The headline insight: 'low coupling' as a slogan is wrong; the right framing is balanced coupling — matching the strength of coupling to the rate of co-change between the coupled things. For C++ specifically, the in-process / cross-process / cross-host hierarchy is meaningful because each step adds latency, failure modes, and operational complexity. A monolith with three tightly-coupled internal modules can be faster and more reliable than a microservices system that fakes loose coupling at the boundaries while still being tightly coupled in business logic. + +## [151] APPENDIX G · COUPLING · Coupling has three dimensions (sldId 406) + +Strength (integration intensity), distance (organisational + technical separation), volatility (rate of change). + +p. 129 + +> Three dimensions, each measurable. STRENGTH: how intensely are the two things coupled — shared memory? shared schema? shared events? shared concepts only? DISTANCE: how far apart are they — same binary? same team? same company? same industry? VOLATILITY: how often does each thing change. The optimization rule: strength should match distance, and both should match co-volatility. Tightly coupled things at low distance changing together fast — that's a healthy relationship. Tightly coupled things at high distance, changing at different rates, cause pain. + +## [152] APPENDIX G · COUPLING · The three dimensions, in practice (sldId 407) + +- STRENGTH — the type of dependency: shared memory > shared schema > shared events > shared concepts > unrelated. Stronger means changes propagate more directly. +- DISTANCE — how far apart: same function > same class > same binary > same team > same company > unrelated parties. More distance means slower communication. +- VOLATILITY — rate of change: things change every day, every quarter, every year, never. Co-change is what matters; things that change together need short distance. +- Pain happens when strength is high but distance is also high — a shared-schema dependency between two teams that can't sync schedules. The dependency cost compounds. +- Health happens when strength matches both distance and co-volatility. Modules within a service can be tightly coupled because they're co-located, co-deployed, co-changed. +- Re-balancing: if strength and distance are mis-matched, move one or the other. Merge services (reduce distance), or extract the shared piece into a contract (reduce strength). + +p. 130 + +> Practical guidance for spotting bad coupling. The strongest signal is the friction of co-ordinated changes — when a single business change requires syncing three teams to deploy on the same day, you have a strength/distance mismatch. The rebalancing tools: merge services to reduce distance (the strangler-fig in reverse), or factor the coupling into an explicit contract (a Published Language) to reduce implicit strength. + +## [153] APPENDIX G · COUPLING · Integration strength: a ladder, worst to best (sldId 408) + +Shared internal model is the worst; events with versioned schemas are the best. Most teams sit somewhere in the middle. + +p. 131 + +> The ladder, from worst (top of coupling stack, hardest to evolve) to best (loosest, easiest to evolve): shared schema or shared model → shared library with breaking changes → synchronous RPC → REST contract → events with versioned schemas. Each step down adds a layer of indirection that absorbs change. Events with schema compatibility rules in Apicurio (§5, §11) are the best practical coupling for cross-service communication — the producer changes safely, the consumer changes safely, the registry rejects the breaking combination. + +## [154] APPENDIX G · COUPLING · Strength in our system (sldId 409) + +- Inside one C++ service: function/class coupling. Strength is high, distance is zero (same binary), co-volatility is high — this is healthy. +- Between C++ services via gRPC: contract coupling on .proto. Apicurio gates breaking changes; backwards-compatible evolution is mechanical. +- Between C++ services via Kafka: event-schema coupling. Lowest strength of all our patterns — producer and consumer are decoupled in time too. +- Between C++ services via shared library (libstateless, libtelemetry): API coupling, distributed via Conan with explicit version pins. Strength is moderate; co-deployment isn't required if ABI is stable. +- Between C++ services via shared database: avoid. This is the worst position — high strength, high distance, no rebalancing without effort. If you're here, treat it as a strangler-fig migration target. +- C++ ABI is a strength multiplier when it appears. Conan + a committed conan.lock pins versions; if every service rebuilds against the same Conan profile, coupling stays manageable. Drift breeds incidents. + +p. 132 + +> Mapping the strength ladder to our specific stack. The C++-specific entries: shared C++ libraries (for example a libtelemetry wrapping OTel boilerplate) are a real form of coupling to manage explicitly. Conan lockfile discipline is what keeps it manageable. Every binary in the system pins to the same versions; upgrading the shared library means a coordinated re-lock across all consumers. The cost is non-trivial but bounded; the win is a single canonical implementation of cross-cutting concerns. + +## [155] APPENDIX G · COUPLING · Balancing coupling: strength vs. distance (sldId 410) + +High strength at low distance is healthy. High strength at high distance is pain. The balance defines architectural quality. + +p. 133 + +> Khononov's balance diagram. Strength on one axis, distance on the other; the diagonal is the healthy zone. Off the diagonal on the strong-and-far side is the pain zone; on the weak-and-close side is wasted indirection that probably should be merged. Practical use: take any architectural review, plot each significant dependency on the grid, and look for the off-diagonal ones. Those are your improvement targets — either move them along strength (factor a contract) or along distance (merge or split services). + +## [156] APPENDIX G · COUPLING · Distance and volatility (sldId 411) + +- Co-volatility means two things change at the same time, in the same direction, in response to the same business or technical drivers. It's the third axis of balance. +- Co-volatile + close + strong = healthy: changes are local and coordinated. +- Co-volatile + distant + weak = pain: changes need to ripple, but the coupling layer absorbs and delays them. +- Independent volatility + distant + weak = excellent: things change on their own schedules without cross-team coordination. +- Independent volatility + close + strong = waste: you're paying coupling cost for things that don't need to move together. Re-decompose. +- C++ specific: a shared header file that ten services include is a strong, close coupling. If those services have independent volatility, every change to that header drags ten services into a coordinated release. That's a smell; factor the contract into a versioned wire interface. + +p. 134 + +> Co-volatility is the most predictive of the three dimensions. Two things that don't change together don't need to be tightly coupled, regardless of how convenient it seems. Two things that do change together need to be close enough for that change to be cheap. Detection signal: count the recurring co-deploys. If service A and service B have to deploy together ten times a year, either they should be one service (merge) or their shared dependency needs to become a stable contract (factor). + +## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldId 412) + +- REST contracts are moderate strength — additive change is safe, breaking change requires consumer migration. The registry gate prevents accidental breaks. +- gRPC contracts are moderate strength — same shape as REST but proto field numbers add an extra layer of permanence. Reserved tags prevent accidental reuse. +- GraphQL contracts are low strength on read paths (additive evolution + @deprecated) but moderate on write paths (mutation input changes). +- Event contracts (Kafka + Apicurio) are lowest strength — producer and consumer don't synchronize; the schema rules absorb most changes; replay is always possible. +- Cross-API coupling: the gateway/BFF (§3 composition) is an Anti-Corruption Layer — it absorbs strength so individual services don't have to. +- C++ specific: gRPC stubs are C++ code generated from .proto. The stub coupling is real (an ABI-incompatible protoc upgrade breaks every consumer). Pin protobuf in Conan; regenerate stubs in CI, never commit them. + +p. 135 + +> Mapping the four wire protocols (App A's framing) to the coupling-strength axis. The general progression: REST and gRPC sit in the middle; events sit at the loose end; shared schemas sit at the tight end. C++-specific gotcha: gRPC stubs are generated C++ that gets compiled into every consumer. The stub generator (protoc) has its own version that must be consistent across consumers, and the .proto contract does not show it. Pin protobuf in Conan, regenerate in CI, and you're fine. Drift creates incidents. + +## [158] APPENDIX G · COUPLING · Heuristics for balanced coupling (sldId 413) + +- Match coupling strength to co-volatility. Things that change together should be coupled strongly enough that the change is one act, not three coordinated ones. +- Match coupling distance to coupling strength. Strong coupling at high distance is pain; the diagonal is where architectures are healthy. +- If two services always deploy together, merge them. If a single service has two pieces that never change together, split it. +- Make breaking changes explicit. The registry's BACKWARD/FORWARD rules + deprecation markers + sunset dates are how strong coupling stays manageable. +- Prefer events over RPC for cross-service coupling unless immediacy is required. The fire-and-forget-a-fact pattern is the lowest-strength practical coupling. +- C++ specific: in-process coupling between aggregates in one service is free — value semantics, RAII, type-checking at compile time. Don't break apart a healthy in-process design just because microservices are fashionable. + +p. 136 + +> Six heuristics. The headline: coupling decisions are architecture decisions. Make them explicitly, not by accident. The C++-specific final bullet: a well-designed C++ monolith with clean in-process aggregates can outperform a poorly decomposed microservices system on latency, reliability, operational cost and developer velocity. The microservices decision should be driven by real DDD-bounded contexts and independent volatility, not by language fashion. + +## [159] Graceful Shutdown (sldId 414) + +Appendix H + +RAII, std::stop_token, SIGTERM: the C++ toolkit. + +> Section Appendix H divider: Graceful +> +> Shutdown. RAII, std::stop_token, SIGTERM: the C++ toolkit. + +## [160] APPENDIX H · SHUTDOWN · Disposability is a promise you have to keep (sldId 415) + +- Twelve-Factor IX (Disposability) — processes start fast and shut down gracefully. Kubernetes assumes this; it terminates pods constantly for scale-down, rollouts, node maintenance. +- Graceful means: stop accepting new work, finish in-flight work within a bounded window, release resources cleanly, exit. Not 'just die when SIGTERM arrives'. +- Failure modes when you skip it: dropped HTTP requests mid-response, uncommitted Kafka offsets (re-delivery), open DB transactions (held locks), partial saga steps (orphaned compensations). +- C++ has the strongest tools for this of any common language: RAII destructors run automatically on scope exit, std::stop_token (since C++20) propagates shutdown cooperatively, deterministic destruction order makes the cleanup sequence predictable. +- Where it gets hard: long-lived connections (WebSockets, SSE), in-flight saga steps, batch jobs. Each workload type has its own graceful-drain shape. + +p. 137 + +> Graceful shutdown is where C++ has a clear advantage in cloud-native architecture. The language gives you tools (RAII, std::stop_token, deterministic destruction) that managed runtimes don't have, but you have to apply them consistently. This appendix is the architectural framing; Example 21 is the runnable C++ version, and cpp-statelessness Doc 09 covers the readiness and signal-handling side (patterncatalyst.github.io/cpp-statelessness/docs/09-health-checks/). + +## [161] APPENDIX H · SHUTDOWN · What happens when Kubernetes stops a pod (sldId 416) + +preStop hook (optional) → SIGTERM → terminationGracePeriodSeconds countdown → SIGKILL. Your job is to finish before SIGKILL. + +p. 138 + +> The Kubernetes termination sequence in detail. (1) The pod is marked Terminating; Endpoints controller removes it from Service endpoints (this is async — there's a brief window where traffic can still arrive). (2) preStop hook runs if configured (useful for additional drain delays). (3) SIGTERM is sent to PID 1. (4) terminationGracePeriodSeconds countdown starts (default 30s). (5) SIGKILL if you haven't exited. The race condition in step 1: traffic can still arrive for a few seconds after SIGTERM. The response is to fail the readiness probe first, so the Service stops sending new traffic, before you stop accepting. The preStop hook is one common way to do this. + +## [162] APPENDIX H · SHUTDOWN · The contract with Kubernetes (sldId 417) + +- On SIGTERM, you have terminationGracePeriodSeconds to exit cleanly. Default 30s; set to whatever your slowest in-flight workload needs. +- Fail readiness first. The Service controller takes seconds to react to your readiness flip; start the countdown early so you stop receiving traffic before you stop accepting. +- Drain in-flight work bounded by the grace period. Reject new work immediately on SIGTERM; finish in-flight work for up to grace_period minus a safety margin. +- Release external resources (DB connections, Kafka consumer group membership, file locks) before exit so the next pod can take over cleanly without waiting for timeouts. +- Exit with 0. Non-zero exit codes get logged as 'crashed' and trigger restart policies you don't want during a normal scale-down. +- C++ specific: signal handlers must be async-signal-safe — set an atomic flag, don't call destructors or take locks. The flag is consumed by your main loop (or the std::stop_source) which does the real cleanup. + +p. 139 + +> Six rules for the K8s graceful-shutdown contract. Five universal, one C++-specific. The async-signal-safety rule matters because installing a signal handler that does non-trivial work (taking locks, calling destructors, even calling printf) is undefined behaviour in C++. The correct pattern: atomic flag in the signal handler, main thread checks the flag and orchestrates shutdown. The main thread can then call std::stop_source::request_stop(), which propagates to every std::stop_token in the program; call it outside the handler, never inside it. + +## [163] APPENDIX H · SHUTDOWN · Graceful means something different per workload (sldId 418) + +HTTP server: finish in-flight responses. Kafka consumer: commit offsets, leave group. WebSocket: signal reconnect. Batch job: checkpoint. + +p. 140 + +> Different workloads, different drain shapes. HTTP servers (Drogon): stop accepting new requests, finish in-flight responses, close connections. Kafka consumers: stop polling, commit the last successful offset, leave the consumer group cleanly so the group doesn't have to wait for session timeout to rebalance. WebSocket servers: signal clients to reconnect (close frame with reason), let the backplane fan-out continue on other pods. Batch jobs: checkpoint state, exit on a clean boundary. The C++ binary should have one shutdown coordinator that knows the order. A complete sequence: stop accepting → drain HTTP → stop Kafka poll → commit offsets → close DB pool → exit (cpp-statelessness Doc 09 walks through the signal-handler side, patterncatalyst.github.io/cpp-statelessness/docs/09-health-checks/). + +## [164] APPENDIX H · SHUTDOWN · Graceful shutdown in Drogon (sldId 419) + +[chip] c++23 · Drogon + +```c++23 +// order-service (excerpt), examples/21-graceful-shutdown/cpp: one SIGTERM drives the drain +std::atomic g_shutting_down{false}; // /readyz answers 503 once it is set +std::atomic g_in_flight{0}; // RAII InFlightGuard: one per request +extern "C" void sigterm_handler(int) { // async-signal-safe: one atomic store + g_shutting_down.store(true, std::memory_order_release); +} +void shutdown_watcher() { // outside signal context + while (!g_shutting_down.load(std::memory_order_acquire)) + std::this_thread::sleep_for(50ms); // (also returns on g_watcher_stop) + const int delay = std::atoi(env("DRAIN_DELAY_SECONDS", "5")); + std::this_thread::sleep_for(std::chrono::seconds(delay)); // endpoints update + const auto deadline = std::chrono::steady_clock::now() + 20s; // bounded in-flight wait + while (g_in_flight.load() > 0 && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(100ms); + app().quit(); // stop the listeners: run() returns +} +int main() { + app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); }); // after init + std::thread watcher(shutdown_watcher); // ... handlers, addListener("0.0.0.0", 8080) + app().run(); g_watcher_stop.store(true); watcher.join(); + g_pool.reset(); // close the database connections; exit 0 +} +``` + +One SIGTERM → fail readiness → serve DRAIN_DELAY_SECONDS → drain in-flight (20 s) → quit → close the pool. + +p. 141 + +> The application side of graceful shutdown, from the C++ example; one SIGTERM, the only signal Kubernetes sends before SIGKILL, drives all of it. (1) The SIGTERM handler only stores an atomic flag, which is async-signal-safe: no locks, no allocation, no destructors. Drogon installs its own SIGTERM handler that quits at once, so the example replaces it after initialisation with registerBeginningAdvice. /readyz answers 503 once the flag is set, which removes the pod from the Service endpoints while the process keeps serving the requests already routed to it. +> +> (2) A watcher thread does the rest outside signal context: it sees the flag, keeps serving for DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) so the endpoint removal propagates, waits up to 20 seconds for the RAII InFlightGuard count to reach zero, and calls app().quit(), which stops the listeners so run() returns. (3) main() joins the watcher, closes the libpq pool and returns 0, inside the 30-second grace period. std::stop_token and std::jthread extend the same pattern to worker threads such as Kafka consumers: the watcher calls request_stop() after it sees the flag, never inside the handler. Deterministic destruction makes the teardown order predictable. + +## [165] APPENDIX H · SHUTDOWN · The Kubernetes side: probes, preStop, grace period (sldId 420) + +- readinessProbe — Kubernetes asks 'are you ready to serve traffic?' Periodic. Failing it removes the pod from Service endpoints. Use /readyz with the in-process readiness flag. +- livenessProbe — 'are you alive?' Failing it restarts the pod. Use sparingly; a too-aggressive liveness probe causes its own outages. Use /healthz with simple process-alive checks. +- startupProbe — 'have you finished starting yet?' Disables liveness until this passes. Useful for slow-starting workloads (C++ with large initialisation, model loading). +- preStop hook — runs before SIGTERM. Common pattern: 'sleep 5' to let Endpoint propagation finish before signal arrives. Or call a graceful-shutdown endpoint that flips readiness. +- terminationGracePeriodSeconds — bounds the entire termination. SIGKILL hits at the end. Default 30s; set as needed for your slowest workload's drain time. +- C++ specific: a Drogon binary's cold-start is sub-second, so startupProbe is rarely needed. Set terminationGracePeriodSeconds based on your longest in-flight gRPC/saga step + 5s safety margin. + +p. 142 + +> The pod spec settings that pair with the in-process shutdown discipline. The combination is what makes graceful drain reliable: readiness flip + Service endpoint removal + preStop sleep + bounded grace period + signal handler + std::stop_token + RAII destructors. Every layer matters; missing one creates a failure mode. + +## [166] APPENDIX H · SHUTDOWN · Beyond HTTP: messages, sockets, jobs (sldId 421) + +- Kafka consumer drain: stop polling, finish processing in-flight messages, commit final offsets, call consumer.unsubscribe() to leave the group cleanly. Skipping unsubscribe forces other consumers to wait for session.timeout.ms. +- Kafka producer drain: producer.flush() before destruction. Otherwise in-memory batches die with the process; no error, just silent loss. +- WebSocket drain: send close frames with reason 1001 (going away) so clients reconnect immediately. The backplane (sw/redis++ pub/sub) keeps fan-out working on remaining pods. +- Long-running gRPC streams: send a status with code OK on the stream, then half-close. Clients should reconnect; the streaming pattern naturally supports this. +- Batch jobs and CronJobs: trap SIGTERM, checkpoint state, exit. The Kubernetes Job controller respects activeDeadlineSeconds; honor it. +- Saga steps in flight: the row-lock + state-machine pattern (App D) handles this automatically. The sweeper picks up the saga on the new pod and continues from the persisted state. + +p. 143 + +> Workload-specific drain notes. Each row is a separate concern; the C++ binary has to orchestrate them in the right order. Order that works for a typical Drogon + Kafka + libpq service: (1) fail readiness, (2) stop Kafka producer (flush), (3) stop Drogon app (drains HTTP), (4) stop Kafka consumer (commits + unsubscribes), (5) close DB pool (RAII), (6) exit. Steps 3-5 can be deterministic via destruction order — main() returns; destructors run; exit code 0. + +## [167] APPENDIX H · SHUTDOWN · Graceful shutdown checklist (sldId 422) + +- Install an async-signal-safe SIGTERM handler that only flips an atomic readiness flag. No locks, no destructors, no printf; the main thread acts on the flag. +- /readyz endpoint returns 503 when readiness flag is false. Service controller removes the pod from endpoints within seconds. +- preStop hook with 'sleep 5' (or longer) so Endpoint propagation finishes before SIGTERM arrives at your handler. +- Pass std::stop_token to every long-running worker (Kafka consumer thread, batch processor, scheduled work). They check stop_requested() in their loop and exit cleanly. +- Use std::jthread (since C++20) for managed worker threads — automatic join + stop_token propagation. No raw std::thread::detach. +- Set terminationGracePeriodSeconds to longer than your slowest in-flight workload — and test it under load. Default 30s is rarely right for production. +- Connect RAII to teardown: connection pools, Kafka clients, OTel TracerProvider all destroy in reverse order when main() returns. Deterministic, no leaked file descriptors. + +p. 144 + +> Seven rules. Following all of them gives you bounded, predictable, deterministic shutdown. + +## [168] L7 Routing & Traffic Mgmt (sldId 423) + +Appendix I + +Software, not silicon: routing decisions across four layers and both directions. + +> Section Appendix I divider: L7 Routing & +> +> Traffic Mgmt. Software, not silicon: routing decisions across four layers and both directions. + +## [169] APPENDIX I · L7 ROUTING · L7 routing: software, not silicon (sldId 424) + +- The hardware load balancer used to be where routing decisions lived. In cloud-native systems that work has moved into software — programmable, observable, runnable anywhere. +- L7 means application layer: the router sees the HTTP or gRPC envelope (host, path, method, headers, cookies, gRPC service.method) and can route on any of it. +- It lives in four layers — edge / ingress, API gateway, service mesh, and (sometimes) inside the app itself. Each makes a different type of L7 decision. +- It runs both directions: north/south (clients into your system) and east/west (service to service). Most L7 decisions happen on internal traffic. +- The open-source stack is composable: HAProxy, Nginx, Envoy, Istio, Linkerd, Kong, Traefik, Drogon, sol2/Lua, Open Policy Agent — plus a YAML or two. + +p. 145 + +> L7 routing is a discipline, not a product. Routing spans four layers and both traffic directions, with many decisions per request. For C++ services the natural fit is the lowest layer that can express the decision — most external routing belongs in the mesh or gateway; business-rule routing belongs in the service itself. + +## [170] APPENDIX I · L7 ROUTING · L4 vs L7: what each layer can decide on (sldId 425) + +L4 sees connections; L7 sees the conversation — paths, headers, methods, cookies, gRPC service.method. + +p. 146 + +> The key difference: L4 only sees connection-level information (IP, port, TLS SNI in some cases). L7 terminates the TLS connection and reads the HTTP/2 envelope: path, method, headers, cookies, body if needed. Every routing decision worth making lives at L7. + +## [171] APPENDIX I · L7 ROUTING · The L7 capability menu (sldId 426) + +- Routing rules: path / host / header / method / cookie / gRPC method → backend. +- TLS termination: decrypt at the edge, re-encrypt (or use mTLS) inward. +- Traffic management: canary, blue/green, A/B, header-based, geo, shadow. +- Payload modification: inject / strip headers, rewrite URLs, transform bodies. +- Resilience: timeouts, retries with backoff + jitter, circuit breakers, outlier ejection — the same toolkit from Appendix E, applied at the network. +- Cross-cutting: authn/authz, rate limiting, WAF, request mirroring, full traces and metrics on every hop. + +p. 147 + +> Six capability categories. A modern L7 layer (Envoy/Istio) can do all of them; configure what you need, leave the rest at defaults. For C++ services downstream, the value is the same as for any language — security and resilience as platform config, no per-service code. + +## [172] APPENDIX I · L7 ROUTING · Where L7 lives: the layered stack (sldId 427) + +Four layers, north/south at the top, east/west at the bottom, each making a different type of L7 decision. + +p. 148 + +> The four layers, top to bottom: edge / ingress (HAProxy, Nginx, cloud LB), API gateway (Kong, Traefik, custom Drogon binary), service mesh sidecar (Envoy via Istio/Linkerd), in-app router (Drogon + sol2/Lua for business-rule routing). Each layer is there because it makes decisions the layers in front of it can't. The mesh shouldn't route on business rules; the in-app router shouldn't terminate TLS. + +## [173] APPENDIX I · L7 ROUTING · A request's L7 path (sldId 428) + +One HTTP request is a chain of L7 decisions made by four independent components. + +p. 149 + +> From client to backend, the same request hits 4-6 L7 decision points. Each adds latency (next-slide content), each adds resilience and observability. The job is to make sure each layer is earning its place; collapse the ones that aren't. + +## [174] APPENDIX I · L7 ROUTING · Open-source L7 building blocks (sldId 429) + +- HAProxy: fast, battle-tested L7 LB; ACL-driven config; classic ingress. Pure C, single binary. +- Nginx: ubiquitous web server / LB; location-block routing; widely used as ingress controller. Also C. +- Envoy: the modern L7 proxy; HTTP/2, gRPC, dynamic config; the sidecar of choice. Written in C++. +- Istio: a control plane built on Envoy; CRDs for routing, security, observability across the mesh. +- Kong, Traefik, Spring Cloud Gateway: opinionated API gateways with plugin ecosystems. +- Drogon + sol2 / Lua: when the routing logic is a business rule, push it into the C++ service — embedded Lua scripts that domain experts can edit, executed safely in a sandboxed VM. + +p. 150 + +> All major L7 building blocks are open-source and well-maintained. For the C++ audience: Envoy is itself a C++ project (CNCF graduated, ~600k LOC modern C++). The C++ ecosystem is the foundation of cloud-native networking. For business-rule routing inside a Drogon service, sol2 (header-only modern C++ Lua wrapper, MIT license) embeds Lua as a sandboxed rule language — domain experts edit .lua files; the service hot-reloads them; the C++ code stays type-safe at its boundaries. + +## [175] APPENDIX I · L7 ROUTING · Content-based routing with HAProxy (sldId 430) + +[chip] haproxy.cfg + +```haproxy.cfg +frontend http_in + bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 # TLS here; h2 for gRPC + acl is_api path_beg /api/ + acl is_grpc req.hdr(content-type) -m beg application/grpc + acl is_internal req.hdr(x-internal) -m str 1 + acl is_canary hdr(cookie) -m sub canary=on + use_backend grpc_pool if is_grpc # by content-type + use_backend order_v2_pool if is_canary # by cookie + use_backend order_int_pool if is_internal # by header + use_backend order_pool if is_api # by path + default_backend web_pool +backend order_v2_pool + balance roundrobin + server v2a 10.1.0.10:8080 check + server v2b 10.1.0.11:8080 check +``` + +ACLs read the L7 envelope (path, header, cookie, content-type) and use_backend dispatches — classic, fast, predictable. + +p. 151 + +> HAProxy's ACL-driven config is the original L7-routing language. It's all C; the config is declarative; the matching is fast. For the C++ audience: HAProxy uses memory-arena allocation per request, the same pattern PMR enables for your own Drogon services. A canonical example of a low-latency C/C++ network stack. + +## [176] APPENDIX I · L7 ROUTING · Content-based routing with Nginx (sldId 431) + +[chip] nginx.conf + +```nginx.conf +# map directive — declarative routing table on a header value +map $http_x_app_version $upstream_pool { + default order_v1; + "v2" order_v2; + "v2beta" order_v2_beta; +} +server { + listen 443 ssl; http2 on; + ssl_certificate /etc/nginx/certs/tls.crt; + ssl_certificate_key /etc/nginx/certs/tls.key; + location /api/orders/ { # path-based routing + proxy_pass http://$upstream_pool; # picked by the map + proxy_set_header X-Request-Id $request_id; + } + location ~ ^/api/grpc/ { # gRPC traffic + grpc_pass grpc://orders_grpc_pool; + } +} +``` + +map turns a header value into an upstream pool name; location blocks dispatch by path and protocol — HTTP and gRPC side by side. + +p. 152 + +> Nginx is the other big C network stack. The 'map' directive is its declarative routing language; location blocks pin behaviour by path; grpc_pass natively understands HTTP/2 + gRPC. For C++ teams who don't run Istio, Nginx + Drogon is a complete, fast, production-grade stack. + +## [177] APPENDIX I · L7 ROUTING · Content-based routing in the mesh: Istio VirtualService (sldId 432) + +[chip] yaml · Istio + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: # gRPC by service.method + - uri: { prefix: /acme.orders.v1.OrderService/ } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by header + - headers: { x-internal: { exact: "1" } } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by cookie + - headers: { cookie: { regex: ".*canary=on.*" } } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] # default +``` + +Match by URI prefix (gRPC service.method), header, or cookie — same L7 inputs, expressed as Kubernetes CRDs. + +p. 153 + +> Istio's VirtualService is the K8s-native version of the HAProxy/Nginx routing config. Same L7 inputs (path, header, cookie); same dispatch model (first match wins); declarative GitOps-friendly. For a C++ service in a Kubernetes deployment, this is where your routing lives — not in C++ code. + +## [178] APPENDIX I · L7 ROUTING · SSL / TLS termination at L7 (sldId 433) + +- Edge terminates TLS so the L7 router can read and route on the request — you cannot inspect headers or paths through an encrypted connection. +- Re-encrypt or mTLS inward: terminate at the edge, then either plaintext within the cluster (rare today) or mTLS via the mesh (the modern default). +- Certificate management: cert-manager + Let's Encrypt for public certs; SPIFFE / SDS for service identity inside the mesh; Istio rotates pod certs automatically. +- TLS passthrough (no termination) when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only. +- Cost: TLS handshakes are real CPU. Modern stacks use session resumption, TLS 1.3 and HTTP/2 to amortise the cost across many requests. +- C++ specific: a Drogon service behind Istio never does TLS itself; the sidecar handles it. drogon::HttpAppFramework runs plaintext on the pod's localhost; mTLS is platform. + +p. 154 + +> TLS at L7 is platform work. For C++ services, the win is that you don't link OpenSSL into your binary — Drogon runs plaintext, Istio's Envoy sidecar adds TLS at the network. The binary stays smaller, the certificate lifecycle is automatic, and rotation doesn't require restarting your service. + +## [179] APPENDIX I · L7 ROUTING · Sticky sessions: cookie vs IP-hash vs none (sldId 434) + +Convenient for legacy state, hostile to elastic scaling — prefer stateless behind a JWT. + +p. 155 + +> Sticky sessions are an anti-pattern in cloud-native systems for the same reason WebSocket pinning is in App C: they tie the client to a specific pod, which breaks scale-down, rolling deployment, and graceful drain. For C++ services that need session state, the right answer is the same: externalize to Redis (sw/redis++) or carry state in JWT claims. + +## [180] APPENDIX I · L7 ROUTING · Sticky sessions in practice (sldId 435) + +- When you need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod. +- Cookie-based is almost always the right choice over IP-hash: it survives NAT and lets clients explicitly drop the cookie. +- What stickiness breaks: even load balancing, fast pod restarts, scale-to-zero, blue/green flips, and a clean Appendix H graceful shutdown for any stateful pod. +- The right fix is usually upstream: move session state to Redis (via sw/redis++) or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable. + +p. 156 + +> Stickiness is one of the things to design out of a C++ service, not into it. cpp-statelessness addresses this directly (patterncatalyst.github.io/cpp-statelessness/docs/01-deployment-posture/): the only state in the binary is request-scoped (arenas) or process-scoped (config, connection pools); never user-scoped state that ties a user to a pod. + +## [181] APPENDIX I · L7 ROUTING · Intelligent traffic steering (sldId 436) + +Six familiar shapes — each is a header or weight rule away. + +p. 157 + +> Six steering patterns: canary, blue/green, A/B test, header-based dark launch, geographic routing, traffic mirroring. Each is a small variation on the same L7 primitive: match on something in the envelope, route to a different backend or split by weight. For a C++ service this is platform configuration — Istio CRDs in Git. The service stays oblivious. + +## [182] APPENDIX I · L7 ROUTING · Canary release: weighted Istio routing (sldId 437) + +[chip] yaml · Istio + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - route: + - destination: { host: orders, subset: v1 } + weight: 90 # 90% on the stable version + - destination: { host: orders, subset: v2 } + weight: 10 # 10% on the canary +--- +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: orders } +spec: + host: orders + subsets: + - name: v1 + labels: { version: v1 } + - name: v2 + labels: { version: v2 } +``` + +VirtualService splits traffic by weight; DestinationRule defines the subsets by pod labels — the canonical canary release pattern. + +p. 158 + +> Canary deployment in Istio is two CRDs: VirtualService for the weight split, DestinationRule for the subset definitions. Pods get labelled by version (Deployment controllers handle this); the subset matches on the label; the weight controls the percentage. + +## [183] APPENDIX I · L7 ROUTING · Header-based steering: dark launches (sldId 438) + +[chip] yaml · Istio + +```yaml +# route internal beta users to v2; everyone else stays on v1 +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: + - headers: + x-acme-beta: { exact: "true" } # set by your auth proxy + route: [{ destination: { host: orders, subset: v2 } }] + - match: + - headers: + user-agent: { regex: ".*AcmeInternal.*" } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] +``` + +First match wins; an internal-only header (set upstream by auth) hands real production traffic to v2 — with zero blast radius. + +p. 159 + +> Header-based dark launch is the safest way to expose new code to production traffic. Your auth proxy (or a feature-flag service) sets x-acme-beta on a controlled population; the mesh routes them to v2; everyone else stays on v1. If v2 misbehaves, the blast radius is exactly the population you opted in. + +## [184] APPENDIX I · L7 ROUTING · Payload modifications (sldId 439) + +- Header injection: X-Request-Id, traceparent, X-Forwarded-For, identity claims — propagate context downstream so the trace stitches together. +- Header stripping: drop Authorization or PII headers before forwarding to a less-trusted backend; strip Server / X-Powered-By outbound. +- URL rewrites: /api/v1/orders → /orders internally; clean public URLs over messy service paths. +- Body transformation: rare but possible — protocol translation, redaction, format conversion (Envoy filters, Drogon middleware). +- Cost: body inspection means reading the full request into memory — the slowest L7 thing you can do. Use it sparingly, never on every request. + +p. 160 + +> Five operations the mesh/gateway can perform on payloads. The headline is the cost note: body inspection breaks streaming and forces buffering. For a C++ service expecting streaming uploads or large request bodies, ensure the mesh policy doesn't accidentally buffer everything for header injection. + +## [185] APPENDIX I · L7 ROUTING · Header injection and URL rewrites (sldId 440) + +[chip] yaml · Istio + +```yaml +# Envoy / Istio: inject identity headers, strip secrets, rewrite paths +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders.acme.io] + http: + - match: [{ uri: { prefix: /api/v1/orders } }] + rewrite: + uri: /orders # external -> internal + route: [{ destination: { host: orders } }] + headers: + request: + set: + x-client-ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" # caller address + x-peer-identity: "%DOWNSTREAM_PEER_URI_SAN%" + remove: [ authorization, cookie ] # don't leak inward + response: + remove: [ server, x-powered-by ] # don't leak outward +``` + +Rewrite paths, inject the caller address and identity, strip secrets — all declaratively in the VirtualService. + +p. 161 + +> Header injection lets the mesh add context and identity and strip secrets before they cross a trust boundary. %DOWNSTREAM_PEER_URI_SAN% is the URI SAN of the client's mTLS certificate, the SPIFFE ID inside the mesh; Envoy creates x-request-id itself when a request has none, and traceparent travels with the request. +> +> For a C++ service downstream, these headers arrive in the request and the Drogon middleware reads them as plain values. + +## [186] APPENDIX I · L7 ROUTING · Beyond the edge: routing inside the app (sldId 441) + +- Sometimes the routing decision is a business rule the platform doesn't know about: route 'high-value-customer claims' here, 'European HL7 messages of type A03' there. +- Pushing those rules into HAProxy ACLs or VirtualService matches does not scale: domain experts own them, they change often, and they depend on the payload, not just the envelope. +- The pattern: an in-app router (Drogon middleware, a custom dispatcher) calls a rule engine and routes on the result. The engine reads the payload and business context. +- It runs inside one service, behind the mesh — not on the network. The mesh still does mTLS, retries and locality; the rule engine just decides where to send the message next. +- C++ specifics: the rules-engine ecosystem is thinner in C++ than in JVM/Python. Three pragmatic options: (1) embed Lua via sol2 (rules as Lua scripts, hot-reloadable); (2) hand-rolled std::function dispatch tables; (3) embed Python via pybind11 for power users. + +p. 162 + +> Business-rule routing in C++. The Python deck uses durable-rules; for C++ services we recommend sol2 + Lua because: (a) sol2 is modern C++ (header-only, MIT licensed); (b) Lua is small, embeddable, and sandboxable; (c) domain experts can edit .lua files without C++ build tooling. The Lua rules and the dispatcher show the pattern in code. + +## [187] APPENDIX I · L7 ROUTING · Drogon + sol2 / Lua: routing by business rules (sldId 442) + +Drogon is the HTTP front; embedded Lua is the rules engine; rdkafka++ (librdkafka) does the dispatch. + +p. 163 + +> The C++ in-app router. Drogon receives the HTTP request; enrichment gathers business context (database lookups, downstream calls); Lua rules evaluate and return the list of destinations; librdkafka publishes to each. Why sol2/Lua vs alternatives: Lua compiles to small bytecode (~50KB VM), runs in a sandbox (no I/O unless you expose it), and reloads instantly. Rules can be tested independently of the C++ service. sol2 makes the C++ ↔ Lua glue type-safe. + +## [188] APPENDIX I · L7 ROUTING · The rules, externalised: Lua (sldId 443) + +[chip] lua + +```lua +-- orders_rules.lua — edited by domain experts, not developers +-- Each rule returns a list of destinations; the dispatcher fans out. + +function route(order, ctx) + local dests = {} + + -- high-value VIP orders + if order.amount_cents > 50000 and ctx.tier == "VIP" then + table.insert(dests, "kafka:orders.priority") + table.insert(dests, "http:notify-vip") + end + + -- European orders -> EU region + if order.region == "EU" then + table.insert(dests, "kafka:orders.eu") + end + + -- catch-all + if #dests == 0 then + table.insert(dests, "kafka:orders.default") + end + return dests +end +``` + +Domain experts read and write the Lua. Reload-on-change, sandboxed, no recompile. The C++ binary is the runtime; the rules are data. + +p. 164 + +> Lua as a rules language. The script lives in a ConfigMap or a Git-versioned file (ROUTING_RULES_FILE in Example 22); the service compiles it once at startup, so a bad script stops the rollout instead of reaching traffic. Sandboxing is a sol2 configuration — only expose the C++ functions you want the rules to call (no os, no io, no debug). Example 22 changes the rule inputs (thresholds, topics) live through PUT /rules; a new script version ships as a rollout. A file watcher that swaps in a freshly compiled Lua state between requests is an extension, not part of the example. + +## [189] APPENDIX I · L7 ROUTING · The dispatcher, in Drogon (sldId 444) + +[chip] c++ · Drogon + sol2 + +{% raw %} +```c++ +// dispatcher.cpp — Drogon controller + sol2 + librdkafka +#include +class Router { // process-scoped + sol::state lua_; + public: + Router() { + lua_.open_libraries(sol::lib::base, sol::lib::table); + lua_.script_file("/etc/orders/rules.lua"); // load DSL + } + std::vector evaluate(const Order& o, const Ctx& c) { + return lua_["route"](o, c); // call route() + } +} router; +Task<> Orders::route(HttpRequestPtr req, auto cb) { + auto order = parse(req); + auto ctx = co_await enrich(order); // DB / HTTP + auto dests = router.evaluate(order, ctx); // rules + for (const auto& dest : dests) { // fan-out + auto [proto, target] = split(dest, ':'); + if (proto == "kafka") produce_to(*producer_, target, order.id, to_json(order)); + else if (proto == "http") co_await http_.post(target, order); + } + cb(json_response({{"routed_to", dests}})); +} +``` +{% endraw %} + +Enrich → evaluate → dispatch. The C++ binary is the type-safe runtime; Lua rules are the editable DSL. + +p. 165 + +> The complete in-app router. Router is process-scoped (one Lua state per binary); evaluate() calls into the Lua route() function with strongly-typed Order and Ctx structs (sol2 handles the marshalling). The handler enriches, evaluates, dispatches. Performance note: Lua evaluation runs at memory-copy speed for table-of-strings results — tens of microseconds per evaluation. Slow compared to native C++ but fast compared to the gRPC and Kafka calls that follow it. The right ratio. + +## [190] APPENDIX I · L7 ROUTING · Every L7 hop costs time (sldId 445) + +Typical p50/p99 added per stage — the budget you spend before your service even runs. + +p. 166 + +> L7 layers earn their keep but they're not free. Each hop adds 0.1-2ms p50 and 1-10ms p99 depending on what it's doing. Body-inspection layers are the slow ones. For C++ services this budget matters: a Drogon binary handling 5ms p99 of business work behind a stack of 5 L7 layers (edge + gateway + ingress + sidecar in + sidecar out) may see total p99 in the 15-25ms range. That's still fine for most workloads, but if you're sub-ms-critical, collapse layers. + +## [191] APPENDIX I · L7 ROUTING · L7 pitfalls and how to avoid them (sldId 446) + +- Too many hops: edge → gateway → ingress → mesh sidecar in → mesh sidecar out adds up fast. Collapse layers that aren't earning their place. +- Regex-heavy ACLs / matches: each regex evaluation is real CPU. Prefer exact / prefix matches; use regex only when needed. +- Sticky sessions break elasticity without any error: the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone. +- Body inspection on every request: blows memory, kills streaming. If you must transform bodies, do it on a small percentage or just at the edge. +- Sync auth lookups on the hot path: cache validated tokens, validate JWT signatures locally, never do a per-request DB lookup for identity. +- Misaligned timeouts and retries between layers — a 30s gateway timeout + 3 mesh retries with 10s each = a retry storm at the bottom (see Appendix E). + +p. 167 + +> Six universal L7 pitfalls. Aligning timeouts is the most common operational mistake: review the full stack's retry × timeout math before going to production. A 'low' timeout at the top of the stack plus retries further down can amplify into a thundering herd. + +## [192] APPENDIX I · L7 ROUTING · East / West: L7 between services, inside the mesh (sldId 447) + +Every service-to-service call is L7-routable through the sidecars. + +p. 168 + +> East/west traffic, service-to-service inside the cluster, is the majority of traffic in a microservices system. The same L7 routing primitives that handle external traffic (VirtualService, DestinationRule) also handle internal. For a C++ Drogon → gRPC Inventory call, both pods have Envoy sidecars; mTLS happens automatically; outlier detection ejects misbehaving inventory pods; canary weights work between services. + +## [193] APPENDIX I · L7 ROUTING · Why east/west L7 matters (sldId 448) + +- Internal traffic dominates: in a microservices system the volume of east/west calls dwarfs north/south by 10x or more. +- Security: mTLS everywhere by default — every internal call has an identity, encryption and an audit trail without per-service code. +- Resilience: timeouts, retries, circuit breakers and outlier ejection live in the sidecar, applied uniformly to every internal hop. +- Observability: every internal call gets a span, latency histogram and per-route success rate — the request lifecycle from §11, populated automatically. +- Locality: prefer same-zone or same-region pods so internal RTT stays in single-digit milliseconds, and graceful failover when a zone goes. +- Routing decisions: header-based canaries and weighted releases work east/west too — you can canary a backend service that has no external traffic at all. + +p. 169 + +> Six reasons east/west L7 matters. For a C++ shop, the headline is that all of this happens without modifying your binary — gRPC clients in your Drogon service talk to the localhost Envoy sidecar, which handles mTLS, retries, outlier detection, and observability uniformly. + +## [194] APPENDIX I · L7 ROUTING · Istio for east/west: DestinationRule + VirtualService (sldId 449) + +[chip] yaml · Istio + +```yaml +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: payments } +spec: + host: payments.acme.svc.cluster.local + trafficPolicy: + connectionPool: { http: { http2MaxRequests: 1000 } } + outlierDetection: { consecutive5xxErrors: 5, interval: 30s, baseEjectionTime: 30s } # eject + loadBalancer: { localityLbSetting: { enabled: true } } # same zone + subsets: + - { name: v1, labels: { version: v1 } } + - { name: v2, labels: { version: v2 } } +--- +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: payments } +spec: + hosts: [payments.acme.svc.cluster.local] # internal DNS — east/west + http: + - route: + - { destination: { host: payments, subset: v1 }, weight: 95 } + - { destination: { host: payments, subset: v2 }, weight: 5 } +``` + +Same CRDs as north/south, pointed at an internal host — outlier detection, locality LB, canary weights between services, no app code. + +p. 170 + +> Same Istio CRDs work east/west. The hostname is the internal service DNS (payments.acme.svc.cluster.local). Outlier detection ejects misbehaving pods; locality LB prefers same-zone for latency; canary weights work between services that have no external traffic. For C++ services this is all transparent — your gRPC client just uses the service DNS name and the platform does the rest. + +## [195] APPENDIX I · L7 ROUTING · gRPC east/west specifics (sldId 450) + +- gRPC is HTTP/2 under the hood, so it works natively in any L7 router that speaks HTTP/2 (Envoy, Istio, modern HAProxy/Nginx). +- Route by gRPC service.method: the URI path is /package.Service/Method — use a uri prefix match in Istio, grpc_pass in Nginx, or path_beg in HAProxy. +- Header-based balancing matters more for gRPC: connections are long-lived and multiplexed, so least-request or per-request load balancing beats round-robin. +- Streaming RPCs (server-streaming, client-streaming, bidi): retries are dangerous if the stream is non-idempotent — cap retries to UNARY calls in the mesh policy. +- Trailers carry status: a 200 + a non-OK trailer is the gRPC equivalent of a 5xx — outlier detection has to be configured to read trailers, not just the status line. +- C++ specifics: grpc::Channel reuse is critical — open one channel per target service, share it across requests. The sidecar load-balances across upstream pods on top of that channel. + +p. 171 + +> Five universal gRPC east/west rules plus one C++-specific one. Channel reuse in gRPC C++ is non-negotiable: creating a new grpc::Channel per request is a 10ms overhead minimum (connection setup + HTTP/2 handshake). One channel per upstream service, shared by reference, lives for the binary's lifetime. + +## [196] APPENDIX I · L7 ROUTING · When to use which L7 layer (sldId 451) + +- Edge / ingress: TLS termination, host/path routing, WAF, public-facing rate limits. Anything north/south that affects all clients. +- API gateway: cross-cutting product policy — auth, per-consumer rate limits, request transformation, monetisation, key management. +- Service mesh: east/west routing, mTLS, retries, outlier detection, canaries between internal services. The default home for service-to-service L7. +- In-app router (Drogon + sol2 / Lua): when routing depends on business rules, payload content, or decisions that domain experts must own. +- Rule of thumb: prefer the lowest layer that can fully express the decision, and never push business rules into mesh CRDs. + +p. 172 + +> Four layers, one rule: lowest layer that can express the decision. A path-based route belongs in the ingress; a per-customer rate limit belongs in the gateway; an outlier-eject belongs in the mesh; a 'route by claim type' decision belongs in the service with embedded rules. + +## [197] APPENDIX I · L7 ROUTING · L7 routing checklist (sldId 452) + +- Pick one ingress, one gateway, one mesh — don't run three competing L7 layers because each team had a favourite. +- TLS terminates at the edge; mTLS between services in the mesh; cert rotation is automatic (cert-manager + SDS), not a quarterly outage. +- Routing rules live in Git, reviewed like code; GitOps reconciles them; rollback is a git revert. +- Every L7 layer emits traces and metrics — sample the spans, alert on per-route SLOs, watch for retry budget exhaustion. +- Stickiness is an exception, not a default; business rules belong in a rule engine inside a service, not in network YAML. +- Test under load and under failure: bring a pod down, eject a zone, ramp a canary, verify the mesh does what the YAML says. +- References: HAProxy / Nginx / Envoy docs; Istio traffic management guide; sol2 (header-only modern C++ Lua bindings); Open Policy Agent for declarative policy; Drogon docs for middleware. + +p. 173 + +> Seven rules. The headline: L7 routing is platform discipline, observed through telemetry, tested under failure. + +## [198] Monolith to Microservices (sldId 453) + +Appendix K + +Patterns that survive the migration with the system still running. + +> Section Appendix K divider: Monolith to +> +> Microservices. Patterns that survive the migration with the system still running. + +## [199] APPENDIX K · MONOLITH · A monolith is a unit of deployment (sldId 454) + +One process, one binary, one heartbeat. All modules live or die together. Often the right starting point — don't be ashamed of it. + +p. 174 + +> A monolith is a unit of deployment, not a unit of design. One C++ binary with well-organised internal modules is a monolith; one C++ binary with everything in main.cpp is also a monolith. They're not the same thing. For C++ specifically, a single binary has real advantages: in-process function calls are nanoseconds, no serialisation, no network failure modes, deterministic destruction order, shared memory between modules is free. Don't break apart a healthy C++ monolith just because microservices are fashionable. + +## [200] APPENDIX K · MODULAR MONOLITH · The modular monolith — often the right destination (sldId 455) + +Still one deployable, but with strict in-process module boundaries enforced by build tooling — not by gentlemen's agreement. + +p. 175 + +> The modular monolith is the destination many systems should aim for, not a stop on the way to microservices. One binary, strict module boundaries, separate teams own separate modules. C++ enforcement mechanisms: separate CMake projects per module, public/private include directories, link visibility settings, namespace discipline. The compiler is your friend here — make boundary violations into build errors, not code review failures. + +## [201] APPENDIX K · DECOMPOSED DBS · Modular monolith with decomposed databases (sldId 456) + +Data ownership becomes per-module — the hardest part of the split, done while still one process. The next step is mechanical. + +p. 176 + +> Database decomposition is harder than process decomposition. By splitting the data first while you're still one binary, you discover the foreign-key entanglements, the shared utility tables, the read-only joins you didn't know existed — and you fix them in one place, in one binary, with one team's release cycle. When the data is per-module, breaking the binary apart is mechanical: each module already has its own libpq connection pool, its own schema, its own migrations. Convert each module's interface to a network protocol (gRPC, REST), ship them as separate Drogon binaries, point them at their schemas. Done. + +## [202] APPENDIX K · STRANGLER FIG · Identify → move → redirect (sldId 457) + +Named after the strangler fig vine: grow the new system around the old until the old can be removed. + +p. 177 + +> Martin Fowler's strangler fig pattern is the safest large-migration approach known. Three steps per slice: (1) Identify the seam — what is the boundary between the piece you're extracting and the rest of the monolith. (2) Move — implement that piece as a new C++ service with its own data. (3) REDIRECT — point traffic at the new service. Repeat. The monolith shrinks; the new system grows; at no point is the system down for more than a config change. + +## [203] APPENDIX K · STRANGLER FIG · With a proxy — step zero of every migration (sldId 458) + +Introduce the routing layer first, before extracting anything. Then redirection becomes a config change, not a release. + +p. 178 + +> The most important step in a strangler migration is putting the proxy in front of the existing monolith before you extract anything. Day 0: 100% of traffic still goes to the monolith; the proxy is just forwarding. Day 1+: you can shift any route to a new service with a config change. For a C++ shop the proxy can be HAProxy, Nginx, Envoy/Istio, or even a Drogon binary with a single forwarding rule. The technology matters less than the operational property: redirection is now a config change, not a release. + +## [204] APPENDIX K · STRANGLER FIG · With proxy + redirection (sldId 459) + +The proxy splits traffic between the new service and the shrinking monolith. Roll forward by widening the route; roll back by narrowing. + +p. 179 + +> Each new service starts with a tiny slice of traffic (1%, 5%) and widens as confidence grows. The proxy controls the split — same primitive as the canary release pattern from App I, applied to migration. Critical observability: at the proxy, per-route success rates and latencies for both the old and the new side. If the new service's error rate spikes during a widening, the proxy narrows automatically (circuit breaker) or you narrow manually with a config push. Either way, no release required. + +## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) + +Process boundary first, data boundary later. Useful intermediate state, not a destination. Schema coupling will bite eventually. + +p. 180 + +> Sometimes you'll extract a service before extracting its data — both old and new code read/write the same tables. This is a useful intermediate state, not a destination. It lets you ship the new service early and prove the contract before the data migration. But schema coupling is real coupling (Appendix G). Two binaries owning the same tables means schema changes coordinate across teams; ALTER TABLE has a blast radius beyond your binary. Plan the data extraction before you start; budget time for it; don't let the intermediate state become permanent. + +## [206] APPENDIX K · ROUTING · Content-based routing during migration (sldId 461) + +[chip] c++ · Drogon + +```c++ +// router/src/main.cpp (excerpt): route POST /orders by tenant during cutover +g_service_urls["monolith"] = env_m ? env_m : "http://monolith:8080"; +g_service_urls["new-service"] = env_n ? env_n : "http://new-service:8080"; +g_tenant_routes["acme"] = "new-service"; // cut Acme over first +g_default_service = "monolith"; // everyone else: legacy + +app().registerHandler("/orders", + [](const HttpRequestPtr& req, std::function&& cb) { + auto body = req->getJsonObject(); // decide by content + std::string tenant; + if (body && body->isMember("tenant")) tenant = (*body)["tenant"].asString(); + forward_request(resolve_upstream(tenant), Post, "/orders", + std::string(req->body()), std::move(cb)); + }, + {Post}); + +// forward_request(): drogon::HttpClient to the chosen upstream, 502 on failure +auto client = HttpClient::newHttpClient(upstream); +auto fwd = HttpRequest::newHttpRequest(); +fwd->setMethod(method); fwd->setPath(path); fwd->setBody(body); +client->sendRequest(fwd, [cb](ReqResult result, const HttpResponsePtr& resp) { + cb(result == ReqResult::Ok ? copy_of(resp) : bad_gateway()); // simplified +}); +``` + +Read the body, decide by content (tenant here; region or a feature flag work the same way), forward to the right backend. Cutover by attribute, not by URL. + +p. 181 + +> Content-based routing during migration is the L7 + business-logic split from App I, applied to cutover. The Drogon handler reads the request body, decides by tenant, and forwards with Drogon's HttpClient. Most teams put this in a sidecar (Envoy with Lua filter, or a dedicated Drogon proxy) so the migration logic doesn't pollute every service. Performance note: reading the body means the middleware is no longer streaming — acceptable during migration, but not a production pattern long-term. + +## [207] APPENDIX K · DECORATOR · The decorating collaborator (sldId 462) + +The new service wraps the legacy one, adds capability, then becomes the API. Useful when you can't modify the legacy code. + +p. 182 + +> The decorating collaborator wraps a system you can't change (vendor product, mainframe, legacy COBOL service) and adds capabilities — caching, eventing, transformation, auditing. Over time, the decorator becomes the API consumers know; the underlying legacy can be replaced without consumers noticing. For C++ this is a natural Drogon pattern: your service exposes the public API, calls the legacy via cpp-httplib or gRPC, layers sw/redis++ caching and librdkafka events around it. The legacy stays untouched; the new behaviour lives in your code. + +## [208] APPENDIX K · DECORATOR · A decorating collaborator in Drogon (sldId 463) + +[chip] c++ · Drogon + +```c++ +// Drogon decorator wrapping a legacy /orders API: Redis caching of GET +// responses and Kafka event emission on writes. The legacy service is unchanged. +class OrdersDecorator : public drogon::HttpController { + public: + METHOD_LIST_BEGIN + METHOD_ADD(OrdersDecorator::getOrder, "/orders/{oid}", Get); + METHOD_ADD(OrdersDecorator::placeOrder, "/orders", Post); + METHOD_LIST_END + Task<> getOrder(HttpRequestPtr req, auto cb, std::string oid) { + auto key = "order:" + oid; + if (auto hit = cache_.get(key)) { // 1. hit (sw/redis++) + cb(json_response(*hit)); co_return; + } + auto body = co_await legacy_.get("/orders/" + oid); // 2. miss → legacy + cache_.setex(key, 60, body); // 3. populate + cb(json_response(body)); + } + Task<> placeOrder(HttpRequestPtr req, auto cb) { + auto body = co_await legacy_.post("/orders", req->body()); // 1. legacy first + auto id = json::parse(body)["id"].get(); + produce_to(*producer_, "order.placed", id, body); // 2. emit (rdkafka++) + cache_.del("order:" + id); // 3. invalidate + cb(json_response(body)); + } +``` + +Drogon wraps the legacy: caching with sw/redis++, events with librdkafka, no legacy code changes. + +p. 183 + +> Idiomatic C++ decorator. legacy_ wraps Drogon's HttpClient; cache_ is sw/redis++; producer_ is a librdkafka producer. The pattern: try cache, call legacy on miss, populate cache. For writes, call legacy first, emit event after, invalidate cache. What you gained without touching the legacy: ~100x faster reads on cache hits, event-stream integration with downstream consumers, an OTel-instrumented hop you can observe. + +## [209] APPENDIX K · DECISIONS · Choosing the right migration pattern (sldId 464) + +- Modular monolith first, microservices later — the cheapest decomposition is often inside one process. Many systems never need to leave this state. +- Decompose the database before decomposing the deployment. The data split is the hard part; doing it in one process is much cheaper than coordinating across services. +- Always introduce the proxy first — the routing layer is what makes incremental cutover possible. No proxy = no incremental migration. +- Strangler with shared database is a transition state. Plan its end before you start it. Schema coupling is debt that accrues. +- Content-based routing when you need to cut over by tenant / region / segment instead of by URL. Costs CPU at the proxy; buys precise risk control. +- Decorating collaborator when you need to add behaviour to a system you can't modify. Most useful for vendor / 3rd-party / legacy-mainframe wrappers. +- C++ specific: a well-modularised C++ monolith is often faster and more reliable than the microservices system that replaces it. The migration is worth doing when team scaling or independent deploy cadence demands it — not because microservices are fashionable. + +p. 184 + +> Seven heuristics. The headline: C++ monoliths can be excellent. The migration is a business / team decision, not a technical one. When you do migrate, do it in this order: modularise inside the binary → split the database → extract one service at a time behind a proxy → repeat. Reversibility at every step. + +## [210] Caching Patterns (sldId 465) + +Appendix L + +Six patterns for keeping a cache useful and your data correct. + +> Section Appendix L divider: Caching +> +> Patterns. Six patterns for keeping a cache useful and your data correct. + +## [211] APPENDIX L · WHY CACHE · Five things a cache earns you (sldId 466) + +- Application performance — a cache hit is microseconds; a database round trip is milliseconds. The hot path becomes ~100x faster on hits. +- Backend load — caching shields the database from repeated reads of unchanged data. Read-heavy workloads can offload 80-95% of queries with a sensible TTL. +- Throughput — the same Pod fleet serves dramatically more requests because it spends less time waiting on the DB. Vertical scaling without buying anything. +- Predictable performance — p99 latency stops being a function of DB load. Tail latencies flatten because the hot path doesn't contend on the same lock-bound resource as everyone else. +- Database cost — fewer queries means smaller (or fewer) DB instances, smaller IOPS budgets, less replication bandwidth. Cache is almost always cheaper than the DB tier it offloads. +- Trade-off: every cache adds a consistency story, an invalidation story, and a failure-mode story. Pick the pattern that matches the consistency you need from the six patterns in this appendix. + +p. 185 + +> Five reasons to cache, one warning. For a C++ service the cache is usually sw/redis++ talking to a Redis instance in the same cluster. Latency to Redis is ~0.5ms p50; to Postgres ~5-50ms p50 depending on query — so the hit is real. + +## [212] APPENDIX L · CACHE-ASIDE · Cache-aside (look-aside) — the default (sldId 467) + +The application reads cache, falls back to DB, populates cache. Explicit and simple; the most common pattern. + +p. 186 + +> Cache-aside is the pattern you start with. The application owns the cache lookup, the DB fetch, and the cache populate. Invalidation on write is explicit. The simplest model, but every caller has to remember the discipline. For C++, the Drogon handler does: try sw/redis++ get, on miss query libpq, populate the cache, return. + +## [213] APPENDIX L · CACHE-ASIDE · Cache-aside with sw/redis++ + libpq (sldId 468) + +[chip] c++ · Drogon + +{% raw %} +```c++ +// Cache-aside with sw/redis++ + libpq. +#include +constexpr int TTL = 60; // seconds +Task<> Products::get(HttpRequestPtr req, auto cb, std::string pid) { + auto key = "product:" + pid; + if (auto cached = cache_.get(key)) { // 1. lookup + cb(json_response(*cached)); co_return; // hit + } + PgConn conn = co_await pg_.acquire(); + auto row = conn.exec_params( // 2. miss → DB + "SELECT id, name, price_cents FROM products WHERE id = $1", pid); + if (row.empty()) throw HttpException{404}; + auto body = to_json(row).dump(); + cache_.setex(key, TTL, body); // 3. populate + cb(json_response(body)); +} +Task<> Products::update(HttpRequestPtr req, auto cb, std::string pid) { + auto body = req->bodyJson(); + PgConn conn = co_await pg_.acquire(); + conn.exec_params("UPDATE products SET name=$1, price_cents=$2 WHERE id=$3", + body["name"], body["price_cents"], pid); + cache_.del("product:" + pid); // 4. invalidate + cb(json_response({{"ok", true}})); +} +``` +{% endraw %} + +Try cache; on miss, hit DB and populate. Invalidate on write. Three operations — the rest is conventions about TTL and naming. + +p. 187 + +> The canonical C++ cache-aside. sw/redis++ Redis class is process-scoped (one connection pool); the get() returns std::optional. libpq through a PgConn checked out from a process-scoped pool (ScopedConn in cpp-statelessness Doc 07, patterncatalyst.github.io/cpp-statelessness/docs/07-state-externalization/). JSON via nlohmann::json. The discipline is in three lines: try cache, on miss query DB and populate, invalidate on write. + +## [214] APPENDIX L · READ-THROUGH · Read-through — the cache fetches on miss (sldId 469) + +Application sees one interface; the cache library handles DB fallback transparently. + +p. 188 + +> Read-through hides the cache-vs-DB decision behind a single get() interface. Same semantics as cache-aside but the discipline is in one place. In C++ this is naturally a template — the loader is parametrised. + +## [215] APPENDIX L · READ-THROUGH · Read-through: a typed loader wrapper (sldId 470) + +[chip] c++ · template + +```c++ +// Read-through wrapper. Caller just calls get(); the template handles the rest. +template +class ReadThrough { + using Loader = std::function>(const std::string&)>; + // ... members: redis_, loader_, ttl_, prefix_ + public: + ReadThrough(sw::redis::Redis& r, Loader l, int ttl, std::string p) + : redis_(r), loader_(std::move(l)), ttl_(ttl), prefix_(std::move(p)) {} + Task> get(const std::string& id) { + auto key = prefix_ + id; + if (auto hit = redis_.get(key)) // cache hit + co_return T::from_json(json::parse(*hit)); + auto value = co_await loader_(id); // miss → loader + if (value) + redis_.setex(key, ttl_, value->to_json().dump()); + co_return value; + } +}; +// Wire it up once at startup — callers don't see Redis directly: +ReadThrough products{ redis_, &load_product_from_db, 60, "product:" }; +Task<> Products::get(HttpRequestPtr req, auto cb, std::string pid) { + auto p = co_await products.get(pid); + cb(p ? json_response(*p) : not_found()); +} +``` + +One template, one prefix, one TTL, one loader: wire it up once and every caller gets the cache-aside discipline by default. + +p. 189 + +> Templated read-through. Type-safe at compile time (the cache returns T, not bytes); the loader is a std::function returning Task>. Compared to Python's dynamic version, the C++ version forces you to declare the type but rewards you with a zero-cost interface — the compiler inlines through it. One ReadThrough per cached type lives at process scope. + +## [216] APPENDIX L · WRITE-THROUGH · Write-through — cache and DB written synchronously (sldId 471) + +Every write hits both the cache and the DB. Cache is never stale; writes pay both latencies. + +p. 190 + +> Write-through trades write latency for read freshness. After a successful write, the cache reflects the new value immediately — no stale-read window. Cost: write latency is DB + cache, not DB alone. For read-heavy workloads with infrequent writes, the trade is excellent. + +## [217] APPENDIX L · WRITE-THROUGH · Write-through with sw/redis++ + libpq (sldId 472) + +[chip] c++ · Drogon + +{% raw %} +```c++ +Task<> Products::update(HttpRequestPtr req, auto cb, std::string pid) { + auto body = req->bodyJson(); + auto key = "product:" + pid; + PgTxn txn = co_await pg_.begin(); + // 1. write the DB first — it's the source of truth + txn.exec_params("UPDATE products SET name=$1, price_cents=$2 WHERE id=$3", + body["name"], body["price_cents"], pid); + auto updated = txn.fetch_one( + "SELECT id, name, price_cents FROM products WHERE id=$1", pid); + co_await txn.commit(); + // 2. write the cache second — set, don't invalidate + cache_.setex(key, TTL, to_json(updated).dump()); + cb(json_response({{"ok", true}})); +} +// Alt: trust the request body (faster, but server-side defaults may diverge) +Task<> Products::updateFast(HttpRequestPtr req, auto cb, std::string pid) { + auto body = req->bodyJson(); + PgTxn txn = co_await pg_.begin(); + txn.exec_params("UPDATE products SET name=$1, price_cents=$2 WHERE id=$3", + body["name"], body["price_cents"], pid); + co_await txn.commit(); + body["id"] = pid; + cache_.setex("product:" + pid, TTL, body.dump()); // trust body + cb(json_response({{"ok", true}})); +} +``` +{% endraw %} + +DB first (source of truth), cache second (read-back the canonical row, or trust the body for speed). + +p. 191 + +> Two variants. The safe version (top) reads back the canonical row after the write to capture server-side defaults, triggers, computed columns. The fast version (bottom) trusts the request body for the cache value — works if your domain has no server-side modifications. Pick the one that matches your data model. + +## [218] APPENDIX L · WRITE-AROUND · Write-around — writes skip the cache (sldId 473) + +Writes go to the DB only. The next read populates the cache lazily. Good for write-heavy, low-re-read workloads. + +p. 192 + +> Write-around suits append-heavy data: events, logs, telemetry, audit records. Writes go straight to the DB and never touch the cache (which would just thrash). Reads use cache-aside; the rare re-read populates lazily. + +## [219] APPENDIX L · WRITE-AROUND · Write-around with selective read-side caching (sldId 474) + +[chip] c++ · Drogon + +{% raw %} +```c++ +// Common with append-heavy data: events, logs, telemetry. +// Writes go straight to DB. Cache is irrelevant on writes. +Task<> Events::record(HttpRequestPtr req, auto cb) { + auto body = req->bodyJson(); + PgTxn txn = co_await pg_.begin(); + txn.exec_params( + "INSERT INTO events (id, type, payload, ts) VALUES ($1, $2, $3, NOW())", + body["id"], body["type"], body["payload"].dump()); + co_await txn.commit(); + // no cache write here — most events are never read again + cb(json_response({{"ok", true}})); +} +// ... optional variant: after the same INSERT, cache_.del("event:" + id) +// Read side is standard cache-aside +Task<> Events::get(HttpRequestPtr req, auto cb, std::string eid) { + auto key = "event:" + eid; + if (auto hit = cache_.get(key)) { cb(json_response(*hit)); co_return; } + auto row = co_await pg_.fetch_one("SELECT * FROM events WHERE id=$1", eid); + if (row.empty()) throw HttpException{404}; + auto body = to_json(row).dump(); + cache_.setex(key, 60, body); + cb(json_response(body)); +} +``` +{% endraw %} + +Writes hit DB only; reads use cache-aside. Skip the cache on writes; populate lazily on the rare re-read. + +p. 193 + +> Write-around in idiomatic C++. The key discipline: don't touch the cache on writes for events/logs/telemetry data — you'd just be polluting the cache with values that will never be re-read. The optional defensive variant invalidates any stale entry, useful if siblings might have read the data before this write. + +## [220] APPENDIX L · WRITE-BACK · Write-back (write-behind) — fast writes, eventual durability (sldId 475) + +Writes return immediately after touching the cache; a background flusher persists to the DB asynchronously. + +p. 194 + +> Write-back is the high-throughput pattern: write to cache, return immediately, flush to DB asynchronously in batches. Used for write-heavy workloads where some delay before DB durability is acceptable (metrics, counters, session state). Critical risks: cache outage = data loss. Configure Redis persistence (AOF) and monitor flush success rates. Graceful shutdown (Appendix H) matters more here than anywhere: pod restart must drain the dirty set before exit. + +## [221] APPENDIX L · WRITE-BACK · Write-back with a background flusher (sldId 476) + +[chip] c++ · sw/redis++ + libpq + +```c++ +// cache-service main.cpp (excerpt), examples/25-caching/cpp: write-back +app().registerHandler("/write-back/metrics/{id}", // cache_val {id, payload}; body {ok, ...} + [](const HttpRequestPtr& req, Callback&& callback, const std::string& id) { + redis_set("metric:" + id, writer.write(cache_val), std::chrono::hours(1)); // 1. cache + redis_sadd("writeback:dirty", "metric:" + id); // 2. mark dirty + auto resp = HttpResponse::newHttpJsonResponse(body); resp->setStatusCode(k202Accepted); + callback(resp); // 3. 202: queued, not yet durable + }, {Put}); +void writeback_flusher() { // std::thread, joined at shutdown + std::map attempts; int failures = 0; // per-key failure count + while (!g_shutdown) { // ... each cycle first sleeps std::min(2 << failures, 8) s + for (const auto& key : redis_smembers("writeback:dirty")) { // key -> id, cached payload + auto res = g_flush_db->exec("INSERT INTO metrics (id, payload) VALUES ($1, $2) " + "ON CONFLICT (id) DO UPDATE SET payload = EXCLUDED.payload, ts = NOW()", + {id, payload}); + if (res && PQresultStatus(res.get()) == PGRES_COMMAND_OK) { + redis_srem("writeback:dirty", key); attempts.erase(key); continue; // DB has it + } // failed: stays dirty, retried next cycle + if (++attempts[key] >= kMaxFlushAttempts) // bounded: park after 5 attempts + { redis_sadd("writeback:failed", key); redis_srem("writeback:dirty", key); } + } // ... failures + 1 (at most 2) after a cycle with a failure, 0 after a clean one + } +``` + +PUT answers 202; the flusher upserts every 2 s, clears a mark once the DB has the row, and parks a key after 5 failures. + +p. 195 + +> Write-back from Example 25: PUT /write-back/metrics/{id} stores the payload in Redis under metric: with a one-hour TTL (a queued write has to outlive a database outage), adds the key to the writeback:dirty set and answers 202 Accepted: the write is queued, not yet durable. A background std::thread wakes every two seconds, reads the dirty set, upserts each metric into Postgres with INSERT ... ON CONFLICT DO UPDATE, and removes the key from the set only after the upsert succeeds, so a failed write stays dirty and is retried on the next cycle. +> +> Retries are bounded: a per-key counter parks a key on writeback:failed after five failed attempts, with its value still in the cache and an error in the log, and the cycle time backs off to four and then eight seconds while flushes fail. The upsert makes a repeated flush harmless. The flusher has its own libpq connection, separate from the request handlers', and main() joins it after the shutdown flag is set (Appendix H). The risk is the one every write-back carries: a write that was acknowledged but not yet flushed is lost if Redis loses it, so keep write-back for data that tolerates that window, such as metrics. + +## [222] APPENDIX L · REFRESH-AHEAD · Refresh-ahead — keep hot keys warm (sldId 477) + +A background task refreshes hot keys before their TTL expires. Users never see a cold miss on popular items. + +p. 196 + +> Refresh-ahead targets the small set of hot keys that serve most traffic. A background task watches their TTLs and refreshes them before expiry, so users never see a miss on the hot path. Cost: extra DB load for the hot set; eliminates user-visible misses on it. + +## [223] APPENDIX L · REFRESH-AHEAD · Refresh-ahead with a Redis sorted set (sldId 478) + +[chip] c++ · jthread + +```c++ +// Refresh-ahead: a jthread refreshes hot keys before TTL expiry. +constexpr int TTL_S = 60; +constexpr int REFRESH_BEFORE_S = 10; // refresh window +Task<> Products::get(HttpRequestPtr req, auto cb, std::string pid) { + auto key = "product:" + pid; + cache_.zadd("product:hot", pid, now_seconds()); // touch + // ... then cache-aside on key with TTL_S +} +void refresher(std::stop_token st) { + while (!st.stop_requested()) { + std::this_thread::sleep_for(5s); + cache_.zremrangebyscore("product:hot", 0, now_seconds() - 300); + std::vector hot; + cache_.zrange("product:hot", 0, -1, std::back_inserter(hot)); + for (auto& pid : hot) { + auto ttl = cache_.ttl("product:" + pid); + if (ttl > 0 && ttl < REFRESH_BEFORE_S) { // danger zone + auto row = pg_.fetch_one("SELECT * FROM products WHERE id=$1", pid); + if (!row.empty()) + cache_.setex("product:" + pid, TTL_S, to_json(row).dump()); + } + } + } +} +``` + +ZADD marks keys hot at read time; the jthread refreshes hot keys whose TTL is in the danger zone. Cold misses disappear on popular items. + +p. 197 + +> Refresh-ahead in C++ uses sw/redis++ sorted sets (ZADD with timestamp as score). The refresher jthread filters by recency (drop cold keys older than 5 minutes), checks TTL of remaining hot keys, and refreshes any in the danger zone. Effect: the hot key set stays warm; cold misses on popular items disappear. Cost: 1-N extra DB queries per refresh cycle for hot keys near expiry. + +## [224] APPENDIX L · ANTI-PATTERNS · When not to cache, and what to watch for (sldId 479) + +- Don't cache by default. Caching adds a consistency story, an invalidation story, and a failure-mode story. If reads are already fast, just don't add a cache. +- Don't cache rarely-read data. Caching write-once-read-never data thrashes your cache and gains nothing. Profile your read patterns first. +- Don't cache without a TTL. Unbounded caches grow until they evict at random, which is the worst way to fail. Always set an explicit TTL or maxmemory-policy on Redis (allkeys-lru typical). +- Avoid cache stampedes (thundering herd): when a hot key expires and many concurrent readers all miss at once, they all hit the DB simultaneously. Mitigate with per-key locks in the loader, refresh-ahead, or jittered TTLs. +- Watch for cache-aside + write-around without invalidation — the write goes to the DB, but a stale cached value can persist for the full TTL. Invalidate on every write, or use a shorter TTL. +- Treat the cache as a performance optimisation, never as the source of truth. A cache outage must mean slower reads, not data loss — always have a DB fallback path. Test this by stopping Redis in staging. + +p. 198 + +> Six caching anti-patterns. The headline rule: the cache is a performance optimisation, never the source of truth. Every cache-dependent code path must work correctly when the cache is empty or unavailable — slower, but correct. + +## [225] APPENDIX L · DECISIONS · Choosing the right caching pattern (sldId 480) + +- Cache-aside — the default. Use when you want explicit control and reads dominate writes. The pattern everyone should know first. +- Read-through — use when the read path is repetitive across the codebase and you want to factor out the if/else. Same semantics as cache-aside; cleaner code. +- Write-through — use when zero stale-read risk after a write matters more than write latency. Read-heavy with infrequent writes is the sweet spot. +- Write-around — use when writes far outnumber re-reads. Avoids cache pollution by write-once data. +- Write-back — use only when extreme write throughput is the constraint, eventual durability is acceptable and your cache is configured for persistence. +- Refresh-ahead — use when a small set of hot keys serves most traffic and miss latency is unacceptable. Adds DB load for the hot set; eliminates user-visible misses on it. +- Most production systems use cache-aside for 95% of cases, with refresh-ahead on a small set of identified hot keys and write-through where stale-read is forbidden. Mix patterns; don't pick one as the rule. + +p. 199 + +> Six patterns, one rule: mix them. + +## [226] Failure Modes (sldId 481) + +Appendix M + +Six modes, the defensive toolkit, and how to layer the patterns. + +> Section Appendix M divider: Failure +> +> Modes. Six modes, the defensive toolkit, and how to layer the patterns. + +## [227] APPENDIX M · Distributed systems fail differently (sldId 482) + +- In a single process, an operation succeeds or it throws. Across a network, a call can succeed, fail, hang forever, succeed-but-the-response-is-lost, or succeed-slowly — and you often cannot tell which. +- Partial failure is the defining trait: some dependencies are up, some down, some degraded, all at the same time. There is no global 'the system is up'. +- Failures are not independent: one slow component can exhaust a caller's resources and take down healthy services around it. Failure spreads. +- Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and what users experience diverge. +- The goal is not zero failures; that is impossible. The goal is to bound the blast radius and recover automatically. All management is risk management. +- This appendix: six failure modes, the defensive patterns that address them (with C++ code), Istio's mesh-level role, graceful shutdown, how to observe failures, and a mapping matrix. + +p. 200 + +> The defining property of distributed systems: failure is not boolean. For C++ services the implications are the same as any language — bound the blast radius, recover automatically. RAII unwinding and cooperative cancellation with std::stop_token make recovery clean (cpp-statelessness Docs 02 and 05, patterncatalyst.github.io/cpp-statelessness/). + +## [228] APPENDIX M · A taxonomy of failure modes (sldId 483) + +Network failures, component failures, load failures — and the defensive toolkit that addresses them. + +p. 201 + +> Six failure modes across three categories. NETWORK: partition, split-brain. COMPONENT: partial failure, gray failure. LOAD: amplification (retry storms), cascading. Each has a different shape and a different defence. The defensive toolkit (timeout, retry+backoff, circuit breaker, bulkhead, load shedding, quorum, fallback) addresses them with varying effectiveness — the failure-mode matrix maps which pattern defends which mode. + +## [229] APPENDIX M · NETWORK · Network partition (sldId 484) + +Nodes are alive and healthy; they cannot reach each other. CAP forces a choice: consistency or availability. + +p. 202 + +> Partition tolerance is the P in CAP. When the network splits, each side must choose: stay available (and risk inconsistency) or stay consistent (and refuse writes). For C++ services this means: connection failures, gRPC UNAVAILABLE, Kafka rebalance, Postgres replica lag — all symptoms of the same underlying cause. + +## [230] APPENDIX M · NETWORK · Split-brain (sldId 485) + +A partition where both sides think they are the leader and both accept writes. State diverges; the worst partition outcome. + +p. 203 + +> Split-brain is the worst partition outcome: two sides both believe they are authoritative and both accept writes. Reconciliation after the partition heals is impossible — there's no canonical history. The only defence is quorum (Raft, Paxos): a majority must agree before any side declares itself leader. For C++ services this is usually delegated — etcd, Zookeeper, or your datastore (CockroachDB, FoundationDB) handles consensus. Don't invent your own. + +## [231] APPENDIX M · COMPONENT · Partial failure (sldId 486) + +The defining trait of distributed systems: some dependencies fail while others keep working. Handle each leg. + +p. 204 + +> Partial failure is the everyday case. Your C++ service depends on Postgres, Kafka, Redis, two downstream gRPC services. At any moment, any subset of them may be unhealthy while the rest work. Your handlers must reason about each leg independently (fail open, fail closed, degrade, retry) per dependency, not service-wide. + +## [232] APPENDIX M · COMPONENT · Gray failure (sldId 487) + +The insidious one: a component is degraded and is perceived differently by different observers. Liveness is green; users suffer. + +p. 205 + +> Gray failure is the failure that lies. The /healthz endpoint returns 200; real traffic is timing out. The node passes its own check; clients are seeing 504s. Detection requires comparing self-reported health to observed traffic outcomes — Istio's outlierDetection (consecutive5xxErrors) is exactly this: eject a pod from the load balancer pool when its real responses look bad, regardless of what it says about itself. + +## [233] APPENDIX M · LOAD · Amplification loops & retry storms (sldId 488) + +A failure triggers retries, which add load, which cause more failures. Backoff, jitter and retry budgets break the loop. + +p. 206 + +> Retry storm: A fails, B retries, A is now more loaded, A fails more, B retries more. The classic positive-feedback loop in distributed systems. Three controls break it: exponential backoff (each retry waits longer), jitter (spread retries randomly so they don't synchronize), retry budget (system-wide cap on retries-per-second). All three are needed; one alone isn't enough. + +## [234] APPENDIX M · LOAD · Cascading failure (sldId 489) + +One component's failure overloads its neighbours, which fail in turn — the domino effect up the call graph. + +p. 207 + +> Cascading failure: A slows down, B's connection pool to A fills up, B can't serve anyone (not even non-A traffic), C calls B and times out, C's pool fills, the cascade climbs the call graph. Bulkheads (App G) prevent this: separate connection pools per dependency mean A's problems are contained to A-related calls; B keeps serving everyone else. + +## [235] APPENDIX M · The defensive toolkit — patterns and what they buy (sldId 490) + +- Timeout + deadline propagation — never wait forever; pass the remaining budget downstream so nobody does doomed work. Defends: partial, cascading. +- Retry with backoff + jitter — ride out transient failures without building a storm; only for idempotent calls; use a retry budget. Defends: partial, amplification (if done right). +- Circuit breaker — stop calling a failing dependency; fail fast; test for recovery. Defends: cascading, amplification, gray. +- Bulkhead (§12) — isolate resources by tenant/call/priority so one failure can't starve the rest. Defends: partial, cascading. +- Load shedding — when overloaded, reject excess early (503) rather than collapsing. Defends: amplification, cascading. +- Quorum / consensus — majority-required action; the only real answer to split-brain. Defends: partition, split-brain. +- Fallback / graceful degradation — return a useful degraded response (cache, default, queue-for-later) instead of an error. Defends: partial, gray. +- Istio mesh policies + graceful shutdown + observability: the platform layer underneath all of these patterns. C++ services lean hard on Istio for uniform timeout/retry/outlier-detection policy. + +p. 208 + +> Eight defensive patterns. For C++, most uniform concerns (timeouts at the network, retries between services, outlier ejection, mesh bulkheads) belong in Istio. Business-aware concerns (fallback to a cached default, idempotency keys, queue-for-later) belong in the C++ service. The split keeps the binary simple. + +## [236] APPENDIX M · TIMEOUTS · Timeouts + deadline propagation (sldId 491) + +Every remote call needs a timeout; the remaining budget should travel downstream so nobody does doomed work. + +p. 209 + +> Timeouts are the most fundamental resilience primitive: bound how long you'll wait. The deadline propagation discipline adds the second half: if you have 5s left in your budget, pass that as the downstream's timeout — they shouldn't spend 10s doing work you've already abandoned. For gRPC C++ this is built in: ClientContext::set_deadline propagates through the HTTP/2 header. For REST you set the header explicitly (X-Deadline-Ms) and read it downstream. + +## [237] APPENDIX M · TIMEOUTS · Timeouts + deadlines in C++ (sldId 492) + +[chip] c++ · gRPC + +```c++ +// ... #include ; using namespace std::chrono; +constexpr auto MIN_BUDGET = 50ms; +Task charge(const Order& order, milliseconds remaining) { + if (remaining <= MIN_BUDGET) // too little to finish + throw DeadlineExceeded{"no budget left"}; + grpc::ClientContext ctx; + ctx.set_deadline(system_clock::now() + remaining); + ChargeReply reply; + auto status = payment_stub_->Charge(&ctx, order_to_proto(order), &reply); + if (status.error_code() == grpc::DEADLINE_EXCEEDED) + throw HttpException{504, "downstream timed out"}; + co_return to_json(reply); +} +// -------- REST (cpp-httplib): propagate via header ------------ +Task charge_rest(const Order& order, milliseconds remaining) { + if (remaining <= MIN_BUDGET) throw DeadlineExceeded{}; + httplib::Client cli{"http://payment.svc:8080"}; + cli.set_connection_timeout(500ms); + cli.set_read_timeout(remaining); // bounded + httplib::Headers hdrs{ {"X-Deadline-Ms", + std::to_string(remaining.count())} }; + auto r = cli.Post("/charge", hdrs, order.to_json().dump(), "application/json"); + if (!r || r->status == 504) throw HttpException{504}; + co_return json::parse(r->body); +} +``` + +gRPC propagates deadlines natively; for REST set the header. Always check remaining budget before starting a hop. + +p. 210 + +> Two variants. gRPC's ClientContext::set_deadline propagates the wall-clock deadline through HTTP/2 headers — every hop knows the budget without explicit code. For cpp-httplib (REST) you set both connection and read timeouts, and propagate the budget via an X-Deadline-Ms header the downstream reads. The MIN_BUDGET check (refuse to start with too little time left) is the discipline that stops cascading timeouts: if the call would just time out anyway, fail immediately instead of starting doomed work. + +## [238] APPENDIX M · RETRY · Retry with exponential backoff + jitter (sldId 493) + +Retry transient failures — but spread retries with exponential backoff + jitter, cap them, and only for idempotent calls. + +p. 211 + +> Retry math: exponential backoff (100ms, 200ms, 400ms, ...) plus random jitter (each client adds 0-100ms random delay) prevents synchronized retry waves. Cap attempts (3 is typical). Only retry idempotent calls (GET, PUT with idempotency key, gRPC unary with explicit idempotency declaration). For C++ the typical recommendation is: do retries at the mesh (Istio retry policy), not in the binary. Keeps the C++ code simple; uniform policy across the fleet. + +## [239] APPENDIX M · RETRY · Retry with backoff + jitter in C++ (sldId 494) + +[chip] c++ + +```c++ +// Retry with exponential backoff + jitter — for idempotent calls only. +#include +#include +template +auto retry_with_backoff(F&& fn, int max_attempts = 3) { + thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_real_distribution jitter{0.0, 100.0}; + std::chrono::milliseconds wait{100}; // initial + for (int attempt = 1; attempt <= max_attempts; ++attempt) { + try { + return fn(); + } catch (const TransportError& e) { + if (attempt == max_attempts) throw; // no budget + auto sleep_ms = wait + std::chrono::milliseconds{(int)jitter(rng)}; + std::this_thread::sleep_for(sleep_ms); // backoff + wait = std::min(wait * 2, 2000ms); // exponential, cap + } + } + std::unreachable(); +} +// Usage: GETs are idempotent → safe to retry +auto quote = retry_with_backoff([&]{ return http_.get("/quote/" + symbol); }); +// Do not retry a non-idempotent POST unless it carries an idempotency key. +// Pair with a circuit breaker so a down dependency stops retries entirely. +``` + +Hand-rolled retry with exponential backoff + jitter. Cap attempts; only idempotent calls; pair with a circuit breaker. + +p. 212 + +> C++ retry helper. Templated to wrap any callable; uses thread-local RNG for the jitter (no global lock contention); doubles the backoff each attempt with a cap. The headline note at the bottom matters: this is for cases where the mesh can't help (client outside the cluster, special control-plane traffic). Inside an Istio mesh, the retry policy belongs in the VirtualService — uniform across the fleet, no per-service code duplication. + +## [240] APPENDIX M · CIRCUIT BREAKER · Circuit breaker — the state machine (sldId 495) + +Closed → Open (fail fast) → Half-Open (test) → Closed. Stops doomed calls; gives the dependency room to recover. + +p. 213 + +> Three-state machine: CLOSED (normal, calls flow), OPEN (failing fast, no calls), HALF-OPEN (testing recovery with a few trial calls). Transitions on failure threshold (e.g. 5 consecutive failures) and reset timeout (e.g. 30s). C++ has no widely adopted circuit-breaker library; Example 26 hand-rolls one (CircuitBreaker in edge-service). The typical production answer for C++ services is to let Istio handle circuit breaking via outlierDetection — uniform, mesh-level, no in-process code. + +## [241] APPENDIX M · CIRCUIT BREAKER · Circuit breaker + fallback (sldId 496) + +[chip] c++ · atomic + +```c++ +// Hand-rolled circuit breaker — atomic state machine, per-dependency. +class CircuitBreaker { + // ... state_ (Closed, Open, HalfOpen), failures_, opened_at_; THRESHOLD 5, RESET_AFTER 30s + public: + template + auto call(F&& fn, Fallback&& fb) { + if (state_.load() == Open) { + if (steady_clock::now() - opened_at_.load() < RESET_AFTER) + return fb(); // fail fast + state_.store(HalfOpen); // test + } + try { + auto r = fn(); + failures_.store(0); state_.store(Closed); // recovered + return r; + } catch (...) { + if (++failures_ >= THRESHOLD) { + opened_at_.store(steady_clock::now()); + state_.store(Open); + } + return fb(); + } + } +}; +``` + +Atomic state machine; per-dependency, never one global breaker; falls back to a useful response when open. + +p. 214 + +> Hand-rolled circuit breaker using std::atomic for lock-free state. One breaker per dependency (one for payment, one for inventory, etc.) — never a global breaker. The fallback is what makes this useful: when the breaker is open, you don't error out — you call the fallback to return a degraded but useful response (cached value, default, queue-for-later). Production note: for most C++ shops, Istio's outlierDetection is the better choice than in-process. It's uniform, mesh-level, configurable in YAML, observable through Envoy metrics. + +## [242] APPENDIX M · BULKHEAD · Bulkhead — isolate so one failure can't sink the rest (sldId 497) + +- Covered in depth in section 12 (Security) as a blast-radius pattern; it is equally a resilience pattern, so it appears here in the failure-mode context. +- Isolate resources (connection pools, thread pools, std::counting_semaphore, queues) by tenant, by call type, or by priority. A storm in one pool exhausts only that pool. +- Against cascading failure: if database calls have their own bounded pool, a slow database exhausts that pool and returns errors for DB-backed calls — but the service's other endpoints keep serving from their own pools. +- Against partial failure: a single misbehaving tenant or dependency is contained to its own compartment instead of consuming the shared resource. +- C++ implementation: std::counting_semaphore for in-process concurrency limits, separate cpp-httplib Client instances or gRPC channels per dependency for connection pools. At the mesh: Istio DestinationRule connectionPool gives the same isolation at the network layer. +- Pair with timeouts: a bulkhead bounds how many calls run concurrently, but without a timeout those calls can still block forever and keep the bulkhead full. The two patterns need each other. + +p. 215 + +> Bulkhead = isolation. The C++ specifics: std::counting_semaphore (since C++20) is the in-process primitive for bounded concurrency; per-dependency gRPC channels and cpp-httplib Clients give per-dependency connection pools. The mesh-level version (Istio DestinationRule connectionPool) gives network-layer isolation. + +## [243] APPENDIX M · SHEDDING & FALLBACK · Load shedding and graceful degradation (sldId 498) + +- Load shedding — when you are over capacity, reject excess work immediately (HTTP 503 + Retry-After) at the edge, before it consumes resources. A system that sheds 20% of load stays up for the other 80%; a system that accepts everything collapses for 100%. +- Shed by priority: drop health-check-style or low-value traffic first, protect revenue-critical paths. Adaptive concurrency limits (e.g. Netflix's, or Envoy's) shed automatically based on observed latency. +- Graceful degradation — when a dependency is unavailable, return a useful reduced response instead of an error: serve stale cache, omit the personalised section, queue the write for later, show a default recommendation. +- Degradation turns a hard failure into a soft one the user may barely notice. A product page that can't reach the reviews service should still show the product — just without reviews. +- Both are the application-level complement to the circuit breaker: the breaker decides to stop calling; the fallback decides what to return instead; load shedding decides what not to accept in the first place. +- Reference §11: you cannot shed or degrade intelligently without observability — you shed based on measured saturation, and you alert when degradation rates climb. + +p. 216 + +> Load shedding + graceful degradation: the application-level pair. For C++ services, Drogon middleware can implement adaptive concurrency limits (count in-flight requests, shed when the count passes a threshold). Envoy's adaptive_concurrency filter at the sidecar does the same uniformly across the fleet. + +## [244] APPENDIX M · ISTIO · Istio — resilience at the mesh layer (sldId 499) + +Timeouts, retries, outlier detection (gray-failure ejection) and circuit breaking — uniform, config-driven, no app code. + +p. 217 + +> Istio's traffic-management CRDs cover most of the toolkit declaratively: timeouts on VirtualService routes, retries with backoff and budget, outlierDetection (per-pod ejection on consecutive 5xx — addresses gray failure), connectionPool limits (bulkhead at the network layer). For a C++ shop the win is: write the binary to handle its own business logic; let Istio handle the uniform resilience concerns. The C++ code stays clean of retry loops, circuit-breaker state machines, and connection-pool tuning. + +## [245] APPENDIX M · SHUTDOWN · Graceful shutdown (see App. H) (sldId 500) + +[chip] c++23 · Drogon + +```c++23 +// Recap of Appendix H in the failure-mode context: shutdown is a failure mode. +// A pod that dies mid-request causes 5xx; a pod that drains cleanly does not. +std::atomic g_shutting_down{false}; // /readyz answers 503 once it is set +std::atomic g_in_flight{0}; // InFlightGuard per request +extern "C" void sigterm_handler(int) { // async-signal-safe: one atomic store + g_shutting_down.store(true, std::memory_order_release); +} +void shutdown_watcher() { // one SIGTERM drives the rest + // ... wait for the flag, sleep DRAIN_DELAY_SECONDS (5), wait for g_in_flight == 0 (20 s) + app().quit(); // stop the listeners: run() returns +} +int main() { + app().registerBeginningAdvice([] { std::signal(SIGTERM, sigterm_handler); }); + std::thread watcher(shutdown_watcher); + app().addListener("0.0.0.0", 8080).run(); + watcher.join(); + g_pool.reset(); // close the pool, then exit 0 +} +// K8s pod spec: terminationGracePeriodSeconds: 30 (>= 5 s delay + 20 s drain + buffer) +``` + +Shutdown is a failure mode: readiness flip, drain delay, bounded in-flight drain, then cleanup. + +p. 218 + +> Recap of App H in the failure-mode context: pod shutdown is itself a failure mode that graceful drain converts into a non-failure. The C++ disposability discipline of Example 21 (one SIGTERM, an async-signal-safe flag, /readyz answering 503, the drain delay, a bounded in-flight wait, app().quit(), the pool closed and exit 0) is what makes rolling deploys clean. + +## [246] APPENDIX M · OBSERVE · Observing failure modes (see §11) (sldId 501) + +- Network partition — watch peer-connectivity and cross-zone error metrics; alert on quorum-loss events from etcd / your datastore. +- Split-brain — alert on more-than-one-leader conditions, divergent-replica metrics, and fencing-token rejections in logs. +- Partial failure — RED metrics per dependency (rate, errors, duration), not just per service. A spike in errors to one downstream is the early signal. +- Gray failure — the hard one: compare health-check status against real-traffic SLOs. Latency percentiles (p99) diverging from the average, and Istio outlier-ejection counts, are your tells. +- Amplification — watch request-rate against user-action-rate (if requests >> actions, retries are multiplying), retry-count metrics, and goodput falling while load rises. +- Cascading — a distributed trace shows the error spreading service-to-service; saturation metrics (thread pools, queue depth, connection pools) climb together across services. +- The unifying tool is the trace_id from §11: when an alert fires, pivot from the metric to an exemplar trace to its logs to find which leg failed first and how it spread. + +p. 219 + +> How to see the failure modes — observation discipline per category. The C++ specifics are the OpenTelemetry C++ SDK plus spdlog with trace_id MDC; pivoting from metric to trace to log requires consistent trace_id propagation across hops, which Istio handles via the traceparent header. + +## [247] APPENDIX M · Which pattern addresses which failure mode (sldId 502) + +Read a row to defend a failure mode; read a column to see what a pattern buys you. Production layers several. + +p. 220 + +> The matrix. No single pattern defends against every failure mode; production systems layer several. The combination most C++ shops settle on: Istio for timeouts / retries / outlier detection / mesh bulkheads + in-process std::counting_semaphore for in-app concurrency caps + application-level fallbacks (cached defaults, queue-for-later) + etcd/Raft for consensus. The C++ binary stays focused on business logic; the platform carries the uniform resilience concerns. + +## [248] APPENDIX M · Take-aways and references (sldId 503) + +- Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often infinite. +- Retry only idempotent calls, with backoff + jitter + a budget, at one layer, always paired with a circuit breaker. Naive retries cause the outages they were meant to prevent. +- Bound the blast radius: bulkheads isolate, circuit breakers stop the bleeding, load shedding protects capacity, fallbacks keep the user served. +- Quorum is the only cure for split-brain. Let etcd / Raft / your datastore handle consensus; don't invent your own. +- Push uniform concerns (timeouts, outlier detection, mesh bulkheads) to Istio; keep business-aware concerns (fallbacks, idempotency) in the app. Don't double-retry. +- Observe from the client's vantage point (RED per dependency, SLOs, outlier ejections) — gray failures are invisible to naive health checks. Drain on shutdown (App. H). +- References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkhead origin); the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling); Istio traffic-management docs; the metastable-failures research (Bronson et al., 2021). + +p. 221 + +> Final take-aways for App M and for the deck overall. The headline: failure is the default; resilience is what you design for. For C++ services in a cloud-native system, the discipline is: cooperate with the platform (Istio for uniform concerns, RAII for resource lifetime, std::stop_token for cooperative shutdown), keep business logic clean (typed exceptions, idempotency keys, fallbacks), observe from the client's vantage (RED per dependency, exemplar traces). The language gives you the tools; the discipline puts them to work. + +## [249] Feature Flags (sldId 504) + +N + +Progressive delivery, decoupled from deploy — OpenFeature + flagd + +> This appendix adds the runtime switch that separates shipping code from releasing a feature. It is a deep-dive you keep open when you are about to roll something out gradually. The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, like every other example in the book. The arc: why deploy is not release, the four kinds of flag, how evaluation works, the code to evaluate and target and roll out, flags as a control plane over patterns we already covered, and the lifecycle discipline that keeps flags from becoming debt. + +## [250] APPENDIX N · FEATURE FLAGS · Deploy is not release (sldId 505) + +p. 222 + +the artifact ships dark; releasing is a runtime flag flip — and so is rollback + +> The reframe that makes everything else work: deploying an artifact and releasing a feature are different events, and a flag is what splits them. The artifact ships to production with the new path turned off - a non-event, visible only in the deploy log. Releasing is then a runtime flag flip: on for one percent, then a wider cohort, then everyone, each step reversible by narrowing the flag rather than rolling back a deployment. This is what lets a team merge to main many times a day yet release on its own cadence, and it is the mechanism behind every canary and every safe cutover in the book. Rollback stops being a redeploy and becomes a config change measured in seconds. + +## [251] APPENDIX N · FEATURE FLAGS · Four kinds of flag (sldId 506) + +p. 223 + +by lifetime and owner — each wants different defaults and lifecycle + +> Feature flag covers four different tools, and conflating them is the root of most flag messes. They differ along two axes - how long the flag lives, and who decides to flip it. Release flags are temporary and developer-owned: they ship new code dark, ramp it, and are then removed. Experiment or A/B flags are temporary and data-owned: they split traffic across variants so a metric can be measured, and they require sticky assignment. Ops or kill-switch flags are long-lived and SRE-owned: a permanent control to disable a feature or dependency fast under load or incident. Permission or entitlement flags are long-lived and product-owned: they gate functionality by plan, tier, or region. The defaults differ too - a release flag fails off, so half-shipped code stays dark, while a kill-switch usually fails on, so the feature survives a flag-system outage. + +## [252] APPENDIX N · FEATURE FLAGS · Evaluating a flag: OpenFeature + flagd (sldId 507) + +p. 224 + +local evaluation, vendor-neutral SDK, fail-safe to the default + +> Evaluation should not couple your code to a vendor. OpenFeature is the CNCF vendor-neutral API standard - one SDK surface across languages - and flagd is its lightweight open-source backend that evaluates flags from a file, ConfigMap, or synced source, in-cluster. Together they keep the whole feature-flag story open-source and running on plain Kubernetes, with no managed SaaS, and swapping flagd for another provider later is a configuration change, not a code change. Two properties matter most. First, evaluation returns the default if the provider is unreachable - a flag-system outage must never take the application down. Second, the SDK is vendor-neutral: the app calls getBooleanValue with a flag key, a default, and an evaluation context, and never names the backend. The context carries the targeting inputs - a stable key plus attributes like plan and region - that the rules act on. + +## [253] APPENDIX N · FEATURE FLAGS · Evaluating a flag (sldId 508) + +p. 225 + +[chip] c++ · flagd gRPC + +```c++ +// flag-service (excerpt): flagd's gRPC ResolveBoolean, called with grpc++ +bool FlagdClient::evaluateBoolean(const std::string& flagKey, bool defaultValue, + const std::string& targetingKey, + const std::string& plan) { + flagd::evaluation::v1::ResolveBooleanRequest request; + request.set_flag_key(flagKey); + auto* fields = request.mutable_context()->mutable_fields(); + (*fields)["targetingKey"].set_string_value(targetingKey); // sticky bucketing + if (!plan.empty()) (*fields)["plan"].set_string_value(plan); + flagd::evaluation::v1::ResolveBooleanResponse response; + grpc::ClientContext ctx; + ctx.set_deadline(std::chrono::system_clock::now() + std::chrono::milliseconds(800)); + auto status = grpc::internal::BlockingUnaryCall( + channel_.get(), method_, &ctx, request, &response); + return status.ok() ? response.value() : defaultValue; // flagd down: default +} +// POST /checkout +bool useNew = flagdClient->evaluateBoolean("new-checkout", false, user, plan); +``` + +The same one line of code serves an on/off, a per-plan entitlement, or a 25% rollout. + +> The C++ example evaluates flags through flagd's gRPC evaluation API, ResolveBoolean, with a small hand-written grpc++ stub: flagd's proto service is named Service, which collides with grpc::Service in generated C++. The request carries the flag key and an evaluation context: the targeting key, which makes a percentage rollout sticky, and attributes such as plan. A deadline bounds the call, and any failure returns the default, so a flagd outage leaves the new path dark instead of failing the request. +> +> In the other languages the shape is the same through the OpenFeature SDK: register a flagd provider, build an evaluation context with a targeting key and attributes, and ask for a boolean with a safe default. The targeting rules live in flagd's configuration, not in the code, so the one call serves an on/off flag, a per-plan entitlement or a 25 percent rollout. + +## [254] APPENDIX N · FEATURE FLAGS · Targeting and rollout live in config (sldId 509) + +p. 226 + +[chip] json · flagd + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "==": [ {"var": "plan"}, "enterprise" ] }, "on", + { "fractional": [ {"var": "targetingKey"}, + ["on", 25], ["off", 75] ] } + ] + } + } + } +} +``` + +Enterprise plans always on (entitlement); everyone else split 25/75 (sticky rollout) — no code change. + +> Because the rules live in flagd, changing who sees a feature never touches the application. A flag definition is a set of variants, a default, and a targeting expression. Two operators do most of the work: a conditional for segmentation - here, the enterprise plan always gets the new checkout, an entitlement - and the fractional operator for percentage rollouts, here splitting everyone else 25/75. The fractional operator buckets on the targeting key, which is what gives sticky assignment: the same user hashes to the same bucket every time. Ramping the rollout is editing the 25 upward; rolling back is editing it down - both take effect in the running fleet within flagd's sync interval, with no redeploy. A kill switch is the same file: set a flag's state to DISABLED and it stops evaluating entirely. + +## [255] APPENDIX N · FEATURE FLAGS · Percentage rollout via sticky bucketing (sldId 510) + +p. 227 + +consistent per-user assignment; instant ramp and rollback + +> Sticky bucketing matters for two reasons. For a rollout, it prevents flicker - a user who got the new checkout on their first visit keeps getting it, rather than bouncing between variants on refresh. For an experiment, it is a correctness requirement: a user must stay in one arm of the test for the measurement to mean anything. The mechanism is simple - hash a stable key, the user id, into a bucket from 0 to 99, and compare it to the rollout threshold. Because the hash is stable, the same user always lands in the same bucket. Ramping the rollout is moving the threshold up; rolling back is moving it down, and both take effect in the running fleet within the sync interval with no deploy on the path of either. + +## [256] APPENDIX N · FEATURE FLAGS · A kill switch (sldId 511) + +p. 228 + +[chip] c++ · flagd + +```c++ +// ops kill switch: default true (fail open), on unless an operator turns it off +bool enabled = flagdClient->evaluateBoolean("recommendations-enabled", true, user); +if (!enabled) { + response["recommendations"] = Json::Value(Json::arrayValue); // page still renders + response["reason"] = "killed"; +} else { + response["recommendations"] = live_recommendations(user); // with a circuit breaker + response["reason"] = "live"; +} +``` + +Default true = fail open: only an operator flipping the flag disables it. The default flips with the flag type. + +> A kill switch is the operational flag you reach for during an incident: wrap a risky or expensive dependency in a flag so it can be disabled instantly without a deploy. It is the human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; an operator flips the kill switch by hand. The default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only an explicit flip removes it. When it is killed, the page still renders - just without the recommendations - which is graceful degradation, the same idea as the load-shedding discussion in the Failure Modes appendix. + +## [257] APPENDIX N · FEATURE FLAGS · Flags over the patterns you already have (sldId 512) + +p. 229 + +a flag is a runtime control surface + +> Feature flags are not a new subsystem so much as a runtime control plane over patterns already in this book. A canary is a percentage flag deciding which build a request exercises, often paired with the L7 routing from the routing appendix. A strangler-fig cutover is a flag, or the proxy's percentage, steering a widening slice from the monolith to the extracted service. A saga step can be guarded by a flag to switch a new compensation path on cautiously. And an experiment is a sticky-bucketed variant flag with a metric attached. Recognising flags as the control surface for all of these keeps you from building a bespoke mechanism for each - the same decoupling, expressed four ways. + +## [258] APPENDIX N · FEATURE FLAGS · The flag lifecycle (sldId 513) + +p. 230 + +a release flag is born with a removal ticket + +> The failure mode of feature flags is not technical, it is organisational: flags that are never removed. Every flag is a branch in your code and a dimension in your test matrix, and a stale one is dead code, a hidden conditional, and a live risk surface that nobody remembers the meaning of. The discipline that keeps it healthy: give every release flag an owner and an expiry from the moment it is created, and treat removing it as part of finishing the feature, not optional cleanup. Long-lived ops and entitlement flags are exempt - they are permanent by design - which is exactly why distinguishing the four types matters. A short-lived flag that has been at 100 percent for two weeks should be deleted along with the branch it guarded. + +## [259] APPENDIX N · FEATURE FLAGS · Testing, observing, and anti-patterns (sldId 514) + +p. 231 + + - Test both sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag. + - Put the resolved variant on your telemetry - a span attribute and a metric label - so a latency or error change attributes to a specific flag and cohort. +- Anti-patterns: flags as a config store; release flags that never die; stacks of flags whose combinations no one reasons about; experiments without sticky assignment; business logic buried in flag rules. + +> A flag multiplies the states your system can be in, so test both sides of every flag you depend on; a test suite that only exercises the default path will not catch a bug behind the flag. Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them explicitly. Observability ties flags back to the Observability section: record the resolved variant as a span attribute and a metric label, so a latency or error change can be attributed to a specific flag and cohort. The recurring anti-patterns: using flags as a general configuration store - they are for release control, not for tuning timeouts; letting release flags become permanent; stacking many flags whose combinations no one reasons about; running experiments without sticky assignment, which makes the data meaningless; and putting real business logic inside flag rules instead of in code. + +## [260] APPENDIX N · FEATURE FLAGS · Take-aways (sldId 515) + +p. 232 + +- Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. +- Distinguish the four flag types - they want different owners, defaults, and lifecycles. +- Evaluate through a vendor-neutral SDK (OpenFeature) against an OSS backend (flagd), always with a safe default. +- Keep targeting and rollout in configuration, not code, and make percentage rollouts sticky. +- Treat release flags as debt with a removal ticket; put the resolved variant on your telemetry. + +References: OpenFeature spec & flagd docs (CNCF) · Hodgson, “Feature Toggles” · Accelerate / DORA · Unleash (OSS alternative). + +> Close on the imperatives. Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. Distinguish the four flag types, because they want different owners, defaults, and lifecycles. Evaluate through a vendor-neutral SDK against an open-source backend, always with a safe default so a flag outage cannot take the app down. Keep targeting and rollout rules in configuration, not code, and make percentage rollouts sticky. Treat release flags as debt with a removal ticket attached, and put the resolved variant on your telemetry so you can attribute a regression to a cohort. The canonical references are the OpenFeature specification and the flagd documentation, Pete Hodgson's Feature Toggles article for the taxonomy of flag types, and the Accelerate and DORA research for the deploy-versus-release distinction; Unleash (AGPL-3.0) is an open-source alternative backend; OpenFeature's C++ contrib ships only a flagd provider, so a C++ service would call Unleash through its own API. + +## [261] API Testing with Newman (sldId 516) + +O + +Postman collections as an automated gate on Docker Engine + +> This appendix turns the Postman collections the team already pokes at by hand into an automated test gate, using Newman, Postman's command-line runner. It is a deep-dive you keep open when you are wiring API tests into CI. The key idea that makes it cheap: Newman tests the API as a black box over HTTP, so one collection is the conformance suite for every implementation in the book, and the only language-specific step is bringing the service under test up. The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid. + +## [262] APPENDIX O · API TESTING · Why Newman (sldId 517) + +p. 233 + +the headless runner turns a Postman collection into CI-readable results + +> A Postman collection is an executable contract, not just a saved list of requests. Newman runs it without the GUI and emits machine-readable results: a cli reporter for a human watching, a junit reporter that CI turns into a test report, and an html reporter to share. There is no new test framework to learn - the tests are the JavaScript assertions already supported inside Postman, and Newman is the bridge from works-when-I-click-it to verified-on-every-pull-request. The same file serves three audiences: a developer in Postman, a reviewer reading the tests, and a pipeline failing the build when an assertion breaks. + +## [263] APPENDIX O · API TESTING · Anatomy of a collection (sldId 518) + +p. 234 + +folders of requests, each with assertions; the environment supplies the variables + +> A collection nests folders and requests; each request can carry a pre-request script, the HTTP call itself, and a block of tests that run against the response. The tests are small JavaScript blocks using Postman's pm API - asserting the status, the response time, the shape of the body. Environment variables supply the parts that change between runs, like the base URL and an auth token, resolved at run time. Storing a value from one response - a new order id - in a variable lets a later request chain off it, which is how a collection models a whole flow rather than isolated calls. + +## [264] APPENDIX O · API TESTING · Tests live in the request (sldId 519) + +p. 235 + +[chip] json · Postman + +{% raw %} +```json +{ + "name": "Create order", + "request": { + "method": "POST", + "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }], + "url": "{{base_url}}/orders", + "body": { "mode": "raw", "raw": "{\"sku\":\"A-1\",\"qty\":2}" } + }, + "event": [{ + "listen": "test", + "script": { "exec": [ + "pm.test('201 Created', () => pm.response.to.have.status(201));", + "pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));", + "const body = pm.response.json();", + "pm.test('returns an id', () => pm.expect(body).to.have.property('id'));", + "pm.collectionVariables.set('order_id', body.id);" + ] } + }] +} +``` +{% endraw %} + +Assertions run after the response; the saved order_id chains into later requests. + +> Here is a request that creates an order and asserts on the result. The test block asserts a 201 Created, parses the body, asserts it carries an id, and stores that id in a collection variable so a later request - fetch, cancel, or pay for the order - can use it. Beyond status codes, a test can validate the response shape against a JSON Schema, which is how you turn the metadata and error-format work from earlier appendices into an enforced contract: assert that an error body matches the Problem Details schema rather than checking exact strings, so the test stays stable as data changes but still catches a removed field or a wrong type. + +## [265] APPENDIX O · API TESTING · One collection, six backends (sldId 520) + +p. 236 + +Newman tests over HTTP, so one collection validates every implementation + +> Because Newman exercises the API as a black box over HTTP, the collection does not know or care which language implements the service. The same collection is the conformance suite for every implementation in this book - point it at whichever service is running and the assertions are identical. The collection is the API specification made executable. Bringing the service under test up is the only language-specific step; the Newman command that follows is the same everywhere. + +## [266] APPENDIX O · API TESTING · Launch the service under test (sldId 521) + +p. 237 + +[chip] bash · C++ + +```bash +# C++ / Drogon — build and run, then the same collection +cmake --build build && ./build/order-service & # http://localhost:8080 +newman run orders.postman_collection.json -e local.postman_environment.json +``` + +Only this step is language-specific; the Newman command is identical for all six. + +> The only per-language step is starting the service. Each implementation exposes the same HTTP API on the same port, so the base URL in the environment file is identical and the Newman command that follows does not change. That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it. In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop. + +## [267] APPENDIX O · API TESTING · Running Newman & shaping output (sldId 522) + +p. 238 + +[chip] bash + +{% raw %} +```bash +# install once (Node.js): +# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1 +newman run orders.postman_collection.json \ + --environment local.postman_environment.json \ + --reporters cli,junit,htmlextra \ + --reporter-junit-export results/newman.xml \ + --timeout-request 5000 \ + --bail +# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure + +# data-driven: one iteration per CSV row; the columns become {{variables}} +newman run orders.postman_collection.json -e local.postman_environment.json \ + --iteration-data cases.csv +``` +{% endraw %} + +Reporters target the audience; --bail gates fast; --iteration-data drives edge cases. + +> The runner takes the collection and an environment file, and you choose reporters for the audience: cli for a human, junit for the XML CI turns into a report, htmlextra for a shareable page. --bail stops at the first failed assertion, which is what you usually want in a gate; drop it to see every failure in one run. A single set of requests can be run repeatedly over a table of inputs with --iteration-data, which is how you cover boundary values, bad input, and edge cases without duplicating requests - and the expected status can travel in the data too, so one request becomes a table of positive and negative cases. + +## [268] APPENDIX O · API TESTING · Data-driven tests (sldId 523) + +p. 239 + +one request set, many inputs — boundary values and negative cases from a table + +> Data-driven testing runs the same requests once per row of a CSV. The columns become variables in the requests, and a column can carry the expected status so a single assertion checks status against the row's expectation. This is how a handful of requests becomes broad coverage: the happy path, a zero-quantity order that must be rejected with a 422, an over-stock order that must also be rejected - all without copy-pasting requests. Keeping the cases in data rather than in duplicated requests is what makes the suite maintainable as the API grows. + +## [269] APPENDIX O · API TESTING · Newman in CI (sldId 524) + +p. 240 + +[chip] yaml · GitHub Actions + +```yaml +# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js) +jobs: + api-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: docker compose up -d --build --wait # order, payment, inventory, ... + - run: ./scripts/wait-for-ready.sh # poll /readyz before testing + - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1 + - run: | + newman run orders.postman_collection.json \ + -e ci.postman_environment.json \ + --reporters cli,junit \ + --reporter-junit-export results/newman.xml + - uses: actions/upload-artifact@v7 # keep the report even on failure + if: always() + with: { name: newman-results, path: results/ } +``` + +Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build. + +> Newman exits non-zero when any assertion fails, which is all a pipeline needs to gate a merge. The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them. The readiness poll matters: start testing only once the services report ready, using the readiness endpoint from the Observability and Graceful Shutdown material, or the first requests race the boot and fail spuriously. Upload the junit report as an artifact even on failure so the failing assertion is visible in the run. + +## [270] APPENDIX O · API TESTING · The gate on Docker Engine (sldId 525) + +p. 241 + +the same stack you run locally, gating every pull request + +> The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks. The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock. + +## [271] APPENDIX O · API TESTING · Where Newman fits (sldId 526) + +p. 242 + +the API/contract tier: on top of unit tests, under a thin end-to-end layer + +> Newman does not replace unit tests; it is the layer on top of them. It owns the API/contract tier: real HTTP against a running service, fast enough to run on every pull request. Keep the base of fast unit tests broad, use Newman for the contract and integration behaviour that only shows up over real HTTP, and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system. Pushing everything into Newman makes a slow, brittle suite; ignoring it leaves the contract untested. Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material. + +## [272] APPENDIX O · API TESTING · Take-aways (sldId 527) + +p. 243 + +- Treat the Postman collection as the executable specification - Newman runs it headlessly to guard every change. +- One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box. +- Validate response shape against a schema, not just status codes; drive edge cases from data, not duplicated requests. +- Assert the failure paths - 401, 403, 409/422, 429, idempotency - as carefully as the happy path. +- Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier. + +References: Postman docs & Newman CLI docs (reporters, --iteration-data, --bail) · Postman Learning Center (pm API, JSON-schema validation). + +> Close on the imperatives. Treat the Postman collection as the executable specification of your API, and let Newman run it headlessly so the same assertions guard every change. Because Newman tests over HTTP, one collection is the conformance suite for all six implementations. Validate response shape against a schema, not just status codes; drive edge cases from data rather than duplicated requests; and assert the failure paths as carefully as the happy path. Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer. The references are the Postman documentation and the Newman command-line documentation, and the Postman Learning Center guides on writing tests with the pm API and on JSON-schema validation. + +## [273] Example 05 on minikube (sldId 528) + +R + +The same services and the same verify.sh on a single-node Kubernetes cluster + +> Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod. +> +> The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results. + +## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) + +p. 244 + +- Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress. +- Scope: Example 05 (event-driven fan-out), three services, all six languages, one node. +- One Kafka broker, one Postgres instance, no service mesh: a local topology, not production. +- The contract carries over: Service names, the Secret, environment variables, the health endpoint. +- The same verify.sh runs against compose and the cluster: ten checks per language. + +Site: Appendix R · Code: examples/05-event-driven/k8s/ + +> What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it. +> +> What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged. +> +> The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec. + +## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) + +p. 245 + +- Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5. +- 8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services. +- NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation. +- No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. +- Every command passes --context cndp-k8s; setup-profile.sh restores the prior context. + +minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 + +> minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version. +> +> Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1. +> +> The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag. + +## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) + +p. 246 + +The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec + +> The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret. +> +> From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs. + +## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) + +p. 247 + +- kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1). +- postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw. +- Credential → Secret cndp-postgres-app, read through secretKeyRef. +- build: → docker build, then minikube image load; imagePullPolicy: Never. +- healthcheck → startup and readiness probes on the order-service /healthz. +- ports: 8080:8080 → NodePort 30080. + +The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses. + +> Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts. +> +> The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing. + +## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) + +p. 248 + +[chip] yaml · kustomize + +```yaml +# k8s/overlays/cpp/kustomization.yaml (excerpt) +images: + - name: order-service + newName: localhost/cndp-05/cpp-order-service + newTag: v1 + # shipping-consumer and notification-consumer map the same way +# appended to each Deployment's environment by a JSON patch +- name: PG_CONNINFO + value: "postgresql://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb" +- name: KAFKA_BOOTSTRAP + value: "cndp-kafka-bootstrap:9092" +# memory: requests 128Mi, limits 256Mi +``` + +base/ is language-neutral; each overlay maps the images and appends the variables its language reads. + +> The C++ overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service. +> +> Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base. + +## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) + +p. 249 + +docker build on the host, minikube image load into the node; pods never pull + +> The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load. +> +> The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest. + +## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) + +p. 250 + +[chip] bash · minikube + +```bash +# from examples/05-event-driven/, with no other profile or container running +k8s/setup-profile.sh # create cndp-k8s; check the published ports +k8s/build-images.sh cpp # docker build, then minikube image load +k8s/deploy.sh cpp # Strimzi, Kafka, Postgres, LGTM, then the overlay +RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh + +# all six languages, a fresh namespace for each +OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh + +# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile +k8s/teardown.sh --operator +minikube stop -p cndp-k8s +``` + +With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged. + +> The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three C++ images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay. +> +> verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0. +> +> What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images. + +## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) + +p. 251 + +- 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). +- Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s. +- Tempo received traces from order-service, shipping-consumer and notification-consumer in every language. +- shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset. +- Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them. + +Times cover the image build and load, the deploy, the checks and the namespace teardown. + +> The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language. +> +> The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster. diff --git a/lgtm-presentation/text/Designing-Cloud-Native-APIs-DotNet.md b/lgtm-presentation/text/Designing-Cloud-Native-APIs-DotNet.md new file mode 100644 index 00000000..20a78e50 --- /dev/null +++ b/lgtm-presentation/text/Designing-Cloud-Native-APIs-DotNet.md @@ -0,0 +1,4730 @@ +# Designing-Cloud-Native-APIs-DotNet + +Source: Designing-Cloud-Native-APIs-DotNet.pptx · 312 slides + +## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native APIs (sldId 256) + +From principles to a running system on Kubernetes — communication, composition, data, events, and the platform that governs them. + +Robert Sedor + +Chief Architect, Application Development + +> Welcome and framing. This is a working architect's tour of designing cloud-native APIs on .NET 10 — not a survey of buzzwords. The audience: .NET developers who lived on .NET Framework 4.x, IIS, WCF and COM+ and now need to ship on Kubernetes. We'll bridge that world to this one at every step, not pretend the old world didn't exist. Set expectations: we build one realistic system (five services and a gateway) and re-examine it from twelve angles, then deep-dive appendices on protocol internals, versioning, WebSockets (including Blazor's place in this story), and saga mechanics. Mention the running stack up front: .NET 10 (LTS through 2028, MIT) with ASP.NET Core for REST, Grpc.AspNetCore for gRPC, HotChocolate for GraphQL, Confluent.Kafka 2.16.0 for events, Streamiz.Kafka.Net 1.8.2 for stream processing, Temporal .NET SDK for durable workflows (Stateless for the lightweight alternative), SignalR for WebSockets, OpenTelemetry .NET for observability — every library MIT or Apache 2.0. On Kubernetes with Kafka (Strimzi), Istio and KEDA. The platform around the code (Strimzi, Apicurio, OpenMetadata, the LGTM stack) is the same for every language; .NET 10 is how we write the services. + +## [002] AGENDA · Agenda (sldId 257) + +- Setting up · Patterns in the age of AI +- 01 · Cloud-Native Principles +- 02 · Communications +- 03 · Composition +- 04 · Data +- 05 · Event-Driven +- 06 · Stream Processing +- 07 · Workflows & Jobs +- 08 · API Management +- 09 · API Registry +- 10 · API Metadata +- 11 · Observability +- 12 · Security +- 13 · Anti-Patterns +- Appendices A–D · Protocols, Versioning, WebSockets, Sagas +- Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing +- Appendices J–L · .NET 10 Toolkit, Monolith → Microservices, Caching +- Appendices M–O · Failure modes, Feature Flags, API Testing +- Appendices P–R · .NET on Linux, Blazor, minikube +- Glossary of acronyms + +p. 1 + +> The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns. +> +> The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing, plus the .NET toolkit, .NET on Linux and Blazor Server. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck. +> +> Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order. + +## [003] OVERVIEW · What we'll build, and the order we'll build it (sldId 258) + +- Foundations — cloud-native principles and the canonical system we carry throughout +- Communications — REST, gRPC, GraphQL, and synchronous vs. asynchronous +- Composition & Data — gateways, federation, database-per-service, outbox, sagas +- Event-driven & Stream processing — the log, schemas, derived streams, KEDA scaling +- Workflows & Jobs — orchestration, choreography, batch and scheduled work +- The API platform — gateway, registry (Apicurio), metadata (OpenMetadata), observability +- Anti-patterns — and the heuristics that keep you out of them + +p. 2 + +> Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close. Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to. For a 90-minute talk, cover the main twelve; for the full three hours, work through the appendices too. + +## [004] SETTING UP · Why patterns matter more in the age of AI (sldId 259) + +- AI tooling (Claude, Claude Code, frontier models) turns intent into code at a rate that was not possible before. The bottleneck shifted: it's no longer typing, it's knowing what to ask for and what to accept. +- Patterns are your vocabulary at three layers: design patterns (factory, strategy, observer), architecture patterns (hexagonal, microservices, event-driven), system design patterns (sagas, caching, strangler fig — the scope of this deck). +- Without that vocabulary you can't specify precisely, can't evaluate what comes back, can't tell good code from plausible-looking code, or notice when an anti-pattern is dressed as a solution. The AI is fluent; you still need to be literate. +- All architecture is the least-worst decision — every choice trades something away. +- All management is risk management — you manage the distribution of failures, not their existence. +- There is no such thing as a free lunch — every pattern has a cost; ask whether you can afford it. +- There is no problem which cannot be made worse — the wrong fix amplifies the original pain. + +p. 3 + +> Open the deck by naming the elephant. AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows. They produce code fluently. From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run. The risk is also new: a developer who doesn't have the vocabulary to evaluate what comes back is rolling dice in production. The patterns in this deck are not nostalgic computer-science trivia; they are the literacy that lets you direct AI tooling instead of being directed by it. Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing). The four maxims are the philosophical spine. ‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit. ‘Risk management’: you manage the distribution of failures, not their existence. ‘No free lunch’: every pattern (cache, saga, eventing, decomposition) has a cost — a stale read window, a compensation path, a coordination tax, an operational complexity. The question is whether you are buying the right thing for what you are paying. ‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap. The closing line is the audience take-home: AI is the amplifier; the patterns are the literacy that decides which direction it amplifies. + +## [005] SETTING UP · Three layers of patterns literacy (sldId 260) + +p. 4 + +> The visual companion to the three layers of patterns literacy. Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch). The arrows up the middle show how each lower layer informs the next: you can't reason about a saga (system layer) without knowing what a state machine and a compensation are (architecture / design layer); you can't reason about content-based routing without understanding the strategy pattern. The blue caption in the middle is the through-line: knowing this vocabulary is what lets you ask AI tooling for the right thing, recognise what it produces, and judge why it is or isn't fit. The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles. Make the audience comfortable with the maxims, especially the last one (no problem cannot be made worse) — it's the warning at the heart of using AI tooling well. + +## [006] THE RUNNING EXAMPLE · One system, carried end to end (sldId 261) + +We will design every concern against these five services and a gateway — the same example, re-examined from each angle. + +p. 5 + +> This is the single most important slide to anchor on — every later example refers back to it. Walk each service: order-service owns the order lifecycle and exposes REST at the edge; inventory, payment and shipping are internal, reached by gRPC and contributing to GraphQL reads; notification-service is Kafka-only, consuming order.placed. The graphql-gateway composes reads across them. Emphasize that each service exposes only the protocols that fit its role — not a uniform surface. Promise the audience we'll return to this picture a dozen times. + +## [007] Cloud-Native Principles (sldId 262) + +01 + +Properties first, tools second. + +> Transition into section one. The core message: cloud-native is not a stack you buy, it is a set of runtime properties your service must exhibit. Tools (Kubernetes, Istio, KEDA) exist to make those properties cheap to obtain — but if your service isn't written to lean on them, no platform can rescue it. Before the pillars themselves, one slide for the audience: a Rosetta-stone from the Windows world (IIS, WCF, COM+, MSMQ) to the Kubernetes world. Almost every pattern you knew has a direct cloud-native analog — the platform owns more, your code owns less. + +## [008] CLOUD-NATIVE PRINCIPLES · From the Windows world to Kubernetes (sldId 263) + +Almost every pattern you knew, from IIS and WCF to COM+ and MSMQ, maps onto a Kubernetes-native equivalent. Mostly a re-skin, not a rewrite of the concepts. + +p. 6 + +> This slide is the orientation gift to the audience. Most of you spent years on .NET Framework 4.x, hosting in IIS, talking via WCF, doing distributed transactions with MSDTC and COM+, queuing with MSMQ or SQL Server Service Broker, and configuring everything through web.config. Cloud-native is a re-platforming of those patterns, not a rejection: Kubernetes owns the host and the messaging substrate, and your code owns less infrastructure plumbing. Walk the audience through 4-5 rows they'll recognise: app pool → Pod, app pool recycle → graceful shutdown + rolling deploy, WCF service → gRPC or ASP.NET Core, COM+ distributed transactions → saga + outbox (which we'll cover deeply in Appendix D), MSMQ → Kafka. The line under the table is the architectural shift to make explicit: you stop owning the host and start owning the code, because the platform now provides the host as a primitive. These six pillars are the rubric Kubernetes enforces on you when you make that switch. + +## [009] CLOUD-NATIVE PRINCIPLES · Cloud-native is a set of runtime properties (sldId 264) + +If your API isn't disposable, observable, resilient, elastic, automatable and loosely coupled, the platform can't help it. + +p. 7 + +> Six pillars. Spend a sentence on each: disposable (12-factor, fast start, clean stop, no local state), observable (traces/metrics/logs, health probes), resilient (timeouts, retries, circuit breakers, graceful degradation), elastic (scale to load and to zero), automatable (declarative config, GitOps, self-healing), loosely coupled (contracts not calls, async where it fits). The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties. Tie back to the Windows-to-Kubernetes bridge: IIS gave you some of these (process recycle, health monitoring) but on a single Windows host; Kubernetes gives you all six, across many hosts, declaratively. + +## [010] CLOUD-NATIVE PRINCIPLES · The platform makes the properties the default (sldId 265) + +- Kubernetes gives you the disposable process model: liveness/readiness probes, rolling deploys, self-healing. +- KEDA (and the http-add-on) make elasticity real — scale on lag or RPS, all the way to zero. +- Istio supplies resilience and security at the wire: timeouts, retries, circuit breaking, mTLS. +- OpenTelemetry + Tempo / Mimir / Prometheus / Grafana / Kiali make every request observable. +- Your job is to write services that lean on these — not to reimplement them per service. + +p. 8 + +> The punchline is the bold line: your job is to lean on the platform, not reimplement it. Concretely — don't write your own retry library when Istio does it at the wire; don't build a bespoke autoscaler when KEDA scales on lag and RPS; don't hand-roll metrics plumbing when OpenTelemetry plus the LGTM stack gives it to you. Every line of resilience code you write per service is a line you maintain forever and that drifts between teams. The platform makes the good behaviour the default. + +## [011] CLOUD-NATIVE PRINCIPLES · Design-first: the contract precedes the code (sldId 266) + +- Agree the interface before implementing it — OpenAPI for REST, .proto for gRPC, AsyncAPI for events. +- The contract is a versioned artifact in the registry, gated in CI, owned by the producing domain. +- Consumers code against the contract and generated stubs, never against your database or internals. +- This is what makes services independently deployable — the promise the contract encodes is the boundary. + - Tooling: the dotnet CLI and NuGet (central package management via Directory.Packages.props) for reproducible builds; Microsoft.AspNetCore.OpenApi (or Swashbuckle) emits the OpenAPI spec; buf / protoc with Grpc.Tools for gRPC; HotChocolate exports the GraphQL SDL. + +p. 9 + +> Design-first is the cultural prerequisite for everything that follows. The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI. Consumers code against the contract and generated stubs, never against your database. Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary. If you skip this, you get distributed coupling that's worse than a monolith because it fails over the network. In the .NET world the contract artifacts come naturally: Microsoft.AspNetCore.OpenApi (built into .NET 9+) emits the REST spec from your controller and minimal-API endpoints, Grpc.Tools compiles the .proto into both server bases and client stubs at build time, and HotChocolate generates the GraphQL SDL from your code-first types (or loads .graphql files if you prefer schema-first) — all checked into the registry. + +## [012] CLOUD-NATIVE PRINCIPLES · The twelve-factor app, revisited (sldId 267) + +- The twelve-factor app (2011) is the original recipe for software that's disposable, scalable and portable — the discipline beneath the runtime properties we just saw. +- It predates Kubernetes but maps onto it almost one-to-one: most factors are now things the platform provides, not code you write. +- So read the factors by ownership — a few you still author (config from the environment, statelessness, clean start/stop, logs as streams) and the rest the platform hands you (scaling, build/release/run, attached backing services). +- In a microservices, event-driven system they get richer: “backing services” means Postgres, Kafka and the schema registry as swappable URLs; “logs” becomes correlated traces, metrics and logs. +- Treat it as a pre-flight check: “will this survive the platform?” The twelve factors are listed and mapped to where each lives in our architecture. + +p. 10 + +> Frame the twelve-factor app for the audience: it's the 2011 Heroku methodology that first codified what we now call cloud-native, and it's worth revisiting because it predates Kubernetes yet maps onto it almost perfectly. The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services). In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability. End by teeing up the list and the mapping diagrams. + +## [013] CLOUD-NATIVE PRINCIPLES · What the twelve factors are (sldId 268) + +The original 2011 checklist — most are now platform concerns; only a few are yours to write. + +p. 11 + +> A lot of people nod along to ‘twelve-factor’ without being able to name the twelve, so here they are at a glance, one line each. Don't read them aloud — let people scan. Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams). That sets up the map of each factor onto our architecture. + +## [014] CLOUD-NATIVE PRINCIPLES · Twelve factors, mapped to our system (sldId 269) + +Most factors are now platform defaults you adopt; a few are discipline you still write. + +p. 12 + +> This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building. Read it left to right: the twelve factors cluster into five things they ask of you, and each maps to a concrete piece of our stack and the section that covers it. Build & ship (codebase, dependencies, build/release/run, parity) becomes one repo, one image per release, the same image everywhere (§1). Configure becomes environment config (§1). Run on the platform (stateless processes, concurrency, disposability, port binding) becomes stateless pods, KEDA scaling, fast start/stop, and services behind Istio (§1, 2, 6). Attach resources becomes Postgres, Kafka and Apicurio as swappable URLs (§4–5). Operate (logs, admin processes) becomes OpenTelemetry into the LGTM stack and admin work as Jobs/CronJobs (§7, 11). The caption is the message: most of these are now defaults you adopt, not code you write — which is exactly what 'cloud-native' buys you. + +## [015] CLOUD-NATIVE PRINCIPLES · A 12-factor ASP.NET Core service, ready for the platform (sldId 270) + +[chip] csharp · .NET 10 + ASP.NET Core + Npgsql + +```csharp +// Program.cs (excerpt), examples/01-cloud-native-principles/dotnet +var builder = WebApplication.CreateBuilder(args); +// ... ConnectionStrings__Default from the environment (factor III) -> NpgsqlDataSource +var app = builder.Build(); +app.MapGet("/healthz", () => new { status = "ok" }); // liveness: the process is up +app.MapGet("/readyz", async (NpgsqlDataSource db) => // readiness: SELECT 1 +{ + try + { + await using var conn = await db.OpenConnectionAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT 1"; + await cmd.ExecuteScalarAsync(); + return Results.Ok(new { status = "ready", checks = new { database = "ok" } }); + } + catch // down: 503, out of rotation + { + return Results.Json(new { status = "down", checks = new { database = "unreachable" } }, + statusCode: StatusCodes.Status503ServiceUnavailable); + } +}); +``` + +Probes, env-config and a thin Program.cs are the price of admission for a Kubernetes Deployment. Same liveness/readiness split a WCF host had — just owned by the cluster now. + +p. 13 + +> Walk the code: ASP.NET Core 10 uses minimal hosting (Program.cs as a top-level script). Config flows from appsettings.json overridden by environment variables (factor III): ConnectionStrings__Default reaches GetConnectionString("Default"), the cloud-native equivalent of web.config transforms, except that the cluster injects the values through ConfigMaps and Secrets instead of an MSDeploy package. The two probes are mapped by hand in Example 01: /healthz is liveness (am I alive? if not, restart me, and Kubernetes kills the Pod) and never checks a dependency; /readyz is readiness (can I serve? if not, take me out of rotation but don't kill me): it opens a pooled Npgsql connection and runs SELECT 1, and when that fails it answers 503 with status down, which removes the Pod from the Service endpoints. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. Getting these wrong is a top cause of cascading restarts, the same way wrong IIS app-pool ping settings used to crash apps under load. AddHealthChecks with MapHealthChecks and the AspNetCore.HealthChecks.* community packages (MIT) are the framework route to the same pair; the example writes the handlers by hand so every language serves the same paths and bodies. The .NET runtime side: the service ships as a Linux container image built from a UBI Containerfile, not an MSI deployed to IIS. + +## [016] Communications (sldId 271) + +02 + +Match the interaction style to the call. + +> Section two. The thesis: there is no single right protocol — there is a right protocol per interaction. Resist the urge to standardize on one wire for everything. The section gives a decision rule and then shows each protocol in code against the running system. + +## [017] COMMUNICATIONS · Pick the protocol for the interaction (sldId 272) + +REST for reach, gRPC for internal speed, GraphQL for client-driven reads, async for decoupled facts. + +p. 14 + +> Four interaction styles, four defaults. REST for the public/edge surface where reach and caching matter; gRPC for internal service-to-service where latency and streaming matter; GraphQL for client-driven reads that compose many sources in one round trip; async events for decoupled facts that fan out. Most real systems use all four — the skill is matching each call to the right one. Note the sync/async chips at the bottom. This slide is the map for the whole section. + +## [018] COMMUNICATIONS · REST at the edge with ASP.NET Core (sldId 273) + +[chip] csharp · .NET 10 + ASP.NET Core minimal APIs + +```csharp +// Program.cs — minimal API style; FluentValidation for input contracts +var orders = app.MapGroup("/orders"); // Microsoft.AspNetCore.OpenApi documents it +orders.MapPost("/", async ( + [FromBody] OrderIn body, + IValidator validator, // FluentValidation + IOrderService svc, + IEventPublisher events) => +{ + var result = await validator.ValidateAsync(body); + if (!result.IsValid) return Results.ValidationProblem(result.ToDictionary()); + var order = await svc.CreateAsync(body); + await events.PublishAsync("order.placed", order); // made safe by the outbox + return Results.Created($"/orders/{order.Id}", order); // 201, not 200 +}); +orders.MapGet("/", (string? after, int limit, IOrderService svc) => + svc.Page(after, limit)); // cursor pagination, not offset +// request record: immutable, validated (types follow the top-level statements) +public record OrderIn( + [Required, StringLength(64)] string Sku, + [Range(1, 1_000)] int Quantity); +``` + +Resource-shaped URLs, FluentValidation contracts, correct status codes, cursor pagination — the WCF endpoint replacement for the outside world. + +p. 15 + +> This is the edge surface. For WCF refugees the mental shift: a Controller or minimal-API endpoint replaces an OperationContract; DataContract becomes a C# record with validation attributes; the binding (BasicHttp, NetTcp, MSMQ) becomes 'pick the right protocol per call' — here REST, gRPC and Kafka coexist instead of one endpoint multi-bound. Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs), not /GetOrder?id=123 like an .asmx service; (2) request contracts validated declaratively, with records carrying [Required] and [Range] or FluentValidation rules, so bad input never reaches your logic; (3) correct status codes: Results.Created (201) on create, Results.ValidationProblem (RFC 9457) for validation failures, not 200 with success=false in the body; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth. Microsoft.AspNetCore.OpenApi (built in since .NET 9) emits the OpenAPI spec automatically — the equivalent of WCF's WSDL but generated at build time, versioned in the registry, and gated in CI. NSwag and Swashbuckle (both MIT) are the alternatives if you need richer customisation. + +## [019] COMMUNICATIONS · gRPC for internal service-to-service calls (sldId 274) + +[chip] protobuf + csharp · .NET 10 + Grpc.AspNetCore + +```protobuf+csharp +// inventory.proto — the contract, registered in Apicurio +syntax = "proto3"; +service Inventory { + rpc ReserveStock (ReserveRequest) returns (ReserveReply); +} +message ReserveRequest { string sku = 1; int32 quantity = 2; } +message ReserveReply { bool reserved = 1; int32 remaining = 2; } +// Grpc.AspNetCore + Grpc.Tools compiles .proto to C# at build time +// (the cloud-native replacement for WCF NetTcpBinding service contracts) +public class InventoryService : Inventory.InventoryBase // generated base +{ + private readonly IStockRepository _stock; + public InventoryService(IStockRepository stock) => _stock = stock; + public override async Task ReserveStock( + ReserveRequest req, ServerCallContext ctx) + { + var r = await _stock.ReserveAsync(req.Sku, req.Quantity); + return new ReserveReply { Reserved = r.Ok, Remaining = r.Remaining }; + } +} +// Program.cs +builder.Services.AddGrpc(); // server registration +app.MapGrpcService(); // expose the service +``` + +Schema-first, binary, multiplexed over HTTP/2 — the WCF NetTcpBinding replacement for chatty internal paths. Test with ghz. + +p. 16 + +> gRPC for the internal paths. For WCF refugees this is the most familiar protocol in cloud-native: the .proto file is the service contract, exactly like a WCF [ServiceContract] interface, and the wire format is binary like NetTcpBinding. The Grpc.Tools NuGet package compiles the .proto with protoc at build time, generating both the C# message classes (similar to DataContracts) and an Inventory.InventoryBase abstract class — you derive from it and override the operations. Highlight that the field numbers (=1, =2) are the wire contract — we return to exactly why in Appendix B. Streaming RPCs (server, client, bidirectional) are first-class through IAsyncEnumerable. The right default for chatty service-to-service calls where you control both ends, and the natural migration target for internal WCF endpoints. Load-test it with ghz. (Optional: mention that for browser clients, gRPC-Web via Grpc.AspNetCore.Web exposes the same service over HTTP/1.1 with limitations.) + +## [020] COMMUNICATIONS · Synchronous coupling vs. asynchronous facts (sldId 275) + +A synchronous call shares fate; an event shares a fact. Reach for async wherever immediacy isn't required. + +p. 17 + +> The central trade-off of the whole talk in one picture. Top row: a synchronous call couples caller to callee in time and availability — if payment-service is down, the order request fails too, and availability multiplies downward across a chain. Bottom row: emitting order.placed as a fact lets the order succeed immediately; consumers catch up from the log if they were down. The rule: reach for async wherever immediacy isn't strictly required. This sets up the entire event-driven section later. + +## [021] COMMUNICATIONS · The mesh handles the wire, not your code (sldId 276) + +Istio sidecars give you mTLS, retries, timeouts and telemetry — uniformly, without per-service code. + +p. 18 + +> Service mesh in one picture. Your app speaks plain HTTP or gRPC to localhost; the Istio sidecar (Envoy) intercepts and adds mTLS, retries, timeouts and telemetry on the wire — uniformly, for every service, with zero application code. istiod is the control plane distributing policy and identity (SPIFFE). The key insight ties back to principles: resilience and security become platform defaults, not per-service reimplementations. The configuration itself is an Istio VirtualService. + +## [022] COMMUNICATIONS · Resilience as configuration, not per-call code (sldId 277) + +[chip] yaml · istio + +```yaml +# Istio: bounded retries and timeout for inventory-service +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: inventory } +spec: + hosts: [inventory] + http: + - timeout: 2s + retries: + attempts: 2 # bounded — never an unbounded retry storm + perTryTimeout: 800ms + retryOn: connect-failure,unavailable + route: + - destination: { host: inventory } +``` + +Bounded retries with backoff live in the mesh. Unbounded client retries are how an incident becomes an outage. + +p. 19 + +> Resilience as configuration. This VirtualService gives inventory a 2-second timeout, two retry attempts with an 800ms per-try timeout, retrying only on connect-failure and unavailable. The critical word is bounded — attempts: 2, not infinite. Tell the war story: unbounded client-side retries are how a brief blip becomes a full outage, because every caller piles on the struggling service (a retry storm). Putting this in the mesh means it's consistent across every service and tunable without redeploying code. + +## [023] Composition (sldId 278) + +03 + +Aggregate at the edge, not inside services. + +> Section three. The question: when a client needs data from several services, where does the joining happen? The answer that keeps services decoupled: at the edge, in a gateway or BFF — never by services calling each other in deep chains. That chaining is how you accidentally rebuild a monolith over the network. + +## [024] COMPOSITION · One query, many services (sldId 279) + +The gateway presents one schema and fans out to REST and gRPC back-ends, then joins and shapes the result. + +p. 20 + +> GraphQL federation against the running system. The gateway presents one schema to clients and fans out to REST and gRPC back-ends, then joins and shapes the result. The client makes one round trip and asks for exactly the fields it needs. The services stay unaware of each other; the gateway owns the composition. Contrast this with services calling services, which we'll warn against on the patterns slide. The resolver code shows how the gateway composes the calls. + +## [025] COMPOSITION · A composing resolver in HotChocolate (sldId 280) + +[chip] csharp · .NET 10 + HotChocolate + +```csharp +// HotChocolate (MIT, by ChilliCream) — the dominant open-source GraphQL server +// for .NET. Code-first or schema-first. Built-in DataLoader for N+1. +public class Query // the gateway's schema +{ + public async Task GetOrder(string id, // REST call to order-service + [Service] IOrderClient orders) => + await orders.GetAsync(id); +} +[ExtendObjectType] // adds fields to Order type +public class OrderExtensions +{ + // batched per-request to kill N+1: one gRPC call for N orders + public async Task GetStock( + [Parent] Order order, + IInventoryStockDataLoader loader) => // generated DataLoader + await loader.LoadAsync(order.Sku); +} +// Program.cs registration +builder.Services + .AddGraphQLServer() + .AddQueryType() + .AddTypeExtension() + .AddDataLoader(); +app.MapGraphQL(); // POST /graphql + IDE +``` + +The gateway owns aggregation; each field resolves against the owning service. DataLoader batches N orders into one gRPC call. + +p. 21 + +> The resolver makes federation concrete in HotChocolate. The top-level GetOrder Query calls order-service over REST; [ExtendObjectType] adds a stock field that resolves against inventory-service over gRPC. Each field resolves against its owning service — the gateway is just the composition layer. Mention the N+1 risk: naive per-field resolution can explode into many back-end calls — HotChocolate's DataLoader (in fact the same DataLoader pattern Facebook open-sourced for JavaScript) batches N orders into one upstream call automatically; the IInventoryStockDataLoader interface is generated by source-generators from your loader implementation. HotChocolate ships first-class Apollo Federation support if you go that route. The schema is exposed at POST /graphql plus an in-browser GraphQL IDE (Banana Cake Pop / Nitro) at /graphql in development. Where this lands for a .NET-from-Windows audience: think of it as the long-overdue replacement for SOAP composition or for hand-rolled aggregation endpoints — one wire, one schema, many backends. + +## [026] COMPOSITION · Three ways to compose — and where each belongs (sldId 281) + +Push aggregation to a gateway or BFF. Chaining calls inside services rebuilds the monolith over the network. + +p. 22 + +> Three composition patterns: an API gateway (one composed schema for everyone), a backend-for-frontend (a tailored aggregate per client type — web vs mobile), and GraphQL federation (a distributed graph). All three push aggregation outward to the edge. The anti-pattern to call out explicitly: service-to-service call chains, where order calls inventory calls pricing calls… — synchronous, deeply coupled, and impossible to reason about. That rebuilds the monolith with network latency between the pieces. + +## [027] COMPOSITION · Blazor Server as a line-of-business BFF (sldId 282) + +- Blazor Server is inherently BFF-shaped: a server-side .NET process holds the UI's view-model and pushes UI diffs to the browser over a SignalR channel — the client is thin. +- Inside that server-side process you can aggregate from REST, gRPC and Kafka back-ends with normal .NET code (typed HttpClient, gRPC client, Confluent.Kafka consumer) — no client-side fan-out, no CORS dance, no token-passing through the browser. +- Runs on Kubernetes as a regular ASP.NET Core Pod, with no managed hosting and no managed SignalR service. The SignalR backplane is open-source Redis (we cover the pinning + backplane story in Appendix C). +- When to choose it: internal LOB apps, admin consoles, dashboards — where you want server-rendered state, server-side auth, and a single trust boundary. Pair with Blazor WebAssembly for the offline / high-interactivity client surface (Appendix C). +- When not to choose it: SEO-critical public sites (use static rendering or SSR), or anything that must work without a persistent connection. Every Blazor Server interaction is a SignalR round-trip and inherits the WebSocket scaling concerns in Appendix C. + +p. 23 + +> The slide makes the BFF pattern from the previous diagram concrete in the .NET world. Blazor Server is more than a UI framework: architecturally it is a backend-for-frontend. The server-side process maintains the user's session-scoped component tree and pushes UI updates (Render Tree Diffs) to a thin JavaScript client over SignalR. That means everything happens server-side: authentication is server-side cookie-based, calls to inventory and pricing are server-to-server (typed HttpClient or gRPC stub), and the browser never sees a back-end URL or token. For LOB and admin tooling this collapses three problems at once (composition, auth boundary, real-time updates) into one server-side process. The Kubernetes posture is straightforward: containerized ASP.NET Core, plus Redis as the SignalR backplane (covered in Appendix C). What it avoids: a managed SignalR service and managed app hosting (Kubernetes is the deployment target); Blazor WASM is hosted as static files inside an nginx Pod or the ASP.NET Core process itself. The trade-off in the bold line is the real one: every interaction is a round-trip, every component instance is a per-user session living in a Pod, and scaling the Blazor Server pool has the same head-of-line / pinning / connection-count concerns we cover in Appendix C — don't pick it for traffic shapes where those bite. For high-interactivity client-side execution use Blazor WebAssembly, also covered in Appendix C. + +## [028] Data (sldId 283) + +04 + +Own your data; share it through contracts. + +> Section four, and the hardest discipline to hold. The rule: each service owns its data and shares it only through APIs and events — never through a shared database or by reaching into another service's tables. This is what makes independent deployment and independent scaling real. The slides cover ownership, the dual-write trap, and the saga. + +## [029] DATA · A database per service (sldId 284) + +Each service owns its schema and storage engine; no other service touches its tables directly. + +p. 24 + +> Database-per-service. Each service owns its schema and even its storage engine — order and payment on Postgres, something else on a document store if that fits. No other service touches those tables directly; the only doors in are the API and the events. The hard part is social, not technical: someone will always want to 'just join across two services' databases' for a report. That shortcut is the single most common way microservice independence dies. Reporting needs go through the metadata/lineage layer or a dedicated read model, not direct table access. + +## [030] DATA · CQRS — Command Query Responsibility Segregation (sldId 285) + +Separate the write model from the read model. They have different shapes, scales and even different stores. + +p. 25 + +> CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises for invariants and consistency: a normalised schema in a transactional store (Postgres, MySQL). The read side optimises for query shape: denormalised wide tables, an Elasticsearch index, a Mongo document, or whatever fits the question being asked. The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern. The cost: eventual consistency, a small lag between the write committing and the read model catching up. The benefit: each side can scale, schema-evolve and even be stored independently. Use this pattern when reads and writes have different shapes and access patterns. Avoid it when a single store handles both; CQRS adds operational complexity that isn't free. The classic motivating case: a service that takes thousands of writes per second but is read by analytics, dashboards and search — the write model serves the writes, the read model serves the rest. CQRS pairs naturally with event sourcing on the write side and event streaming for projections; the Event-Driven section draws both distinctions. + +## [031] DATA · CQRS in practice (sldId 286) + +[chip] csharp · ASP.NET Core 10 + EF Core + Confluent.Kafka + +```csharp +// Write side — the command handler commits, then emits the fact +[ApiController, Route("orders")] +public class OrderWriteController(WriteDb writes, IProducer producer) + : ControllerBase +{ + [HttpPost] + public async Task> Create(OrderIn body, CancellationToken ct) + { + var order = new Order(body); + writes.Orders.Add(order); + await writes.SaveChangesAsync(ct); // write model + await producer.ProduceAsync("order.placed", new Message + { Key = order.Id, Value = JsonSerializer.Serialize(order) }, ct); + return Ok(order); // the outbox makes this crash-safe + } +} +// Read side — a different, denormalised store (OrderReadController(ReadDb reads)) +[HttpGet("{id}/summary")] +public async Task> Summary(string id, CancellationToken ct) => + await reads.OrderSummaries.FindAsync([id], ct) is { } s ? Ok(s) : NotFound(); +// Projection — a Confluent.Kafka consumer, idempotent upsert into the read model +var cr = consumer.Consume(ct); +var summary = OrderSummary.From(JsonSerializer.Deserialize(cr.Message.Value)!); +await reads.UpsertAsync(summary, ct); // keyed by order id +consumer.Commit(cr); // ack after the write +``` + +p. 26 + +> The .NET shape with primary constructors. OrderWriteController handles writes through WriteDb, an EF Core context on the write database, then produces order.placed with Confluent.Kafka, keyed by the order id. The read side queries ReadDb, a separate context on a denormalised store: another Postgres, or a search index. +> +> The projection is a Confluent.Kafka consumer loop. It deserializes each event, upserts the summary row keyed by the order id, and commits the offset only after the write, so a crash means redelivery and the upsert keeps the replay idempotent. +> +> Producing right after SaveChangesAsync is a dual write; in production the outbox from the Data section closes that gap. Each side scales on its own load: the write side on commands, the read side on queries. + +## [032] DATA · Log tailing — the database commit log is the event stream (sldId 287) + +Debezium tails the WAL and publishes every committed row as a Kafka event. No dual-write. + +p. 27 + +> The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC. The application writes ordinary SQL. The database commits and the change lands in the write-ahead log (WAL on Postgres, redo log on Oracle, binlog on MySQL, transaction log on SQL Server). Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved. The application doesn't know Debezium exists. There is no dual-write — the commit is the single source of truth, and the event is a strictly downstream consequence. This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use. Debezium supports Postgres, MySQL, MariaDB, SQL Server, Oracle, MongoDB, Db2, Cassandra, and Vitess out of the box. Run mode: deploy as Kafka Connect workers, or use Debezium Server (standalone) if you don't want a Kafka Connect cluster. WAL = Write-Ahead Log: the database's commit-ordered durable log that already exists for replication and crash recovery; Debezium just subscribes to it. + +## [033] DATA · Dual-write is unsafe — use the outbox (sldId 288) + +Write state and the outbox row in one transaction; CDC relays the committed fact to Kafka. + +p. 28 + +> This is the slide that prevents a class of production bugs. The dual-write problem: if you write to your database and then publish to Kafka as two separate operations, a crash between them leaves you inconsistent — the order exists but the event was never sent, or vice versa. The fix is the transactional outbox: write the state change and an outbox row in one local transaction, then a change-data-capture tool (Debezium) tails the database log and relays the committed row to Kafka. The database commit is the single source of truth. The outbox write itself is one database transaction, shown in code. + +## [034] DATA · The outbox write — one transaction, no lost events (sldId 289) + +[chip] csharp · EF Core + Debezium CDC + +```csharp +// EF Core + Debezium CDC — the .NET reading of the outbox pattern. +// For WCF/COM+ refugees: this replaces the MSDTC two-phase commit you used +// to make 'save the order and queue the event' atomic across databases. +public class OrderService(OrdersDbContext db) : IOrderService +{ + public async Task CreateAsync(OrderIn body) + { + await using var tx = await db.Database.BeginTransactionAsync(); + var order = Order.From(body); + db.Orders.Add(order); // state change + db.Outbox.Add(new OutboxEntry( // same txn — outbox row + EventType: "order.placed", + AggregateType: "orders", + AggregateId: order.Id, + Payload: JsonSerializer.Serialize(order), + CreatedAt: DateTimeOffset.UtcNow)); + await db.SaveChangesAsync(); // single local commit + await tx.CommitAsync(); // Debezium tails WAL → Kafka + return order; + } +} +// No Kafka call on the request path: if the broker is down, the order still +// commits and the event ships when the Debezium connector catches up. +// Wolverine (MIT) ships an EF Core transactional outbox if you prefer a framework. +``` + +One local DB commit replaces MSDTC. Debezium tails the write-ahead log and turns the outbox row into a guaranteed, ordered Kafka event. + +p. 29 + +> For the WCF/COM+ audience this is the most important pattern. You used to write code like 'TransactionScope: save order, write to MSMQ, commit' — and MSDTC made it atomic across the SQL Server and MSMQ resource managers. Kubernetes has no MSDTC. You don't want one (two-phase commits across services don't scale and create coupling). Instead: write the state change and an outbox row in one local database transaction — atomic by definition because it's a single resource manager. There's no Kafka call inside the request path — if Kafka is down, the order still commits, and the event ships later when Debezium tails the Postgres WAL (or SQL Server CDC) and relays the outbox row in commit order. This decouples your request latency and availability from Kafka's, which COM+ never could. Two ways to wire it up in .NET: write the OutboxEntry entity manually as shown (Postgres/SQL Server + the Debezium Outbox Event Router single-message transform handles the unwrap on the connector side); or use a framework outbox such as Wolverine's (MIT) EF Core integration, which gives the same guarantees with framework-managed plumbing. Either way: this same commit-together discipline reappears in the saga appendix. + +## [035] DATA · Consistency without distributed transactions: the saga (sldId 290) + +A chain of local transactions; failures trigger compensating actions, never a cross-service rollback. + +p. 30 + +> The saga is how you get consistency across services without a distributed transaction (there is no two-phase commit here, and you don't want one). It's a sequence of local transactions, each emitting the event that triggers the next: order created, payment taken, stock reserved, shipment booked. When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order). Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics. + +## [036] Event-Driven (sldId 291) + +05 + +Emit facts; let consumers decide. + +> Section five — the asynchronous backbone we kept pointing at. The mental shift: a producer emits a fact about something that happened (order.placed) and does not know or care who consumes it. Consumers decide what to do. This inverts the dependency direction, and it is what decouples services in time and ownership. + +## [037] EVENT-DRIVEN · The event backbone — one fact, many reactions (sldId 292) + +order.placed fans out to consumers the producer never names. Add a consumer without touching the producer. + +p. 31 + +> order.placed fans out to payment, shipping and notification — and the producer names none of them. The power move: you can add a new consumer (say, a fraud-check service) without touching or redeploying the producer. Contrast with the synchronous world, where adding a dependency means changing the caller. This is the open/closed principle at the architecture level. Kafka (run by Strimzi on Kubernetes) is the backbone; Pulsar is an alternative with similar semantics. + +## [038] EVENT-DRIVEN · Producing and consuming with Confluent.Kafka (sldId 293) + +[chip] csharp · .NET 10 + Confluent.Kafka 2.16.0 + +```csharp +// Confluent.Kafka 2.16.0 — the low-level Kafka client (Apache 2.0). +// Auto-commit off, commit after the work: at-least-once processing. +// producer — order-service emits the fact +var producer = new ProducerBuilder( + new ProducerConfig { BootstrapServers = kafkaBootstrap }).Build(); +var json = JsonSerializer.Serialize(new { id, sku, quantity, status }); +await producer.ProduceAsync("order.placed", + new Message { Value = json }); // awaits the broker ack +// consumer — notification-service +var consumer = new ConsumerBuilder(new ConsumerConfig +{ + BootstrapServers = kafkaBootstrap, + GroupId = "notification-group", + AutoOffsetReset = AutoOffsetReset.Earliest, + EnableAutoCommit = false, +}).Build(); +consumer.Subscribe("order.placed"); +while (!ct.IsCancellationRequested) +{ + var cr = consumer.Consume(TimeSpan.FromSeconds(1)); + if (cr is null) continue; + await ProcessNotification(cr.Message.Value); // do the work + consumer.Commit(cr); // ack after — at-least-once +} +``` + +Confluent.Kafka keeps the Kafka semantics visible: keys, partitions, offsets. Commit after the work; handlers are idempotent. + +p. 32 + +> Confluent.Kafka is the .NET Kafka client for every Kafka path in this deck, pinned at 2.16.0. MassTransit was dropped: its abstraction hides the Kafka semantics this section teaches (keys, partitions, consumer groups, offsets), and version 9 moved to a commercial license. +> +> The producer emits order.placed and awaits the broker's delivery report. The consumer joins notification-group, starts from the earliest offset, and runs with auto-commit off. +> +> Two points to drill home. First, the offset is committed only after the work succeeds; a crash before the commit means redelivery, so delivery is at-least-once and handlers must be idempotent (dedupe by order id or an idempotency key). Exactly-once is mostly at-least-once plus idempotency. Second, the loop is yours: retries, dead-letter topics and shutdown are explicit code, shown in Appendix E and Appendix H. In the running example the loop runs in a plain console worker that PosixSignalRegistration cancels on SIGTERM. Apicurio holds the schemas. + +## [039] EVENT-DRIVEN · Schemas are the contract — the registry enforces them (sldId 294) + +Apicurio holds versioned schemas; compatibility is checked at publish time so breaking changes are rejected early. + +p. 33 + +> Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate. The schema (Avro, Protobuf or JSON Schema) lives in Apicurio. Producers and consumers fetch it; compatibility is checked at publish time so a breaking change is rejected before it ever reaches the topic. This is what makes adding a consumer safe. We come back to event versioning rules in Appendix B. + +## [040] EVENT-DRIVEN · Event sourcing vs. event streaming (sldId 295) + +Two patterns, often confused. Sourcing: the events are the state. Streaming: events are the transport. + +p. 34 + +> The vocabulary clarification most teams need before they go further with events. Event sourcing means events are the database — there is no 'current state' row anywhere; current state is computed by folding the entire event history. Adding a credit to an account isn't UPDATE balance SET balance = balance + 100; it's INSERT INTO events VALUES ('credit', 100), and reading the balance means summing every event for that account (with snapshots for performance). Strong fit for audit-heavy systems (banking, healthcare records, regulatory), temporal queries ("what was the balance on March 3rd?"), and any domain where the history is itself a primary asset. Tools: Marten (Postgres-backed, .NET), EventStoreDB, Axon (JVM). Event streaming means events are how facts move between services — each service still keeps its current state in a normal database, but emits events ("order.placed", "payment.processed") that other services subscribe to. The event is a transport mechanism, not a storage mechanism. Strong fit for integration, fan-out, decoupling. Tools: Kafka, Pulsar, NATS JetStream. These are not mutually exclusive. A common combination is full CQRS: the write side is event-sourced (so you have an audit trail of state changes), and the read-side projections are fed by streaming those events to consumers that build denormalised views. The acid test: is the event itself the source of truth (sourcing), or is it a propagated fact about a change that happened in a store (streaming)? In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core. + +## [041] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 296) + +Different jobs, often together: substrate (Kafka, Pulsar), compute (Flink), movement (NiFi, Debezium). + +p. 35 + +> Each of the four tools does a different job. Kafka is the substrate: a partitioned, ordered, durable log. Consumers track their own offsets and can replay. It's the de facto event spine for cloud-native architectures; Strimzi runs it natively on Kubernetes. Pulsar is also substrate, in the same broad category as Kafka but with native pub/sub plus queue semantics in the same product, tiered storage that pushes cold data to cheap object stores automatically, and multi-tenancy built into the broker. Pulsar has a Kafka-protocol shim so consumers can speak Kafka while writing to Pulsar. Flink is compute: not a transport layer, but a stateful stream-processing engine. Flink sits on top of Kafka or Pulsar, consumes streams, applies operators (windowed aggregations, joins, filters, stateful transformations) with exactly-once semantics via checkpointing, and produces new streams. Alternatives: Kafka Streams (lighter, JVM-only, embedded library), Spark Structured Streaming (heavier, batch-mindset). NiFi is movement: a visual dataflow / ETL tool with 200+ source-and-sink connectors and back-pressure built in. Think of it as the answer to 'I need to get data from SFTP to Kafka to an object store with these transforms in the middle' — you draw the flow in the UI rather than writing a pipeline. Debezium (covered in the Data section) is the CDC variant of movement. Together they compose: typical pipeline is source DB → Debezium or NiFi → Kafka or Pulsar → Flink for windowed aggregations → sink. The mistake is treating these as competitors. They complement each other; pick by the question your team is asking: durably hold events (substrate), compute over them (Flink), move them around (NiFi/Debezium). + +## [042] EVENT-DRIVEN · The log is the source of truth (sldId 297) + +Consumers track their own offset; replay is just rewinding. Kafka (Strimzi) and Pulsar are logs, not queues. + +p. 36 + +> The mental model correction most people need: Kafka is a log, not a queue. Messages are retained after they are read, and each consumer group tracks its own offset. That means replay is just rewinding an offset: a new consumer can process all of history, and a buggy consumer can be fixed and replayed. This is the foundation for event sourcing and for rebuilding stream-processing state, the subject of stream processing. Strimzi runs Kafka on Kubernetes; Pulsar offers similar log semantics with tiered storage. + +## [043] Stream Processing (sldId 298) + +06 + +Streams in, derived streams out. + +> Section six builds directly on the log. Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams. This is where real-time analytics, materialized views and complex event reactions live. + +## [044] STREAM PROCESSING · Derive new streams from streams (sldId 299) + +A streaming app is a topology of sources, stateful processors and sinks — joins, windows and aggregates. + +p. 37 + +> A stream-processing app is a topology: sources (input topics), stateful processors (filter, join, window, aggregate) and sinks (output topics or stores). The output of one topology is just another stream that other services consume. Emphasize stateful — unlike a simple consumer, these processors keep local state (running totals, window buffers) that must survive restarts. That state-management problem is what the next code slide and KEDA slide address. + +## [045] STREAM PROCESSING · A windowed aggregation with Streamiz.Kafka.Net (sldId 300) + +[chip] csharp · Streamiz.Kafka.Net 1.8.2 + +```csharp +// StreamProcessor/Program.cs (excerpt) — Streamiz.Kafka.Net 1.8.2 +var config = new StreamConfig +{ + ApplicationId = "stream-processor", // also the consumer group + BootstrapServers = kafkaBootstrap, + StateDir = "/tmp/kafka-streams", // local RocksDB, changelog-backed +}; +var builder = new StreamBuilder(); +builder.Stream("order.placed") + .Map((_, json, _) => Rekey(json)) // key by merchant_id + .Filter((merchant, _, _) => merchant.Length > 0) + .GroupByKey() + .WindowedBy(TumblingWindowOptions.Of(TimeSpan.FromSeconds(windowSeconds), TimeSpan.Zero)) + .Aggregate(() => new Acc(), + (merchant, amount, acc) => Add(merchant, acc, amount), // exact decimal total + RocksDbWindows.As("revenue-store") + .WithKeySerdes(new StringSerDes()) + .WithValueSerdes(new JsonSerDes())) + .ToStream() + // .Map(...): window_start, merchant_id, order_count, revenue rounded half-up + .To("revenue.by-merchant"); +await new KafkaStream(builder.Build(), config).StartAsync(cts.Token); +``` + +Local RocksDB + a Kafka changelog topic gives fault-tolerant aggregation — no JVM, no separate Flink cluster, just C# in a Pod. + +p. 38 + +> Streamiz.Kafka.Net 1.8.2 (MIT) is a .NET implementation of the Kafka Streams model: the same topology, state-store and changelog design, in native C#, with no JVM in the container. +> +> The processor in the running example keys each order by merchant, groups by key, and aggregates into tumbling windows (WINDOW_SECONDS, 10 s in the example) held in a RocksDB window store backed by a Kafka changelog topic. If the Pod dies, the state rebuilds by replaying the changelog. The aggregate keeps an exact decimal total and skips records whose amount does not parse; the output rounds half-up to two places, the same contract as the Spring Boot and Quarkus Kafka Streams processors. +> +> ApplicationId is also the consumer group, so KEDA can scale the processor on its lag. For this audience it replaces SQL Server Service Broker activation procedures or a hand-rolled Windows Service draining MSMQ. Alternatives if Streamiz doesn't fit: Apache Flink as a separate service fed by Kafka, Akka.NET Streams for in-process reactive streams, or Orleans Streams on the virtual-actor model. + +## [046] STREAM PROCESSING · KEDA scales workers on the signal that matters (sldId 301) + +Scale Kafka workers on consumer lag and HTTP services on request rate — both down to zero when idle. + +p. 39 + +> KEDA closes the elasticity loop. The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU. KEDA reads that lag and scales workers up, and down to zero when there's no lag. For synchronous HTTP services, the KEDA http-add-on scales on request concurrency/rate. One mechanism, two signals. Scale-to-zero is a real cost lever for spiky workloads. The KEDA configuration covers both: lag for events and RPS for HTTP. + +## [047] STREAM PROCESSING · Scale on lag (events) and on RPS (HTTP) (sldId 302) + +[chip] yaml · KEDA + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: { name: payment-consumer } +spec: + scaleTargetRef: { name: payment-consumer } + minReplicaCount: 0 + maxReplicaCount: 20 + triggers: + - type: kafka + metadata: { bootstrapServers: "cndp-kafka-bootstrap:9092", topic: order.placed, + consumerGroup: payment, lagThreshold: "100" } +--- +# HTTP: scale the ASP.NET Core service on concurrent requests (KEDA http-add-on) +apiVersion: http.keda.sh/v1alpha1 +kind: HTTPScaledObject +metadata: { name: order-service } +spec: + hosts: [orders.example.com] + scaleTargetRef: { name: order-service, service: order-service, port: 8080 } + replicas: { min: 0, max: 30 } +``` + +One mechanism, two signals: queue depth for async workers, concurrency for synchronous APIs. + +p. 40 + +> Two manifests, one idea. The ScaledObject scales payment-consumer on Kafka lag for the order.placed topic, with minReplicaCount: 0 — true scale to zero when the topic is quiet, springing back when lag builds. The HTTPScaledObject does the same for the synchronous order-service based on request load via the http-add-on. The lagThreshold of 100 is the tuning knob: how many messages of backlog before adding a worker. Point out that scale-to-zero plus a cold-start budget is the trade-off to discuss with the audience for latency-sensitive paths. + +## [048] Workflows and Jobs (sldId 303) + +07 + +Not every unit of work is a request. + +> Section seven. A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work. Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes. For orchestration in this stack we feature the Temporal .NET SDK for durable execution as the primary approach, with the Stateless library as a lighter-weight in-process alternative for state-driven flows. Dapr Workflows gets a brief mention for environments that prefer the sidecar pattern. Appendix D shows the primary pair (Temporal .NET + Stateless), side by side, against the saga from the data section. + +## [049] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 304) + +A coordinator that commands keeps the flow in one place; events that trigger each other decouple but hide the flow. + +p. 41 + +> The two ways to coordinate a multi-step process. Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service. Choreography: each service reacts to the previous one's event with no central brain — maximally decoupled, but the end-to-end flow is emergent and hard to see. Neither is universally right; choose by the selection criteria in the comparison. This connects straight to the saga discussion and Appendix D. + +## [050] WORKFLOWS AND JOBS · Choosing between the two (sldId 305) + +Orchestration + +- One coordinator owns the saga and its state +- Flow is explicit and centrally observable +- One place to reason about, change and audit +- Risk: the orchestrator becomes a god-service +- Good for complex, long-lived business processes + +Choreography + +- Each service reacts to the previous event +- Maximally decoupled; no central bottleneck +- Flow is emergent — harder to see end-to-end +- Risk: nobody owns the overall process +- Good for simple, additive event reactions + +p. 42 + +> The decision heuristic: use orchestration for complex, long-lived business processes where you need auditability and a clear owner (order fulfillment, loan approval). Use choreography for simple, additive reactions where decoupling matters more than visibility (send a welcome email when a user signs up). The failure modes are mirror images: orchestration risks a god-service; choreography risks nobody owning the process and no one able to answer 'where is order 42 stuck?'. Observability (section 11) is what makes choreography survivable. + +## [051] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 306) + +Long, batch or periodic work belongs in Jobs/CronJobs or a scaled queue worker — never on the request path. + +p. 43 + +> Where non-request work lives on Kubernetes. A Job runs a task to completion (a data migration, a nightly export). A CronJob schedules Jobs (the reconciliation that runs at 2am). A queue worker continuously drains a topic and is scaled by KEDA on lag. The anti-pattern: doing long or heavy work inside an HTTP handler, blocking the request and timing out the client. The rule of thumb on screen — long, batch or periodic work never belongs on the request path. Hand it to a Job or a worker and return immediately, ideally with a status URL the client can poll. In-app, BackgroundService with PeriodicTimer (or Quartz.NET for cron expressions, with DistributedLock for cluster-aware locking) covers recurring work in the same process; Kubernetes Jobs and CronJobs own the heavier or isolated batch runs; and KEDA's Cron scaler is the declarative way to wake a worker on a schedule — typically how Windows Scheduled Tasks become cloud-native. + +## [052] API Management (sldId 307) + +08 + +Treat the API surface as a platform. + +> Section eight zooms out from individual services to the API surface as a whole. Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability). The next three sections each take one of these in depth. + +## [053] API MANAGEMENT · Four cooperating planes (sldId 308) + +Gateway and mesh at runtime; registry for contracts; metadata for discovery; observability for signals. + +p. 44 + +> The four planes, and they map exactly to the rest of the platform sections. Runtime plane: the gateway and Istio mesh handle traffic, auth, rate-limiting and resilience. Contract plane: the registry (section 9) holds every API and event schema, versioned and gated. Discovery plane: metadata/catalog (section 10) lets people find and trust APIs. Signal plane: observability (section 11) tells you what is happening. The point: these are distinct responsibilities with distinct owners, not one monolithic 'API gateway product' that tries to do everything. + +## [054] API MANAGEMENT · Versioning and evolution are part of management (sldId 309) + +- Evolve additively — new optional fields and endpoints; never repurpose or remove without a new version. +- Carry a major version in the path (/v2) or media type; deprecate with headers and a sunset date. +- The registry's compatibility rules (BACKWARD / FORWARD / FULL) enforce this mechanically at publish time. +- Run old and new side by side; migrate consumers off the old contract before you retire it. +- Lifecycle: design → register → publish → consume → deprecate → retire — each step has an owner and a gate. + +p. 45 + +> Versioning is a management concern, not an afterthought. The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period. When you must break, carry a major version (in the path or media type) and run old and new side by side with a published sunset date, migrating consumers off before retiring. The registry's compatibility rules enforce the additive discipline mechanically at publish time. The bold lifecycle line is the takeaway: every contract moves design → register → publish → consume → deprecate → retire, and each transition has an owner and a gate. Appendix B drills into how REST, gRPC and GraphQL each handle this differently. + +## [055] API Registry (sldId 310) + +09 + +One versioned home for every contract. + +> Section nine. If contracts are the boundary, they need one authoritative home. The registry is where every OpenAPI spec, .proto, AsyncAPI doc and event schema lives — versioned, compatibility-checked, and gated in CI. This is the difference between 'we have contracts somewhere in git' and 'contracts are enforced before code ships'. + +## [056] API REGISTRY · Apicurio: contracts, versioned and gated (sldId 311) + +OpenAPI, AsyncAPI, proto and Avro/JSON schemas live in one registry with compatibility rules and CI gating. + +p. 46 + +> Apicurio is the concrete registry in this stack. Every contract type lands here: REST OpenAPI, GraphQL SDL, gRPC .proto, and event schemas (Avro/JSON). Each artifact has versions and a compatibility rule (BACKWARD, FORWARD, FULL). At publish time the registry checks the new version against the rule and rejects incompatible changes. Producers register; consumers fetch and generate stubs. The serde libraries for Kafka pull schemas from here by ID at runtime. A CI gate turns this into an automatic guardrail. + +## [057] API REGISTRY · Gate breaking changes in CI (sldId 312) + +[chip] yaml · CI + Apicurio v3 REST + +```yaml +# No Maven plugin for .NET: gate breaking changes through the Apicurio v3 REST API. +# dryRun=true runs the COMPATIBILITY rule server-side and stores nothing. +# .github/workflows/contracts.yml (runs on every PR; no app build needed) +jobs: + schema-compat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Gate the new Avro schema against the registry rule + env: + REGISTRY: http://apicurio.registry.svc/apis/registry/v3 + run: | + body=$(jq -n --rawfile s src/Schemas/order-placed.avsc \ + '{content: {content: $s, contentType: "application/json"}}') + code=$(curl -s -o resp.json -w '%{http_code}' -X POST \ + "$REGISTRY/groups/orders/artifacts/order-placed/versions?dryRun=true" \ + -H 'Content-Type: application/json' -d "$body") + # 400 with RuleViolationException: the COMPATIBILITY rule rejected it + [ "$code" = "200" ] || { echo 'Breaking schema change'; cat resp.json; exit 1; } +``` + +The dry-run fails the build on an incompatible change — the contract is enforced before code ships, not after. + +p. 47 + +> This is the guardrail that makes the whole contract story real, done the .NET way. A CI step posts the new schema to the Apicurio Registry v3 REST API as a dry-run create-version, which runs the artifact's compatibility rule server-side without persisting it — and fails the build (and the merge) if it's incompatible. The point to make: the contract is enforced before code ships, not discovered broken in production by a downstream team. The rule (e.g. BACKWARD — new consumers can read old data) is set once per artifact and enforced automatically forever; a separate (non-dry-run) POST publishes the version on the main branch after merge. Tie it back: this is design-first from section 1, now mechanized in the build. + +## [058] API Metadata (sldId 313) + +10 + +If they can't find it, it doesn't exist. + +> Section ten. A contract that's enforced but undiscoverable is half useless. Metadata is the discovery and trust layer: a searchable catalog of every API, topic and table, with ownership, lineage and quality signals. If a consumer can't find your API and trust it, they'll build their own or screen-scrape — and your boundary erodes. + +## [059] API METADATA · Discovery, lineage and ownership (sldId 314) + +OpenMetadata ingests from services, the registry and Kafka to build a searchable catalog with lineage. + +p. 48 + +> OpenMetadata is the catalog in this stack. It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog. The differentiator over a wiki is automated lineage: it knows that this topic feeds that table feeds this API, so it can answer 'if I change this field, who breaks?' before you change it. Ingestion is a scheduled connector, so the catalog stays current without manual upkeep. What consumers get from the catalog is the point of running it. + +## [060] API METADATA · What the catalog gives every consumer (sldId 315) + +- Discovery — search across APIs, topics and tables; find the owning team and how to get access. +- Trust — published SLOs, data-quality test results, freshness, and a tier/criticality rating. +- Lineage — column- and topic-level lineage answers 'if I change this, who breaks?' before you change it. +- Ownership & governance — tags, classifications (PII), and accountable owners attached to every asset. + - Ingestion runs as a scheduled connector against OpenAPI specs, the registry, Kafka and Postgres. + +p. 49 + +> Four concrete payoffs for consumers. Discovery: search across APIs, topics and tables, and find the owning team and how to get access — no more asking around in Slack. Trust: published SLOs, data-quality results, freshness and a criticality tier tell you whether to depend on it. Lineage: column- and topic-level impact analysis answers the change-safety question proactively. Ownership and governance: tags, PII classifications and accountable owners on every asset — which is also what your compliance team needs. The throughline of sections 9–10: a contract is only as good as your ability to find it, trust it, and know who owns it. + +## [061] Observability (sldId 316) + +11 + +Instrument once; correlate everything. + +> Section eleven, and the one that makes everything else operable. In a distributed system you cannot debug by reading one log file — a single user action touches five services. Observability is how you reconstruct that story. The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them. + +## [062] OBSERVABILITY · Three signals, one correlated story (sldId 317) + +OpenTelemetry emits traces, metrics and logs; the LGTM-style stack stores them and Grafana joins them. + +p. 50 + +> Three signals, each answering a different question. Traces: where did the time go and which service failed in this one request? Metrics: what are the aggregate rates, errors and latencies over time? Logs: what exactly happened in this component? OpenTelemetry is the vendor-neutral way to emit all three from your code. The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them. The magic is the shared trace_id linking a metric spike to the exact traces and logs behind it. The instrumentation comes first, then the trace. + +## [063] OBSERVABILITY · The signals in depth — traces, metrics, logs, baggage, profiles (sldId 318) + +Three classic pillars plus two that matter more every year. OpenTelemetry emits all five; one trace_id correlates them. + +p. 51 + +> Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer where the time went and which hop failed in one request. Metrics answer the trend question — aggregate rates, errors, latencies over time (the RED method: Rate, Errors, Duration; the USE method: Utilisation, Saturation, Errors). Logs answer what exactly happened in one component. Baggage is the signal most people miss — it's key-value context (tenant_id, user_tier, feature_flag) that propagates along the whole trace, so a value set at the edge is readable in a Kafka consumer three hops downstream. Profiles are the newest signal — continuous CPU / memory / lock sampling per service, answering which function is burning the CPU (Pyroscope, Parca, Grafana Phlare). The correlation bar at the bottom is the whole point: traces, metrics, logs, baggage and profiles all share trace_id + span_id + resource attributes (service.name, k8s.pod.name, deployment.version), so you can pivot — a metric spike leads to an exemplar trace, which leads to its logs, which leads to its CPU profile. OpenTelemetry is the vendor-neutral SDK and wire format for all five; traces/metrics/logs are GA, baggage is stable, profiling is the newest (the OTel profiling signal reached the ecosystem in 2024). + +## [064] OBSERVABILITY · What each signal answers — and when to reach for it (sldId 319) + +- Traces — 'where did the time go in this request, which hop failed?' Reach for it when debugging latency or a specific failed request. Stored in Tempo / Jaeger. +- Metrics — 'what is the aggregate rate / error / latency trend, is it getting worse?' Reach for it for dashboards, alerting, capacity. RED (Rate, Errors, Duration) for request-driven services; USE (Utilisation, Saturation, Errors) for resources. Stored in Prometheus / Mimir. +- Logs — 'what exactly happened in this component at this instant?' Reach for it for detail the trace doesn't carry. Structured (JSON), keyed by trace_id. Stored in Loki / ELK. +- Baggage — 'carry a value (tenant_id, user_tier) along the whole request so downstream services and their telemetry can use it.' Set once at the edge, readable everywhere downstream. W3C Baggage spec. +- Profiles — 'which function is burning CPU / allocating memory / holding locks, continuously, in production?' Reach for it for the latency the trace shows but doesn't explain. Pyroscope / Parca. + - The discipline: emit traces + metrics + logs always; add baggage when downstream needs request-scoped context; add profiling when you have CPU/memory mysteries a trace can't resolve. + +p. 52 + +> Walk each signal and the question it answers — the framing 'what question does this answer' is more useful to an engineer than 'what is this signal.' Traces are per-request; metrics are aggregate; logs are detail. The two newer ones deserve emphasis. Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant. Profiling is the frontier: continuous profilers (Pyroscope, Parca, Grafana Phlare) run in production at low overhead and answer the 'the trace says this span took 200ms but why' question by showing the actual call stack burning the CPU. The sub-bullet is the practical guidance on which to adopt when. + +## [065] OBSERVABILITY · Auto-injection vs. instrumentation — three distinct things (sldId 320) + +- Manual instrumentation — you write the spans. activitySource.StartActivity(...), activity.SetTag(...), activity.AddEvent(...). Needed for business operations the libraries can't know about ('reserve_stock', 'score_fraud'). The most signal per line, the most effort. +- Auto-instrumentation — a library / agent wraps known frameworks for you. HTTP servers and clients, gRPC, JDBC / database drivers, Kafka, Redis — each becomes spans with zero code change. Covers the plumbing; misses your domain. +- Auto-injection — the platform puts the auto-instrumentation agent into the pod for you. The OpenTelemetry Operator watches for an annotation (instrumentation.opentelemetry.io/inject) and mutates the pod at admission — no agent in your image, no Dockerfile change, no redeploy of the app to add tracing. +- How they layer: auto-injection delivers the agent → the agent provides auto-instrumentation of frameworks → you add manual spans for business logic on top. All three coexist in the same trace. + - Trade-off: auto-instrumentation is convenient but can be noisy (every framework call becomes a span) and slightly slower. Manual is precise but is work you own. Most teams: auto for the plumbing, manual for the domain, injection to roll it out fleet-wide. + +p. 53 + +> Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate. Manual: you call the tracer API yourself; necessary for domain operations. Auto-instrumentation: the OTel instrumentation libraries (or a language agent) wrap known frameworks: your HTTP handler, your DB driver, your Kafka client all emit spans without you touching them. Auto-injection: a Kubernetes-level mechanism; the OpenTelemetry Operator sees a pod annotation and injects the language agent + config as an init container / env vars at admission time, so you don't even put the agent in your image. Per-language specifics: Python: 'opentelemetry-instrument' wrapper or the operator injects it; instrumentation packages per library. Java/Quarkus: the OTel Java agent (-javaagent) auto-instruments everything; Quarkus also has build-time OTel extension. Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource. The bold line is the mental model: injection delivers the agent, the agent instruments frameworks, you add manual spans on top — they layer into one trace. + +## [066] OBSERVABILITY · Auto-instrument with OpenTelemetry .NET (sldId 321) + +[chip] csharp · .NET 10 + OpenTelemetry.NET + +```csharp +// Program.cs — OpenTelemetry .NET replaces Windows performance counters, +// ETW and the Event Log with one model. +builder.Services.AddOpenTelemetry() + .ConfigureResource(r => r.AddService("order-service")) + .WithTracing(t => t + .AddAspNetCoreInstrumentation() // every HTTP request → span + .AddSource("order-service") // your own ActivitySource + .AddOtlpExporter()) // endpoint and protocol from the env + .WithMetrics(m => m + .AddMeter("order-service") + .AddOtlpExporter()); +// compose: OTEL_EXPORTER_OTLP_ENDPOINT=http://lgtm:4318 +// OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +// custom business spans use the BCL ActivitySource (no extra dependency) +var activitySource = new ActivitySource("order-service"); +app.MapPost("/orders", async (HttpContext ctx) => +{ + // ...parse sku and quantity from the body + using var reserveSpan = activitySource.StartActivity("reserve-stock"); + reserveSpan?.SetTag("sku", sku); + var reply = await inventoryClient.ReserveStockAsync( // gRPC; HttpClient + new ReserveRequest { Sku = sku, Quantity = quantity }); // carries traceparent + // ...insert the order, produce order.placed: trace_id rides every hop +}); +``` + +ASP.NET Core requests become spans with no code; ActivitySource is the built-in API for your own spans. OTLP ships traces to Tempo, metrics to Mimir, logs to Loki. + +p. 54 + +> OpenTelemetry .NET implements the OTel spec on top of types the runtime already has: System.Diagnostics.ActivitySource is the span API, built into the BCL since .NET 6, so starting a span needs no extra dependency. +> +> This is the running example's setup. AddAspNetCoreInstrumentation turns every HTTP request into a span; AddSource registers the service's own ActivitySource; AddOtlpExporter reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_PROTOCOL from the environment, which compose sets to http://lgtm:4318 and http/protobuf. Add OpenTelemetry.Instrumentation.Http, GrpcNetClient or Runtime when you want outbound HTTP, gRPC client and GC or thread-pool signals as well. +> +> For business operations, declare one ActivitySource and wrap the work in using var span = source.StartActivity(...); the using stops the span when the scope exits. The gRPC call to inventory carries the W3C traceparent through HttpClient, so the inventory span lands in the same trace. For this audience this replaces Windows performance counters, ETW providers and the Event Log with one model that works the same in a container, in CI and on any cluster. + +## [067] OBSERVABILITY · How correlation works — context propagation (sldId 322) + +The trace context rides in-band with the request (W3C traceparent). The same trace_id at every hop is what stitches the story. + +p. 55 + +> Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling with the request, in-band, at every hop. The W3C traceparent header is the standard: version-traceid-parentspanid-flags. The trace_id (16 bytes) is constant across every hop of the request — that constancy is literally what stitches the distributed story into one trace. The span_id is new per operation, and each span records its parent span_id, which is how the backend rebuilds the tree. The flags byte carries the sampling decision (01 = sampled) so every hop honours the same decision. Alongside traceparent rides tracestate (vendor-specific) and baggage (your key-values). The transport differs per hop but the mechanism is identical: HTTP uses the traceparent header, gRPC uses metadata, Kafka uses message headers. The SDK injects the context on the way out and extracts it on the way in — automatically, which is why auto-instrumentation 'just works' across services. This propagated context is exactly why the same trace_id shows up on the trace waterfall, on every structured log line, and as an exemplar on your metrics — they all read it from the same in-band context. + +## [068] OBSERVABILITY · Baggage in practice — carry request context downstream (sldId 323) + +[chip] csharp · OpenTelemetry Baggage + Confluent.Kafka + +```csharp +using OpenTelemetry; // Baggage: the W3C baggage API +using OpenTelemetry.Context.Propagation; +// set at the edge in middleware; the propagator writes the baggage header +app.Use(async (ctx, next) => +{ + Baggage.SetBaggage("tenant_id", + ctx.Request.Headers["X-Tenant"].FirstOrDefault() ?? "unknown"); + await next(); +}); +// Confluent.Kafka has no built-in propagation: the producer injects the context +// into the record headers and the consumer extracts it +var parent = Propagators.DefaultTextMapPropagator.Extract(default, + cr.Message.Headers, ReadHeaders); // ReadHeaders: UTF-8 values for a key +Baggage.Current = parent.Baggage; +using var span = source.StartActivity("process_shipment", ActivityKind.Consumer, + parent.ActivityContext); +var tenant = Baggage.GetBaggage("tenant_id"); +span?.SetTag("tenant_id", tenant); // filterable in the trace UI +``` + +Baggage.SetBaggage at the edge; extract from the record headers and GetBaggage downstream. SetTag to filter traces. + +p. 56 + +> Baggage in .NET goes through OpenTelemetry's Baggage API: Baggage.SetBaggage at the edge, and the default propagator writes the W3C baggage header on every outbound HTTP and gRPC call. +> +> Kafka is different: Confluent.Kafka has no built-in tracing, so the producer injects the trace context and baggage into the record headers and the consumer extracts them, as examples/05-event-driven/dotnet does (OrderService injects, ShippingConsumer and NotificationConsumer extract). After the extract, Baggage.Current carries the values and the consumer span is a child of the producer span. +> +> Baggage propagates on the wire; span attributes do not, but they are recorded and searchable. The idiom is to read the baggage value and set it as a tag on the local span so traces filter by tenant. Baggage travels to every downstream hop, so never put secrets or PII in it. + +## [069] OBSERVABILITY · A single trace stitches the whole request (sldId 324) + +One trace_id propagates from the edge REST call through gRPC, Kafka and the consumer — five services, one story. + +p. 57 + +> This is what the instrumentation buys you, visually. One trace_id propagates from the edge REST POST through the gRPC ReserveStock call, onto the published order.placed event, and into the consumer that sends the notification. Five services, one readable waterfall. Point at the time axis: you can see exactly which span dominated latency. That same trace_id appears as an exemplar on your metrics and on every log line, so a latency alert is one click from the offending trace and its logs. This is what makes the choreographed, event-driven design from earlier sections debuggable. + +## [070] OBSERVABILITY · Sampling — head vs. tail (sldId 325) + +You can't store every trace at scale. Head decides at the start (cheap, blind); tail decides at the end (smart, costly). + +p. 58 + +> The cost-control reality: at scale you cannot store every trace, so you sample. The key question is when the keep/drop decision is made. Head sampling decides at the start, in the SDK, before the trace even completes — typically 'keep 1 in N' (probabilistic) or 'keep N per second' (rate-limiting). It's cheap and low-overhead, but it is blind at decision time: it can't keep a trace because it errored or was slow, because it decides before knowing. Tail sampling decides at the end, in the Collector, after the whole trace has been seen — the Collector buffers spans, waits for the trace to complete, then applies rules: keep all errors, keep all traces over 500ms, keep rare routes, sample the boring successful ones at 1%. Tail is smart but costs memory (buffering in-flight traces) and CPU (the Collector does real work). The strategies, named: probabilistic (keep X%), rate-limiting (cap per second), parent-based (follow the upstream's decision so a trace isn't half-sampled), and tail rules (error / latency / attribute based). The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate. Two rules to drill home: always sample by trace, not by span (a half-sampled trace is worse than no trace), and propagate the sampling flag (the 01 in traceparent) so every service agrees. Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy. + +## [071] OBSERVABILITY · Choosing a sampling strategy (sldId 326) + +- Probabilistic (head) — keep a fixed percentage (e.g. 10%). Simplest; predictable volume; blind to errors. Good baseline for high-traffic, low-stakes services. +- Rate-limiting (head) — keep at most N traces/sec per service. Protects your backend from traffic spikes; good when load is bursty. +- Parent-based (head) — honour the upstream service's sampling decision (carried in the traceparent flags). Essential in distributed systems: it stops a trace being sampled at service A but dropped at service B, which would give you broken half-traces. +- Tail-based (Collector) — buffer the whole trace, then keep by rule: all errors, all traces over a latency threshold, rare routes, plus a small % of the rest. The gold standard for keeping what matters, at the cost of Collector memory + CPU. +- Recommended production setup: parent-based head sampling at a modest baseline (1-10%) for representativeness, plus tail sampling at the Collector to guarantee every error and slow trace is kept. + - Don't sample metrics the way you sample traces (metrics aggregate everything). Keep logs in full with retention limits, or sample them on their own policy keyed by trace_id so a kept trace keeps its logs. + +p. 59 + +> The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-limiting is better when traffic is bursty because it caps absolute volume. Parent-based is non-negotiable in a distributed system: without it, service A samples a trace but service B independently drops it, and you get a broken trace with holes — worse than no trace. Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident. The cost is the Collector must buffer in-flight traces (memory) and evaluate rules (CPU), and it must see all spans of a trace (which constrains how you load-balance the Collector — use a trace-ID-aware load balancer). The recommended setup combines head (for a cheap representative baseline) and tail (for guaranteed capture of the interesting traces). The sub-bullet is the reminder: traces, metrics and logs are sampled differently — you never sample a metric counter, and logs typically follow their own retention or are sampled to match their parent trace. + +## [072] OBSERVABILITY · Prove it with simple tools before you ship (sldId 327) + +- curl / Postman — exercise REST contracts and inspect status codes, headers and pagination. +- hey — quick HTTP load to watch latency, error rate and the KEDA http-add-on scale the service. +- ghz — load-test gRPC endpoints and confirm timeouts and retries behave under pressure. +- GraphQL UI (GraphiQL) — explore the composed schema and watch the gateway fan out in Tempo. +- Kiali — read the live service-mesh topology, mTLS status and request flow during a load test. + +p. 60 + +> Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure. The GraphQL IDE (Nitro / Banana Cake Pop, which HotChocolate serves at /graphql in dev) explores the composed schema and, with tracing on, shows the gateway fanning out in Tempo. Kiali visualizes the live mesh: topology, mTLS status and real-time request flow during a load test. The message: prove the behaviour with simple tools before you ship, not after an incident. + +## [073] Security (sldId 328) + +12 + +Defence in depth, in layers. + +> Section 12 — security. The Anti-Patterns section that used to be 12 has shifted to 13. We organise this section around two ideas: the 4 C's of cloud-native security (concentric layers, each protecting what is inside it), and the seven patterns that you'll meet over and over in practice. The bias of this section: security is not a single product or a single team. It's a property of how the system is designed and run, owned at every layer. Each pattern here exists because no single layer is enough on its own. + +## [074] SECURITY · The 4 C's of cloud-native security (sldId 329) + +Cloud → Cluster → Container → Code. Concentric layers, each layer protects what is inside. + +p. 61 + +> The mental model from the CNCF security paper. Four concentric layers of responsibility, each enforcing controls appropriate to its scope. Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin. Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE. Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform. Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams. The point of the concentric metaphor: a weakness in any layer leaks into the inner ones. Strong code on a weak container is still vulnerable. Strong container on a weak cluster is still vulnerable. We design controls at all four. The 4 C's table lists each layer's controls and its owner. + +## [075] SECURITY · The 4 C's — what each layer enforces, and who owns it (sldId 330) + +- Cloud — IAM (principles of least privilege), VPC isolation, KMS for keys, audit logging, region & data-residency controls. Owner: platform / cloud admin. +- Cluster — Kubernetes RBAC, NetworkPolicy (default-deny + targeted allows), Pod Security Standards (restricted profile), admission control (OPA Gatekeeper / Kyverno), secret encryption at rest, etcd encryption. Owner: platform / SRE. +- Container — minimal base images (distroless, UBI-minimal), non-root users, read-only root filesystems, image signing (Sigstore / Cosign), vulnerability scanning (Trivy, Grype) on every build, secret mounting via projected volumes (never bake secrets into images). Owner: app team + platform. +- Code — secure defaults (parameterised queries, output encoding), input validation at the trust boundary, dependency scanning (Dependabot, Snyk, OSV-Scanner), secrets handling (no plaintext in source, no logging secrets), CSRF / SSRF / XXE awareness, threat modeling. Owner: app team. +- Cross-cutting: observability (audit who did what when), incident response runbooks, threat modeling per service, regular dependency updates. + - The pattern: a control at the wrong layer is the most common source of false confidence. Network ACLs cannot save you from an SQL injection. Code-level input validation cannot save you from a compromised image. Defence in depth is real. + +p. 62 + +> Walk each layer. The Cloud layer is where you control the perimeter and the identities and the keys; failures here often look like 'someone left a storage bucket open to the internet' or 'an over-permissive IAM role enabled lateral movement to another account.' The Cluster layer is the Kubernetes operating system for your workloads; failures look like 'the developer Pod could reach the database Pod' (no NetworkPolicy) or 'a privileged container was admitted' (no Pod Security Standards). The Container layer is about the image and runtime; failures look like 'a CVE in the base image' or 'the container ran as root and got out via a kernel exploit' or 'a secret was in the image.' Code is where most developer focus already lives. The sub-bullet is the take-home: a control at the wrong layer is false confidence. The pattern catalog holds seven patterns that show up across these four layers. + +## [076] SECURITY · Seven patterns to know (sldId 331) + +- Sidecar — move security concerns out of the app into a companion process. mTLS, JWT, audit, rate-limiting belong there. +- Valet Key — hand the client a short-lived signed token to access a resource directly. Less app proxying, less attack surface. +- Strangler Fig (for security modernisation) — encapsulate legacy auth behind an API gateway, replace pieces over time. +- Zero-Trust / Identity-Aware Proxy — every request authenticated and authorised. Perimeter trust is dead. +- Policy-as-Code — security gates declared in Git, evaluated at admission and at build. No human runs a checklist. +- Bulkhead — isolate resources so one failure (or compromise) cannot starve or cascade into others. +- Claim-Check — send a reference, not the payload. Keeps PII out of message bus history and limits the blast radius if a topic leaks. + - The seven appear in different layers and combine. Each slide that follows shows the pattern, then implementation — platform config where it's mostly YAML, code where it's mostly code. + +p. 63 + +> The roadmap for the next fourteen slides. These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation). You'll see each pattern with a diagram explaining how it works, then either a YAML configuration block (for the platform-heavy patterns) or language-specific code (for the code-heavy patterns). Use the sub-bullet to set audience expectation that the section alternates between pattern-and-implementation pairs. + +## [077] SECURITY · SIDECAR · Sidecar pattern — push security out of the app (sldId 332) + +mTLS, JWT validation, rate limiting, audit logging — a companion process in the same Pod handles them all. + +p. 64 + +> The sidecar pattern — a separate container running in the same Pod as the application, sharing localhost. The sidecar handles cross-cutting security concerns: terminates mTLS, validates JWTs, rate-limits, audits, enforces policy. The app receives only authenticated, authorised requests with trusted headers attached (X-Forwarded-Client-Cert, the validated JWT claims, the request ID). Why this matters: one security policy applied uniformly across every workload regardless of language. Same Envoy sidecar protects your Java app, your Python app, your Go app. App teams write business code, not crypto code. The sidecar is enforced by the platform (Istio injects them automatically) so it can't be disabled by accident. Examples in the field: Istio Envoy sidecars (the dominant choice), Linkerd2-proxy, OPA Sidecar for policy decisions, Cilium L7 with per-endpoint policy. + +## [078] SECURITY · SIDECAR · Reading the sidecar's trusted headers (sldId 333) + +- The sidecar handles mTLS, JWT validation, and policy decisions — the app trusts the headers the sidecar attaches and reads them as plain values. +- X-Forwarded-Client-Cert — the validated client identity from mTLS. Includes the SPIFFE ID (spiffe://cluster.local/ns/orders/sa/order-service) or the X.509 Subject. Set only by the sidecar; the app must reject requests that don't have it. +- JWT claims — the sidecar validates the signature, then passes the claims forward as a header or sets request.auth.claims via an envoy filter. The app reads sub, aud, scope, custom claims; it never re-validates the signature. +- Request ID — the sidecar attaches X-Request-Id for traceability. Log it; propagate it to downstream calls; correlate it with audit logs. +- Mode: only trust these headers when the sidecar is in front. In direct-to-Pod traffic (port-forward, debugging, mistakes), they could be spoofed. Use a NetworkPolicy to ensure traffic only enters via the sidecar's mTLS port. + - Examples: Istio Envoy sidecars (auto-injected), Linkerd2-proxy, OPA Sidecar (policy decisions), Cilium L7 policy. License: all Apache 2.0. + +p. 65 + +> The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches. The most common failure mode is an app that ignores X-Forwarded-Client-Cert and falls back to 'allow if you got here.' Make the headers required, not optional. The second-most-common failure mode is forgetting that traffic can sometimes bypass the sidecar (port-forward for debugging, host networking, direct pod-to-pod calls within the same node). NetworkPolicy is what closes that gap — only allow inbound traffic on the sidecar's mTLS port, never the app's container port directly. With both the sidecar and the NetworkPolicy, the app can fully trust the headers and become much simpler — no crypto code, no JWT parsing library, just header reads. + +## [079] SECURITY · VALET KEY · Valet key pattern — short-lived signed token, direct access (sldId 334) + +App mints a scoped, time-bound token; client uses it directly against the resource. App never proxies the bytes. + +p. 66 + +> The valet key pattern: instead of streaming a large file through your application, the application mints a scoped, time-bound, operation-restricted token (a 'valet key') and the client uses it to interact directly with the storage system. Pros: dramatically less data through the application (memory, bandwidth, latency); no long-lived credentials at the client; no app-side credential to leak; the token can expire and be revoked. Cons: requires the storage side to validate tokens; the token's permissions and TTL must be carefully scoped — a leaked token still has its window. Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape. The code shows the pattern in the service's language. + +## [080] SECURITY · VALET KEY · Valet key — scoped, time-bound, op-restricted (sldId 335) + +[chip] csharp · ASP.NET Core 10 + HMACSHA256 + +```csharp +// Program.cs (excerpt), examples/12-security/dotnet: an HMAC-SHA256 valet key +var valetSecret = Environment.GetEnvironmentVariable("VALET_SECRET") + ?? "demo-secret-do-not-use-in-prod"; +app.MapPost("/valet-key", (HttpContext ctx) => +{ + var resource = ctx.Request.Query["resource"].ToString(); + var operation = ctx.Request.Query["operation"].FirstOrDefault() ?? "GET"; + var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 300; // time-bound + var payload = $"{resource}:{operation}:{expires}"; // one object, one op + var sig = ComputeHmac(valetSecret, payload); + return Results.Ok(new { resource, operation, expires, token = sig }); +}); +// GET /verify-valet: 403 once expired, then a constant-time compare + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > expires) + return Results.Json(new { detail = "expired valet key" }, statusCode: 403); + var expected = ComputeHmac(valetSecret, $"{resource}:{operation}:{expires}"); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(token), Encoding.UTF8.GetBytes(expected))) + return Results.Json(new { detail = "invalid or expired valet key" }, statusCode: 403); +// ComputeHmac: HMACSHA256 keyed with the secret, Convert.ToHexStringLower of the hash +``` + +p. 67 + +> The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (HMACSHA256). POST /valet-key returns the resource, the operation, the expiry and the token. +> +> The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with CryptographicOperations.FixedTimeEquals, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403. +> +> The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth. + +## [081] SECURITY · STRANGLER FIG · Strangler Fig for security modernisation (sldId 336) + +- Use case: encapsulate legacy authentication (LDAP-only, HTTP Basic, hardcoded session cookies) behind a modern API gateway, then replace the legacy auth path one service at a time. +- Step 0: introduce the gateway in front of the monolith. Clients now hit the gateway, which forwards to the legacy auth. Day-zero behaviour unchanged. +- Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Dex), and translates to whatever the legacy expects; it speaks both languages. +- Step 2: per route, switch the gateway from 'forward legacy headers' to 'forward only the JWT claims to the new service'. Legacy auth is no longer in the path for that route. +- Step 3: when no routes still need legacy auth, remove it. +- Bounded blast radius: each route's auth migration is a config change in the gateway, reversible without a release. + - See Appendix K for the general strangler-fig family (identify → move → redirect, with proxy, with shared database, with content-based routing). Same mechanics, applied to auth instead of business logic. + +p. 68 + +> The strangler fig was covered as a general pattern in Appendix K — here it shows up specifically for security modernisation, which is a common need: most enterprises have legacy auth (LDAP-only, custom SSO, NTLM, hardcoded API keys) buried in critical systems, and a 'big bang' replacement is too risky. The gateway becomes the bilingual translator: it validates modern JWTs from your IdP at the edge and translates to whatever the legacy backend expects (X-Remote-User header, basic auth, NTLM, custom session cookie). Per route, you migrate when the new path is verified — if anything breaks, flip the gateway config back to forwarding legacy headers. The sub-bullet points to Appendix K for the full mechanics; this slide is the application of those mechanics to a specific security need. + +## [082] SECURITY · ZERO-TRUST · Zero-trust / Identity-Aware Proxy (sldId 337) + +Every request authenticated and authorised, on every hop. Perimeter trust is dead. SPIFFE inside, IAP at the edge. + +p. 69 + +> The model: there is no trusted network. Every request, from any source, to any service, on every hop — authenticated and authorised. The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance. The new model (bottom): the user authenticates to an IAP (Identity-Aware Proxy) which evaluates identity, device posture, location, MFA. The IAP then forwards to a policy engine (OPA, Cedar) which returns an allow/deny per-request. The downstream service still authenticates the caller (mTLS + SPIFFE ID) and re-authorises against policy. Inside the cluster: mTLS via SPIFFE identity (every workload has an X.509 cert identifying its service account), AuthorizationPolicy per workload restricting which other workloads can call which endpoints. A breach in one service gives you nothing because the next call still has to prove identity and pass policy. + +## [083] SECURITY · ZERO-TRUST · Implementing zero-trust (sldId 338) + +- Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — they terminate TLS, validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT the downstream service sees in X-Forwarded-* headers. +- Inside the cluster (mTLS): Istio + SPIFFE assigns every workload an X.509 cert identifying its service account (spiffe://cluster.local/ns/orders/sa/order-service). Every service-to-service call is mutually authenticated automatically. +- Authorization: Istio AuthorizationPolicy declares 'order-service can call payment-service /charge but nothing else, only when carrying a valid customer JWT.' Per-workload, fine-grained, declarative. +- Policy decisions: OPA / Cedar evaluate context (user, time, device, target, action) and return allow/deny. Out-of-process so policy can change without redeploying every service. +- App-side discipline: read the validated peer identity from X-Forwarded-Client-Cert; never accept calls that arrive without it; log every authorization decision. + - License notes: Istio, SPIFFE/SPIRE, OPA, Cedar — all open source (Apache 2.0 or similar). Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too. + +p. 70 + +> Concrete implementations to ground the model. The edge IAP is what your users hit — they authenticate against your IdP (Keycloak, or Dex federating an existing directory), the IAP issues a session, every request from then on carries identity. Inside the cluster, SPIFFE is the service-identity mechanism that makes mTLS work without manual cert management — it's what gives every Pod a verifiable identity. Istio's AuthorizationPolicy is the declarative layer on top: 'who can call what.' OPA / Cedar is the more flexible policy engine when your decision logic is complex (per-tenant, per-time-of-day, per-data-classification). The bold line is the app-side discipline: don't make these external policies redundant by accepting unauthenticated calls in your code; use the peer identity from the headers as a required input. + +## [084] SECURITY · POLICY-AS-CODE · Policy-as-Code — security gates in Git (sldId 339) + +OPA Gatekeeper / Kyverno enforce declarative policies at admission. CI runs the same policies pre-merge. + +p. 71 + +> Security policies as code: declarative, version-controlled, tested, applied automatically. The flow on the diagram: a developer commits manifests; CI runs conftest or Kyverno against the manifests pre-merge (catches violations before they reach the cluster); on `kubectl apply` (or ArgoCD / Flux), the Kubernetes API server calls the admission controller (OPA Gatekeeper / Kyverno) which evaluates the ConstraintTemplate / ClusterPolicy against the incoming object. Non-conformant resources are rejected — they never become running Pods. The policy file lives in Git, reviewed like code, applied by the cluster. No human runs a checklist; consistency is enforced uniformly. Example policies: images must come from approved registries, no privileged containers, no host network, no host path mounts, all containers have resource limits, all images are signed. + +## [085] SECURITY · POLICY-AS-CODE · OPA Gatekeeper ConstraintTemplate (Rego) (sldId 340) + +[chip] yaml · OPA Gatekeeper (language-neutral) + +```yaml +# ConstraintTemplate — reusable policy logic in Rego +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8srequiresignedimages +spec: + # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array) + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiresignedimages + violation[{"msg": msg}] { + container := input.review.object.spec.containers[_] + not startswith(container.image, input.parameters.allowedRegistries[_]) + msg := sprintf("image %v not from an allowed registry", [container.image]) + } +--- +# Constraint — applies the policy to namespaces +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sRequireSignedImages +metadata: { name: must-be-signed } +spec: + match: + kinds: [{apiGroups: [""], kinds: ["Pod"]}] + excludedNamespaces: ["kube-system", "gatekeeper-system"] + parameters: + allowedRegistries: ["registry.acme.com/", "quay.io/redhat/"] +``` + +p. 72 + +> The two-piece pattern: ConstraintTemplate defines the logic (in Rego, OPA's policy language), Constraint is the instance that applies the logic to specific resources with specific parameters. The Rego in violation[{msg}] reads as 'for every container in the Pod, if its image doesn't start with any of the allowed registries, emit a violation with this message.' Gatekeeper translates that into an admission webhook that rejects non-conformant Pods before they're scheduled. The Constraint binds the template to the kinds (Pods) and namespaces (everything except system namespaces), with the allowedRegistries list as the parameter. Lives in Git, applied via `kubectl apply` (or ArgoCD), versioned and reviewed like code. An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic. + +## [086] SECURITY · BULKHEAD · Bulkhead — isolate resources, bound the blast radius (sldId 341) + +Per-tenant / per-call / per-priority pools. One tenant's storm cannot starve others. One compromise cannot cascade. + +p. 73 + +> Bulkhead: borrowed from naval architecture, where watertight compartments stop one breach from sinking the ship. In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. The left side of the diagram (red) shows the default: one shared pool means a single tenant's traffic storm starves everyone else's. The right side: separate pools, each with its own bounded size. A storm from tenant A consumes tenant A's pool and rejects further A requests — tenants B and background workloads keep running. Why this matters for security as well as resilience: a compromised tenant or a compromised endpoint can be used to mount a denial-of-service against the shared system; bulkheads cap that to its own pool. Implementations: Resilience4j Bulkhead (JVM), Polly (.NET), asyncio.Semaphore (Python), Istio per-destination connection pools at the mesh layer. + +## [087] SECURITY · BULKHEAD · Per-tenant bulkheads in ASP.NET Core (sldId 342) + +[chip] csharp · ASP.NET Core 10 + SemaphoreSlim + +```csharp +// Per-tenant bulkhead with a SemaphoreSlim; A's storm can't drain B's capacity +public class TenantBulkhead +{ + private readonly ConcurrentDictionary _sems = new(); + public async Task HandleAsync(string tenant, Func> work) + { + var sem = _sems.GetOrAdd(tenant, _ => new SemaphoreSlim(5)); // 5 / tenant + await sem.WaitAsync(); // blocks only this tenant when full + try { return await work(); } + finally { sem.Release(); } + } +} +// Polly 8 alternative: ResiliencePipelineBuilder().AddConcurrencyLimiter(permitLimit, +// queueLimit) per tenant, from the Polly.RateLimiting package. +``` + +p. 74 + +> The bulkhead in its simplest .NET form: one SemaphoreSlim per tenant, created on first use. A tenant that floods the service fills only its own five slots and waits; other tenants keep their capacity. +> +> Polly 8 offers the same isolation as a pipeline strategy: AddConcurrencyLimiter (Polly.RateLimiting) with a permit limit and a queue limit, one pipeline per tenant, combined with a timeout so a slow downstream fails fast. Microsoft.Extensions.Http.Resilience can attach such pipelines to named HttpClients. +> +> At the platform layer, Istio connection-pool limits per destination complement these in-process limits. + +## [088] SECURITY · CLAIM-CHECK · Claim-check — send a reference, not the payload (sldId 343) + +Large or sensitive payload goes to a secure store; the message bus carries only an opaque token to fetch it. + +p. 75 + +> The claim-check pattern: a producer puts the (large or sensitive) payload into a secure object store and publishes only a claim ticket (an opaque token plus a fetch URI) to the message bus. The consumer receives the ticket, fetches the payload from the store with the token. Two big security wins: PII never sits in Kafka's topic history (which is replicated, retained, and possibly visible to operators and other tools); and the token can expire and be revoked, while a Kafka message cannot. The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security. Compare to the write-around caching pattern: same shape, different motivation. Where: any flow where sensitive payloads (health records, financial documents, customer PII) cross messaging infrastructure that shouldn't see them; any flow where payloads are large enough that the bus would be the bottleneck. + +## [089] SECURITY · CLAIM-CHECK · Producer mints claim, consumer fetches payload (sldId 344) + +[chip] csharp · ASP.NET Core 10 + Confluent.Kafka + +```csharp +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +var key = $"payloads/{orderId}"; +await store.PutAsync(key, largePayload, encrypt: true); // encrypted at rest, ACL'd +var expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 900; +var token = ComputeHmac(valetSecret, $"{key}:GET:{expires}"); // GET only, 15 minutes +await producer.ProduceAsync("order.documents", + new Message { Value = JsonSerializer.Serialize(new { + order_id = orderId, + uri = $"https://objects.internal/{key}", + resource = key, operation = "GET", expires, token, + }) }); // claim only: small, routable +// consumer: read the claim, fetch the body out of band +var claim = JsonSerializer.Deserialize(cr.Message.Value)!; +var body = await store.GetAsync(claim.Uri, claim.Token); // PII never touched the topic +consumer.Commit(cr); +``` + +p. 76 + +> Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's ComputeHmac (900 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with Confluent.Kafka's ProduceAsync. The PII is never on Kafka's wire. +> +> The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give. +> +> Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing. + +## [090] SECURITY · Choosing the right pattern (sldId 345) + +- Sidecar — when you want one security policy uniformly across every workload, regardless of language. Cost: an extra container per Pod (memory + startup time). +- Valet Key — when clients need direct access to a large or specific resource (file upload, image download). Cost: storage system must validate tokens (most do). +- Strangler Fig (auth) — when you have legacy authentication you can't change all at once. Cost: a gateway running translation logic during the transition. +- Zero-Trust / IAP — the default model for any system with users outside your immediate control. Cost: mTLS everywhere has real CPU and operational overhead. +- Policy-as-Code — always. Always. The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno. +- Bulkhead — any multi-tenant system, any system with mixed-priority workloads. Cost: pools are extra moving parts to size and tune. +- Claim-Check — when payloads carry PII or are large enough to bloat the message bus. Cost: an extra fetch hop and the store-and-bus must agree on token semantics. +- Most production systems combine all of these, not as a checklist but as a decision per concern. + +p. 77 + +> Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a few MB of memory, a couple hundred ms of startup); for most systems the security uniformity makes it worth that. Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry. Strangler fig (auth) is a deliberate transition strategy; the gateway running translation is the cost. Zero-trust / mTLS everywhere has real CPU overhead (TLS handshakes are not free) — mesh implementations like Istio amortise it well but it's not zero. Policy-as-code's cost is the upfront investment in writing policies; the ongoing cost is near zero. Bulkheads are extra moving parts to tune. Claim-check adds a hop. The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist. + +## [091] SECURITY · Take-aways and references (sldId 346) + +- Defence in depth across the 4 C's: cloud, cluster, container, code. A control at the wrong layer is false confidence. +- Identity is the new perimeter. mTLS + SPIFFE inside; IAP / OIDC at the edge. Every request authenticated and authorised, on every hop. +- Policy in Git, enforced by the cluster. OPA Gatekeeper or Kyverno at admission; conftest in CI for early feedback. +- PII never crosses your message bus in cleartext. Claim-check or end-to-end encrypt. +- Isolate by tenant, by call, by priority. Bulkheads bound the blast radius of both incidents and outages. + - References: NIST SP 800-204A (microservices security); CNCF Cloud Native Security Whitepaper; OWASP API Security Top 10; the OpenSSF SLSA framework; Istio AuthorizationPolicy docs; OPA Gatekeeper library; OWASP Microservices Security Cheat Sheet. + - Open-source pillars: Istio (Apache 2.0), SPIFFE/SPIRE (Apache 2.0), OPA / Gatekeeper (Apache 2.0), Kyverno (Apache 2.0), Sigstore (Apache 2.0), Falco (Apache 2.0), Trivy (Apache 2.0). + +p. 78 + +> Close with the take-aways the audience should remember. Defence in depth across the 4 C's is the philosophical anchor. Identity-as-perimeter is the operational anchor. Policy-in-Git is the automation anchor. The PII-never-in-cleartext and bulkhead lines are the specific applications. The reference list is what you'd recommend for further reading — NIST 800-204A is the canonical microservices security paper; CNCF's Cloud Native Security Whitepaper organises the same material; OWASP API Top 10 is the application-layer reference; SLSA covers supply-chain security (signing, provenance, build integrity). The open-source list emphasises that the entire security stack is buildable with Apache 2.0 components. + +## [092] Anti-Patterns (sldId 347) + +13 + +Most outages re-couple what you separated. + +> The final main section, and the most memorable framing of the whole talk: nearly every cloud-native failure is coupling sneaking back in. You did the work to separate things; entropy and deadlines pull them back together. This section names the specific ways that happens and gives heuristics to catch it early. + +## [093] ANTI-PATTERNS · How cloud-native API systems go wrong (sldId 348) + +Each anti-pattern reintroduces the coupling the architecture worked to remove. + +p. 79 + +> Walk the grid briefly — most of these are callbacks to earlier sections. Distributed monolith (services that must deploy together), shared database (two services on one table), chatty calls (deep synchronous chains), no contract/leaky internal models (DB rows as the public API), missing observability (debugging by guesswork), retry storms (no backoff). The unifying diagnosis is on screen: each one reintroduces the coupling the architecture worked to remove. If you only remember one slide for code review, it's this one. The anti-pattern heuristics turn these into positive rules. + +## [094] ANTI-PATTERNS · The heuristics that keep you out of them (sldId 349) + +- If two services must deploy together, they are one service — merge them or fix the contract. +- One writer per dataset; everyone else reads through the API or subscribes to its events. +- Push aggregation to a gateway/BFF; forbid service-to-service call chains deeper than one hop. +- No contract in the registry, no merge — and every retry has a timeout and a bound. +- If you can't trace it, you can't run it: observability is a launch requirement, not a follow-up. + +p. 80 + +> These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or fix the contract. (2) One writer per dataset; everyone else reads through the API or subscribes to events — no shared tables. (3) Push aggregation to a gateway or BFF and forbid synchronous call chains deeper than one hop. (4) No contract in the registry, no merge — and every retry has a timeout and a bound. (5) If you can't trace it, you can't run it — observability is a launch requirement, not a follow-up. Offer these as a checklist the team can literally paste into a PR template. + +## [095] IN CLOSING · The whole talk, in five lines (sldId 350) + +- Cloud-native is a set of properties; the platform exists to make them the default. +- Match each interaction to its style (REST, gRPC, GraphQL or async) and compose at the edge. +- Own your data; share it as contracts and facts, never as shared tables. +- Make the contract a governed artifact: registry to enforce it, metadata to find it, observability to run it. +- Every anti-pattern is coupling sneaking back in. Design to keep things apart. + +p. 81 + +> The recap — read these five as the entire talk compressed. If the audience remembers nothing else: cloud-native is properties not tools; match the protocol to the interaction and compose at the edge; own your data and share it as contracts and facts; make the contract a governed artifact (registry, metadata, observability); and watch for coupling sneaking back in. Pause here before References and Q&A. If it's the long-form session, this is the natural point to say 'and now the appendices go deeper on four things people always ask about' and continue. + +## [096] REFERENCES · The shoulders this stands on (sldId 351) + +- Cloud-Native Patterns and Architecture +- Building Microservices, 2nd ed. — Sam Newman (O'Reilly) +- Designing Data-Intensive Applications, 2nd ed. — Martin Kleppmann & Chris Riccomini (O'Reilly) +- Building Event-Driven Microservices — Adam Bellemare (O'Reilly) +- Cloud Native Patterns — Cornelia Davis (Manning) +- Patterns of Distributed Systems — Unmesh Joshi (Addison-Wesley) +- Architecture Modernization — Nick Tune & Jean-Georges Perrin (Manning) +- Cloud Native Anti-Patterns — Gerald Bachlmayr +- Principles of Web API Design — James Higginbotham (Addison-Wesley) +- Patterns for API Design — Olaf Zimmermann et al. (Addison-Wesley) +- API Design Patterns — JJ Geewax (Manning) +- Learning Domain-Driven Design — Vlad Khononov (O'Reilly) +- Balancing Coupling in Software Design — Vlad Khononov (Addison-Wesley) +- ASP.NET Core 10 reference (learn.microsoft.com/aspnet/core), OpenTelemetry .NET, Temporal .NET SDK (docs.temporal.io/dev-guide/dotnet), Confluent.Kafka, HotChocolate, Streamiz.Kafka.Net, EF Core and Debezium docs — for the runtime specifics + +p. 82 + +> The reading list this talk stands on. Call out a couple of pairings: Newman's Building Microservices for the boundaries and integration story; Kleppmann's Designing Data-Intensive Applications for the data and consistency foundations behind the outbox and saga; Bellemare for the event-driven backbone; and the API-design trio (Higginbotham, Zimmermann et al., Geewax) for contract and versioning depth. The architecture books are language-agnostic — they apply whether you build on .NET, Java, Go, or anything else. The ASP.NET Core reference docs, OpenTelemetry .NET guides, Temporal .NET SDK docs and Debezium docs are the practical references for the runtime specifics shown in the code. The slides and these references are enough to build the system, not only to discuss it. + +## [097] Thank you (sldId 352) + +Questions? + +> End of the main talk. Thank the audience and open for questions. The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session. They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation. + +## [098] REFERENCE · Glossary of acronyms (1 of 2) — protocols, data, events (sldId 353) + +- Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface). +- Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue, where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe). +- Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log, the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used, a cache eviction policy); SETEX (the Redis "SET with EXpiry" command, which sets a key and its TTL atomically); ORM (Object-Relational Mapping); EF Core (Entity Framework Core, .NET's ORM); LINQ (Language-Integrated Query). +- Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service: a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD, which translates between models; ACL also means Access Control List in security); Saga (not an acronym: a sequence of local transactions with compensating actions). + +p. 83 + +> Glossary part 1, the protocol/data/event vocabulary. Call out the ones most likely to trip people up: gRPC is recursively 'gRPC Remote Procedure Call' but practically it's Google's high-performance RPC over HTTP/2 with Protocol Buffers. mTLS (mutual TLS) is the one people often gloss — ordinary TLS authenticates the server to the client; mutual TLS also authenticates the client to the server, which is the basis of zero-trust service identity. The data row has the SETEX and TTL the audience asked about explicitly: TTL is the expiry duration on a cached key; SETEX is the Redis command that sets a value and its TTL in one atomic operation (SETEX key seconds value). LRU is the eviction policy that drops the least-recently-touched key when the cache is full. CDC and WAL go together — CDC tails the WAL. Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List. + +## [099] REFERENCE · Glossary of acronyms (2 of 2) — platform, security, telemetry (sldId 354) + +- Kubernetes & cloud — K8s (Kubernetes); RBAC (Role-Based Access Control); HPA (Horizontal Pod Autoscaler); KEDA (Kubernetes Event-Driven Autoscaling); PSS (Pod Security Standards); CRD (Custom Resource Definition); IAM (Identity & Access Management); KMS (Key Management Service); VPC (Virtual Private Cloud); CNI (Container Network Interface). +- Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials). +- Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency). +- Runtime & build — CLR (Common Language Runtime, .NET's execution engine); BCL (Base Class Library); JIT (Just-In-Time compilation); AOT (Ahead-Of-Time / Native AOT compilation); GC (Garbage Collection); DI (Dependency Injection); DTO (Data Transfer Object); LINQ (Language-Integrated Query). + +p. 84 + +> Glossary part 2, the platform/security/observability vocabulary. The Kubernetes row: HPA scales on CPU/memory, KEDA scales on event sources (queue depth, Kafka lag, HTTP rate) — the distinction matters in the autoscaling discussion. PSS (Pod Security Standards) replaced the deprecated PodSecurityPolicy. The security row has the terms from section 12: SPIFFE/SPIRE give workloads cryptographic identities for mTLS; OPA is the policy engine behind policy-as-code; IAP is the zero-trust edge proxy; JWT/OIDC/OAuth are the token/identity standards; SLSA/CVE/SBOM are supply-chain security. The observability row has RED and USE, the two metrics methods worth memorising: RED (Rate, Errors, Duration) for services, USE (Utilisation, Saturation, Errors) for resources. p99 means the 99th-percentile latency — 99% of requests are faster than this; it's what you alert on, not the average. The runtime row is tailored to this deck's language. + +## [100] Appendix A · Protocols in Depth (sldId 355) + +A + +REST, gRPC and GraphQL: how they differ. + +> Appendix A. This is the deep dive behind the 'pick the protocol' slide from section 2. We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire. By the end the audience should be able to defend a protocol choice on technical grounds, not fashion. + +## [101] APPENDIX A · PROTOCOLS · Three paradigms and their mental models (sldId 356) + +REST models resources, gRPC models procedures, GraphQL models a typed graph — the same intent, three shapes. + +p. 85 + +> Start with the mental model, because it drives everything else. REST is resource-oriented: nouns and HTTP verbs, you transfer representations of state. gRPC is procedure-oriented: you call typed methods, it's behaviour not state, and the .proto is the contract. GraphQL is graph-oriented: one typed schema the client traverses, picking exactly the fields it wants. The same place_order intent takes three different shapes. Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction. + +## [102] APPENDIX A · PROTOCOLS · Over-fetching, under-fetching and round trips (sldId 357) + +REST trades round trips or bespoke endpoints for simplicity; GraphQL shapes one response; gRPC is a typed call. + +p. 86 + +> This is the practical difference clients feel. With REST you often need several round trips (order, then its items, then stock, then shipping) or you build a bespoke aggregate endpoint — and you still get fixed payload shapes that over- or under-fetch. GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three. The trade: GraphQL gives clients flexibility at the cost of gateway complexity; gRPC gives speed at the cost of rigidity; REST gives reach and simplicity at the cost of round trips. + +## [103] APPENDIX A · PROTOCOLS · The wire: transport, encoding and streaming (sldId 358) + +Reach and caching, or raw speed, or client-shaped responses — choose on the wire properties you need. + +p. 87 + +> What travels on the wire, and why it constrains your choice. REST: HTTP/1.1 or /2, JSON text, request/response, cacheable via standard HTTP, browser-native — maximum reach. gRPC: HTTP/2 only, protobuf binary, full uni- and bidirectional streaming, no HTTP caching, and it needs grpc-web to work from a browser. GraphQL: HTTP POST to a single URL, JSON, request/response plus subscriptions, caching is application-level (not HTTP), browser-native. The decision rule: if you need broad reach and HTTP caching, REST; if you need raw speed and streaming between services you control, gRPC; if you need client-shaped responses over many sources, GraphQL. + +## [104] APPENDIX A · PROTOCOLS · Choosing between them in practice (sldId 359) + +- Public or partner-facing, browser or third-party clients, caching matters → REST. +- Internal service-to-service, low latency, high call volume, streaming → gRPC. +- Aggregating many back-ends for varied clients that each want different fields → GraphQL at the gateway. +- A fact that many consumers react to, decoupled in time → none of these; emit an event. +- Real systems use all four; standardizing on one wire for everything is itself an anti-pattern. + +p. 88 + +> The decision guide. Public/partner/browser with caching → REST. Internal, hot, streaming → gRPC. Client-driven aggregation across back-ends → GraphQL at the gateway. A fact many consumers react to → don't make it a call at all, emit an event. The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done. + +## [105] APPENDIX A · PROTOCOLS · Common mistakes with each (sldId 360) + +- REST: verbs in URLs (/createOrder), 200-for-everything, offset pagination, and ignoring cache headers. +- gRPC: exposing it directly to browsers without grpc-web; treating it as REST-with-protobuf and ignoring streaming. +- GraphQL: the N+1 resolver explosion; no query depth/complexity limits; using it for simple single-resource reads. +- All three: leaking internal database models straight onto the wire, so you can never evolve them. +- Async: using a queue when you needed a log (no replay), or a request when you needed a fact. + +p. 89 + +> The failure modes, drawn from real review comments. REST: verbs in URLs, returning 200 with an error body, offset pagination that breaks under load, and throwing away free HTTP caching. gRPC: trying to call it straight from a browser without grpc-web, or using it as 'REST with protobuf' and never touching its streaming superpower. GraphQL: the classic N+1 resolver explosion (fix with DataLoader batching), no depth or complexity limits so a malicious query melts the gateway, and reaching for it when a plain GET would do. The universal one is the last bold-adjacent: leaking your database schema onto the wire kills your ability to evolve — the same leaky-internal-models anti-pattern from section 12. + +## [106] Appendix B · Versioning & Evolution (sldId 361) + +B + +REST vs RESTful, proto breaking changes, GraphQL evolution. + +> Appendix B answers the question every team fights about: how do we version this without breaking everyone? The answer differs sharply by protocol. REST has several strategies with real trade-offs; gRPC has precise wire-compatibility rules; GraphQL rejects versioning entirely in favour of evolution. We'll see each, with code. + +## [107] APPENDIX B · VERSIONING · Four ways to version a REST API (sldId 362) + +URI versioning is common and visible; media-type versioning keeps URLs stable — the more RESTful choice. + +p. 90 + +> REST has four strategies, ordered from coarse to fine. URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation. Query param: simple to default but muddies caches. Custom header: keeps URLs clean but is invisible and often forgotten. Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach. In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct. Know which you're choosing and why. The media-type versioning code shows how. + +## [108] APPENDIX B · VERSIONING · Media-type versioning with ASP.NET Core (sldId 363) + +[chip] csharp · .NET 10 + Asp.Versioning + +```csharp +// Asp.Versioning.Http (MIT, .NET Foundation): media-type, URI segment, +// query string or header versioning, picked centrally. +builder.Services.AddApiVersioning(o => +{ + o.ApiVersionReader = new MediaTypeApiVersionReaderBuilder() + .Template("application/vnd.acme.order.v{version}+json") // Accept header + .Build(); + o.DefaultApiVersion = new ApiVersion(1, 0); + o.AssumeDefaultVersionWhenUnspecified = true; +}); +// one resource URL, /orders/{id}; the representation is versioned +var orders = app.NewVersionedApi("Orders").MapGroup("/orders") + .HasApiVersion(1, 0).HasApiVersion(2, 0); +orders.MapGet("/{id}", (string id, IOrderService svc) => OrderV2.From(svc.Get(id))) + .MapToApiVersion(2, 0); +orders.MapGet("/{id}", (string id, IOrderService svc) => OrderV1.From(svc.Get(id))) + .MapToApiVersion(1, 0); // the default for old clients +public record OrderV1(string Id, double Total); +public record OrderV2(string Id, double Total, string Currency); +``` + +The resource URL is stable; the representation is negotiated. New clients ask for v2; old clients keep getting v1. + +p. 91 + +> ASP.NET Core versioning lives in Asp.Versioning.Http (.NET Foundation). The reader is pluggable: MediaTypeApiVersionReaderBuilder with a template reads the version from the Accept media type (application/vnd.acme.order.v2+json), the most RESTful choice; UrlSegment, QueryString and Header readers swap in without changing handlers. +> +> There is one URL for the order resource, and it never changes. NewVersionedApi declares the versions the group supports, and MapToApiVersion binds each handler to one. New clients ask for the v2 media type and get the currency field; old clients send the default and get v1, unaffected. Contrast with /v2/orders, which gives one resource two URLs. +> +> OrderV1 and OrderV2 are explicit representation DTOs: the wire contract stays decoupled from the domain entity, a discipline WCF DataContracts taught, applied to HTTP. + +## [109] APPENDIX B · VERSIONING · gRPC / protobuf: what is a breaking change (sldId 364) + +Field numbers are the contract — names and order are not. Never reuse or renumber a field. + +p. 92 + +> gRPC's rules are precise because the wire format is positional. Safe, wire-compatible changes: add a field with a new number, add an rpc method, add an enum value, rename a field (the number is what matters, not the name), mark fields deprecated, and reserve removed tags. Breaking changes: change a field's number or type, reuse a removed number, remove a field without reserving its tag, rename or move a service or package, or change the request/response message type. The one people miss is on screen at the bottom: changing a method between unary and any streaming mode changes the HTTP/2 framing and is always breaking. The discipline: field numbers are forever; never reuse or renumber. Code shows safe evolution next. + +## [110] APPENDIX B · VERSIONING · Evolving a .proto safely (sldId 365) + +[chip] protobuf + +```protobuf +syntax = "proto3"; +message Order { + string id = 1; + string sku = 2; + int32 qty = 3; + reserved 4; // tag 4 once held 'notes' — never reuse it + reserved "notes"; + string currency = 5; // safe: brand-new field, new tag number + PaymentState state = 6; // safe: new field; old clients ignore it +} +enum PaymentState { PENDING = 0; PAID = 1; REFUNDED = 2; } // 0 must exist +// breaking (don't): qty -> int64, or moving 'sku' to tag 7, or reusing tag 4 +``` + +Add with new tags, reserve what you remove, and never change an existing field's number or type. + +p. 93 + +> Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire. We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work. Note the enum must keep a zero value as its default. The comment at the bottom lists the things that would break the wire: widening qty's type, moving an existing field to a new tag, or reusing a reserved tag. Apicurio's compatibility check (section 9) enforces exactly these rules in CI. + +## [111] APPENDIX B · VERSIONING · GraphQL versions by evolution, not by number (sldId 366) + +There is no /v2 in GraphQL: add fields, mark old ones @deprecated, watch usage, then retire. + +p. 94 + +> GraphQL takes a completely different stance: there are no versions. There's one schema that evolves continuously. The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero. The insight: GraphQL pushes you toward data-driven deprecation instead of big-bang version cutovers. Code shows the @deprecated directive next. + +## [112] APPENDIX B · VERSIONING · Deprecating a field in HotChocolate (sldId 367) + +[chip] csharp + graphql · .NET 10 + HotChocolate + +```csharp+graphql +// HotChocolate — [GraphQLDeprecated] maps to the SDL @deprecated directive +public class Order +{ + public string Id { get; init; } + [GraphQLDeprecated("Use totalMinor (integer cents) instead.")] + public double Total { get; init; } // still served; shows in introspection + public int TotalMinor => (int)(Total * 100); // the replacement, added additively +} +// Schema-first alternative, if your team prefers .graphql files: +// type Order { +// id: ID! +// total: Float @deprecated(reason: "Use totalMinor (integer cents) instead.") +// totalMinor: Int! +// } +// Per-field usage counters (a diagnostic event listener exporting to OpenTelemetry) +// show when 'total' has zero callers. Only then remove it. No /v2 endpoint. +``` + +Additive change plus [GraphQLDeprecated] plus usage metrics replaces versioning entirely. + +p. 95 + +> The mechanics in HotChocolate. Code-first is the dominant style in .NET GraphQL: you write a C# record (or class) and HotChocolate infers the schema from your types. We add TotalMinor additively and mark the old Total field with [GraphQLDeprecated("reason")] — the attribute maps directly to GraphQL's native @deprecated schema directive. If your team prefers schema-first, the .graphql file syntax works identically and the C# resolvers bind to it. The field keeps working and nothing breaks, but the deprecation shows up in introspection, in any IDE plugin that reads the schema, and in HotChocolate's bundled GraphQL IDE (Nitro, formerly Banana Cake Pop) at /graphql. The crucial operational piece is the comment: you instrument field-level usage (HotChocolate has IDiagnosticEventListener hooks; pair with OpenTelemetry to push per-field counters), and you only remove Total when its usage drops to zero. That's the whole GraphQL philosophy — evolution governed by data, with the breaking removal deferred until provably safe. Each protocol answers the same problem differently. + +## [113] APPENDIX B · VERSIONING · Principles that cut across all three (sldId 368) + +- Additive change is always safe; removal and mutation are where you break people. +- Make compatibility mechanical — the registry's BACKWARD/FORWARD/FULL rules, checked in CI, not by reviewers' memory. +- Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. +- Run old and new in parallel and migrate consumers before retiring — retirement is the only step that can break. +- Version data the same way you version APIs: event schemas in the registry follow the identical rules. + +p. 96 + +> Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break people — so design to add. Make compatibility mechanical: the registry's rules checked in CI, never a reviewer remembering. Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing. Run old and new in parallel and migrate before you retire, because retirement is the only inherently breaking move. And the bold line that connects this back to the event-driven section: your event schemas version by exactly the same rules — the registry treats an Avro schema and an OpenAPI spec identically. Versioning data and versioning APIs are the same discipline. + +## [114] Appendix C · WebSockets at Scale (sldId 369) + +C + +Raw WebSockets, pod pinning, the backplane, binary framing, reconnection — and where Blazor fits. + +> Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes. WebSockets are long-lived, stateful and pinned to a Pod: the opposite of the stateless request model the platform assumes. +> +> We build an endpoint with raw ASP.NET Core WebSockets, as the running example does, see why it fights scaling, fix it with a Redis backplane, make it fast and versioned with protobuf frames, and make it survivable with reconnection. Then Blazor: Blazor Server sits on the SignalR pinning side of this discussion and Blazor WebAssembly on the other; both run on Kubernetes as plain ASP.NET Core Pods. SignalR itself is the subject of Appendix Q. + +## [115] APPENDIX C · WEBSOCKETS · A WebSocket endpoint in ASP.NET Core (sldId 370) + +[chip] csharp · ASP.NET Core 10 WebSockets + +```csharp +// raw ASP.NET Core WebSockets, as in the running example (no SignalR) +app.UseWebSockets(); +app.Map("/ws/{clientId}", async (HttpContext ctx, string clientId) => +{ + if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; } + var ws = await ctx.WebSockets.AcceptWebSocketAsync(); + clients[clientId] = ws; // per-pod state + var buf = new byte[4096]; + while (ws.State == WebSocketState.Open) + { + var result = await ws.ReceiveAsync(buf, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) break; + var msg = Encoding.UTF8.GetString(buf, 0, result.Count); + await Broadcast(msg); // fan-out to this pod's clients + } + clients.TryRemove(clientId, out _); +}); +``` + +Raw ASP.NET Core WebSockets, as in the running example. The clients map lives in this Pod: that single fact is the scaling problem. + +p. 97 + +> The running example's endpoint uses raw ASP.NET Core WebSockets: UseWebSockets, a mapped route, AcceptWebSocketAsync, then a receive loop. No SignalR, so any client can connect and verify.sh can test it with a plain WebSocket client. +> +> Point at the clients dictionary: it lives in this Pod's memory. The connection, the client id and the fan-out all stay on one Pod, and that single fact is the scaling problem a Redis backplane solves, which the example also runs. +> +> SignalR (Appendix Q) wraps the same transport with hubs, groups, reconnection and serialization; it is the choice when server and clients are .NET. The per-Pod state problem is the same either way. + +## [116] APPENDIX C · WEBSOCKETS · Why WebSockets fight Kubernetes scaling (sldId 371) + +A long-lived connection pins to one pod; new pods get no existing connections and scale-down drops them. + +p. 98 + +> Here's the collision with Kubernetes. A WebSocket connection, once established through the load balancer, is pinned to one pod for its entire lifetime. Two problems follow. Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have. Scale-down and rollouts: terminating a pod severs every connection it was holding, so a routine deploy disconnects thousands of users at once. Stateless HTTP shrugs both off; WebSockets don't. The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling. + +## [117] APPENDIX C · WEBSOCKETS · The scaling problem, stated precisely (sldId 372) + +- Pinning: a connection is bound to one pod; the LB can't move it without dropping it. +- Uneven load: new replicas only receive new connections, so existing hotspots persist. +- Rollouts hurt: every deploy or scale-down severs the connections on the affected pods. +- Cross-pod delivery: to push to user X you must reach whichever pod holds X — which you don't track. +- Connection limits & memory: each socket is held-open state; pods cap out on file descriptors, not CPU. + +p. 99 + +> Name the five distinct problems so the audience can recognize them. Pinning: the LB can't migrate a live socket. Uneven load: scaling adds capacity for new connections but doesn't rebalance existing ones. Rollouts hurt: every deploy is a mass disconnect event — you need graceful drain and client reconnection. Cross-pod delivery: to push a message to user X you must route to the specific pod holding X's socket, and nothing tracks that by default. And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too. The pub/sub backplane solves or eases every one of these. + +## [118] APPENDIX C · WEBSOCKETS · Scaling out: a pub/sub backplane (sldId 373) + +Each pod owns only its own sockets; a Redis or Kafka backplane fans a message out to whichever pod holds the client. + +p. 100 + +> The pattern that makes WebSockets horizontally scalable. Each Pod still owns only its own sockets, but every Pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay. In SignalR specifically, this is one line of code: services.AddSignalR().AddStackExchangeRedis("redis:6379") wires up the Microsoft.AspNetCore.SignalR.StackExchangeRedis package (MIT). When a publisher calls Clients.User("X").SendAsync(…), the message goes to the backplane without knowing which Pod holds X; the backplane fans it to all Pods, and the one Pod that holds X's connection delivers it. Now scale-up and zero-downtime rollouts work again, because no Pod is the single home of a conversation — connections can land anywhere and still receive everything. Redis is the common open-source default; Kafka if you already run it and want the durability. A managed, cloud-hosted SignalR service is not used: the open-source Redis backplane is what runs on your own Kubernetes. + +## [119] APPENDIX C · WEBSOCKETS · Binary framing with protobuf — fast and versioned (sldId 374) + +[chip] csharp · Google.Protobuf + WebSockets + +```csharp +// raw System.Net.WebSockets: a protobuf binary frame, not JSON text +async Task Push(WebSocket socket, ulong seq, OrderPlaced ev) +{ + var env = new Envelope { + Version = 2, Seq = seq, Type = "OrderPlaced", + Payload = ev.ToByteString() }; // nested protobuf + await socket.SendAsync(env.ToByteArray(), + WebSocketMessageType.Binary, endOfMessage: true, ct); +} +// receive: parse the Envelope, dispatch on Type +var result = await ws.ReceiveAsync(buf, ct); +if (result.MessageType == WebSocketMessageType.Binary) +{ + var env = Envelope.Parser.ParseFrom(buf, 0, result.Count); + switch (env.Type) + { + case "OrderPlaced": + HandleOrderPlaced(env.Seq, OrderPlaced.Parser.ParseFrom(env.Payload)); + break; + } +} +``` + +Protobuf frames are smaller and faster than JSON; the versioned envelope and seq number enable evolution and resume. + +p. 101 + +> Two wins in one slide. Performance: protobuf binary frames are smaller and cheaper to parse than JSON text, which matters when pushing high-frequency updates to many connections. +> +> Versioning: a WebSocket message has no URL to hang a version on, so you version the envelope. The Version field lets the client detect an incompatible payload format and renegotiate; the Type field dispatches to the right typed payload; and a monotonic Seq number per connection is what makes reconnection and replay possible, the subject of the failure-handling slide. +> +> The envelope is a protobuf message compiled with Grpc.Tools, so the same .proto evolution rules from Appendix B apply. With SignalR (Appendix Q) the equivalent is the MessagePack hub protocol plus a versioned envelope record. + +## [120] APPENDIX C · WEBSOCKETS · Failure handling: resume, don't restart (sldId 375) + +Heartbeats detect dead links; the client reconnects with backoff and resumes from the last acknowledged sequence. + +p. 102 + +> The cloud-native posture: treat every connection as disposable and design for its loss. Heartbeats (ping/pong) detect a dead link quickly — TCP alone can hang for minutes. When the connection drops, the client reconnects with exponential backoff plus jitter (so ten thousand clients don't reconnect in lockstep and hammer your pods after a rollout). On reconnect, the client sends its last-seen sequence number (last_seq = 1042); the server replays only the events the client missed (1043 onward), and because each frame carries a seq, the client can dedupe — so the whole thing is at-least-once and idempotent. The principle on screen: resume, don't restart. A reconnect should be invisible to the user, not a full state reload. + +## [121] APPENDIX C · WEBSOCKETS · A cloud-native WebSocket checklist (sldId 376) + +- Externalize fan-out to a backplane (Redis or Kafka); never assume one pod holds the client. +- Scale and trigger on connection count and memory, not CPU; set realistic per-pod connection caps. +- Drain gracefully on shutdown: stop accepting, signal clients to reconnect, let backoff spread the herd. +- Heartbeat to detect dead links (SignalR ships keep-alive built in: KeepAliveInterval defaults to 15s, ClientTimeoutInterval to 30s); reconnect with exponential backoff + jitter (also built into the JS / .NET clients); resume via a per-connection seq. +- Use binary frames with a versioned envelope; authenticate on connect and re-check on resume. + +p. 103 + +> The operational checklist to leave on screen. Externalize fan-out to a backplane so no pod is special. Scale and set your KEDA/HPA trigger on connection count and memory, not CPU, and cap connections per pod realistically. Drain gracefully on shutdown — stop accepting, tell clients to reconnect, and let backoff-with-jitter spread the reconnect storm across your rollout. Heartbeat to detect dead links fast; reconnect with backoff and jitter; resume from a per-connection sequence number so the user never notices. And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere. Do these and WebSockets are just another well-behaved cloud-native workload. + +## [122] APPENDIX C · WEBSOCKETS · Blazor on Kubernetes: Server vs WebAssembly (sldId 377) + +Two Blazor hosting models with different scaling profiles; both run on plain ASP.NET Core Pods and need no managed SignalR service. + +p. 104 + +> Blazor deserves its own slide in this appendix because it sits on both sides of the WebSocket scaling discussion. Walk the picture left to right. Blazor Server (left): a server-side ASP.NET Core process holds the user's component tree and view-model; a thin JS client renders DOM diffs streamed over a persistent SignalR WebSocket. Every UI interaction, every button click, is a round-trip. Every component instance is pinned to one Pod for its session lifetime. Calls to inventory, payment, etc. happen server-to-server. Once you scale to N Pods, you need a Redis backplane (one line: AddStackExchangeRedis), and every pinning, connection-count and drain concern from this appendix applies in full: it is a WebSocket workload that happens to drive UI updates. Blazor WebAssembly (right): the entire .NET runtime ships to the browser as WebAssembly; your C# components run client-side; the Pod is just a static-file host (nginx or ASP.NET Core serving .wasm + .dll + .css + .html). No SignalR, no per-user server state, no backplane — scales like any other static site. The trade: WASM has a one-time download cost (.NET 8+ AOT trims it considerably; .NET 10 trims it further) and your APIs must be browser-callable (CORS, public auth, gRPC-Web instead of gRPC). The decision rule at the bottom: pick Server for line-of-business apps where you want server-side auth and real-time state with minimal client work; pick WebAssembly for offline, high-interactivity, or SEO-friendly client surfaces. Most production .NET shops run both: Blazor Server for the admin console, Blazor WebAssembly for the customer-facing app. The cloud-native posture in either case: a containerised ASP.NET Core Pod, open-source Redis if needed, KEDA scaling on connections (Server) or RPS (WASM static host). + +## [123] APPENDIX C · WEBSOCKETS · Choosing between Blazor Server and Blazor WebAssembly (sldId 378) + +- Blazor Server — inherits everything in this appendix. SignalR-pinned; needs Redis backplane to scale (Microsoft.AspNetCore.SignalR.StackExchangeRedis, Apache 2.0); KEDA scales on connection count, not CPU; every deploy disconnects everyone. +- Blazor WebAssembly — escapes everything in this appendix. No persistent connection; static-file host scales like nginx; deploys are an atomic file swap; client runs .NET in WASM with full C# capability. +- Cost trade: Server has tiny client download but pays per-interaction round-trip latency and server-side memory per user. WebAssembly has a larger first-load (mitigated by AOT + trimming in .NET 8+, smaller again in .NET 10) but zero server state per user. +- Auth trade: Server uses normal cookie / server-side identity (the WCF / IIS-era mental model). WebAssembly uses bearer tokens visible to the browser — OAuth2 / OIDC with public clients. +- Both are MIT, both run on Kubernetes as plain ASP.NET Core Pods, both work without any managed cloud service. Open-source Redis for the backplane is all you need. + +p. 105 + +> The take-home for the audience. For the WCF/IIS-era developer, Blazor Server feels familiar (server-side state, cookie auth, classic web app shape), which is both its appeal and its scaling problem. Blazor WebAssembly feels foreign at first (real client-side .NET running in a sandbox), but it scales the way the rest of cloud-native does: stateless static hosting + back-end APIs. There's no wrong answer; there are appropriate answers per workload. The bold final line is the one to leave up: nothing here requires a managed cloud service. SignalR is in-box ASP.NET Core. The Redis backplane is the StackExchangeRedis package (MIT) against any Redis (or Valkey, or KeyDB). The static-file host can be nginx or ASP.NET Core's UseStaticFiles. Hosting on Kubernetes is just two Deployments and two Services. A team moving from managed app hosting to Kubernetes keeps Blazor: it runs Redis itself as the backplane instead of a managed SignalR service. + +## [124] Appendix D · Saga State & Compensation (sldId 379) + +D + +Persisting state, compensating cleanly, and the step-B-needs-A's-data problem. + +> Appendix D is the deep mechanics behind the saga slide in the Data section. Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them. + +## [125] APPENDIX D · SAGAS · Where a saga keeps its state (sldId 380) + +Memory won't survive a Pod restart — persist every step transition in a DB, an event log, or a workflow engine. + +p. 106 + +> First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter: a Pod restart would strand every in-flight saga with no idea what to compensate. Three real options. A DB-backed state machine: one row per saga instance holding its status and a context blob; you own the transitions with EF Core; the simplest option, and enough for most cases. Event-sourced: append each step as an event and rebuild state by replay — full audit history; Marten on Postgres is the natural .NET fit, or EF Core for hand-rolled. A workflow engine like Temporal: durable execution with retries and timers built in — most power, least code, but a new dependency to run. Stateless is the lighter in-process alternative, compared with Temporal in this appendix. For the WCF/COM+ audience: this slide is where MSDTC distributed transactions get permanently retired — you can't have a two-phase commit across services in this world, so you reach for compensation-based consistency instead. The cross-cutting rule on screen: the state transition and the outbound command must commit together (the outbox pattern again), or you get lost or duplicated steps. + +## [126] APPENDIX D · SAGAS · A persisted saga state machine (EF Core) (sldId 381) + +[chip] csharp · .NET 10 + EF Core + +```csharp +static readonly string[] Steps = { + "charge_payment", "reserve_stock", "book_shipping" }; +public class SagaCoordinator(SagaDbContext db) : ISagaCoordinator +{ + public async Task AdvanceAsync(string sagaId, CancellationToken ct) + { + await using var tx = await db.Database + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + var s = await db.Sagas.FromSqlInterpolated( + $"SELECT * FROM sagas WHERE id = {sagaId} FOR UPDATE") + .FirstAsync(ct); + if (s.Status != "RUNNING") return; // idempotent: already done/failed + var step = Steps[s.StepIndex]; + try { + var result = await InvokeAsync(step, s.Context, ct); // call the service + s.Context[step] = result; // accumulate output + if (++s.StepIndex == Steps.Length) s.Status = "COMPLETED"; + } + catch (StepException) { + s.Status = "COMPENSATING"; // hand off to the unwinder + } + await db.SaveChangesAsync(ct); // durable after every step + await tx.CommitAsync(ct); + } +} +``` + +One locked row per saga, advanced one durable step at a time. A crash just means 'load the row and call AdvanceAsync again.' + +p. 107 + +> This is the DB-backed option made concrete with EF Core — the default choice when the saga is bounded and the team wants to own the transitions. Key ideas: one Saga entity row, loaded with a pessimistic lock (FOR UPDATE in Postgres, equivalent in SQL Server) so two workers can't advance the same saga concurrently. The status guard makes AdvanceAsync idempotent — calling it on a finished saga is a no-op, which matters because retries and redeliveries will call it again. Each successful step accumulates its result into the Context dictionary, which is what the data-dependency slide relies on, and bumps the step index; the EF Core SaveChangesAsync makes the row durable after every step. Recovery needs no special path: a BackgroundService with PeriodicTimer (or Quartz.NET on a cron) plus DistributedLock loads RUNNING sagas and calls AdvanceAsync again — the cloud-native equivalent of a Windows Service that processed an MSMQ queue. Temporal .NET and Stateless, both in this appendix, solve the same problem at a different point on the build-vs-buy spectrum. + +## [127] APPENDIX D · SAGAS · Compensating transactions unwind a failed saga (sldId 382) + +Each committed step has an inverse; on failure they run in reverse order — there is no cross-service rollback. + +p. 108 + +> Compensation is how a saga 'rolls back' when there's no actual rollback available across services. Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release. When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first. Two things that bite people, both on screen: compensations must themselves be idempotent and durable, because a crash mid-unwind has to resume cleanly; and a compensation can itself fail, so it needs its own retries. The saga ends in a persisted COMPENSATED state with a full audit trail of what was done and undone. + +## [128] APPENDIX D · SAGAS · Running compensations in reverse (sldId 383) + +[chip] csharp · .NET 10 + EF Core + +```csharp +static readonly Dictionary Compensations = new() // inverse per step +{ + ["charge_payment"] = "refund_payment", + ["reserve_stock"] = "release_stock", + ["book_shipping"] = "cancel_shipping", +}; +public async Task CompensateAsync(string sagaId, CancellationToken ct) +{ + await using var tx = await db.Database + .BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); + var s = await db.Sagas.FromSqlInterpolated( + $"SELECT * FROM sagas WHERE id = {sagaId} FOR UPDATE").FirstAsync(ct); + // undo only the steps that actually completed, newest first + foreach (var step in Steps.Take(s.StepIndex).Reverse()) + { + await InvokeAsync(Compensations[step], s.Context, + idempotencyKey: $"{sagaId}:{step}", ct); // safe to retry + } + s.Status = "COMPENSATED"; + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); +} +``` + +Compensate only completed steps, in reverse, with an idempotency key so a retry never double-refunds. + +p. 109 + +> The compensation logic. A simple dictionary gives each forward step its inverse. We iterate the completed steps (Take(StepIndex) then Reverse) and invoke each compensation. The idempotency key built from sagaId plus step is the line that prevents the nightmare scenario: a retry double-refunding a customer. The compensating service dedupes on that key. In production you'd track per-step compensation status too, so if compensation itself crashes halfway you resume from the right place — but the idempotency key makes even naive re-runs safe. This is why compensations are designed up front, as part of each step, not bolted on after. For this audience: this replaces the old MSDTC rollback semantics — in a distributed system the 'transaction' is the saga, the 'rollback' is the compensation sequence, and the 'commit' is when the last step succeeds. With Temporal .NET the workflow keeps a stack of compensations and runs them in reverse on failure, with the same guarantees and durable history. + +## [129] APPENDIX D · SAGAS · When step B needs data only step A produced (sldId 384) + +Don't make B call A. Persist A's output into the saga context and pass it forward; on resume, read it back. + +p. 110 + +> This case is a common source of bad designs. The orchestrator calls service A, then service B — but B needs a value that only A produced (say A returns a reservation_id and a warehouse region that B requires to proceed). The tempting wrong answer is to have B call A directly — now you've created a hidden synchronous dependency between B and A, exactly the chatty coupling the whole architecture fights. The right answer is on screen: the orchestrator owns a saga context (a 'blackboard'), persists A's output into it after step A, and passes the needed values into B's call. B never knows A exists. And note this is already in our state-machine code — that 's.Context[step] = result' line is exactly this. Temporal .NET does the same automatically through its workflow input/output passing; Stateless does it via the OnTransitioned listener that lets you write the payload into the saga's persisted state. + +## [130] APPENDIX D · SAGAS · Two ways to make a saga durable without rolling your own (sldId 385) + +- When you outgrow the hand-rolled EF Core state machine, you have two well-trodden open-source paths in the .NET ecosystem — both featured in the diagrams that follow. +- Temporal .NET SDK — a durable execution engine (Temporal Server MIT, .NET SDK Apache 2.0, on NuGet as Temporalio). You write workflows and activities as plain C#; the Server persists every event and replays them deterministically on recovery. Most power, separate cluster to run. +- Stateless — a tiny in-process state-machine library (Apache 2.0). States, triggers, transitions, guards, entry/exit actions; you persist the current state to your own DB. Lighter weight, no extra cluster, less ceremony. +- Decision rule: reach for Temporal when the saga is long, complex, or shared across services and you want first-class retries, timers, signals and child workflows. Reach for Stateless when it's small, in-process and you'd rather keep one fewer thing to operate. +- Sidebar: Dapr Workflows is a third option if your platform already runs Dapr sidecars (CNCF Incubating, MIT) — same durable-execution model as Temporal, exposed via the Dapr sidecar instead of a direct SDK. Use it when the team's already invested in Dapr. + +p. 111 + +> Frame the choice as managed durability against lightweight in-process state, both open source and production-grade. +> +> Temporal: an MIT-licensed Server and an open-source .NET SDK, on NuGet as Temporalio, actively maintained by the team that built Cadence. You get durable execution, deterministic replay, retries with backoff, timers, child workflows, signals and queries, and a CLI and UI to inspect runs. The cost is operating a Temporal cluster on your own Kubernetes, or choosing Temporal Cloud. +> +> Stateless: a small in-process .NET state-machine library (a community project). No extra cluster; you persist the CurrentState property to your own database, one column, and restore it before firing the next trigger. Better when the saga is small, the team already works in EF Core, and another runtime is overkill. +> +> Dapr Workflows is a sidebar: a third option when your environment already uses the sidecar pattern, not the focus of this deck. The decision is how much engine this saga needs. + +## [131] APPENDIX D · SAGAS · Temporal .NET: a plain-C# saga the Server makes durable (sldId 386) + +Worker (your ASP.NET Core BackgroundService) runs Workflow + Activity code; the Temporal Server persists every event and replays deterministically. + +p. 112 + +> Walk the picture. Three boxes. On the left, your client, a REST handler in an ASP.NET Core app, injects ITemporalClient and calls StartWorkflowAsync(…) to kick off a workflow. In the middle, the Temporal Server: a cluster of Frontend, Matching and History services backed by Postgres (or MySQL / Cassandra) that persists every workflow event (every activity invocation, every result, every timer firing), so the workflow can be reconstructed from scratch by replay. On the right, the Worker: another ASP.NET Core app where you implement the workflow as a regular C# class annotated [Workflow] with a [WorkflowRun] method, and activities as plain C# classes annotated [Activity]. The worker runs as a BackgroundService that polls a task queue, gets the next workflow task, executes the next deterministic step. Crash the worker mid-flow and Temporal reissues the task to another worker, which replays history and resumes exactly where you were — no lost steps, no double-charges. That's durable execution. For this audience: think of it as 'BizTalk orchestration done right, open-source, polyglot, on Kubernetes' — except the workflow is just C# code with normal IDE tooling and unit testing, not a BizTalk visual designer. The bottom captions confirm the open-source posture: Temporal Server is MIT, the .NET SDK is Apache 2.0, both running on your Kubernetes if you want, no SaaS lock-in. + +## [132] APPENDIX D · SAGAS · Temporal .NET workflow + activity, in ASP.NET Core (sldId 387) + +[chip] csharp · Temporalio + ASP.NET Core + +```csharp +// PaymentActivities: [Activity] ChargeAsync / RefundAsync, plain C# methods +[Workflow] +public class FulfillmentWorkflow // deterministic C#, replayed by a Worker after a crash +{ + private static readonly ActivityOptions Opts = + new() { StartToCloseTimeout = TimeSpan.FromSeconds(10) }; + [WorkflowRun] + public async Task RunAsync(SagaInput input) + { + var undo = new Stack>(); // compensations, newest on top + try + { + var paymentId = await Workflow.ExecuteActivityAsync( + (PaymentActivities a) => a.ChargeAsync(input), Opts); + undo.Push(() => Workflow.ExecuteActivityAsync( + (PaymentActivities a) => a.RefundAsync(paymentId), Opts)); + var reservationId = await Workflow.ExecuteActivityAsync( + (InventoryActivities a) => a.ReserveAsync(input, paymentId), Opts); + undo.Push(() => Workflow.ExecuteActivityAsync( + (InventoryActivities a) => a.ReleaseAsync(reservationId), Opts)); + return await Workflow.ExecuteActivityAsync( + (ShippingActivities a) => a.BookAsync(input, reservationId), Opts); + } + catch (ActivityFailureException) { while (undo.TryPop(out var u)) await u(); throw; } + } +} +``` + +Workflow and activities are plain C# classes; the workflow keeps its own compensation stack. State is durable on the Server. + +p. 113 + +> The whole story on one slide. Activity classes are plain C#; methods marked [Activity] are registered with the worker at startup. The workflow class is marked [Workflow] with one [WorkflowRun] entry point. The body reads like synchronous code, but every activity call goes through Temporal's task queue and is durably scheduled, retried and timed out on the Server. StartToCloseTimeout is required on every activity call, so the options object is shared. +> +> The output of each activity (paymentId, reservationId) is captured in a local variable and in Temporal's event history. If the worker dies between charge and reserve, a replacement worker replays the history, recovers paymentId, and continues at reserve without re-running charge. +> +> Compensation is plain C# as well: each completed step pushes its undo onto a stack, and on an activity failure the workflow pops and runs them in reverse, the same discipline as the hand-rolled version, now durable because each undo is itself an activity. Hosting in ASP.NET Core: Temporalio.Extensions.Hosting registers the client and a hosted worker that lists the workflow and activity types. + +## [133] APPENDIX D · SAGAS · Stateless: lighter, in-process, no extra cluster (sldId 388) + +States, triggers, transitions, guards — plus a tiny serialization of CurrentState to save/restore across crashes. + +p. 114 + +> Walk the picture. The top row is the saga as a state machine: Created → Paid → Reserved → Shipped → Done, with each transition triggered by an event (PayOk, ReserveOk, ShipOk, Delivered). On StepFail at any point, the machine drops down into the Compensating state and ultimately Failed — the same compensation discipline expressed as state transitions. Under it, three pieces. The StateMachine itself, configured in a small builder method via the Stateless package's fluent API — you declare states, transitions, guards (predicates that gate a transition), entry/exit actions (code that runs on a transition, where you'd call _payments.ChargeAsync or _inventory.ReserveAsync), and OnTransitioned listeners for notifications. The 'persist state yourself' block is the lightweight bit: serialize the CurrentState property to a single column in your saga row using EF Core or Dapper, restore it before firing the next trigger. No persister framework needed. And on the right, the code that drives the machine: await sm.FireAsync(Trigger.PayOk, payload) runs guards, actions, transitions, and the OnTransitioned hook writes the new state to your DB. The whole thing runs in-process inside your ASP.NET Core app — no extra cluster, Apache-2.0 open source. + +## [134] APPENDIX D · SAGAS · Stateless: configure and drive (sldId 389) + +[chip] csharp · Stateless package + EF Core + +```csharp +public enum State { Created, Paid, Reserved, Shipped, Done, Compensating, Failed } +public enum Trigger { PayOk, ReserveOk, ShipOk, Delivered, StepFail } +public class FulfillmentSagaMachine +{ + private readonly StateMachine _sm; + // ... CurrentState => _sm.State; OrderId { get; } + public FulfillmentSagaMachine(string orderId, State initial, + IPaymentService payments, + IInventoryService inventory, + IShippingService shipping) + { + OrderId = orderId; + _sm = new StateMachine(initial); + _sm.Configure(State.Created) + .Permit(Trigger.PayOk, State.Paid) + .OnEntryAsync(() => payments.ChargeAsync(orderId)); + _sm.Configure(State.Paid) + .Permit(Trigger.ReserveOk, State.Reserved) + .Permit(Trigger.StepFail, State.Compensating) + .OnEntryAsync(() => inventory.ReserveAsync(orderId)); + // ... Reserved -> Shipped and Compensating -> Failed are configured the same way + } + public Task FireAsync(Trigger t) => _sm.FireAsync(t); +} +// ... drive it: FulfillmentService.OnAsync loads the saga row, builds the machine, fires, saves +``` + +All in your ASP.NET Core process: states + triggers declared, entry actions invoke services, CurrentState persisted to your own DB. + +p. 115 + +> FulfillmentSagaMachine wraps a Stateless StateMachine with enums for both type parameters: type-safe states and triggers. Configure() declares each state's permitted transitions and its entry action; OnEntryAsync runs the business call when the state is entered, and FireAsync is required once entry actions are asynchronous. The Permit chain reads like the state diagram. +> +> FulfillmentService.OnAsync is the entry point, called from an HTTP handler or a Kafka consumer. It restores the saga's state by order id from EF Core, rebuilds the machine in that state, fires the trigger, and saves the new CurrentState. +> +> The transactional discipline is yours: wrap the action's effects and the state save in one DbContext transaction, the same commit-together rule as the hand-rolled version. Compared with Temporal there is no separate server, but also no automatic retries, timers or replay; the BackgroundService sweeper from the persisted-state slide still applies. + +## [135] Appendix E · API Error Handling (sldId 390) + +E + +One discipline, four wire formats. + +> Appendix E. Error handling is where cloud-native systems fall apart, more often than the happy path. The thesis: agree one error contract (a stable machine code, a safe human message, a correlation id, a retryable flag, field details) and then map that same model onto REST, gRPC, Kafka and GraphQL. We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it. + +## [136] APPENDIX E · ERRORS · One error model, four wire formats (sldId 391) + +The same five facts in every error; only the encoding changes per protocol. + +p. 116 + +> Start with what every error must carry, regardless of protocol: a stable machine-readable code clients can branch on (not a localized string), a human-readable message safe to show, a correlation/trace id that ties the error to the distributed trace from section 11, a retryable flag (with retry-after when known), and structured field-level details. The four protocols just encode those same five facts differently — REST as problem+json, gRPC as status+details, Kafka via headers and a DLQ record, GraphQL as an errors[] entry with extensions. Agree this once as a shared contract; never leak stack traces or internal type names to callers. + +## [137] APPENDIX E · ERRORS · REST: status, then Problem Details (sldId 392) + +The status code is the category; the problem+json body carries the machine code and specifics. + +p. 117 + +> REST has two layers. First the HTTP status class, which is the category and drives client behaviour: 4xx means the client must fix the request and should not blindly retry; 429 and 503 mean 'busy' and must be paired with a Retry-After header so clients back off; 5xx means a server fault that idempotent calls may retry with backoff. Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId. Status for the category, body for the specifics. + +## [138] APPENDIX E · ERRORS · REST errors with ASP.NET Core ProblemDetails (sldId 393) + +[chip] csharp · .NET 10 + IExceptionHandler + +```csharp +// ProblemDetails (RFC 9457) in the box since .NET 6; IExceptionHandler since .NET 8. +builder.Services.AddProblemDetails(); // standard problem+json output +builder.Services.AddExceptionHandler(); +app.UseExceptionHandler(); // wires up the chain +public class DomainExceptionHandler : IExceptionHandler // one place: domain → wire +{ + public async ValueTask TryHandleAsync(HttpContext ctx, Exception ex, + CancellationToken ct) + { + var (status, problem) = ex switch + { + StockException s => (StatusCodes.Status409Conflict, new ProblemDetails { + Type = "https://errors.acme.io/stock", Title = "Insufficient stock", + Status = StatusCodes.Status409Conflict, Detail = s.Message, + Extensions = { + ["code"] = "STOCK_UNAVAILABLE", // stable + ["traceId"] = Activity.Current?.TraceId.ToString() } }), + _ => (StatusCodes.Status500InternalServerError, new ProblemDetails()) + }; + ctx.Response.StatusCode = status; + await ctx.Response.WriteAsJsonAsync(problem, (JsonSerializerOptions?)null, + "application/problem+json", ct); // RFC 9457 media type + return true; + } +} +``` + +ProblemDetails + IExceptionHandler give you RFC 9457 out of the box — stable code, safe message, trace id; never a raw stack trace. + +p. 118 + +> ASP.NET Core has had ProblemDetails (RFC 9457) built into Microsoft.AspNetCore.Mvc since .NET 6 — the wire format, the media type (application/problem+json), and the integration with minimal APIs (Results.Problem) and controllers ([ProducesProblemDetails]) are all included. The IExceptionHandler interface added in .NET 8 is the central mapping seam — register one or more, each one inspects the exception and returns true if it handled it, false to defer to the next. Use it for domain-to-wire mapping so you never scatter ad-hoc error JSON across handlers. The status matches the category (409 here — a conflict the client can act on), and Extensions carries the stable machine code and the W3C trace id from System.Diagnostics.Activity for correlation. Nothing internal leaks: no stack trace, exception class name, or SQL. Those are an attack surface, and the WCF-era habit of returning SoapFault detail with full stack traces is exactly what this replaces. For validation specifically, ValidationProblemDetails is the typed subclass and Results.ValidationProblem the minimal-API helper. + +## [139] APPENDIX E · ERRORS · gRPC: status codes + rich details (sldId 394) + +A small fixed set of canonical codes, plus structured google.rpc.Status details in the trailers. + +p. 119 + +> gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it. INVALID_ARGUMENT and NOT_FOUND are client bugs: don't retry. FAILED_PRECONDITION and ALREADY_EXISTS are state issues: don't retry. UNAVAILABLE and ABORTED are transient: retry with backoff. RESOURCE_EXHAUSTED means you're being rate-limited — honour the RetryInfo delay. DEADLINE_EXCEEDED means tune the timeout. Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else. + +## [140] APPENDIX E · ERRORS · gRPC errors with rich status details (sldId 395) + +[chip] csharp · grpc-dotnet + Google.Rpc + +```csharp +using Grpc.Core; +using Google.Rpc; // ErrorInfo, RetryInfo, Status +public override async Task ReserveStock( + ReserveRequest req, ServerCallContext context) +{ + if (_inventoryDown) + { + var status = new Google.Rpc.Status + { + Code = (int)Code.Unavailable, // canonical code + Message = "inventory temporarily down", + Details = + { + Any.Pack(new ErrorInfo { + Reason = "STOCK_DOWN", Domain = "inventory" }), + Any.Pack(new RetryInfo { + RetryDelay = Duration.FromTimeSpan(TimeSpan.FromSeconds(2)) }), + } + }; + // grpc-dotnet: throw RpcException with the rich status in trailers + throw status.ToRpcException(); // extension from Grpc.StatusProto + } + return ToReply(await _stock.ReserveAsync(req)); +} +``` + +Pick the canonical code (Unavailable = retryable) and attach ErrorInfo + RetryInfo so clients back off correctly. + +p. 120 + +> This is grpc-dotnet's standard rich-error model — the same wire shape as every other gRPC stack because google.rpc.Status is the cross-language contract. The canonical code Unavailable tells the client and the mesh this is transient and retryable; the Status carries structured details — ErrorInfo with a machine reason and domain, and RetryInfo telling the client exactly how long to wait. The Grpc.StatusProto NuGet package provides the ToRpcException extension that packs the rich Status into the trailers, and throwing the RpcException signals the failure. For the audience: this is the gRPC equivalent of WCF's FaultException with a fault contract — except the contract is google.rpc.Status (standard across languages), the codes are canonical, and the mesh (Istio) can act on them. The key discipline: don't overload a generic Internal code for everything — the code is how automated clients and Istio decide whether and when to retry, so getting it right is what prevents both lost work and retry storms. + +## [141] APPENDIX E · ERRORS · Kafka: retry topics and a dead-letter queue (sldId 396) + +Commit the offset, route the failed record aside, and keep consuming — a poison record never blocks the partition. + +p. 121 + +> Kafka error handling has a failure mode unique to logs: a single poison record can block the whole partition forever if you keep retrying it in place, because the offset never advances — head-of-line blocking that stalls every record behind it. The fix is to move failures aside: try a bounded number of in-place retries, then route the record to a delayed-retry topic (reprocessed after a delay without blocking the live stream), and after N attempts send it to a dead-letter queue for humans or tooling. You commit the offset either way, so the main consumer keeps making progress. With Confluent.Kafka the loop is yours, so the invariant is explicit: bounded retries, then a delayed-retry topic, then a DLQ, with the offset committed on every branch. + +## [142] APPENDIX E · ERRORS · Kafka retry + DLQ with Confluent.Kafka (sldId 397) + +[chip] csharp · Confluent.Kafka + Polly 8 + +```csharp +// Confluent.Kafka + Polly 8: bounded in-place retries, a delayed-retry topic, a DLQ. +var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions { + MaxRetryAttempts = 3, Delay = TimeSpan.FromMilliseconds(200), // in-place + BackoffType = DelayBackoffType.Exponential, UseJitter = true, + ShouldHandle = new PredicateBuilder() + .Handle(e => e is not ValidationException) }) + .Build(); +while (!ct.IsCancellationRequested) +{ + var cr = consumer.Consume(ct); + try { await pipeline.ExecuteAsync(async t => await HandleAsync(cr.Message.Value, t), ct); } + catch (Exception ex) + { + var attempts = cr.Message.Headers.TryGetLastBytes("retries", out var raw) + ? int.Parse(Encoding.UTF8.GetString(raw)) : 0; + var dlq = ex is ValidationException || attempts >= MaxRetries; // 4xx or exhausted + var headers = new Headers(); + if (dlq) headers.Add("error", Encoding.UTF8.GetBytes(ex.GetType().Name)); + else headers.Add("retries", Encoding.UTF8.GetBytes((attempts + 1).ToString())); + await producer.ProduceAsync(dlq ? "orders.DLQ" : "orders.retry.30s", + new Message { Value = cr.Message.Value, Headers = headers }); + } + consumer.Commit(cr); // every branch commits: no head-of-line block +} +``` + +Bounded in-place retries, a delayed-retry topic, then a DLQ; the offset is committed on every branch, so a poison record can't stall the partition. + +p. 122 + +> This is the Appendix E pattern over Confluent.Kafka. The loop is yours, so the invariant is explicit and readable: a Polly 8 retry pipeline gives bounded in-place retries with exponential backoff and jitter, and treats ValidationException as non-retryable, since retrying a 4xx gains nothing. +> +> When the in-place retries are exhausted, the record is re-published to a delayed-retry topic with a retries header; after MaxRetries attempts, or immediately for a validation failure, it goes to the dead-letter topic with the error type attached. Either way the offset is committed, so the consumer keeps making progress and a poison record never blocks the partition. +> +> Make the handler idempotent: at-least-once delivery means a retried record may have partly applied. For the MSMQ audience this is the MaxRetryCount, ReceiveErrorHandling and poison-queue model, written as code you can test. + +## [143] APPENDIX E · ERRORS · GraphQL: partial success with errors[] (sldId 398) + +One HTTP 200; the failed field is null and explained in errors[] with a machine code in extensions. + +p. 123 + +> GraphQL breaks the one-request-one-status assumption: a single query can touch many resolvers, so the response is almost always HTTP 200 and carries both a data object and an errors array. When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId. The rest of the data still returns. The anti-pattern to call out: blanket-failing the whole query (or returning a 500) when only one field broke — that throws away useful data the client already asked for. Partial success is a feature; let the client decide whether to retry just the failed field. + +## [144] APPENDIX E · ERRORS · GraphQL field errors with HotChocolate (sldId 399) + +[chip] csharp · .NET 10 + HotChocolate + +```csharp +public class GraphQLErrorFilter : IErrorFilter +{ + public IError OnError(IError error) => + error.Exception switch + { + InventoryUnavailable iu => error + .WithMessage(iu.Message) + .WithCode("UNAVAILABLE") // → extensions.code + .SetExtension("retryable", true) + .SetExtension("traceId", Activity.Current?.TraceId.ToString()) + .RemoveException(), // don't leak stack trace + _ => error // default for everything else + }; +} +// ... Program.cs: AddGraphQLServer().AddErrorFilter() +[ExtendObjectType] +public class OrderExtensions +{ + public async Task GetStock( + [Parent] Order order, IInventoryService inventory) + { + // ... RemainingAsync; on UpstreamException throws InventoryUnavailable (nulls the field) + } +} +``` + +Throw from the field resolver; an IErrorFilter nulls that field and adds it to errors[] with code + retryable + traceId in extensions. + +p. 124 + +> Throwing from a single field's resolver is exactly right: HotChocolate's exception-resolution chain catches it, sets that field to null, and appends an entry to errors[] with the message and path — while every other field resolves and returns normally. The IErrorFilter is the central mapping point — one IErrorFilter implementation, one method per exception family (here using a switch expression), return a transformed IError with WithCode and SetExtension calls. The .WithCode("UNAVAILABLE") sets the standard GraphQL error code in extensions.code; SetExtension lets you add any structured fields — retryable flag, traceId from Activity.Current, anything else you want. The .RemoveException() call is critical security hygiene: by default HotChocolate may include the exception type and stack in extensions during development; in production you strip it. Resist letting the exception bubble to the top and fail the whole operation — that's the blanket-failure anti-pattern. The client sees the order and total, sees stock is null, reads extensions.code, and decides whether to retry just stock. The same discipline works for HotChocolate's mutation errors (the 6a-style error unions) if you go that route for richer error contracts. + +## [145] APPENDIX E · ERRORS · How a retry storm forms — and how to stop it (sldId 400) + +Naive retries multiply load on a struggling service; bounded, spaced, circuit-broken retries break the feedback loop. + +p. 125 + +> This is the failure mode that turns a brief blip into a full outage. The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries. It's a positive feedback loop, and naive retry logic is the amplifier. The controls that break it, bottom of the slide: bound every call with a timeout; cap retries to a small number and only for idempotent calls; space them with exponential backoff plus jitter (so clients don't retry in lockstep); add a circuit breaker to fail fast while the dependency is down; enforce a retry budget (cap retries as a percentage of traffic); and shed load early with 429/UNAVAILABLE rather than queueing work you can't do. In .NET, Polly is the canonical resilience library (BSD-3, ships under Microsoft.Extensions.Resilience and Microsoft.Extensions.Http.Resilience) — retries, circuit breakers, bulkheads, timeouts, hedging, all composable into a single ResiliencePipeline. Plus the Istio config from section 2, you get these at both the code and mesh layers. + +## [146] APPENDIX E · ERRORS · Error-handling heuristics across all four (sldId 401) + +- One contract, four encodings: a stable code, a safe message, a trace id, a retryable flag, field details — everywhere. +- The category lives in the protocol: HTTP status, gRPC code, GraphQL errors[] entry, Kafka DLQ vs retry topic. +- Make retryability explicit and honour it: Retry-After, RetryInfo, extensions.retryable — don't make clients guess. +- Never leak internals: no stack traces, SQL, or class names on the wire; map them to a code and log the detail server-side. +- Bound every retry (@Timeout, @Retry + jitter, @CircuitBreaker, retry budget) and make handlers idempotent. + +p. 126 + +> The cross-cutting checklist to leave on screen. One contract expressed four ways — the same five facts whether it's problem+json, google.rpc.Status, a GraphQL extensions object, or a Kafka DLQ header. Let each protocol carry the category natively rather than inventing your own. Make retryability explicit and machine-readable so clients and the mesh back off correctly instead of guessing. Never leak internals — that's both a security issue and a coupling issue. And the bold line is the one that prevents outages: every retry is bounded and spaced (Polly's AddRetry with exponential backoff and jitter, AddCircuitBreaker, AddTimeout composed into a ResiliencePipeline), protected by a retry budget, and every handler idempotent so a redelivered or retried request is safe. Get these right and your failure paths become as boring as your happy paths. + +## [147] Appendix F · DDD & Hexagonal Architecture (sldId 402) + +F + +Where the boundaries and the contracts come from. + +> Appendix F. The whole talk assumes you've drawn good service boundaries and know what each API should say — this is where those come from. Domain-Driven Design gives you the strategic tools to place boundaries (subdomains, bounded contexts) and the tactical tools to model what's inside (aggregates, domain events); hexagonal architecture keeps the four protocols we've discussed as mere adapters around a clean domain core. Together they answer: where does an API boundary go, what should its contract say, and how do I keep REST/gRPC/GraphQL/Kafka out of my business logic. The reference is Vlad Khononov's Learning Domain-Driven Design. + +## [148] APPENDIX F · DDD · Why DDD belongs in an API talk (sldId 403) + +- An API is a contract between two bounded contexts — so “where's the boundary?” and “what does the contract say?” are DDD questions before they're API questions. +- Strategic DDD places the boundaries: subdomains (core, supporting, generic) and bounded contexts, each with its own model and ubiquitous language. +- Tactical DDD models what's inside: aggregates (consistency boundaries), entities, value objects and domain events — which become your resources and your Kafka events. +- Hexagonal architecture keeps it clean: the domain core is isolated, and REST, gRPC, GraphQL and Kafka are just adapters plugged into ports. +- Get the boundaries right and the APIs almost design themselves; get them wrong and no amount of REST polish will save you. + +p. 127 + +> Set up why a DDD appendix belongs in an API talk. The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries. Distinguish strategic DDD (where the lines go) from tactical DDD (how you model inside a line), and preview that hexagonal architecture is what stops the four protocols from contaminating the domain. The closing line is the one to land: boundaries dominate outcomes far more than protocol polish. + +## [149] APPENDIX F · DDD · Strategic DDD: subdomains & bounded contexts (sldId 404) + +Place boundaries by business value and rate of change; each context owns one model and exposes it through an API. + +p. 128 + +> Strategic DDD first. Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications). Each subdomain becomes one or more bounded contexts — a boundary inside which one model and one ubiquitous language are consistent. The punchline here is the bottom box: that boundary is exactly where the API sits, and the contract translates between two contexts' models rather than sharing internals. Map it to our system: order and pricing are core, inventory and shipping supporting, auth and notification generic. + +## [150] APPENDIX F · DDD · A bounded context is a service-and-API boundary (sldId 405) + +- A bounded context is the scope within which one model and one ubiquitous language stay consistent — the natural unit for a service and its API. +- The same word means different things across contexts: an “order” in fulfillment is not an “order” in billing. Don't force one shared model across the system. +- The context's published API is its only public surface; the schema, storage and internal types stay private — this is the “no leaky internal models” rule from anti-patterns, restated. +- Put boundaries where the business naturally splits and where a team can own them end-to-end (Conway's law) — that's where the integration cost is worth paying. + +p. 129 + +> Make the bounded context concrete as the service/API boundary. The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead. Tie back explicitly to the anti-patterns section: 'no leaky internal models' is just 'keep the bounded context's internals private.' And invoke Conway's law: boundaries that don't match team ownership generate constant cross-team coordination. + +## [151] APPENDIX F · DDD · Context mapping: how contexts integrate (sldId 406) + +The relationship patterns between contexts, and which ones your API implements. + +p. 130 + +> Context mapping catalogues the ways two contexts relate. Walk the starred ones because they're what APIs implement: an Open-Host Service is a context that, because many others integrate with it, publishes a stable public API; the Published Language is the shared, versioned schema that API speaks; and an Anticorruption Layer is the translation you build when consuming someone else's context so their model doesn't leak into yours. The others are trade-offs: Conformist means you just accept the upstream model; Shared Kernel means you share a model and therefore change in lockstep — high coupling, use sparingly; Partnership means two contexts co-evolve; Separate Ways means don't integrate. The starred three are the ones to design explicitly. + +## [152] APPENDIX F · DDD · Open-Host Service & Published Language (sldId 407) + +- Open-Host Service: a context integrated by many publishes a stable, documented API as its public protocol — that's your REST / gRPC / GraphQL surface. +- Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry. +- Anticorruption Layer: when you consume another context, translate their model into yours at the edge so their concepts never leak into your domain. +- Conformist is the cheap opposite — accept their model wholesale and inherit their changes; choose an ACL instead when the upstream model is messy or volatile. +- So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer. + +p. 131 + +> This slide does the real work of connecting DDD vocabulary to the platform we built. Open-Host Service = the public API; Published Language = the schema in the registry; Anticorruption Layer = the translation at your edge. The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory. Emphasize the ACL-vs-Conformist choice as a real decision: conform when the upstream is clean and stable, build an ACL when it's messy or changes a lot, because the ACL localizes the blast radius of their changes to one translation layer. + +## [153] APPENDIX F · HEXAGONAL · Hexagonal architecture: ports & adapters (sldId 408) + +The domain core is isolated; REST, gRPC, GraphQL and Kafka are just adapters that plug into ports. + +p. 132 + +> This is the architectural payoff of the whole protocols discussion. The domain core in the middle holds entities, aggregates and domain logic and depends on nothing — no framework, no I/O. It declares ports: inbound ports (what it can be asked to do) and outbound ports (what it needs from the world). Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port. Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports. The arrows all point inward: adapters depend on the domain, never the reverse. The consequence we care about: the four protocols we spent the talk on are interchangeable adapters; you can add gRPC alongside REST, or a Kafka consumer, without touching a line of domain logic. + +## [154] APPENDIX F · HEXAGONAL · Ports & adapters in code (sldId 409) + +[chip] csharp · ports & adapters in .NET + +```csharp +// domain — the core declares the interfaces (ports) it needs; no framework +public interface IOrderRepository { Task SaveAsync(Order order, CancellationToken ct); } +public interface IEventPublisher { Task PublishAsync(IDomainEvent e, CancellationToken ct); } +// pure application logic; C# 12 primary constructor — no DI annotations needed +public class PlaceOrder(IOrderRepository repo, IEventPublisher events) +{ + public async Task HandleAsync(PlaceOrderCmd cmd, CancellationToken ct) + { + var order = Order.Create(cmd); // aggregate enforces invariants + await repo.SaveAsync(order, ct); + await events.PublishAsync(new OrderPlaced(order.Id), ct); + return order; + } +} +// adapter — a driving adapter; gRPC/GraphQL adapters look the same +[ApiController, Route("orders")] +public class OrderController(PlaceOrder placeOrder) : ControllerBase +{ + [HttpPost] public Task Place( + [FromBody] OrderIn body, CancellationToken ct) => + placeOrder.HandleAsync(body.ToCmd(), ct); +} +// outbound adapters (separate project): EfCoreOrderRepository : IOrderRepository, +// KafkaEventPublisher : IEventPublisher. +``` + +The domain depends only on ports it declares; the .NET container injects the adapters (EF Core repo, Kafka publisher). Swap or add one without touching PlaceOrder. + +p. 133 + +> Walk the three blocks. Ports: the domain declares plain C# interfaces, a repository and an event publisher, and depends on nothing else (no ASP.NET Core, no EF Core, no Confluent.Kafka imports anywhere in the domain project). The application service PlaceOrder is pure logic: it creates the aggregate, saves via the port, publishes a domain event via the port, and never references framework types directly. C# 12 primary constructors give you the cleanest possible injection — no [Inject] attribute and no explicit constructor, just the parameter list on the class, available everywhere in the class body. The Microsoft.Extensions.DependencyInjection container resolves them at construction. The REST controller is a thin driving adapter — turn the HTTP body into a command and call the use case; a gRPC service or HotChocolate resolver is the same shape. The outbound ports are implemented by adapters (EfCoreOrderRepository for persistence, KafkaEventPublisher on Confluent.Kafka for events) in separate projects so the domain project has zero infrastructure references — enforced by the .csproj graph. That's dependency inversion serving the boundary: protocols are replaceable, the domain stays stable. For audiences coming from MVC's classic Service → Repository → DAL stack: this is the same instinct, formalised by the .csproj dependency direction (domain depends on nothing; infrastructure depends on domain; web depends on application; nothing depends on web). + +## [155] APPENDIX F · DDD · Aggregates and domain events (sldId 410) + +- An aggregate is a cluster of objects treated as one consistency unit — the boundary inside which invariants hold and a single transaction commits. It's usually what sits behind one API resource. +- That makes the aggregate the natural transaction boundary, and therefore the outbox boundary: the state change and the domain event commit together (section 4). +- Domain events (“OrderPlaced”) are first-class in DDD; they become the Kafka events other contexts react to (section 5) — choreography between bounded contexts. +- Keep aggregates small: one aggregate per transaction, reference others by id, and let events connect them — which is exactly the saga story in Appendix D. + +p. 134 + +> Tactical DDD, and it ties three earlier sections together. The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource. Because it's the transaction boundary, it's also the outbox boundary from section 4: state plus event commit atomically. Domain events are the DDD concept that becomes your Kafka topic payloads, enabling choreography across contexts (section 5). And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D). This slide is where DDD stops being abstract and visibly underpins the data, event and saga designs. + +## [156] APPENDIX F · DDD · How DDD maps to our API stack (sldId 411) + +- Bounded context → a service and its API boundary. +- Open-Host Service / Published Language → the REST/gRPC/GraphQL contract + the Apicurio registry. +- Anticorruption Layer → model translation at the gateway / consumer edge. +- Aggregate → an API resource and the transaction/outbox boundary behind it. +- Domain event → a Kafka event other contexts consume. + - Reference: Vlad Khononov — Learning Domain-Driven Design (O'Reilly, 2021). + +p. 135 + +> The summary map — leave it up while you recap. Every DDD concept on the left has shown up concretely on the right somewhere in the talk: contexts are services, the OHS/Published Language is the registry-backed contract, the ACL is edge translation, aggregates are resources and outbox boundaries, domain events are Kafka events. The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on. Point people to Khononov's Learning Domain-Driven Design for the full treatment. + +## [157] Appendix G · Balancing Coupling (sldId 412) + +G + +Coupling isn't the enemy — imbalance is. + +> Appendix G. We've thrown around “coupled” and “decoupled” throughout as if loose is always better. This sharpens it, using Vlad Khononov's Balancing Coupling model. The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it. Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together. This gives precise language for why a shared database is bad but a registry-backed API contract is fine, and it ties every anti-pattern from section 12 back to one model. + +## [158] APPENDIX G · COUPLING · Coupling is necessary — balance it, don't eliminate it (sldId 413) + +- Components must share some knowledge to collaborate; a system with zero coupling also has zero behaviour. “Loosely coupled” is too blunt a goal. +- Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility. +- The headline: a coupling is only painful when strength, distance and volatility are all high at once — reduce any one and the pain drops. +- This is why “just split it into microservices” can backfire: spreading strong, volatile coupling across a network maximises all three dimensions — a distributed monolith. + +p. 136 + +> Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real skill is balancing it. Credit Khononov's Balancing Coupling model and stress that it measures the connection between components, not the components, along three dimensions. The headline sentence is the whole model in one line — pain needs all three of strength, distance and volatility to be high. The bold line is the cautionary tale that resonates with anyone burned by microservices: splitting strongly- and volatile-coupled code across a network doesn't remove the coupling, it just adds distance to it — the distributed monolith. + +## [159] APPENDIX G · COUPLING · Coupling has three dimensions (sldId 414) + +Integration strength × distance × volatility — a coupling only hurts when all three are high. + +p. 137 + +> The model in one picture. Integration strength is how much knowledge two parts share — the more they share, the more likely a change in one forces a change in the other. Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary. Volatility is how often the coupled thing changes. The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile. A strong, distant coupling on something that never changes costs you nothing. That's why all three dimensions have to be considered together rather than chasing “lose coupling” in the abstract. + +## [160] APPENDIX G · COUPLING · The three dimensions, in practice (sldId 415) + +- Integration strength: how much knowledge two parts share — from intrusive (private internals) down to contract (an explicit interface). Less shared knowledge = looser coupling. +- Distance: how far apart they sit — method, class, module, service, system. Greater distance raises the cost of any change that crosses the boundary. +- Volatility: how often the coupled thing changes. Khononov derives it from DDD subdomain type — core is volatile, generic is stable. +- Modularity = strength XOR distance: keep strong coupling local, and make anything distant a weak (contract) coupling. Being both strong and distant is the trap. + +p. 138 + +> Unpack each dimension with a concrete handle. Strength is a ladder (intrusive to contract). Distance is intuitive — the further apart, the more it costs to change across the seam. Volatility is the clever part of the model: you can't know the future, but you can predict change-rate from DDD subdomain type (core changes a lot, generic barely changes), which connects this appendix straight back to Appendix F. The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith. + +## [161] APPENDIX G · COUPLING · Integration strength: a ladder, worst to best (sldId 416) + +From intrusive (shared internals) to contract (a versioned schema) — across distance, aim for the bottom rung. + +p. 139 + +> Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the classic case is two services on one database, where either can break the other invisibly. Functional coupling: they share business logic or rules and must change together for functional reasons. Model coupling: they share a data model or DTOs — a shared library of types. Contract coupling: they integrate only through an explicit, stable, versioned contract — and that's our REST, gRPC, GraphQL and Kafka schemas in the registry. Lower on the ladder means less shared knowledge and looser coupling. The rule that matters: the greater the distance between two components, the further down this ladder you need to be. + +## [162] APPENDIX G · COUPLING · Strength in our system (sldId 417) + +- Intrusive (worst): two services sharing one database, or reaching into another's internals — any change can break the other invisibly. This is the shared-database anti-pattern. +- Functional / Model: sharing business logic or a model — e.g. a shared library of types. Fine within a context; risky across teams because it forces lockstep change. +- Contract (best across distance): integrate only through an explicit, versioned schema — our REST/gRPC/GraphQL APIs and Kafka event schemas, governed by the registry. +- The rule: the greater the distance, the weaker the coupling must be. Across services that means contract coupling — never shared state. + +p. 140 + +> Map the ladder onto decisions in our system. The shared database is intrusive coupling and it's the worst precisely because the integration strength is maximal and invisible — neither side knows what the other depends on. Shared libraries are model/functional coupling: perfectly fine inside one bounded context or team, dangerous when they force two independently-deployed teams to release together. Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance. The bold rule is the actionable takeaway and it's exactly why every cross-service integration in the talk went through a registered contract rather than a shared table. + +## [163] APPENDIX G · COUPLING · Balancing coupling: strength vs. distance (sldId 418) + +Strong or distant, rarely both — and put any unavoidable strong-and-distant coupling where volatility is low. + +p. 141 + +> This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module — perfectly healthy, keep it local. Low strength and distant is healthy microservices — services integrating through contracts. Low strength and close is needless indirection — you've split things that could just be merged. And the danger quadrant, top-right, is high strength at high distance: the distributed monolith, global complexity, where one change ripples across services and teams. The right-hand panel adds the third dimension: even a top-right coupling is tolerable if it never changes, but on volatile core-domain code it's where slow delivery and outages come from. So the formula: balance equals strength-XOR-distance, OR low volatility. + +## [164] APPENDIX G · COUPLING · Distance and volatility (sldId 419) + +- Distance is the cost multiplier: a change contained in one module is cheap; the same change spanning two services and two teams is slow and expensive. +- Volatility is the trigger: a strong, distant coupling on code that never changes is harmless; on volatile core-domain code it's where outages and slow delivery originate. +- Predict volatility from the domain: core subdomains change often — keep their couplings weak and local; generic subdomains are stable, so you can afford more coupling. +- Balance = (strength XOR distance) OR low volatility — put any unavoidable strong-and-distant coupling where the code rarely changes. + +p. 142 + +> Deepen distance and volatility. Distance is why the same logical change can be a one-line edit or a multi-team, multi-deploy project — it multiplies cost. Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt. The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas. The bold formula is Khononov's balance condition — worth writing on a whiteboard in a design review. + +## [165] APPENDIX G · COUPLING · Coupling and our APIs (sldId 420) + +- Every protocol choice we've made is a coupling choice: REST, gRPC, GraphQL and events are all contract coupling — the loosest kind that still lets services collaborate. +- The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration strength on its consumers. +- Connascence (Page-Jones) is a finer lens on the same idea — API versioning is managing connascence of name and position in your schemas (why protobuf field numbers matter). +- Every anti-pattern from section 12 is an imbalance: shared DB = intrusive at distance; chatty sync chains = strong + distant + volatile; leaky models = model coupling across a boundary. + +p. 143 + +> Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing contract coupling because it's the loosest that still works across distance. The registry's compatibility gate is, in this language, a guard against a producer silently increasing integration strength on consumers — that's what a breaking change is. Mention connascence as the finer-grained classical model (Page-Jones) that versioning manages — protobuf field numbers are connascence of position made explicit and stable. The bold line re-diagnoses the anti-patterns section through the coupling model, which is a satisfying way to show these aren't separate lists of rules but one underlying principle. + +## [166] APPENDIX G · COUPLING · Heuristics for balanced coupling (sldId 421) + +- Don't chase zero coupling — chase balance. Strong coupling is fine when it's local and stable. +- As distance grows, drive strength down: across services, integrate only through versioned contracts, never shared state. +- Keep strong, volatile couplings inside a single bounded context or aggregate, where distance is small. +- Use DDD subdomain types to predict volatility, and put your weakest couplings around the parts that change most. + - References: Vlad Khononov — Learning Domain-Driven Design (O'Reilly) and Balancing Coupling in Software Design (Addison-Wesley). + +p. 144 + +> The closing heuristics — the slide to leave up. Don't eliminate coupling, balance it; lower strength as distance rises; keep strong-and-volatile couplings local (inside an aggregate or context); and use subdomain types to predict volatility so your loosest seams are around your most-changing code. These five lines are a usable design-review checklist. Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices. + +## [167] Appendix H · Graceful Shutdown (sldId 422) + +H + +Disposability, kept as a promise. + +> Appendix H. This is the operational flip side of factor 9 (disposability) and the cloud-native pillars: pods are killed and replaced constantly, so a service has to be able to go away cleanly. Ungraceful exits are the source of the 5xx blips users see during otherwise-healthy rolling deploys. The thesis: graceful shutdown isn't one setting, it's a short protocol between your app and Kubernetes — stop new traffic, finish in-flight work, drain consumers and sockets, then exit before the grace period runs out. + +## [168] APPENDIX H · SHUTDOWN · Disposability is a promise you have to keep (sldId 423) + +- Pods are cattle: every deploy, scale-down, autoscale event or reschedule kills and replaces them — often many times a day. +- An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout. +- Graceful shutdown is the flip side of factor 9, disposability: stop taking new work, finish what you accepted, release resources, then exit. +- It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in. + +p. 145 + +> Set up the appendix by connecting it to two earlier ideas: factor 9 (disposability) from the twelve-factor arc, and the resilience pillar. The motivating story is the one every team has lived: a rolling deploy that looks healthy in tests but throws a scatter of 5xx errors in production because pods were torn down mid-request. Stress that this is not about a magic flag — it's a sequence the app and Kubernetes perform together, on a clock. This appendix walks that sequence, then shows the code on both sides of it. + +## [169] APPENDIX H · SHUTDOWN · What happens when Kubernetes stops a pod (sldId 424) + +SIGTERM starts a race; readiness-down plus a short preStop keep new traffic off until you've drained. + +p. 146 + +> Walk the two lanes. Top, Kubernetes: it sends SIGTERM to PID 1, starts the terminationGracePeriodSeconds clock, and will SIGKILL you if you overrun. The catch is the red box — removing the pod from the Service endpoints is asynchronous (kube-proxy, kubelet, the mesh all have to catch up), so for a moment after SIGTERM traffic can still arrive. Bottom, your pod: the response is to flip readiness DOWN immediately (which starts the deregistration) and run a short preStop sleep to cover the lag, then stop accepting, drain in-flight work, stop consumers and close resources, and exit — ideally well before the grace period ends. The red arrows are the whole point: readiness + preStop are what keep new traffic off your dying pod during the race. + +## [170] APPENDIX H · SHUTDOWN · The contract with Kubernetes (sldId 425) + +- SIGTERM is the signal: Kubernetes sends it to PID 1, then waits terminationGracePeriodSeconds (default 30s) before SIGKILL. +- Readiness DOWN is how you stop new traffic — the pod leaves the Service endpoints but keeps running to finish in-flight work. +- There's a race: endpoint removal is asynchronous, so traffic can still arrive briefly after SIGTERM — a short preStop sleep covers the lag. +- Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down. +- Set the grace period larger than your longest acceptable in-flight operation — too short cuts work off, too long stalls deploys. + +p. 147 + +> These five points are the rules of the protocol. The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining). The asynchronous-deregistration race is the subtle one — it's why a preStop sleep exists at all; it's not about doing work, it's about waiting for the rest of the cluster to stop routing to you. And the sizing rule on the grace period is the practical lever: it must exceed your real worst-case in-flight operation, with headroom, or you'll be SIGKILLed mid-request. + +## [171] APPENDIX H · SHUTDOWN · Graceful means something different per workload (sldId 426) + +Only HTTP drains automatically; Kafka consumers, WebSockets and jobs each need their own drain. + +p. 148 + +> The trap is assuming 'graceful shutdown' is one thing. Only HTTP request draining tends to be automatic (the server stops accepting and finishes in-flight). Everything else you wire up yourself in your shutdown hook. Kafka consumers: stop polling, finish the records already in hand, commit offsets, and leave the group so the rebalance is clean. WebSockets (from Appendix C): you can't finish a long-lived connection, so send a close/go-away and rely on client reconnection plus the per-connection seq and backplane. Jobs and schedulers: stop starting new work and either let running jobs finish or checkpoint so they resume after restart. The green footer is the safety net under all of it — because a hard SIGKILL is always possible, idempotent handlers make a redelivered request or message harmless. + +## [172] APPENDIX H · SHUTDOWN · Graceful shutdown in ASP.NET Core (sldId 427) + +[chip] csharp · .NET 10 + ASP.NET Core + +```csharp +// Program.cs (excerpt), examples/21-graceful-shutdown/dotnet: one SIGTERM drives the drain +builder.Services.AddSingleton(); // not ConsoleLifetime +builder.Services.Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20)); +var app = builder.Build(); +var shuttingDown = false; // GET /readyz answers 503 once it is set +var drainDelay = int.TryParse(Environment.GetEnvironmentVariable("DRAIN_DELAY_SECONDS"), + out var d) && d >= 0 ? d : 5; +PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => +{ + ctx.Cancel = true; // the host does not stop at once + if (shuttingDown) return; // already draining + shuttingDown = true; // 1. readiness fails first + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(drainDelay)); // 2. serve on: endpoints update + app.Lifetime.StopApplication(); // 3. Kestrel stops accepting, finishes in-flight + }); +}); +app.Lifetime.ApplicationStopped.Register(() => dataSource.Dispose()); // 4. close the pool +// ... MapGet /healthz (always 200), /readyz, /orders; app.Run(); +// ManualLifetime : IHostLifetime: WaitForStartAsync and StopAsync return Task.CompletedTask +``` + +One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, StopApplication() lets Kestrel drain in-flight requests within ShutdownTimeout (20 s). + +p. 149 + +> ASP.NET Core's default ConsoleLifetime stops the host as soon as SIGTERM arrives, which leaves no time to fail readiness first. Example 21 replaces it with a ManualLifetime and takes the signal itself through PosixSignalRegistration; one SIGTERM, the only signal Kubernetes sends before SIGKILL, drives everything. The handler cancels the default action and flips a flag, so the hand-written /readyz answers 503 and the Service stops routing, then a task keeps the service serving for DRAIN_DELAY_SECONDS (5 s; it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls StopApplication(): the host signals ApplicationStopping, Kestrel stops accepting and finishes in-flight requests, and every IHostedService gets StopAsync, all bounded by HostOptions.ShutdownTimeout (20 s here; the default is 30 s). ApplicationStopped disposes the data source and the process exits 0, inside the 30-second grace period. The pattern for other work: one BackgroundService per long-running concern (a Kafka consumer, a scheduler, the Temporal worker from Appendix D, the Streamiz processor from Section 6), each draining in its own StopAsync: consumer.Close() commits offsets and leaves the group, a SignalR hub sends close frames, a Temporal worker shuts down. For the WCF refugees: this is the in-box equivalent of ServiceHost.Close, except that SIGTERM triggers it and the budget is HostOptions.ShutdownTimeout rather than a per-binding timeout. The Kubernetes settings (probes, preStop, grace period) are the platform half of the contract. + +## [173] APPENDIX H · SHUTDOWN · The Kubernetes side: probes, preStop, grace period (sldId 428) + +[chip] yaml · Kubernetes + +```yaml +# deployment.yaml — give the pod time to deregister and drain +spec: + terminationGracePeriodSeconds: 30 # >= preStop + drain + buffer + containers: + - name: order-service + readinessProbe: # flips the Pod out of the Service + httpGet: { path: /readyz, port: 8080 } + periodSeconds: 5 + failureThreshold: 2 # ~10s to stop routing after DOWN + livenessProbe: # keeps answering during the drain + httpGet: { path: /healthz, port: 8080 } + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] # cover async endpoint removal +``` + +Readiness removes you from the Service; preStop covers the dereg lag; terminationGracePeriodSeconds must exceed your full drain or you get SIGKILLed mid-flight. + +p. 150 + +> The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover. +> +> Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages. +> +> The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number. + +## [174] APPENDIX H · SHUTDOWN · Beyond HTTP: messages, sockets, jobs (sldId 429) + +- Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook. +- Kafka consumers: stop polling, finish the records in hand, commit offsets, and leave the group so the rebalance is clean (ties to Event-Driven). +- WebSockets: send a close / go-away, let clients reconnect to another pod, and lean on the per-connection seq + backplane from Appendix C. +- Jobs and schedulers: don't start new work; let running jobs finish or checkpoint so they resume after restart. +- A hard SIGKILL is always possible — idempotent handlers make a redelivered request or message safe, which is your real safety net. + +p. 151 + +> Reinforce the per-workload point from the diagram with the concrete drain steps. The Kafka sequence is the one to emphasize because it connects to the at-least-once delivery discussion from the Event-Driven section: committing offsets on the way out minimizes redelivery, and leaving the group makes the rebalance fast. WebSockets can't be 'finished,' so graceful there means handing the client off, which is exactly what the Appendix C reconnection design enables. The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare. + +## [175] APPENDIX H · SHUTDOWN · Graceful shutdown checklist (sldId 430) + +- Handle SIGTERM; flip readiness DOWN first so the Service stops routing before you stop accepting. +- Add a short preStop sleep to cover asynchronous endpoint removal; keep liveness UP throughout the drain. +- Drain in-flight HTTP, commit consumer offsets, close sockets and pools — all bounded by terminationGracePeriodSeconds. +- Make the work idempotent so the inevitable occasional SIGKILL is survivable. +- Test it under a real SIGTERM during a rolling deploy — not just a happy-path integration test. + - References: ASP.NET Core hosting — “BackgroundService / IHostedService” and “IHostApplicationLifetime” on learn.microsoft.com; HostOptions.ShutdownTimeout; Confluent.Kafka consumer Close semantics; Kubernetes “Termination of Pods” documentation. + +p. 152 + +> The leave-up summary. Read it as an ordered runbook: readiness first, preStop to cover the race, then the actual drains, all inside the grace budget. The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event. And the bold final point is the one teams skip: graceful shutdown that's never tested under a real SIGTERM during a rollout is just hopeful configuration. Encourage a small script (or a chaos test) that kills a Pod mid-request and asserts the client still got an answer — a 30-line `dotnet test` integration test with a `kubectl delete pod` step is enough to prove the contract holds. + +## [176] Appendix I · L7 Routing & Traffic Management (sldId 431) + +I + +Software-defined, north/south and east/west. + +> Appendix I. This one is large enough to be its own talk — the user explicitly suggested it could be cut later for that. The thesis: L7 routing isn't one box in a rack any more; it's a four-layer stack of software (edge, gateway, mesh, in-app) that makes decisions on every HTTP and gRPC envelope crossing the network, north/south and east/west. We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with a Confluent.Kafka consumer and NRules, then circle back to east/west, where most decisions happen. + +## [177] APPENDIX I · L7 ROUTING · L7 routing: software, not silicon (sldId 432) + +- The hardware load balancer used to be where routing decisions lived. In a cloud-native system that work has moved into software — programmable, observable, and runnable anywhere. +- L7 means application layer: the router sees the HTTP or gRPC envelope (host, path, method, headers, cookies, gRPC service.method) and can route on any of it. +- It lives in four layers — edge / ingress, API gateway, service mesh, and (sometimes) inside the app itself. Each makes a different type of L7 decision. +- It runs both directions: north/south (clients into your system) and east/west (service to service). Most L7 decisions happen on internal traffic. +- The non-hardware stack is open source and composable: HAProxy, Nginx, Envoy, Istio, Linkerd, Kong, Traefik, YARP, NRules — plus a YAML or two. + +p. 153 + +> Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else. The four-layer mental model (edge, gateway, mesh, in-app) is the backbone of the whole appendix — we'll touch each layer in turn. Stress north/south plus east/west early; people default to thinking about ingress, but inside a microservices system the volume of east/west calls dwarfs north/south, and that's where mTLS, retries and locality routing earn their keep. Name the open-source pieces explicitly so the audience knows the talk is about real, free, runnable software. + +## [178] APPENDIX I · L7 ROUTING · L4 vs L7: what each layer can decide on (sldId 433) + +L4 sees connections; L7 sees the conversation — paths, headers, methods, cookies, gRPC service.method. + +p. 154 + +> Anchor the discussion in the OSI model just enough to be useful. L4 is the layer of IPs, ports and TCP — a classic load balancer can pick a backend by 5-tuple and hash-balance traffic, but it cannot read URLs, headers, cookies, methods or gRPC metadata. L7 sees the full HTTP/gRPC envelope: host, path, headers, cookies, JWT claims, query parameters, methods, and the gRPC service.method pair. That's the entire menu of inputs the routing patterns in this appendix use to make routing decisions. The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower). + +## [179] APPENDIX I · L7 ROUTING · The L7 capability menu (sldId 434) + +- Routing rules: path / host / header / method / cookie / gRPC method → backend. +- TLS termination: decrypt at the edge, re-encrypt (or use mTLS) inward. +- Traffic management: canary, blue/green, A/B, header-based, geo, shadow. +- Payload modification: inject / strip headers, rewrite URLs, transform bodies. +- Resilience: timeouts, retries with backoff + jitter, circuit breakers, outlier ejection — the same toolkit from Appendix E, applied at the network. +- Cross-cutting: authn/authz, rate limiting, WAF, request mirroring, full traces and metrics on every hop. + +p. 155 + +> Read this as the menu of L7 features you can mix and match across layers. The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section. Most of these features used to be hardware appliances; now they're an annotation or a YAML block away. The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification. + +## [180] APPENDIX I · L7 ROUTING · Where L7 lives: the layered stack (sldId 435) + +Four layers, north/south at the top, east/west at the bottom, each making a different type of L7 decision. + +p. 156 + +> This is the backbone diagram for the whole appendix. North/south traffic (clients into the system) enters through the edge/ingress layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API gateway (Kong, Istio gateway, YARP), which handles product-level concerns like auth, rate limiting and transformation. Once inside the cluster, east/west traffic is governed by the service mesh (Istio + Envoy sidecars, or Linkerd) — every internal call gets mTLS, retries, outlier detection and locality-aware load balancing. Finally, when L7 decisions need to consider business rules the platform doesn't know about, you push routing into the app itself (.NET: a Confluent.Kafka consumer + NRules). Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them. + +## [181] APPENDIX I · L7 ROUTING · A request's L7 path (sldId 436) + +One HTTP request is a chain of L7 decisions made by four independent components. + +p. 157 + +> Walk a single request left to right. It starts at the client — say a browser or another service. The edge load balancer terminates TLS, does host/path routing and maybe WAF; the API gateway authenticates the caller, applies rate limits and may transform; the mesh sidecar adds mTLS for the cluster's internal hop, applies retries and locality routing; and finally the service can do its own in-app routing if the business rules demand it. Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end. This is the request lifecycle from section 11, viewed through the L7 lens. + +## [182] APPENDIX I · L7 ROUTING · Open-source L7 building blocks (sldId 437) + +- HAProxy: fast, battle-tested L7 LB; ACL-driven config; classic ingress. +- Nginx: ubiquitous web server / LB; location-block routing; widely used as ingress controller. +- Envoy: the modern L7 proxy; HTTP/2, gRPC, dynamic config; the sidecar of choice. +- Istio: a control plane built on Envoy; CRDs for routing, security, observability across the mesh. +- Kong, Traefik, YARP: opinionated API gateways with plugin ecosystems. YARP (Yet Another Reverse Proxy, MIT, from Microsoft) is the .NET-native one — a reverse proxy library you embed in an ASP.NET Core app for full code-level control. +- Kafka consumer + NRules: when the routing logic is a business rule, push it into the app — a content-based router + a rule engine. + +p. 158 + +> Differentiate the pieces so the audience knows when to reach for which. HAProxy and Nginx are the workhorses of the edge — mature, fast, predictable. Envoy is the modern choice for sidecars because it speaks HTTP/2 and gRPC natively and supports dynamic configuration via xDS. Istio (and Linkerd) wrap Envoy in a control plane and give you the mesh CRDs (VirtualService, DestinationRule). Kong, Traefik and YARP are API gateways with plugin ecosystems and a more product-aware feature set; YARP (MIT, from Microsoft) is the .NET-native option — a library you embed in an ASP.NET Core app for full code-level control of routing, transforms and load-balancing. The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster. + +## [183] APPENDIX I · L7 ROUTING · Content-based routing with HAProxy (sldId 438) + +[chip] haproxy.cfg + +```haproxy.cfg +frontend http_in + bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 # TLS here; h2 for gRPC + acl is_api path_beg /api/ + acl is_grpc req.hdr(content-type) -m beg application/grpc + acl is_internal req.hdr(x-internal) -m str 1 + acl is_canary hdr(cookie) -m sub canary=on + use_backend grpc_pool if is_grpc # by content-type + use_backend order_v2_pool if is_canary # by cookie + use_backend order_int_pool if is_internal # by header + use_backend order_pool if is_api # by path + default_backend web_pool +backend order_v2_pool + balance roundrobin + server v2a 10.1.0.10:8080 check + server v2b 10.1.0.11:8080 check +``` + +ACLs read the L7 envelope (path, header, cookie, content-type) and use_backend dispatches — classic, fast, predictable. + +p. 159 + +> HAProxy is the lingua franca of L7 LBs. The pattern is always the same: declare ACLs that read parts of the L7 envelope (path_beg for URL prefixes, hdr/req.hdr for headers, content-type for the body type), then use_backend dispatches based on those ACLs in order. This single frontend shows four different content-based routing decisions — by content-type (gRPC traffic goes to a gRPC pool), by cookie (canary users), by header (internal users), by path. Notice TLS terminates at this frontend; everything inside is plaintext within the cluster (or re-encrypted via mTLS at the mesh layer). HAProxy's reload semantics are graceful by default — it can drain old workers while new ones take traffic, which matters for Appendix H's shutdown discipline. + +## [184] APPENDIX I · L7 ROUTING · Content-based routing with Nginx (sldId 439) + +[chip] nginx.conf + +```nginx.conf +# map directive — declarative routing table on a header value +map $http_x_app_version $upstream_pool { + default order_v1; + "v2" order_v2; + "v2beta" order_v2_beta; +} +server { + listen 443 ssl; http2 on; + ssl_certificate /etc/nginx/certs/tls.crt; + ssl_certificate_key /etc/nginx/certs/tls.key; + location /api/orders/ { # path-based routing + proxy_pass http://$upstream_pool; # picked by the map + proxy_set_header X-Request-Id $request_id; + } + location ~ ^/api/grpc/ { # gRPC traffic + grpc_pass grpc://orders_grpc_pool; + } +} +``` + +map turns a header value into an upstream pool name; location blocks dispatch by path and protocol — HTTP and gRPC side by side. + +p. 160 + +> Nginx's content-based routing is built around location blocks and the map directive. The map is the elegant bit: it converts a header value into a variable, which then drives proxy_pass — you express the routing table declaratively, then point a single location at the variable. Location blocks split HTTP from gRPC (grpc_pass) so you can run both protocols through one server. The X-Request-Id injection is a tiny example of payload modification — the L7 router can inject correlation headers so downstream traces can be stitched together. Nginx as an ingress controller in Kubernetes (the ingress-nginx project) is one of the most-deployed L7 routers in the world. + +## [185] APPENDIX I · L7 ROUTING · Content-based routing in the mesh: Istio VirtualService (sldId 440) + +[chip] yaml · Istio VirtualService + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: # gRPC by service.method + - uri: { prefix: /acme.orders.v1.OrderService/ } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by header + - headers: { x-internal: { exact: "1" } } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by cookie + - headers: { cookie: { regex: ".*canary=on.*" } } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] # default +``` + +Match by URI prefix (gRPC service.method), header, or cookie — same L7 inputs, expressed as Kubernetes CRDs. + +p. 161 + +> The same content-based routing primitives, but expressed as Kubernetes CRDs that the mesh control plane (Istio) compiles down to Envoy sidecar configuration. Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie. The default route at the bottom is critical; without it, unmatched requests have nowhere to go. The CRD lives in Git, gets reviewed like any other change, and Istio reconfigures every sidecar in the cluster within seconds. This is the modern face of L7 routing — declarative, GitOps-managed, distributed across thousands of pods. + +## [186] APPENDIX I · L7 ROUTING · SSL / TLS termination at L7 (sldId 441) + +- Edge terminates TLS so the L7 router can read and route on the request — you cannot inspect headers or paths through an encrypted connection. +- Re-encrypt or mTLS inward: terminate at the edge, then either plaintext within the cluster (rare today) or mTLS via the mesh (the modern default). +- Certificate management: cert-manager + Let's Encrypt for public certs; SPIFFE / SDS for service identity inside the mesh; Istio rotates pod certs automatically. +- TLS passthrough (no termination) when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only. +- Cost: TLS handshakes are real CPU. Modern stacks use session resumption, TLS 1.3 and HTTP/2 to amortise the cost across many requests. + +p. 162 + +> Cover the three modes of TLS at the edge: terminate-and-decrypt (the common case, lets you do L7 things), terminate-and-re-encrypt (most secure default in a mesh, since mTLS inside is automatic), and passthrough (when you must not see the payload, e.g. client mTLS for regulated traffic — route by SNI only). cert-manager is the standard cluster-side answer for public certificates; SPIFFE-style identity and Envoy's SDS handle service-to-service certs automatically inside the mesh. Mention the CPU cost briefly so the L7 latency budget makes sense — a TLS handshake is milliseconds, and you want to amortise it via session resumption, TLS 1.3 and HTTP/2 multiplexing. + +## [187] APPENDIX I · L7 ROUTING · Sticky sessions: cookie vs IP-hash vs none (sldId 442) + +Convenient for legacy state, hostile to elastic scaling — prefer stateless behind a JWT. + +p. 163 + +> Three modes worth knowing about. No affinity is the default and what you want — any pod gets any request, load is even, and client state lives somewhere else (Redis, the JWT, the database). Cookie-based stickiness is what you fall back to when something stateful (a shopping cart in memory, a legacy session) has to stay with one pod; the LB sets a cookie on the first response and uses it to route subsequent requests to the same pod. It works behind any L7 LB but ties the user's session to one pod's lifetime — restart that pod, lose the session. IP-hash hashes the source IP to a pod; it needs no cookies but is fragile to NAT and mobile networks where many users share an IP, leading to skewed load. The red footer is the real cost: stickiness fights elastic scaling, hurts pod restarts and breaks scale-to-zero. Use it only for a known constraint, never as a default. + +## [188] APPENDIX I · L7 ROUTING · Sticky sessions in practice (sldId 443) + +- When you need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod. +- Cookie-based is almost always the right choice over IP-hash: it survives NAT and lets clients explicitly drop the cookie. +- What stickiness breaks: even load balancing, fast pod restarts, scale-to-zero, blue/green flips, and a clean Appendix H graceful shutdown for any stateful pod. +- The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable. + +p. 164 + +> Make stickiness a last resort, not a first reach. Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it. The list of things stickiness breaks is the real argument against it: it actively fights the entire cloud-native delivery model — elastic scaling, fast restarts, graceful shutdown. The bold final point is the architectural fix: externalise the state. Redis for session data, JWT claims for user identity, the WebSocket backplane from Appendix C for fan-out. With that done, your LB is back to no-affinity and the platform works the way it wants to. + +## [189] APPENDIX I · L7 ROUTING · Intelligent traffic steering (sldId 444) + +Six familiar shapes — each is a header or weight rule away. + +p. 165 + +> Six patterns that all share the same L7 primitives. Canary: 90/10 weighted routing to a new version, watching metrics and ramping. Blue/green: two identical environments, traffic flipped atomically (and rolled back atomically). A/B test: a stable cohort maps to a variant via a user identifier so the same user always sees the same variant — critical for valid measurement. Header-based: dark launches and internal-only previews by routing on x-internal or similar. Geo / latency: send EU users to EU pods for RTT and data residency. Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load. The point is they're not different things mechanically; they're the same routing rule expressed with different inputs and weights. The difficult part is the deploy discipline around them: alerts that trigger an automatic rollback, gradual ramps, and the metrics that tell you the canary is bad before users do. + +## [190] APPENDIX I · L7 ROUTING · Canary release: weighted Istio routing (sldId 445) + +[chip] yaml · Istio canary + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - route: + - destination: { host: orders, subset: v1 } + weight: 90 # 90% on the stable version + - destination: { host: orders, subset: v2 } + weight: 10 # 10% on the canary +--- +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: orders } +spec: + host: orders + subsets: + - name: v1 + labels: { version: v1 } + - name: v2 + labels: { version: v2 } +``` + +VirtualService splits traffic by weight; DestinationRule defines the subsets by pod labels — the canonical canary release pattern. + +p. 166 + +> The canonical Istio canary. Two CRDs working together: DestinationRule defines subsets by pod label (version=v1 and version=v2), and VirtualService routes by weight to those subsets. Changing the weights in Git and reconciling the cluster ramps the canary — typically through 10%, 25%, 50%, 100% over hours or days, with metric gates between each step. If error rate or latency on v2 crosses a threshold, an automated controller (Flagger, Argo Rollouts) flips the weights back to 100/0 in seconds. The same weighted-routing primitive does shadow traffic, geo balance, and blue/green flips — the only thing that changes is which subsets you target and what the weights are. + +## [191] APPENDIX I · L7 ROUTING · Header-based steering: dark launches (sldId 446) + +[chip] yaml · Istio header-based + +```yaml +# route internal beta users to v2; everyone else stays on v1 +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: + - headers: + x-acme-beta: { exact: "true" } # set by your auth proxy + route: [{ destination: { host: orders, subset: v2 } }] + - match: + - headers: + user-agent: { regex: ".*AcmeInternal.*" } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] +``` + +First match wins; an internal-only header (set upstream by auth) hands real production traffic to v2 — with zero blast radius. + +p. 167 + +> Header-based steering is how you do dark launches and betas safely. An upstream identity proxy sets an x-acme-beta header for opted-in users, and the VirtualService routes those users to v2 — in real production traffic, on real data, but with zero exposure to anyone outside the beta. First match wins, so order the rules from most-specific to least, ending with the catch-all default. This is also how you do internal-only previews (route by an internal user-agent string) and per-tenant feature gates (route by an x-tenant-id). Combined with weighted routing, you can do 'roll the canary forward only for the beta cohort,' which is the most surgical release pattern in the toolbox. + +## [192] APPENDIX I · L7 ROUTING · Payload modifications (sldId 447) + +- Header injection: X-Request-Id, traceparent, X-Forwarded-For, identity claims — propagate context downstream so the trace stitches together. +- Header stripping: drop Authorization or PII headers before forwarding to a less-trusted backend; strip Server / X-Powered-By outbound. +- URL rewrites: /api/v1/orders → /orders internally; clean public URLs over messy service paths. +- Body transformation: rare but possible — protocol translation, redaction, format conversion (Envoy filters, Camel transforms). +- Cost: body inspection means reading the full request into memory — the slowest L7 thing you can do. Use it sparingly, never on every request. + +p. 168 + +> Distinguish the cheap modifications from the expensive ones. Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers. Body transformation is the expensive one because it forces the router to buffer the full payload, which kills streaming, blows memory on large requests and can become a bottleneck under load. Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate). The bold point is the practical advice: if you can do it in the headers, do it in the headers. + +## [193] APPENDIX I · L7 ROUTING · Header injection and URL rewrites (sldId 448) + +[chip] yaml · Envoy / Istio + +```yaml +# Envoy / Istio: inject identity headers, strip secrets, rewrite paths +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders.acme.io] + http: + - match: [{ uri: { prefix: /api/v1/orders } }] + rewrite: + uri: /orders # external -> internal path + route: [{ destination: { host: orders } }] + headers: + request: + set: + x-client-ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" # caller address + x-peer-identity: "%DOWNSTREAM_PEER_URI_SAN%" + remove: [ authorization, cookie ] # don't leak inward + response: + remove: [ server, x-powered-by ] # don't leak outward +``` + +Rewrite paths, inject the caller address and identity, strip secrets — all declaratively in the VirtualService. + +p. 169 + +> Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders. +> +> The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none. +> +> The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved. + +## [194] APPENDIX I · L7 ROUTING · Beyond the edge: routing inside the app (sldId 449) + +- Sometimes the routing decision is a business rule the platform doesn't know about: route 'high-value-customer claims' here, 'European HL7 messages of type A03' there. +- Pushing those rules into HAProxy ACLs or VirtualService matches does not scale: domain experts own them, they change often, and they depend on the payload, not just the envelope. +- The pattern: an in-app router (a Kafka consumer, Wolverine routing, a custom dispatcher) calls a rule engine (NRules) and routes on the result. +- It runs inside one service, behind the mesh — not on the network. The mesh still does mTLS, retries and locality; the rule engine just decides where to send the message next. + +p. 170 + +> Pivot from network-layer routing to in-app routing. The threshold: if the decision fits in a few YAML lines and depends only on the envelope, do it at the mesh. If it depends on the payload, needs a decision table that domain experts edit, or involves many intersecting conditions, push it into the app. +> +> The classic example is HL7 healthcare messages, routed by patient class, encounter type and message subtype: dozens of conditions a non-developer maintains. That belongs in a rule engine, not a VirtualService. In-app routing sits behind the mesh and complements it. +> +> In .NET the router is a plain Confluent.Kafka consumer (or Wolverine's routing), and NRules (MIT, Rete-based, the same algorithm as Drools) is the open-source decision engine. The rule-based-routing slide shows them together. + +## [195] APPENDIX I · L7 ROUTING · .NET + Confluent.Kafka + NRules: rule-based routing (sldId 450) + +A Kafka consumer owns the plumbing; NRules owns the decision logic; the whole thing runs in-process behind the Istio mesh. + +p. 171 + +> The .NET take on rule-based routing. Inputs arrive over REST, gRPC or Kafka and land in a Confluent.Kafka consumer, which does two things: it enriches the message with context from an HTTP lookup (customer, region, tier), then evaluates the enriched fact in an NRules session. +> +> NRules is an MIT-licensed Rete rules engine, the algorithm Drools uses. Rules are C# classes with a fluent Match/Do API, so the IDE catches typos and the test runner runs them, or external NRules.Language files when domain experts edit the rules on their own. The rules set a WhereTo property on the fact, and the consumer dispatches to each destination: a Kafka topic or an HTTP endpoint. +> +> Everything runs in the ASP.NET Core Pod, with no separate rule server, behind Istio, which still owns mTLS, retries and locality. The running example keeps the rules in a runtime-editable table instead of NRules; the shape is the same. + +## [196] APPENDIX I · L7 ROUTING · A Kafka consumer in .NET, calling NRules (sldId 451) + +[chip] csharp · .NET 10 + Confluent.Kafka + NRules + +```csharp +// NRules + Confluent.Kafka — the consumer enriches, evaluates, dispatches. +// ... record OrderFact(OrderId, AmountCents, Customer) with a settable WhereTo +public class OrderRouter(HttpClient http, ISessionFactory rules, + IProducer producer) +{ + public async Task Route(OrderInbound msg) + { + var customer = await http.GetFromJsonAsync( // 1) enrich + $"http://customer-svc/lookup/{msg.OrderId}") ?? throw new NotFoundException(); + var fact = new OrderFact(msg.OrderId, msg.AmountCents, customer); + var session = rules.CreateSession(); // 2) evaluate; one session per message + session.Insert(fact); session.Fire(); // matching rules set fact.WhereTo + var body = JsonSerializer.Serialize(msg); // 3) dispatch: kafka:, http: + foreach (var dest in (fact.WhereTo ?? "kafka:orders.default").Split(',')) + { + var parts = dest.Split(':', 2); + if (parts[0] == "kafka") + await producer.ProduceAsync(parts[1], + new Message { Value = body }); + else if (parts[0] == "http") + await http.PostAsync(parts[1], + new StringContent(body, Encoding.UTF8, "application/json")); + } + } +} +``` + +enrich → NRules evaluate → dispatch to Kafka or HTTP: the content-based router, with the decision externalised to NRules. + +p. 172 + +> The .NET reading of the Camel and Drools pattern, on Confluent.Kafka. OrderRouter takes an HttpClient for enrichment and destinations, an NRules ISessionFactory, and a producer. +> +> The flow: enrich the message by calling customer-svc (attach Polly pipelines through Microsoft.Extensions.Http.Resilience, as in Appendix E); create a session per message, because sessions are not thread-safe; insert the fact and call Fire(), which runs every rule whose pattern matches and sets WhereTo; then dispatch to each destination, producing to a Kafka topic or posting to an HTTP endpoint. +> +> NRules.Language, also MIT, parses an external rule file into the same model, so domain experts can change routing without rebuilding the consumer; the NRules rule slide shows the C# class form. The EIP semantics match Apache Camel; the API is C#-native. + +## [197] APPENDIX I · L7 ROUTING · An NRules rule: the decision, externalised (sldId 452) + +[chip] csharp · NRules.Fluent (or external NRules.Language DSL) + +```csharp +[Name("High-value VIP orders go to the priority queue")] +public class HighValueVipRule : Rule +{ + public override void Define() + { + OrderFact order = default!; // declare the fact binding + When() + .Match(() => order, + f => f.AmountCents > 50_000, + f => f.Customer.Tier == "VIP"); + Then() + .Do(ctx => order.WhereTo = "kafka:orders.priority,http:notify-vip"); + } +} +// ... EuropeanOrderRule: Customer.Region == "EU" sets WhereTo = "kafka:orders.eu" +[Name("Default route")] [Priority(-100)] // salience equivalent — fires last +public class DefaultRouteRule : Rule +{ + public override void Define() + { + OrderFact order = default!; + When().Match(() => order, f => f.WhereTo == null); + Then().Do(ctx => order.WhereTo = "kafka:orders.default"); + } +} +``` + +Each rule is a small business statement: when X is true, then route to Y. Compile-time-checked, IDE-friendly, unit-testable — or move to NRules.Language for domain-expert ownership. + +p. 173 + +> NRules rules are 'When/Then' statements, structurally identical to Drools 'when/then'. When().Match() is a pattern match against facts in the working memory (here, OrderFact), with one or more predicates that all must hold. Then().Do() is the action — in our case, setting the WhereTo property the router will consume. Two real rules and a default with Priority -100 (salience equivalent) so it fires last if nothing else matched. The point isn't the syntax: these rules are first-class C# classes (type-safe, refactor-friendly, unit-testable through NRules.Testing) and live in their own .csproj. For domain experts who'd rather work outside C# (closer to the Drools .drl experience), NRules.Language is a separate sub-project that parses an external DSL into the same rule model, so non-developers can edit and redeploy the rule file without rebuilding the consumer. Decision tables: if your rules fit a spreadsheet shape (the DMN sweet spot), the .NET ecosystem doesn't ship a strong open-source DMN runtime; the pragmatic answer is to run Apache KIE (Kogito) DMN as a separate service and call it via REST from the same enrichment step, or implement the table convention in NRules. The toolkit appendix (Appendix J) lists that gap. + +## [198] APPENDIX I · L7 ROUTING · Every L7 hop costs time (sldId 453) + +Typical p50/p99 added per stage — the budget you spend before your service even runs. + +p. 174 + +> Set expectations with the numbers. Each L7 capability has a typical cost: TLS termination is sub-2ms, path/header routing is sub-1ms, auth/JWT validation is a few ms, WAF a few more, mesh sidecar another millisecond per hop, rate-limit Redis lookup is a couple of ms. Body inspection is the outlier — it can be 5 to 50 ms depending on size, which is why we say to use it sparingly. The numbers are typical, not authoritative; profile your own stack. The right-side card has the working heuristic: stay under ~10ms total L7 on hot paths. With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency. The retry-storm discipline from Appendix E applies here too — timeouts must account for the total L7 budget plus the service's own work, or retries will pile up. + +## [199] APPENDIX I · L7 ROUTING · L7 pitfalls and how to avoid them (sldId 454) + +- Too many hops: edge → gateway → ingress → mesh sidecar in → mesh sidecar out adds up fast. Collapse layers that aren't earning their place. +- Regex-heavy ACLs / matches: each regex evaluation is real CPU. Prefer exact / prefix matches; use regex only when needed. +- Sticky sessions break elasticity without any error: the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone. +- Body inspection on every request: blows memory, kills streaming. If you must transform bodies, do it on a small percentage or just at the edge. +- Sync auth lookups on the hot path: cache validated tokens, validate JWT signatures locally, never do a per-request DB lookup for identity. +- Misaligned timeouts and retries between layers — a 30s gateway timeout + 3 mesh retries with 10s each = a retry storm at the bottom (see Appendix E). + +p. 175 + +> Run quickly through the failure modes. The hop-count one is the most common: every layer feels free until you measure p99 — then you realise the request crossed seven proxies. The regex point is a perf footgun many people miss. The sticky-session one ties back to the sticky-sessions comparison. Body inspection is the latency-budget killer. The auth-lookup one is the most common cause of cascading slowdowns under load. And the timeout-misalignment is the gateway-to-mesh-to-service version of the retry storm we covered in Appendix E — the lesson is identical: bound retries, align timeouts top-down, give the deepest layer the strictest budget. + +## [200] APPENDIX I · L7 ROUTING · East / West: L7 between services, inside the mesh (sldId 455) + +Every service-to-service call is L7-routable through the sidecars. + +p. 176 + +> Most L7 decisions in a microservices system don't happen at the edge — they happen east/west, between services, every time service A calls service B. The mesh's sidecar pair handles that traffic: mTLS between services (encryption and identity), HTTP/2 multiplexing, native gRPC support, retries with backoff and jitter, outlier detection (eject misbehaving pods automatically), locality-aware load balancing (prefer same-zone pods to keep RTT low), header-based routing and canary weights for east/west versions, plus rich per-call traces and metrics. The headline is the second box: all of that, per call, with no app code. Application services keep talking plain HTTP or gRPC; the sidecars make it secure, reliable and observable in flight. This is the modern answer to 'how do you build a microservices system that works in production.' + +## [201] APPENDIX I · L7 ROUTING · Why east/west L7 matters (sldId 456) + +- Internal traffic dominates: in a microservices system the volume of east/west calls dwarfs north/south by 10x or more. +- Security: mTLS everywhere by default — every internal call has an identity, encryption and an audit trail without per-service code. +- Resilience: timeouts, retries, circuit breakers and outlier ejection live in the sidecar, applied uniformly to every internal hop. +- Observability: every internal call gets a span, latency histogram and per-route success rate — the request lifecycle from section 11, populated automatically. +- Locality: prefer same-zone or same-region pods so internal RTT stays in single-digit milliseconds, and graceful failover when a zone goes. +- Routing decisions: header-based canaries and weighted releases work east/west too — you can canary a backend service that has no external traffic at all. + +p. 177 + +> Hammer the volume point first: most production L7 routing happens between services, not at the edge. The security argument is the one that justifies the mesh on its own — mTLS everywhere is otherwise hard to retrofit. The resilience and observability arguments are the operational ones; you stop writing the same boilerplate in every service. Locality is the perf argument: with the right Istio config, two pods in the same zone talk over a few milliseconds; cross-region traffic gets degraded gracefully. And the last point is non-obvious but important: canary releases of a deeply internal service (say, a payments calculation engine that no client touches directly) are possible because the mesh can split east/west traffic by weight — you cannot canary internal services through an edge LB. + +## [202] APPENDIX I · L7 ROUTING · Istio for east/west: DestinationRule + VirtualService (sldId 457) + +[chip] yaml · Istio east/west + +```yaml +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: payments } +spec: + host: payments.acme.svc.cluster.local + trafficPolicy: + # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10 + outlierDetection: # eject bad pods + consecutive5xxErrors: 5 + interval: 30s + baseEjectionTime: 30s + # ... loadBalancer: localityLbSetting enabled (prefer same zone) + subsets: + - { name: v1, labels: { version: v1 } } + - { name: v2, labels: { version: v2 } } +--- +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: payments } +spec: + hosts: [payments.acme.svc.cluster.local] # internal DNS — east/west + http: + - route: + - { destination: { host: payments, subset: v1 }, weight: 95 } + - { destination: { host: payments, subset: v2 }, weight: 5 } +``` + +Same CRDs as north/south, but pointed at an internal host — outlier detection, locality LB, canary weights, all between services with no app code. + +p. 178 + +> Same Istio primitives as the weighted canary, but applied to an internal service (note the cluster.local host). DestinationRule sets up the connection pool (HTTP/2 multiplexing for gRPC), enables outlier detection (5 consecutive 5xxs in 30s ejects the pod), enables locality-aware load balancing (same-zone preferred), and defines version subsets. VirtualService weights east/west traffic 95/5 between v1 and v2 — a canary release of a backend service that no external client knows exists. This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars. + +## [203] APPENDIX I · L7 ROUTING · gRPC east/west specifics (sldId 458) + +- gRPC is HTTP/2 under the hood, so it works natively in any L7 router that speaks HTTP/2 (Envoy, Istio, modern HAProxy/Nginx). +- Route by gRPC service.method: the URI path is /package.Service/Method — use a uri prefix match in Istio, grpc_pass in Nginx, or path_beg in HAProxy. +- Header-based balancing matters more for gRPC: connections are long-lived and multiplexed, so least-request or per-request load balancing beats round-robin. +- Streaming RPCs (server-streaming, client-streaming, bidi): retries are dangerous if the stream is non-idempotent — cap retries to UNARY calls in the mesh policy. +- Trailers carry status: a 200 + a non-OK trailer is the gRPC equivalent of a 5xx — outlier detection has to be configured to read trailers, not just the status line. + +p. 179 + +> gRPC has a few sharp edges of its own. Route on the gRPC URI prefix to do per-method canaries. Use least-request (or random-3-choices) load balancing instead of round-robin because gRPC connections are long-lived — a few long-running streams can starve other backends if you round-robin connections instead of requests. Be careful with retries on streaming RPCs: the mesh can retry a UNARY call safely if it's idempotent, but retrying a half-completed streaming RPC will replay half the work — Istio lets you scope retries to specific gRPC status codes and request kinds. And the trailer point is subtle: gRPC returns its real status in HTTP trailers (a 200 with a non-OK gRPC status is a failure), so outlier detection has to know to read those, or it will think a failing service is healthy. + +## [204] APPENDIX I · L7 ROUTING · When to use which L7 layer (sldId 459) + +- Edge / ingress: TLS termination, host/path routing, WAF, public-facing rate limits. Anything north/south that affects all clients. +- API gateway: cross-cutting product policy — auth, per-consumer rate limits, request transformation, monetisation, key management. +- Service mesh: east/west routing, mTLS, retries, outlier detection, canaries between internal services. The default home for service-to-service L7. +- In-app router (a Kafka consumer + NRules): when routing depends on business rules, payload content, or decisions that domain experts must own. +- Rule of thumb: prefer the lowest layer that can fully express the decision, and never push business rules into mesh CRDs. + +p. 180 + +> This is the decision guide to leave on screen for a beat. The mental model: each layer has a job it's good at, and pushing work to the wrong layer is where complexity comes from. Edge/ingress for the things every client crosses. API gateway for product-shaped policy. Mesh for east/west and everything internal. In-app for business rules. The bold rule of thumb prevents the common antipattern of growing increasingly baroque Istio VirtualService configs to encode rules that domain experts need to maintain — if it's a business rule, it belongs in a rule engine inside a service, not in a network CRD. + +## [205] APPENDIX I · L7 ROUTING · L7 routing checklist (sldId 460) + +- Pick one ingress, one gateway, one mesh — don't run three competing L7 layers because each team had a favourite. +- TLS terminates at the edge; mTLS between services in the mesh; cert rotation is automatic (cert-manager + SDS), not a quarterly outage. +- Routing rules live in Git, reviewed like code; GitOps reconciles them; rollback is a git revert. +- Every L7 layer emits traces and metrics — sample the spans, alert on per-route SLOs, watch for retry budget exhaustion. +- Stickiness is an exception, not a default; business rules belong in a rule engine inside a service, not in network YAML. +- Test under load and under failure: bring a pod down, eject a zone, ramp a canary, verify the mesh does what the YAML says. + - References: HAProxy / Nginx / Envoy docs; Istio traffic management guide; Confluent.Kafka .NET client documentation; NRules documentation (Fluent API + NRules.Language + NRules.Testing); YARP docs for the reverse-proxy library. For DMN specifically, the Apache KIE (Kogito) DMN docs — run as a separate service called over REST when you need decision tables. + +p. 181 + +> Leave the checklist up at the end. The first point is the most common organisational failure — a cluster ends up running three ingress controllers because three teams each picked one. Pick one and standardise. The cert-rotation point is the one teams underestimate — expired certs are still a top-3 outage cause in many shops. GitOps for routing is the operational discipline that makes the whole thing reviewable. Observability and the load/failure-test point are non-negotiable. The references sub-bullet is the reading list: official docs for each piece, plus the NRules samples. The architecture itself is language-neutral — the same pattern lives behind any service-mesh stack in any language; we've just made it concrete in .NET. + +## [206] Appendix J · .NET 10 Toolkit & Migration (sldId 461) + +J + +Pinned stack, expanded migration table, known gaps. + +> Appendix J is the reference appendix — the tables you keep open in another window while you build. Three diagrams: every NuGet library in your csproj, every cloud-native platform component your Pods rely on, and an expanded version of the Section 1 bridge diagram with concrete migration rationale. Then the gaps in the .NET cloud-native story, and a short closing recipe to start a new cloud-native service. Built specifically for the .NET-developers-moving-from-Windows audience this whole deck is aimed at. + +## [207] APPENDIX J · TOOLKIT · The pinned .NET 10 toolkit (sldId 462) + +Every library in your csproj: purpose, license, NuGet package. All MIT, Apache 2.0 or BSD-3; no cloud-vendor lock-in. + +p. 182 + +> The application-side bill of materials: every NuGet library used in the deck, grouped by concern, each MIT, Apache 2.0 or BSD-3, with no commercial-only dependency and no cloud-only service. +> +> Highlights: Microsoft.AspNetCore.OpenApi and SignalR are part of ASP.NET Core 10; Confluent.Kafka 2.16.0 is the Kafka client for every producer and consumer, chosen because it keeps the Kafka semantics visible (MassTransit was dropped: it hides those semantics and moved to a commercial license in version 9); Streamiz.Kafka.Net 1.8.2 is the Kafka Streams model in .NET, so windowed aggregations need no Flink cluster; Temporalio pairs with the Temporal Server for durable workflows; Stateless covers the lightweight state-machine slot; NRules covers rules; YARP and Wolverine appear where they fit. +> +> The running examples pin every package exactly on .NET 10.0.12: OpenTelemetry 1.19.1, Npgsql 10.0.3, Grpc 2.84.0, StackExchange.Redis 3.4.0, HotChocolate 16.6.8, OpenFeature 2.14.1. Everything runs on plain Kubernetes; nothing requires a cloud-specific service. + +## [208] APPENDIX J · TOOLKIT · The pinned cloud-native platform stack (sldId 463) + +The cluster-side OSS components your .NET Pods rely on — standard CNCF-friendly stack. + +p. 183 + +> The platform half of the bill of materials. These components are what your .NET Pods talk to — the substrate the cluster provides. None of them know or care that your services happen to be .NET; the same stack would serve a Spring Boot, Quarkus or Python deployment identically. That's the point: the .NET library choices in the toolkit table are entirely separable from the platform choices on this one. If you're already running this platform with a Java or Python application, you can drop .NET services in alongside without changing anything under the application layer. A few callouts: Strimzi is the Kubernetes-native Kafka operator (open-source, Apache 2.0); Apicurio is the open-source schema registry that holds your OpenAPI / .proto / Avro contracts; OpenMetadata is the asset-catalog/lineage tool; LGTM (Tempo + Mimir + Loki + Grafana) is Grafana Labs' open-source observability stack (note the AGPL-3.0 licensing if that's a concern for you); PostgreSQL is the primary OLTP store; Redis (or its BSD-licensed Valkey fork) is the SignalR backplane and distributed-lock substrate; Temporal Server pairs with the Temporal .NET SDK from the application stack. The bottom line: standard cloud-native stack, your .NET Pods are just one workload type running on it. + +## [209] APPENDIX J · MIGRATION · From Windows / .NET 4.x to Kubernetes / .NET 10 (sldId 464) + +Concrete patterns, side by side, with a one-line rationale per row. Print this and keep it on your desk. + +p. 184 + +> The expanded version of the bridge diagram from Cloud-Native Principles, a 22-row table with four columns: the concern, what you came from in the Windows world, what it becomes in the cloud-native world, and what changes most. Read the rightmost column first; that is where the architectural shift lives. +> +> Almost every row keeps the shape of the pattern (request handlers, background workers, scheduled jobs, distributed locks, health checks, configuration, telemetry) and changes who owns the substrate. IIS owned the host; Kubernetes does now. MSDTC owned distributed transactions; the saga and outbox do. MSMQ owned messaging; Kafka with Confluent.Kafka does. Active Directory owned service identity; SPIFFE and mTLS through Istio do. Performance counters and the Event Log owned telemetry; OpenTelemetry with Loki, Tempo and Mimir does. +> +> Two rows are conceptual rewrites, not substrate swaps. COM+ and MSDTC distributed transactions become sagas, with compensations written explicitly because there is no two-phase commit across services. BizTalk orchestration becomes a Temporal workflow or a Kafka consumer with NRules routing: orchestration is C# in your repository, not an artifact in a separate runtime. + +## [210] APPENDIX J · GAPS · What the .NET cloud-native story lacks (sldId 465) + +- Native DMN runtime — .NET doesn't ship an open-source DMN (Decision Model and Notation) engine. If you need spreadsheet-style decision tables for non-developers, run a DMN engine such as Apache KIE (Kogito) as a separate service and call it over REST from your enrichment step. NRules covers the rule-engine slot but not the DMN UX. +- Apache Camel ecosystem — nothing in .NET matches the breadth of Camel's 300+ component connectors. For integration with long-tail systems (HL7, AS2, EDI, mainframe connectors) you may still want a Camel sidecar in a JVM Pod. +- Stream processing — Streamiz.Kafka.Net is excellent but is one project (one maintainer, MIT) compared to Kafka Streams' larger contributor base. Flink remains the heavyweight option — typically as a separate JVM service if your stream workloads are central. +- DI complexity — Microsoft.Extensions.DependencyInjection is simple by design. For property injection, decorators, or advanced lifetime scopes, look at Autofac (MIT) or Lamar (MIT) — both drop-in replacements. +- MediatR is no longer open source — it moved to a commercial license in 2025. Wolverine (MIT) is the modern OSS equivalent; consider it for new projects. +- These are real gaps with known workarounds: in each case there is either a workable .NET path or a polyglot path. The .NET cloud-native story is solid for >95% of cases; the remaining 5% is where you mix runtimes or accept a different trade-off. + +p. 185 + +> Every ecosystem has gaps; naming them keeps trust. (1) DMN: the closest open-source option in .NET is a decision-table convention on top of NRules; for spreadsheet authoring, decision-table semantics and the FEEL expression language, run Apache KIE (Kogito) as a separate service. +> +> (2) Camel breadth: Apache Camel has about 300 components, from HL7 over MLLP to FTP and mainframe gateways. An integration shop with heavy mainframe, EDI or HL7 needs may run Camel in a JVM Pod and call it from .NET. (3) Streamiz scale: it works well, but its maintainer count and ecosystem are smaller than Kafka Streams'; if streams are central, plan for that. +> +> (4) DI: Microsoft.Extensions.DependencyInjection is simple enough that complex patterns sometimes want Autofac. (5) Licensing moves: MediatR and MassTransit both moved to commercial licenses; many teams pick Wolverine (MIT) to replace MediatR, and this deck uses Confluent.Kafka directly instead of MassTransit. For LOB and SaaS workloads these gaps rarely bite; for integration platforms or heavy stream processing, plan for a polyglot runtime. + +## [211] APPENDIX J · RECIPE · The five-line recipe for a new .NET 10 cloud-native service (sldId 466) + +- 1. dotnet new web; add Microsoft.AspNetCore.OpenApi, AspNetCore.HealthChecks.NpgSql, OpenTelemetry.Extensions.Hosting. Map health checks at /healthz and /readyz; expose ActivitySource-based telemetry from line one. +- 2. Pick exactly the protocols you need: ASP.NET Core minimal APIs for REST at the edge, Grpc.AspNetCore for internal service-to-service, HotChocolate only if you need GraphQL aggregation. Don't surface all three by default. +- 3. For data: EF Core for the OLTP store, with the outbox table in the same DbContext as your aggregates. Add Debezium to Kafka via Strimzi. Produce and consume with Confluent.Kafka directly; it keeps the Kafka semantics (keys, partitions, offsets) visible. +- 4. For state across calls: Temporal .NET SDK if the saga is durable, long, or cross-service; Stateless if it's small and in-process. Persist every transition; never trust in-memory state to survive a Pod restart. +- 5. Ship it on Kubernetes with KEDA scaling on lag (Kafka) or RPS (HTTP); behind Istio for mTLS and retries; with OpenTelemetry exporting to LGTM. Use Asp.Versioning for media-type evolution; never invent your own. + - Six libraries get you 95% of the way: ASP.NET Core, EF Core, Confluent.Kafka, Temporalio, OpenTelemetry, Polly. Add NRules / Streamiz / HotChocolate / SignalR per workload — only when the use case earns them. + +p. 186 + +> Close the appendix with an opinionated recipe. The order of the five steps is the point: instrument first (factor XI and observability), then choose the protocol surface (avoid the one-of-each trap), then settle data ownership (outbox plus DbContext), then choose the workflow tier (Temporal or Stateless), then deploy with the cluster doing the heavy lifting. +> +> The six-library sub-bullet is the one to leave up: a new .NET cloud-native service that starts with those packages, on the platform stack from the platform table, has most of the patterns in this deck working from day one. SignalR, Streamiz, NRules and HotChocolate are opt-in per workload. The audience leaves with a recipe and the reference tables. + +## [212] Appendix K · Monolith to Microservices (sldId 467) + +K + +Patterns that survive the migration with the system still running. + +> Appendix K covers the migration patterns. The thesis: you don't decompose a monolith by stopping it and rewriting; you decompose it incrementally, one bounded asset at a time, with the system serving users the whole way through. We'll walk through the monolith as the starting point, modular monoliths as a frequently-correct destination, the strangler-fig family for progressive extraction, content-based routing for fine-grained cutover, and the decorating collaborator for wrapping legacy code you can't touch. Code in this appendix uses ASP.NET Core 10 + IHttpClientFactory + IDistributedCache + StackExchange.Redis + Confluent.Kafka + FusionCache, but the patterns are language-neutral. + +## [213] APPENDIX K · MONOLITH · A monolith is a unit of deployment (sldId 468) + +One process, one binary, one heartbeat. All modules live or die together. Often the right starting point — don't be ashamed of it. + +p. 187 + +> Define the term before fixing it. A monolith is not a code-organisation problem — it's a deployment-organisation property. Even a well-organised ASP.NET Core codebase with clear Controllers, Services, and 1000 unit tests is a monolith if it ships as one image on one heartbeat. The defining property: change anywhere, redeploy everything; a bug in Reporting can take down Payments; one scaling unit for workloads that scale differently. Monoliths are not always wrong. For new products, small teams, or anything that fits in one team's head, they're often the simplest and fastest path to value — especially with .NET 10's fast cold-start. Premature decomposition is its own anti-pattern. The conversation is: when does this monolith start hurting more than it helps? + +## [214] APPENDIX K · MODULAR MONOLITH · The modular monolith — often the right destination (sldId 469) + +Still one deployable, but with strict in-process module boundaries enforced by build tooling — not by gentlemen's agreement. + +p. 188 + +> The intermediate pattern that's often the right destination, not a stepping stone. Modules within one .NET process have clear public APIs and private internals that other modules cannot reach. In the .NET world, enforcement uses multi-project solutions: each module is a separate .csproj with explicit ProjectReference graphs in CI — ArchUnitNET tests the dependency rules. .NET 8+ also added internal-by-default semantics for new project templates which helps enforce module-internal types. The .NET ecosystem doesn't ship a runtime module-graph framework out of the box, but the discipline is identical — separate projects, internal types per assembly, ArchUnitNET in CI. Modules can share a database, but each owns a schema and no cross-schema joins are allowed. + +## [215] APPENDIX K · DECOMPOSED DBS · Modular monolith with decomposed databases (sldId 470) + +Data ownership becomes per-module — the hardest part of the split, done while still one process. The next step is mechanical. + +p. 189 + +> Splitting the database is the hard part of decomposition. Do that work while you're still one .NET process. Each module gets its own EF Core DbContext, its own DataSource, its own EF Migrations history. ASP.NET Core makes this clean — multiple AddDbContext registrations in Program.cs, each pointing at its own connection string from configuration (appsettings.json sections or environment variables), each module's repositories typed against the right DbContext. Cross-module data is now async or event-driven (an in-process event dispatcher such as Wolverine for module events, Confluent.Kafka for cross-service events). Once this is done, promoting a module to a separate service is a refactor of the deployment topology, not a data-migration project. + +## [216] APPENDIX K · STRANGLER FIG · Identify → move → redirect (sldId 471) + +Named after the strangler fig vine: grow the new system around the old until the old can be removed. + +p. 190 + +> The Martin Fowler strangler-fig pattern. The three steps you repeat. Identify: pick a bounded asset — 'order creation' or 'order status query' specifically. The smaller the better. Move: build the new ASP.NET Core service alongside the monolith, replicate the behaviour, test it in parallel without production traffic (often by shadowing reads through the new service and comparing outputs in a side-by-side test harness). REDIRECT: switch callers to the new service. Each cycle is independently reversible — if the new service is wrong, redirect back. There are three ways to do the redirect: via a proxy, via a proxy plus partial routing, or via a shared database. + +## [217] APPENDIX K · STRANGLER FIG · With a proxy — step zero of every migration (sldId 472) + +Introduce the routing layer first, before extracting anything. Then redirection becomes a config change, not a release. + +p. 191 + +> The pre-condition for incremental migration: clients hit the proxy, the proxy forwards to the monolith. Day zero, behaviour is unchanged. But you now have the lever — the proxy is where you'll later route 1% of /orders/* traffic to the extracted .NET service, then 5%, then 100%. Without this step, every cutover is a coordinated client release. With it, every cutover is a proxy config change. For .NET deployments this is typically nginx, Envoy, or Istio gateway as a Kubernetes Service ahead of your application Pods — or YARP (the Microsoft-built reverse proxy library, MIT licensed) as an in-app gateway if you prefer C# control of routing. + +## [218] APPENDIX K · STRANGLER FIG · With proxy + redirection (sldId 473) + +The proxy splits traffic between the new service and the shrinking monolith. Roll forward by widening the route; roll back by narrowing. + +p. 192 + +> The proxy is what makes this work: by path, by header (X-Tenant: acme), by percentage (1% canary), or by feature flag (Microsoft.FeatureManagement). The new ASP.NET Core service handles its slice; the monolith handles everything else. Clients still see one URL. Roll forward by widening; roll back by narrowing. The new service can fail completely and the monolith picks up — bounded blast radius is the whole point. Instrument both paths via OpenTelemetry so you can verify the new service produces the same outcomes as the monolith before widening. For .NET deployments this typically lives in Istio VirtualService rules, nginx server blocks, or YARP RouteConfig + cluster predicates. + +## [219] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 474) + +Process boundary first, data boundary later. Useful intermediate state, not a destination. Schema coupling will bite eventually. + +p. 193 + +> Sometimes the data split is too risky to do at the same time as the process split. The new .NET service and the monolith share a database for a while. Both apps read and write the same tables; the new service owns the API but not the data. Useful for extracting reads first, or as a stepping stone. It is an intermediate state, not a destination: every EF Core migration now needs both apps to agree, and the coordination tax grows. The next step is to give the new service its own store: CDC out of the monolith's DB into the new service's via Debezium (running as a Kafka Connect connector), or dual-writing during a brief sync window, or event-sourcing the data into the new service via Marten / EventStore. + +## [220] APPENDIX K · ROUTING · Content-based routing during migration (sldId 475) + +[chip] csharp · ASP.NET Core 10 minimal API + IHttpClientFactory + +```csharp +// ASP.NET Core minimal API + IHttpClientFactory — content-aware proxy that +// reads the request body and forwards to the new service or the monolith. +// Program.cs +builder.Services.AddHttpClient("monolith", c => c.BaseAddress = new("http://monolith.svc:8080")); +builder.Services.AddHttpClient("orders", c => c.BaseAddress = new("http://orders.svc:8080")); +builder.Services.AddHttpClient("ordersEu", c => c.BaseAddress = new("http://orders-eu.svc:8080")); +app.MapPost("/orders", async (HttpContext ctx, IHttpClientFactory factory) => +{ + var body = await ctx.Request.ReadFromJsonAsync() + ?? throw new BadHttpRequestException("empty body"); + // decide by payload content, not URL + var tenant = body["tenant"]?.GetValue() ?? ""; + var region = body["region"]?.GetValue() ?? ""; + var upstream = (tenant, region) switch + { + ("acme", _) => factory.CreateClient("orders"), // new service + (_, "EU") => factory.CreateClient("ordersEu"), // regional + _ => factory.CreateClient("monolith") // default: legacy + }; + using var response = await upstream.PostAsJsonAsync("/orders", body); + return Results.Stream( + async stream => await response.Content.CopyToAsync(stream), + response.Content.Headers.ContentType?.ToString()); +}); +``` + +Read the body, decide by content (tenant / region / feature flag), forward via IHttpClientFactory named clients. + +p. 194 + +> The .NET shape. Three named HttpClients registered via IHttpClientFactory — the canonical pattern for managed HttpClient lifetimes in ASP.NET Core (per-request socket reuse, DNS refresh, Polly resilience policies, OpenTelemetry instrumentation all attach here). The minimal API reads the body as a JsonObject (System.Text.Json), inspects payload fields, and uses a C# switch expression to pick the upstream. PostAsJsonAsync is the one-line serialize+POST. The decision is by payload content: tenant, region, feature flag — not URL pattern. For .NET running behind a real proxy (nginx, Envoy, Istio, YARP), express the same logic in Lua / WASM filters at the gateway. The CPU cost of body parsing is the only real downside — over months it adds up; over a migration window of weeks it's irrelevant compared to the risk control it buys. Add Polly resilience pipelines (Microsoft.Extensions.Http.Resilience) on the AddHttpClient lines for retry / circuit-breaker / timeout on the upstream calls. + +## [221] APPENDIX K · DECORATOR · The decorating collaborator (sldId 476) + +The new service wraps the legacy one, adds capability, then becomes the API. Useful when you can't modify the legacy code. + +p. 195 + +> Different from the proxy patterns: the decorator owns behaviour, not only routing. The new ASP.NET Core service sits in front of the legacy API and adds cross-cutting capability: a Redis cache, Kafka events with Confluent.Kafka, audit logging, validation, enrichment, rate limiting, OpenTelemetry metrics. The legacy service is unaware. +> +> Clients talk to the decorator's API, which delegates the underlying work and contributes everything else. It is most useful when the legacy system is a third-party product, a vendor system, or fragile code you may not touch. The decorator is a full .NET microservice with its own deploy, scaling and data; the running example builds it on StackExchange.Redis and Confluent.Kafka. + +## [222] APPENDIX K · DECORATOR · A decorating collaborator in ASP.NET Core (sldId 477) + +``` +csharp · ASP.NET Core 10 + StackExchange.Redis + Confluent.Kafka +``` + +``` +// Wraps the legacy /orders API: Redis cache, Kafka events. +app.MapGet("/orders/{id}", async (string id, HttpContext ctx) => +{ + var cached = await redis.GetDatabase().StringGetAsync($"order:{id}"); + if (cached.HasValue) // 1. cache hit + return Results.Content(cached!, "application/json"); + var resp = await legacyClient.GetAsync($"/orders/{id}"); // 2. legacy fallback + var json = await resp.Content.ReadAsStringAsync(); + await redis.GetDatabase().StringSetAsync( + $"order:{id}", json, TimeSpan.FromSeconds(60)); // 3. populate + return Results.Content(json, "application/json"); +}); +app.MapPost("/orders", async (HttpContext ctx) => +{ + using var body = await JsonDocument.ParseAsync(ctx.Request.Body); + var resp = await legacyClient.PostAsync("/orders", + new StringContent(body.RootElement.GetRawText(), Encoding.UTF8, "application/json")); + var json = await resp.Content.ReadAsStringAsync(); + using var order = JsonDocument.Parse(json); + var orderId = order.RootElement.GetProperty("id").GetString()!; + await producer.ProduceAsync("order.placed", // Confluent.Kafka + new Message { Value = json }); + await redis.GetDatabase().KeyDeleteAsync($"order:{orderId}"); // invalidate cache + return Results.Content(json, "application/json", statusCode: 201); +}); +``` + +The decorator adds caching, event emission — capabilities the legacy service can't add on its own. + +p. 196 + +> The running example's decorator: an ASP.NET Core minimal API in front of the legacy /orders API, with StackExchange.Redis for the cache and Confluent.Kafka for events. +> +> The GET path is cache-aside: try Redis, fall back to the legacy service on a miss, populate the key with a 60-second TTL. The POST path delegates to the legacy service, which stays the source of truth, then produces order.placed and deletes the cached entry so the next read is fresh. +> +> The legacy service has no idea it is wrapped. Over time you move more behaviour into the decorator (validation, idempotency keys, deduplication, multi-source reads); when the legacy service retires, the decorator owns the storage and the API clients see does not change. + +## [223] APPENDIX K · DECISIONS · Choosing the right migration pattern (sldId 478) + +- Modular monolith first, microservices later — with multi-project .NET solutions and ArchUnitNET enforcing module boundaries, the cheapest decomposition is often inside one ASP.NET Core process. Many systems never need to leave this state. +- Decompose the database before decomposing the deployment. The data split is the hard part; doing it with multiple AddDbContext registrations is much cheaper than coordinating across services. +- Always introduce the proxy first (YARP, nginx, or Istio) — the routing layer is what makes incremental cutover possible. No proxy = no incremental migration. +- Strangler with shared database is a transition state. Plan its end before you start it. EF Core migration coordination is debt that accrues. +- Content-based routing when you need to cut over by tenant / region / segment instead of by URL. Costs CPU at the proxy; buys precise risk control. +- Decorating collaborator when you need to add behaviour to a system you can't modify. Most useful for vendor / 3rd-party / legacy-mainframe wrappers. +- Reversibility is the design property to optimise for. Every migration step should be undo-able by a config change, not a release. + +p. 197 + +> Close the appendix with the decision rules. The first point is the most important and most often skipped — with multi-project solutions and good build discipline, a modular monolith is frequently a fine destination. Microservices have real operational cost. The second point is the most underrated: the database split is where projects get stuck for years; do it early, while still one .NET process. The third point: no proxy, no incremental migration. The fourth: shared-database strangler is OK as a means but never as an end. The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release. Migrations fail when they reach an irreversible step too early. + +## [224] Appendix L · Caching patterns (sldId 479) + +L + +Six patterns for keeping a cache useful and your data correct. + +> Appendix L is the caching reference. Five reasons to cache and six patterns to do it well. The patterns differ in who reads/writes the cache, when, and who owns the consistency story. We use Redis as the canonical cache because it's the default open-source choice on Kubernetes — same patterns apply to in-memory caches or any KV store. Code uses IDistributedCache (Microsoft.Extensions.Caching.StackExchangeRedis) for the standard patterns, IConnectionMultiplexer (StackExchange.Redis) for advanced operations, and FusionCache for the read-through / declarative pattern. + +## [225] APPENDIX L · WHY CACHE · Five things a cache earns you (sldId 480) + +- Application performance — a cache hit is microseconds; a database round trip is milliseconds. The hot path becomes ~100x faster on hits. +- Backend load — caching shields the database from repeated reads of unchanged data. Read-heavy workloads can offload 80-95% of queries with a sensible TTL. +- Throughput — the same Pod fleet serves dramatically more requests because it spends less time waiting on the DB. Vertical scaling without buying anything. +- Predictable performance — p99 latency stops being a function of DB load. Tail latencies flatten because the hot path doesn't contend on the same lock-bound resource as everyone else. +- Database cost — fewer queries means smaller (or fewer) DB instances, smaller IOPS budgets, less replication bandwidth. Cache is almost always cheaper than the DB tier it offloads. + - Trade-off: every cache adds a consistency story, an invalidation story, and a failure-mode story. Pick the pattern that matches the consistency you need. + +p. 198 + +> Five concrete reasons that justify the caching investment. Application performance is the user-facing win. Backend load matters more in steady state — shielding the database is what lets your DB tier stay smaller. Throughput follows from both. Predictable performance flattens p99. Database cost often pays for the caching infrastructure. The sub-bullet is what trips teams up: every cache adds complexity; pick the pattern that matches the consistency you need. + +## [226] APPENDIX L · CACHE-ASIDE · Cache-aside (look-aside) — the default (sldId 481) + +The application reads cache, falls back to DB, populates cache. Explicit and simple; the most common pattern. + +p. 199 + +> The pattern everyone starts with. The ASP.NET Core application is in charge: try IDistributedCache first, on a miss query EF Core, populate the cache on the way back, return. Every other pattern in this appendix differs in who does the work. Pros: explicit, debuggable, no magic. Cons: every read has the if/else; cache miss costs two round trips; cache logic is repeated across services if you don't extract it. The TTL is the consistency knob — longer = fewer DB hits, more stale-read risk. Use when you want explicit control and reads dominate writes. + +## [227] APPENDIX L · CACHE-ASIDE · Cache-aside in ASP.NET Core with Redis (sldId 482) + +[chip] csharp · ASP.NET Core 10 + EF Core + IDistributedCache + +```csharp +[ApiController, Route("products")] +public class ProductsController(ShopDb db, IDistributedCache cache) : ControllerBase +{ + private static readonly DistributedCacheEntryOptions TTL = + new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) }; + [HttpGet("{id}")] + public async Task> Get(string id, CancellationToken ct) + { + var key = $"product:{id}"; + var cached = await cache.GetStringAsync(key, ct); // 1. cache lookup + if (cached is not null) return Ok(JsonSerializer.Deserialize(cached)); + if (await db.Products.FindAsync([id], ct) is not { } p) return NotFound(); // 2. DB + await cache.SetStringAsync(key, JsonSerializer.Serialize(p), TTL, ct); // 3. populate + return Ok(p); + } + [HttpPut("{id}")] + public async Task> Update( + string id, Product body, CancellationToken ct) + { + // ... FindAsync (404 when missing), p.Update(body), SaveChangesAsync + await cache.RemoveAsync($"product:{id}", ct); // 4. invalidate + return Ok(p); + } +} +``` + +Try cache; on miss, EF query and populate. Invalidate on write. Three operations; the rest is naming conventions. + +p. 200 + +> Minimal cache-aside in ASP.NET Core. IDistributedCache is the Microsoft.Extensions.Caching abstraction; AddStackExchangeRedisCache wires it to Redis. The cache stores byte arrays (or strings); we JSON-serialize Product manually for type fidelity. The handler tries the cache first, falls back to EF Core's FindAsync on miss, populates the cache with a 60-second TTL, returns. The PUT path removes the cache entry after a successful DB write — invalidate-on-write. Note: IDistributedCache has no typed get/set API; it is low-level by design. For typed caching with retry, hedging and cache-stampede protection out of the box, FusionCache (the read-through slide) is the modern idiomatic alternative. For Redis failures, IDistributedCache's calls can throw RedisConnectionException — wrap in try/catch (or use Polly's resilience pipelines via Microsoft.Extensions.Http.Resilience) so a Redis outage falls back to EF Core and the read still works. For high-cardinality keys, configure Redis with maxmemory-policy allkeys-lru so the cache doesn't grow unbounded. + +## [228] APPENDIX L · READ-THROUGH · Read-through — the cache fetches on miss (sldId 483) + +Application sees one interface; the cache library handles DB fallback transparently. + +p. 201 + +> The variation where the cache library, not the application, calls the database on a miss. The .NET BCL doesn't ship a declarative read-through cache, but FusionCache (MIT, open-source) is the modern idiomatic answer — it gives you GetOrSetAsync with a factory function (the loader), built-in stampede protection, fail-safe fallback, and L1+L2 layering (in-memory + Redis). Pros: cleaner application code, single read interface, built-in protection from cache stampedes (no thundering herd). Cons: you're adding a library; less control over what triggers a DB hit. Use when read paths are repetitive and you want declarative caching with safety nets. Alternative for the conservative shop: HybridCache (new in .NET 9, Microsoft-built, similar semantics to FusionCache but Microsoft-supported). + +## [229] APPENDIX L · READ-THROUGH · Read-through in ASP.NET Core with FusionCache (sldId 484) + +[chip] csharp · ASP.NET Core 10 + FusionCache (MIT, open-source) + +```csharp +// FusionCache (MIT): GetOrSetAsync is read-through, one factory call per missing key. +// Program.cs: AddFusionCache() with Redis as L2, a 60 s duration, 5-minute fail-safe. +[ApiController, Route("products")] +public class ProductsController(ShopDb db, IFusionCache cache) : ControllerBase +{ + [HttpGet("{id}")] + public async Task> Get(string id, CancellationToken ct) + { + var product = await cache.GetOrSetAsync( + $"product:{id}", + async token => await db.Products.FindAsync([id], token), // loader + token: ct); + return product is null ? NotFound() : Ok(product); + } + [HttpPut("{id}")] + public async Task> Update( + string id, Product body, CancellationToken ct) + { + if (await db.Products.FindAsync([id], ct) is not { } p) return NotFound(); + p.Update(body); + await db.SaveChangesAsync(ct); + await cache.RemoveAsync($"product:{id}", token: ct); // invalidate + return Ok(p); + } +} +``` + +GetOrSetAsync is read-through. Factory runs only on miss. Built-in stampede protection. + +p. 202 + +> FusionCache is the modern .NET answer for declarative read-through caching — MIT licensed, actively maintained, with features the BCL caches don't have out of the box (stampede protection, fail-safe with stale-fallback, two-level L1+L2 cache). The Program.cs registration shows the typical setup: AddFusionCache, configure a JSON serializer, pair with IDistributedCache (Redis), set default duration and fail-safe behaviour. The controller uses GetOrSetAsync with a factory. The factory runs only on a cache miss, and FusionCache's built-in concurrency control ensures that if 100 concurrent requests hit the same missing key, the factory runs once and the other 99 await the result: no thundering herd. The fail-safe option (SetFailSafe) is the killer feature: when the factory throws (DB outage), FusionCache returns the stale cached value if available rather than propagating the failure. The trade vs IDistributedCache: you add a library dependency (24KB DLL), get safer caching out of the box. Alternative: HybridCache (Microsoft.Extensions.Caching.Hybrid, .NET 9+) gives similar L1+L2 semantics but is newer and the API is slightly different. + +## [230] APPENDIX L · WRITE-THROUGH · Write-through — cache and DB written synchronously (sldId 485) + +Every write hits both the cache and the DB. Cache is never stale; writes pay both latencies. + +p. 203 + +> When stale reads are not acceptable, write-through forces every write to update both stores. The ASP.NET Core handler calls SaveChangesAsync on the DbContext and SetStringAsync on the cache; both must succeed. Pros: a read immediately after a write sees the new value. Cons: write latency is the sum of both stores; you have to handle the case where the cache write succeeds but the DB write fails (or vice versa). The discipline: write the DB first inside a SaveChanges call, write the cache second, accept that a failure between them leaves a brief inconsistency. Short TTL self-heals. + +## [231] APPENDIX L · WRITE-THROUGH · Write-through in ASP.NET Core (sldId 486) + +[chip] csharp · ASP.NET Core 10 + IDistributedCache or FusionCache + +```csharp +// Two flavours: explicit IDistributedCache write, or FusionCache.SetAsync. +[ApiController, Route("products")] +public class ProductsController( + ShopDb db, + IDistributedCache cache) : ControllerBase +{ + private static readonly DistributedCacheEntryOptions TTL = + new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) }; + [HttpPut("{id}")] + public async Task> Update( + string id, Product body, CancellationToken ct) + { + if (await db.Products.FindAsync([id], ct) is not { } p) return NotFound(); + p.Update(body); + await db.SaveChangesAsync(ct); // 1. DB first + await cache.SetStringAsync($"product:{id}", // 2. cache second + JsonSerializer.Serialize(p), TTL, ct); // set, not remove + return Ok(p); + } +} +// FusionCache flavour — SetAsync writes the value directly. +// L1 (memory) and L2 (Redis) both get updated; backplane invalidates other Pods' L1. +// ... same controller on IFusionCache: await cache.SetAsync($"product:{id}", p, token: ct); +``` + +DB first (source of truth), cache second (set, not remove). FusionCache.SetAsync handles L1+L2 + backplane. + +p. 204 + +> Two write-through flavours. The first uses IDistributedCache directly: save EF Core changes, then SetStringAsync to update the cache with the new value (set, not remove — the difference between write-through and cache-aside-with-invalidate). The second uses FusionCache.SetAsync, which has a significant advantage in multi-Pod deployments: FusionCache's backplane (Redis pub/sub by default) automatically invalidates the L1 (in-memory) cache on all other Pods when one Pod writes — critical because each Pod's L1 cache otherwise has its own stale view. For mission-critical durability, consider transactional outbox: write the cache invalidation event to an outbox table inside the SaveChanges transaction, then Debezium drains it to Kafka, then a consumer invalidates the cache — this guarantees the cache invalidation happens iff the DB transaction commits, at the cost of a brief invalidation delay. + +## [232] APPENDIX L · WRITE-AROUND · Write-around — writes skip the cache (sldId 487) + +Writes go to the DB only. The next read populates the cache lazily. Good for write-heavy, low-re-read workloads. + +p. 205 + +> When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful: most cached entries expire before being read again. Write-around solves this: write the DB, leave the cache alone. The next reader pays the cache-miss cost and populates the cache. Pros: no cache pollution by write-only data. Cons: the first read after every write is slow (cache miss); reads of recently-written data are slower than under cache-aside. Pair with cache-aside or FusionCache.GetOrSetAsync on the read side. Common use: any 'append-only' or 'mostly-written' workload — audit entries, IoT telemetry. + +## [233] APPENDIX L · WRITE-AROUND · Write-around in ASP.NET Core (sldId 488) + +[chip] csharp · ASP.NET Core 10 + EF Core + IDistributedCache + +```csharp +// Append-heavy data: writes hit the DB only; reads use cache-aside. +[ApiController, Route("events")] +public class EventsController(ShopDb db, IDistributedCache cache) : ControllerBase +{ + [HttpPost] + public async Task> Record(Event body, CancellationToken ct) + { + db.Events.Add(body); + await db.SaveChangesAsync(ct); // DB only, cache untouched + return CreatedAtAction(nameof(Get), new { id = body.Id }, body); + } + [HttpGet("{id}")] + public async Task> Get(string id, CancellationToken ct) + { + var key = $"event:{id}"; + var cached = await cache.GetStringAsync(key, ct); + if (cached is not null) return Ok(JsonSerializer.Deserialize(cached)); + var e = await db.Events.FindAsync([id], ct); + if (e is null) return NotFound(); + await cache.SetStringAsync(key, JsonSerializer.Serialize(e), + new DistributedCacheEntryOptions { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60) }, ct); + return Ok(e); + } +} +``` + +Writes skip the cache; first read populates. Avoids cache thrash from one-time writes. + +p. 206 + +> Write-around: the POST writes to the database only and leaves the cache alone; the GET is plain cache-aside, so the first read after a write populates the cache. +> +> The pattern fits workloads with a high write-to-re-read ratio. A security event log might take 10,000 writes a minute and 100 reads a day for active alerts; caching every write would only churn entries. Compare a product catalog, where every read is a likely hit and write-through makes more sense because readers should see the writer's update at once. + +## [234] APPENDIX L · WRITE-BACK · Write-back (write-behind) — fast writes, eventual durability (sldId 489) + +Writes return immediately after touching the cache; a background flusher persists to the DB asynchronously. + +p. 207 + +> The fastest-write pattern, with the most caveats. The ASP.NET Core handler writes to Redis and returns immediately — the cache is the source of truth for that window. A background flusher drains the cache to the DB, batching writes for efficiency. Pros: write latency equals cache latency — microseconds, not milliseconds; massive throughput. Cons: a cache crash before the flusher catches up loses unflushed writes; Redis must be configured for persistence (AOF or RDB), and even then you might lose seconds of data. Use only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've thought about what 'lost the last 5 seconds of writes' means for your business. + +## [235] APPENDIX L · WRITE-BACK · Write-back with a background flusher in ASP.NET Core (sldId 490) + +[chip] csharp · ASP.NET Core 10 + StackExchange.Redis + Npgsql + +```csharp +// Program.cs (excerpt), examples/25-caching/dotnet: write-back +app.MapPut("/write-back/metrics/{mid}", async (string mid, HttpContext ctx) => +{ + // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down) + return Results.Json(new { ok = true, pattern = "write-back", action = "cached-for-flush" }, + statusCode: StatusCodes.Status202Accepted); // 202: queued, not yet durable +}); +_ = Task.Run(async () => // the flusher, started at boot +{ + var failures = 0; + while (!cts.Token.IsCancellationRequested) + { + await Task.Delay(Math.Min(FlushPeriodMs << failures, MaxFlushBackoffMs), cts.Token); + var ids = await db.SetPopAsync("metric:dirty", 100); // atomic batch dequeue + if (ids.Length == 0) continue; + var failed = new List(); // Upsert: HGETALL, INSERT ... ON CONFLICT (id) + foreach (var mid in ids) + try { await Upsert(mid); await db.HashDeleteAsync("metric:attempts", mid); } + catch (Exception ex) { logger.LogError(ex, "flush failed"); failed.Add(mid); } + if (failed.Count == 0) { failures = 0; continue; } + failures = Math.Min(failures + 1, 3); // back off: 2 s, 4 s, 8 s + await Requeue(db, failed); // HINCRBY metric:attempts: dirty again, failed after 5 + } +}); +``` + +A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed. + +p. 208 + +> Write-back in .NET, as Example 25 runs it. PUT /write-back/metrics/{mid} writes the metric fields to a Redis hash, adds the id to a 'dirty set' that tells the flusher what to drain, and answers 202 Accepted in microseconds: the value is queued, not yet durable. The flusher is a background loop started with the app (a BackgroundService works the same way) that SPOPs up to 100 dirty ids at once. SPOP is the right primitive: it removes and returns elements in one call, so two flusher instances would each get a distinct subset of ids and horizontal scale works. Each id is upserted with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice stays one row. An id whose upsert fails is re-queued: HashIncrementAsync counts its attempts in metric:attempts and it goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in the cache and the failure in the log; the loop then waits two, four and at most eight seconds between runs. Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale. + +## [236] APPENDIX L · REFRESH-AHEAD · Refresh-ahead — keep hot keys warm (sldId 491) + +A background task refreshes hot keys before their TTL expires. Users never see a cold miss on popular items. + +p. 209 + +> The pattern for eliminating the cache-miss latency cliff on popular keys. Plain cache-aside has a sharp behaviour: while a key is cached, reads are fast; the moment its TTL expires, the next reader pays the slow DB-fetch path. Refresh-ahead pre-empts this: a BackgroundService watches the cache (or maintains a Redis sorted set of 'hot' keys) and refreshes their values from the DB before the TTL fires. Users always read warm cache. Pros: no user-visible misses on hot keys. Cons: extra DB load for keys that may not be requested again. FusionCache also has 'eager refresh' as a built-in feature — set EagerRefreshThreshold and it'll refresh on access when a value is close to expiry. + +## [237] APPENDIX L · REFRESH-AHEAD · Refresh-ahead in ASP.NET Core (sldId 492) + +``` +csharp · BackgroundService + StackExchange.Redis sorted set or FusionCache eager-refresh +``` + +``` +// Two approaches: (1) BackgroundService + sorted-set hot-key tracking, +// (2) FusionCache built-in EagerRefreshThreshold. +// ... ProductService.FindAsync: ZADD product:hot , then cache-aside on product:{id} +public class HotKeyRefresher( + IConnectionMultiplexer redis, + IServiceScopeFactory scopes) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken ct) + { + var r = redis.GetDatabase(); + while (!ct.IsCancellationRequested) + { + await Task.Delay(5000, ct); + // remove keys not touched in 5 minutes; refresh those with low TTL + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-5).ToUnixTimeMilliseconds(); + await r.SortedSetRemoveRangeByScoreAsync("product:hot", 0, cutoff); + var hotIds = await r.SortedSetRangeByRankAsync("product:hot"); + // ... for each hot id with a TTL under 10 s: reload from ShopDb, SET with a fresh TTL + } + } +} +// Approach 2: FusionCache built-in eager refresh +// builder.Services.AddFusionCache().WithDefaultEntryOptions(o => +// o.Duration = TimeSpan.FromSeconds(60) +// .SetEagerRefresh(0.8f)); // refresh when 80% of TTL elapsed +``` + +Hot keys live in a Redis sorted set scored by last-access time. Or use FusionCache's built-in EagerRefresh. + +p. 210 + +> Two approaches. Approach 1 uses an explicit sorted-set (ZADD) to track hot keys, scored by last-access timestamp. The FindAsync method touches the hot-set on every read. A HotKeyRefresher : BackgroundService runs every 5 seconds: removes keys not accessed in 5 minutes, then for each remaining hot key checks its TTL via KeyTimeToLiveAsync, refreshes if TTL is below 10 seconds. Use DistributedLock.NET around the refresh loop if you want only one Pod refreshing at a time. Approach 2 is simpler: FusionCache has a built-in 'eager refresh' feature — set SetEagerRefresh(0.8f) and FusionCache will trigger an async background refresh when a value is accessed and has used 80% of its TTL. The user-facing read still returns the cached value (no waiting); the refresh happens in the background. Most production .NET shops use Approach 2 — it's one line of config and FusionCache handles all the locking and stampede prevention. This pattern shines for read-heavy workloads where a small set of keys serves most traffic and miss latency is user-visible. + +## [238] APPENDIX L · ANTI-PATTERNS · When not to cache, and what to watch for (sldId 493) + +- Don't cache by default. Caching adds a consistency story, an invalidation story, and a failure-mode story. If reads are already fast, just don't add a cache — the cheapest cache is no cache. +- Don't cache rarely-read data. Caching write-once-read-never data thrashes your cache and gains nothing. Profile read patterns first — OpenTelemetry's cache metrics give you hit/miss ratios per cache. +- Don't cache without a TTL. Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable. Always set explicit AbsoluteExpirationRelativeToNow or maxmemory-policy on Redis (allkeys-lru typical). +- Avoid cache stampedes (thundering herd): when a hot key expires and many concurrent ASP.NET Core Pods all miss at once, they all hit the DB. Mitigate with DistributedLock.NET per-key locks, or FusionCache (built-in stampede protection), or refresh-ahead, or jittered TTLs. +- Watch for cache aside + write-around without invalidation — the write goes to the DB, but a stale cached value can persist for the full TTL. Always RemoveAsync on writes, or use a short TTL. +- Treat the cache as a performance optimisation, never as the source of truth. A Redis outage must mean slower reads, not data loss — always have an EF Core fallback path. Test this by stopping Redis in staging. + +p. 211 + +> Six things that go wrong with caching. The first point is the most important: caching is a complexity tax, only worth paying when reads hurt. The second is the profile-first rule. The third is operational. The fourth is the cache stampede problem — FusionCache solves this out of the box, otherwise use DistributedLock.NET. The fifth is the most common stale-data bug. The bold final line is the architecturally important one: the cache is never the source of truth. If Redis dies, you must serve slower reads, not return errors. Test this by killing Redis in staging. + +## [239] APPENDIX L · DECISIONS · Choosing the right caching pattern (sldId 494) + +- Cache-aside — the default. Use when you want explicit control and reads dominate writes. +- Read-through (FusionCache.GetOrSetAsync) — use when the read path is repetitive and you want declarative caching with built-in stampede protection. Like HybridCache (.NET 9), but with fail-safe and stampede protection built in. +- Write-through (FusionCache.SetAsync or IDistributedCache.SetStringAsync) — use when zero stale-read risk after a write matters more than write latency. +- Write-around — use when writes far outnumber re-reads. Avoids cache pollution by write-once data. +- Write-back (BackgroundService + dirty-set) — use only when extreme write throughput is the constraint and eventual durability is acceptable. +- Refresh-ahead (FusionCache eager-refresh, or BackgroundService) — use when a small set of hot keys serves most traffic and miss latency is unacceptable. +- Most production ASP.NET Core systems use FusionCache for 95% of cases (it gets you read-through + stampede protection + eager-refresh in one library), with IDistributedCache directly where you need full control. Mix patterns; don't pick one as the rule. + +p. 212 + +> Decision rules to leave audience with. FusionCache + IDistributedCache is the default modern stack: read-through with stampede protection via GetOrSetAsync, write-through via SetAsync, eager-refresh built in. Drop to IDistributedCache directly when you want full control. Write-around for telemetry. Write-back is the niche extreme-throughput pattern. The bold final line is the take-home: don't pick one pattern as 'the' cache strategy. FusionCache for 95% of cases is the modern default; everything else is a specialization. + +## [240] Appendix M · Failure modes (sldId 495) + +M + +Where it all breaks — and the patterns that hold. + +> Appendix M is the synthesis appendix — it brings together error handling, graceful shutdown, observability and the resilience patterns from across the deck, and points them at the specific ways distributed systems fail. If you can only take one slide from this appendix, take the matrix at the end: it maps each failure mode to the patterns that address it. This appendix references section 11 (Observability), section 12 (Security — bulkheads), and Appendix H (Graceful shutdown); it is the place where those threads are tied together. If this runs long in your session, it stands alone and can be presented on its own. + +## [241] APPENDIX M · Distributed systems fail differently (sldId 496) + +- In a single process, an operation succeeds or it throws. Across a network, a call can succeed, fail, hang forever, succeed-but-the-response-is-lost, or succeed-slowly — and you often cannot tell which. +- Partial failure is the defining trait: some dependencies are up, some down, some degraded, all at the same time. There is no global 'the system is up'. +- Failures are not independent: one slow component can exhaust a caller's resources and take down healthy services around it. Failure spreads. +- Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and what users experience diverge. +- The goal is not zero failures; that is impossible. The goal is to bound the blast radius and recover automatically. All management is risk management. + - This appendix: six failure modes, the defensive patterns that address them (with code), Istio's mesh-level role, graceful shutdown, how to observe failures, and a mapping matrix. + +p. 213 + +> Set the frame. The single most important conceptual shift for developers moving from monoliths is partial failure: in a monolith a function call either returns or throws, synchronously, in-process. Across a network there is a whole spectrum of failure, including the worst kind — the call that hangs, or the one that succeeded on the server but whose response was lost, leaving the client unsure whether to retry. Emphasise that failures are correlated (one failure causes others) and that some failures are actively deceptive (gray failures). Land the bold point hard: we are not trying to prevent failure, we are trying to bound it and recover. This reframes everything that follows from 'how do we stop failures' to 'how do we contain and survive them.' + +## [242] APPENDIX M · A taxonomy of failure modes (sldId 497) + +Network failures, component failures, load failures — and the defensive toolkit that addresses them. + +p. 214 + +> The map for the appendix. Three families. NETWORK failures: partition (nodes alive but unreachable) and split-brain (both sides act as leader). COMPONENT failures: partial (some deps fail) and gray (some degrade and are perceived differently by different observers). LOAD failures: amplification (a failure feeds on itself) and cascading (failure spreads to neighbours). Underneath sits the defensive toolkit — timeouts and deadlines, retry with backoff and jitter, circuit breakers, bulkheads (section 12), load shedding, fallback/degradation, quorum/consensus, Istio mesh policies, graceful shutdown (Appendix H), and observability (section 11). Every pattern addresses specific failure modes; the closing matrix maps which to which. Reconnect to the section-1 maxim: all management is risk management — you manage the distribution of failures, not their existence. + +## [243] APPENDIX M · NETWORK · Network partition (sldId 498) + +Nodes are alive and healthy; they cannot reach each other. CAP forces a choice: consistency or availability. + +p. 215 + +> See the diagram notes. The key teaching point: a partition is not a crash. Every node is healthy and running; the network between them is the thing that failed. This is what makes partitions so confusing to debug — every individual component looks fine. CAP theorem becomes concrete here: while partitioned, you choose to stay consistent (the minority side refuses writes, sacrificing availability) or available (both sides accept writes, sacrificing consistency, reconciling later). Most data systems let you tune this. Kubernetes itself uses etcd, which chooses consistency (the minority side of a control-plane partition stops accepting writes). Address with quorum/consensus, idempotent writes (so a retried write after heal is safe), and partition-tolerant design. + +## [244] APPENDIX M · NETWORK · Split-brain (sldId 499) + +A partition where both sides think they are the leader and both accept writes. State diverges; the worst partition outcome. + +p. 216 + +> See the diagram notes. Split-brain is the specific catastrophic outcome of a partition in a system that elects a leader or primary. Both sides lose contact, each assumes the other died, each promotes itself, and now you have two primaries accepting conflicting writes. On heal you have divergent state and no clean way to merge. The prevention is quorum: an action requires a majority (N/2 + 1) of nodes to agree, so a minority partition mathematically cannot act — it knows it is the minority and steps down. This is why cluster sizes are odd (3, 5, 7): to always have a clear majority. Fencing tokens (monotonically increasing IDs that let resources reject a stale leader's writes) and leases are complementary. etcd/Raft give the Kubernetes control plane this property out of the box. + +## [245] APPENDIX M · COMPONENT · Partial failure (sldId 500) + +The defining trait of distributed systems: some dependencies fail while others keep working. Handle each leg. + +p. 217 + +> See the diagram notes. This is the slide for developers moving from monoliths. In a monolith, the order service and the inventory service are function calls in the same process — if one is 'down' the whole process is down. Distributed, the order service calls inventory (healthy), payment (down), and shipping (slow) and must make a per-dependency decision for each: use the healthy result, fall back or fail for the down one, time out and fall back for the slow one. The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller. Address with per-call timeouts, per-dependency circuit breakers, fallbacks, and bulkheads. The next several slides are exactly these patterns, with code. + +## [246] APPENDIX M · COMPONENT · Gray failure (sldId 501) + +The insidious one: a component is degraded and is perceived differently by different observers. Liveness is green; users suffer. + +p. 218 + +> See the diagram notes. Gray failure is the failure mode that defeats naive health checking. The classic shape: a node is slow or dropping a fraction of requests, but its liveness probe (GET /healthz returning 200 from a trivial handler) still passes, so Kubernetes keeps it in rotation and the load balancer keeps sending it traffic. The system believes it is healthy; users experience errors and latency. This is 'differential observability' — the observer that matters (the client) sees something different from the observer that decides (the probe). The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims. This ties directly to section 11. + +## [247] APPENDIX M · LOAD · Amplification loops & retry storms (sldId 502) + +A failure triggers retries, which add load, which cause more failures. Backoff, jitter and retry budgets break the loop. + +p. 219 + +> See the diagram notes. Amplification is a positive feedback loop: the system's own defensive behaviour (retrying) becomes the attack. Two compounding effects. First, retry multiplication across layers: if the gateway retries 3x, and it calls a service that retries 3x, which calls a service that retries 3x, one user request becomes 27 requests to the bottom of the stack. Second, synchronisation: every client that failed at the same moment retries at the same moment, creating a thundering herd. The result can be metastable — the system stays collapsed even after the original trigger is gone, because the retry load alone sustains the failure. Breaking it: exponential backoff with jitter (de-synchronise), retry budgets (cap retries to a small percentage of live traffic so they can never multiply unboundedly), retry at exactly one layer, and circuit breakers to stop the doomed calls entirely. This is the failure mode most often caused by well-intentioned resilience code. + +## [248] APPENDIX M · LOAD · Cascading failure (sldId 503) + +One component's failure overloads its neighbours, which fail in turn — the domino effect up the call graph. + +p. 220 + +> See the diagram notes. Cascading failure is how a single slow dependency takes down an entire system. The mechanism: a slow database causes the calling service's request threads (or async tasks, or connection pool) to fill up waiting; with its resources exhausted, that service now fails or slows for all callers, not just the ones touching the database; its callers' resources then fill up waiting on it; and so on up the graph until the user-facing edge is down. The amplifiers are synchronous call chains and unbounded resources (unbounded queues, unbounded thread pools, no timeouts). Each defensive pattern breaks one link: timeouts stop threads blocking forever; bulkheads isolate the pool so the database calls can't consume all of it; circuit breakers stop calling the slow dependency entirely; load shedding rejects excess work at the edge before it propagates. This is the failure mode bulkheads (section 12) were invented for. + +## [249] APPENDIX M · The defensive toolkit — patterns and what they buy (sldId 504) + +- Timeout + deadline propagation — never wait forever; pass the remaining budget downstream so nobody does doomed work. Defends: partial, cascading. +- Retry with backoff + jitter — ride out transient failures without building a storm; only for idempotent calls; use a retry budget. Defends: partial, amplification (if done right). +- Circuit breaker — stop calling a failing dependency; fail fast; test for recovery. Defends: cascading, amplification, gray. +- Bulkhead (§12) — isolate resources by tenant/call/priority so one failure can't starve the rest. Defends: partial, cascading. +- Load shedding — when overloaded, reject excess early (503) rather than collapsing. Defends: amplification, cascading. +- Quorum / consensus — majority-required action; the only real answer to split-brain. Defends: partition, split-brain. +- Fallback / graceful degradation — return a useful degraded response (cache, default, queue-for-later) instead of an error. Defends: partial, gray. +- Istio mesh policies + graceful shutdown + observability: the platform layer underneath all of these patterns. Each one gets code, and a matrix maps patterns to modes. + +p. 221 + +> The toolkit overview before the deep dives. Each line names a pattern and the failure modes it defends against — this is the verbal version of the closing matrix. The thing to stress: no pattern is sufficient alone, and several patterns interact (retry without a circuit breaker amplifies; a circuit breaker without a fallback just fails faster; bulkheads without timeouts still let threads block within a pool). Production resilience is the composition of several of these, tuned together. Each gets its own diagram and per-language code so developers can implement them; the bold line flags that the platform-level pieces (Istio, shutdown, observability) sit underneath the application-level patterns. + +## [250] APPENDIX M · TIMEOUTS · Timeouts + deadline propagation (sldId 505) + +Every remote call needs a timeout; the remaining budget should travel downstream so nobody does doomed work. + +p. 222 + +> See the diagram notes. Two ideas. First, every remote call must have a timeout — the default in many HTTP clients is infinite, which is how a single slow dependency hangs a whole service. Second, deadline propagation: the original deadline (say 1000ms) travels with the request, and each hop subtracts the time it has used and passes the remainder downstream. When the remaining budget is too small to plausibly finish, the downstream fails immediately rather than starting work that will be thrown away. gRPC does this natively with deadlines; for HTTP you pass a deadline header and honour it. The waste this prevents is real: without it, your database spends 500ms on a query for a request the user abandoned 450ms ago, and that wasted work is exactly what tips an overloaded system over. The code example shows the language-specific mechanics. + +## [251] APPENDIX M · TIMEOUTS · Timeouts + deadlines (sldId 506) + +[chip] csharp · ASP.NET Core 10 + HttpClient + +```csharp +public class PaymentService(HttpClient http) // http.Timeout set at registration +{ + public async Task Charge(Order o, TimeSpan deadline, + CancellationToken ct) + { + if (deadline < TimeSpan.FromMilliseconds(50)) + throw new DeadlineExceededException(); // too little budget + // link the caller's token with a deadline-bounded one + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(deadline); + var req = new HttpRequestMessage(HttpMethod.Post, "/charge") + { Content = JsonContent.Create(o) }; + req.Headers.Add("X-Deadline-Ms", // propagate budget + deadline.TotalMilliseconds.ToString("0")); + var resp = await http.SendAsync(req, cts.Token); // throws on timeout + return (await resp.Content.ReadFromJsonAsync(cts.Token))!; + } +} +``` + +p. 223 + +> The .NET idiom is CancellationToken-based. HttpClient.Timeout is set when the typed client is registered (AddHttpClient). Per-call, link the caller's CancellationToken with a deadline-bounded one via CreateLinkedTokenSource + CancelAfter — so the call is cancelled when either the caller gives up or the deadline passes. The remaining budget is propagated in a header. CancellationToken flows all the way down through SendAsync and the JSON read, so cancellation is cooperative and prompt. + +## [252] APPENDIX M · RETRY · Retry with exponential backoff + jitter (sldId 507) + +Retry transient failures — but spread retries with exponential backoff + jitter, cap them, and only for idempotent calls. + +p. 224 + +> See the diagram notes. Retry is the most abused resilience pattern because the naive version (retry immediately, a fixed number of times, at every layer) is precisely how you build the amplification storm from the amplification-loop discussion. The correct version has four properties: exponential backoff (each retry waits longer: 100ms, 200ms, 400ms), jitter (add randomness so clients don't synchronise), a cap on both attempts and total time, and a retry budget (retries may never exceed, say, 10% of live traffic). And it only applies to idempotent operations — retrying a non-idempotent POST without an idempotency key can double-charge a customer. Always pair retry with a circuit breaker so that once a dependency is clearly down, you stop retrying entirely. The code example shows the idiomatic library for each language. + +## [253] APPENDIX M · RETRY · Retry with backoff + jitter (sldId 508) + +[chip] csharp · ASP.NET Core 10 + Polly v8 + +```csharp +// Polly v8: exponential backoff with jitter is built in +private static readonly ResiliencePipeline _pipeline = + new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions { + ShouldHandle = new PredicateBuilder() + .Handle(), // transient only + MaxRetryAttempts = 3, // hard cap + BackoffType = DelayBackoffType.Exponential, + UseJitter = true, // de-synchronise + Delay = TimeSpan.FromMilliseconds(100) + }) + .AddCircuitBreaker(new CircuitBreakerStrategyOptions { // pair it + FailureRatio = 0.5, MinimumThroughput = 10, + BreakDuration = TimeSpan.FromSeconds(5) + }) + .Build(); +public Task Quote(string symbol, CancellationToken ct) => // GET + _pipeline.ExecuteAsync(async t => + await http.GetFromJsonAsync($"/quote/{symbol}", t), ct).AsTask(); +``` + +p. 225 + +> Polly v8's ResiliencePipelineBuilder composes strategies in order: the retry strategy with exponential backoff and UseJitter (built in — no manual jitter maths), handling only transient exceptions, capped at 3 attempts; then a circuit breaker wrapping it. Order matters — the breaker is added after retry so it sits outermost and short-circuits retries once tripped. Microsoft.Extensions.Http.Resilience can attach this pipeline to a named HttpClient via AddResilienceHandler. Idempotent calls only. + +## [254] APPENDIX M · CIRCUIT BREAKER · Circuit breaker — the state machine (sldId 509) + +Closed → Open (fail fast) → Half-Open (test) → Closed. Stops doomed calls; gives the dependency room to recover. + +p. 226 + +> See the diagram notes. The circuit breaker is the pattern that most directly stops cascading and amplification. Three states. CLOSED: calls pass through, failures are counted over a rolling window. When the failure ratio crosses the threshold, the breaker trips to OPEN: calls fail immediately without even attempting the downstream, which both gives a fast answer to the caller and gives the struggling dependency room to recover instead of being hammered. After a reset timeout the breaker goes HALF-OPEN: it allows a few trial calls; if they succeed it closes (recovered), if they fail it re-opens. Crucial details: one breaker per dependency (never a single global breaker), and pair it with a fallback so that an open breaker degrades gracefully rather than just erroring faster. Libraries differ per language; the code example shows each. + +## [255] APPENDIX M · CIRCUIT BREAKER · Circuit breaker + fallback (sldId 510) + +[chip] csharp · ASP.NET Core 10 + Polly v8 + +```csharp +public class PaymentService(HttpClient http, IOutbox outbox) +{ + private readonly ResiliencePipeline _pipeline = + new ResiliencePipelineBuilder() + .AddCircuitBreaker(new CircuitBreakerStrategyOptions { + FailureRatio = 0.5, // open at 50% + MinimumThroughput = 10, // over a window of 10 + BreakDuration = TimeSpan.FromSeconds(5), + ShouldHandle = new PredicateBuilder() + .Handle() + }) + .AddFallback(new FallbackStrategyOptions { // degrade + ShouldHandle = new PredicateBuilder() + .Handle(), + FallbackAction = args => { + outbox.Enqueue(args.Context.GetOrder()); + return Outcome.FromResultAsValueTask(Receipt.Pending()); + } + }) + .Build(); +} +``` + +p. 227 + +> Polly v8 composes a circuit breaker and a fallback into one typed pipeline. The breaker opens at a 50% failure ratio over a minimum throughput of 10 calls, stays open 5s, and handles HttpRequestException. The fallback strategy catches BrokenCircuitException (thrown while the breaker is open) and degrades: enqueue to the outbox, return a pending receipt. Adding fallback after the breaker means it wraps the breaker and catches its open-state exception. One pipeline per dependency. + +## [256] APPENDIX M · BULKHEAD · Bulkhead — isolate so one failure can't sink the rest (sldId 511) + +- Covered in depth in section 12 (Security) as a blast-radius pattern; it is equally a resilience pattern, so it appears here in the failure-mode context. +- Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. A storm in one pool exhausts only that pool. +- Against cascading failure: if database calls have their own bounded pool, a slow database exhausts that pool and returns errors for DB-backed calls — but the service's other endpoints keep serving from their own pools. +- Against partial failure: a single misbehaving tenant or dependency is contained to its own compartment instead of consuming the shared resource. +- Implementation (from §12): Polly v8 AddConcurrencyLimiter strategy on a ResiliencePipeline, paired with AddTimeout. At the mesh: Istio DestinationRule connectionPool gives the same isolation at the network layer. + - Pair with timeouts: a bulkhead bounds how many calls run concurrently, but without a timeout those calls can still block forever and keep the bulkhead full. The two patterns need each other. + +p. 228 + +> Bulkhead got full treatment in section 12, so here we just re-frame it in the failure-mode context and cross-reference. The key insight to land: a bulkhead bounds concurrency, but concurrency-bounding alone doesn't help if the bounded calls block forever — you must combine bulkhead (limit how many) with timeout (limit how long). Point back to section 12 for the per-language code (we don't repeat it here to save slides), and forward to the Istio slide for mesh-level bulkheading via connectionPool limits. + +## [257] APPENDIX M · SHEDDING & FALLBACK · Load shedding and graceful degradation (sldId 512) + +- Load shedding — when you are over capacity, reject excess work immediately (HTTP 503 + Retry-After) at the edge, before it consumes resources. A system that sheds 20% of load stays up for the other 80%; a system that accepts everything collapses for 100%. +- Shed by priority: drop health-check-style or low-value traffic first, protect revenue-critical paths. Adaptive concurrency limits (e.g. Netflix's, or Envoy's) shed automatically based on observed latency. +- Graceful degradation — when a dependency is unavailable, return a useful reduced response instead of an error: serve stale cache, omit the personalised section, queue the write for later, show a default recommendation. +- Degradation turns a hard failure into a soft one the user may barely notice. A product page that can't reach the reviews service should still show the product — just without reviews. +- Both are the application-level complement to the circuit breaker: the breaker decides to stop calling; the fallback decides what to return instead; load shedding decides what not to accept in the first place. + - Reference §11: you cannot shed or degrade intelligently without observability — you shed based on measured saturation, and you alert when degradation rates climb. + +p. 229 + +> Two related patterns that often get skipped. Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses. The maths is simple: if you are at 120% of capacity and accept everything, everything is slow and timing out (0% goodput); if you shed the excess 20%, the 80% you accept is served well. Adaptive limiters do this automatically based on latency. Graceful degradation is the product-level decision about what to return when you can't return everything — it requires the product/design team's input on what's essential vs nice-to-have. The bold line ties shedding, breakers and fallback together as a system: stop accepting (shed), stop calling (break), return something useful (fall back). + +## [258] APPENDIX M · ISTIO · Istio — resilience at the mesh layer (sldId 513) + +Timeouts, retries, outlier detection (gray-failure ejection) and circuit breaking — uniform, config-driven, no app code. + +p. 230 + +> See the diagram notes. The service mesh gives you a large slice of the resilience toolkit without writing code in every service, in every language. VirtualService configures per-route timeouts and retries (with Envoy adding backoff and jitter automatically, and retryOn conditions controlling which failures are retried). DestinationRule configures connectionPool limits (mesh-level bulkheading) and outlierDetection — which is the mesh's answer to gray failure: it watches each host, and when one returns consecutive errors it ejects that host from the load-balancing pool for a cooldown period, regardless of what the host's own health check claims. That is exactly the gray-failure defence the health-check can't provide. The important discipline: don't double up. If Istio is retrying, don't also retry in the app (that's amplification). Decide where each concern lives — typically: timeouts and outlier detection at the mesh, business-aware retries and fallbacks in the app. + +## [259] APPENDIX M · SHUTDOWN · Graceful shutdown (see App. H) (sldId 514) + +[chip] csharp · ASP.NET Core 10 + +```csharp +// OrderService/Program.cs (excerpt, example 21): one SIGTERM drives the drain +var shuttingDown = false; +PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => +{ + ctx.Cancel = true; // the app drains; the host does not stop at once + if (shuttingDown) return; // already draining + shuttingDown = true; // 1. readiness → 503: the Service stops routing + _ = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromSeconds(drainDelay)); // 2. endpoints update (5 s) + app.Lifetime.StopApplication(); // 3. Kestrel drains in-flight (ShutdownTimeout 20 s) + }); +}); +app.MapGet("/healthz", () => new { status = "ok" }); // liveness stays 200 +app.MapGet("/readyz", () => shuttingDown // (the example also pings + ? Results.Json(new { ready = false, reason = "shutting down" }, statusCode: 503) + : Results.Ok(new { ready = true })); // Postgres here) +app.MapPost("/orders", async (HttpContext ctx, NpgsqlDataSource db) => +{ + if (shuttingDown) return Results.Json(new { error = "shutting down" }, statusCode: 503); + // ... in-flight requests finish before the host stops; 4. the pool is disposed, exit 0 +}); +``` + +p. 231 + +> The application half of graceful shutdown, from the running example's .NET order-service. PosixSignalRegistration catches the single SIGTERM and cancels the default immediate shutdown; the handler flips a flag so /readyz answers 503 and Kubernetes takes the Pod out of the Service, while /healthz stays 200 so the kubelet does not restart a Pod that is winding down. +> +> New requests get 503. After DRAIN_DELAY_SECONDS the handler calls StopApplication(): Kestrel stops accepting and finishes in-flight requests within HostOptions.ShutdownTimeout (20 s), the data source is disposed and the process exits 0, inside terminationGracePeriodSeconds. Appendix H covers the hosted-service drain hooks, the preStop hook and the grace period. + +## [260] APPENDIX M · OBSERVE · Observing failure modes (see §11) (sldId 515) + +- Network partition — watch peer-connectivity and cross-zone error metrics; alert on quorum-loss events from etcd / your datastore. +- Split-brain — alert on more-than-one-leader conditions, divergent-replica metrics, and fencing-token rejections in logs. +- Partial failure — RED metrics per dependency (rate, errors, duration), not just per service. A spike in errors to one downstream is the early signal. +- Gray failure — the hard one: compare health-check status against real-traffic SLOs. Latency percentiles (p99) diverging from the average, and Istio outlier-ejection counts, are your tells. +- Amplification — watch request-rate against user-action-rate (if requests >> actions, retries are multiplying), retry-count metrics, and goodput falling while load rises. +- Cascading — a distributed trace shows the error spreading service-to-service; saturation metrics (thread pools, queue depth, connection pools) climb together across services. + - The unifying tool is the trace_id from §11: when an alert fires, pivot from the metric to an exemplar trace to its logs to find which leg failed first and how it spread. + +p. 232 + +> Tie the appendix back to section 11. Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop. The most important specific guidance: measure RED per dependency (so partial failures are visible), compare health-check status to real-traffic SLOs (so gray failures are visible), and watch request-rate vs user-action-rate (so amplification is visible). The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response. + +## [261] APPENDIX M · Which pattern addresses which failure mode (sldId 516) + +Read a row to defend a failure mode; read a column to see what a pattern buys you. Production layers several. + +p. 233 + +> The synthesis slide — if the audience keeps one thing, keep this. Reading across a row tells you the portfolio of patterns that defend a given failure mode (e.g. cascading failure is addressed by timeouts, circuit breakers, bulkheads, load shedding, and graceful shutdown together). Reading down a column tells you what a given pattern buys you across failure modes. Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule. Second, almost every other cell is about assembling a portfolio — there is no single pattern that handles partial, gray, amplification and cascading failures; you layer timeouts, retries-with-budget, breakers, bulkheads, shedding and fallbacks, and you observe the result. Defence in depth, exactly as in the security section, applied to reliability. + +## [262] APPENDIX M · Take-aways and references (sldId 517) + +- Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often infinite. +- Retry only idempotent calls, with backoff + jitter + a budget, at one layer, always paired with a circuit breaker. Naive retries cause the outages they were meant to prevent. +- Bound the blast radius: bulkheads isolate, circuit breakers stop the bleeding, load shedding protects capacity, fallbacks keep the user served. +- Quorum is the only cure for split-brain. Let etcd / Raft / your datastore handle consensus; don't invent your own. +- Push uniform concerns (timeouts, outlier detection, mesh bulkheads) to Istio; keep business-aware concerns (fallbacks, idempotency) in the app. Don't double-retry. +- Observe from the client's vantage point (RED per dependency, SLOs, outlier ejections) — gray failures are invisible to naive health checks. Drain on shutdown (App. H). + - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkhead origin); the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling); Istio traffic-management docs; the metastable-failures research (Bronson et al., 2021). + +p. 234 + +> Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: design for partial failure, timeout everything, retry carefully, bound the blast radius, use quorum for consensus, split concerns between mesh and app, and observe from the client's perspective. The references are the canonical sources: the Google SRE book's cascading-failures and overload chapters are the single best free resource; Michael Nygard's 'Release It!' is where the circuit breaker and bulkhead patterns were named for software; the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol; and the metastable failures paper (Bronson et al.) is the modern formalisation of amplification loops. Encourage the audience to read the SRE cascading-failures chapter as the next step. + +## [263] Feature Flags (sldId 518) + +N + +Progressive delivery, decoupled from deploy — OpenFeature + flagd + +> This appendix adds the runtime switch that separates shipping code from releasing a feature. It is a deep-dive you keep open when you are about to roll something out gradually. The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, like every other example in the book. The arc: why deploy is not release, the four kinds of flag, how evaluation works, the code to evaluate and target and roll out, flags as a control plane over patterns we already covered, and the lifecycle discipline that keeps flags from becoming debt. + +## [264] APPENDIX N · FEATURE FLAGS · Deploy is not release (sldId 519) + +p. 235 + +the artifact ships dark; releasing is a runtime flag flip — and so is rollback + +> The reframe that makes everything else work: deploying an artifact and releasing a feature are different events, and a flag is what splits them. The artifact ships to production with the new path turned off - a non-event, visible only in the deploy log. Releasing is then a runtime flag flip: on for one percent, then a wider cohort, then everyone, each step reversible by narrowing the flag rather than rolling back a deployment. This is what lets a team merge to main many times a day yet release on its own cadence, and it is the mechanism behind every canary and every safe cutover in the book. Rollback stops being a redeploy and becomes a config change measured in seconds. + +## [265] APPENDIX N · FEATURE FLAGS · Four kinds of flag (sldId 520) + +p. 236 + +by lifetime and owner — each wants different defaults and lifecycle + +> Feature flag covers four different tools, and conflating them is the root of most flag messes. They differ along two axes - how long the flag lives, and who decides to flip it. Release flags are temporary and developer-owned: they ship new code dark, ramp it, and are then removed. Experiment or A/B flags are temporary and data-owned: they split traffic across variants so a metric can be measured, and they require sticky assignment. Ops or kill-switch flags are long-lived and SRE-owned: a permanent control to disable a feature or dependency fast under load or incident. Permission or entitlement flags are long-lived and product-owned: they gate functionality by plan, tier, or region. The defaults differ too - a release flag fails off, so half-shipped code stays dark, while a kill-switch usually fails on, so the feature survives a flag-system outage. + +## [266] APPENDIX N · FEATURE FLAGS · Evaluating a flag: OpenFeature + flagd (sldId 521) + +p. 237 + +local evaluation, vendor-neutral SDK, fail-safe to the default + +> Evaluation should not couple your code to a vendor. OpenFeature is the CNCF vendor-neutral API standard - one SDK surface across languages - and flagd is its lightweight open-source backend that evaluates flags from a file, ConfigMap, or synced source, in-cluster. Together they keep the whole feature-flag story open-source and running on plain Kubernetes, with no managed SaaS, and swapping flagd for another provider later is a configuration change, not a code change. Two properties matter most. First, evaluation returns the default if the provider is unreachable - a flag-system outage must never take the application down. Second, the SDK is vendor-neutral: the app calls getBooleanValue with a flag key, a default, and an evaluation context, and never names the backend. The context carries the targeting inputs - a stable key plus attributes like plan and region - that the rules act on. + +## [267] APPENDIX N · FEATURE FLAGS · Evaluating a flag (sldId 522) + +p. 238 + +[chip] csharp · .NET + +```csharp +// Program.cs — set the provider once at startup (OpenFeature + flagd provider) +await Api.Instance.SetProviderAsync(new FlagdProvider(new Uri("http://flagd:8013"))); + +app.MapPost("/checkout", async (Cart cart, [FromHeader(Name = "X-User")] string user) => +{ + var flags = Api.Instance.GetClient(); + var ctx = EvaluationContext.Builder() + .SetTargetingKey(user) // sticky key + .Set("plan", cart.Plan).Set("region", cart.Region).Build(); + var useNew = await flags.GetBooleanValueAsync("new-checkout", false, ctx); + return useNew ? NewCheckout(cart) : LegacyCheckout(cart); +}); +``` + +The same one line of code serves an on/off, a per-plan entitlement, or a 25% rollout. + +> The provider is registered once at startup; from then on the application asks the client for a value. The crucial thing to notice is that the application code is the same one line regardless of whether the flag is a simple on/off, a per-plan entitlement, or a 5 percent rollout. All of that lives in the flag's configuration, not the code. The default is what gets returned if flagd is unreachable, so the new path stays dark on a flag-system outage rather than erroring. The targeting key is the user id, which is what makes a percentage rollout sticky. Across every language the shape is identical: register a flagd provider, get a client, build an evaluation context with a targeting key and attributes, and call getBooleanValue with a safe default. + +## [268] APPENDIX N · FEATURE FLAGS · Targeting and rollout live in config (sldId 523) + +p. 239 + +[chip] json · flagd + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "==": [ {"var": "plan"}, "enterprise" ] }, "on", + { "fractional": [ {"var": "targetingKey"}, + ["on", 25], ["off", 75] ] } + ] + } + } + } +} +``` + +Enterprise plans always on (entitlement); everyone else split 25/75 (sticky rollout) — no code change. + +> Because the rules live in flagd, changing who sees a feature never touches the application. A flag definition is a set of variants, a default, and a targeting expression. Two operators do most of the work: a conditional for segmentation - here, the enterprise plan always gets the new checkout, an entitlement - and the fractional operator for percentage rollouts, here splitting everyone else 25/75. The fractional operator buckets on the targeting key, which is what gives sticky assignment: the same user hashes to the same bucket every time. Ramping the rollout is editing the 25 upward; rolling back is editing it down - both take effect in the running fleet within flagd's sync interval, with no redeploy. A kill switch is the same file: set a flag's state to DISABLED and it stops evaluating entirely. + +## [269] APPENDIX N · FEATURE FLAGS · Percentage rollout via sticky bucketing (sldId 524) + +p. 240 + +consistent per-user assignment; instant ramp and rollback + +> Sticky bucketing matters for two reasons. For a rollout, it prevents flicker - a user who got the new checkout on their first visit keeps getting it, rather than bouncing between variants on refresh. For an experiment, it is a correctness requirement: a user must stay in one arm of the test for the measurement to mean anything. The mechanism is simple - hash a stable key, the user id, into a bucket from 0 to 99, and compare it to the rollout threshold. Because the hash is stable, the same user always lands in the same bucket. Ramping the rollout is moving the threshold up; rolling back is moving it down, and both take effect in the running fleet within the sync interval with no deploy on the path of either. + +## [270] APPENDIX N · FEATURE FLAGS · A kill switch (sldId 525) + +p. 241 + +[chip] csharp · .NET + +```csharp +// ops kill-switch - default true (fail open): on until an operator kills it +public async Task Recommend(string user) +{ + if (!await flags.GetBooleanValueAsync("recommendations-enabled", true, Ctx(user))) + return Recommendations.Empty; // degrade - still serve the page + return await _recs.FetchAsync(user); // pairs with a circuit breaker +} +``` + +Default true = fail open: only an operator flipping the flag disables it. The default flips with the flag type. + +> A kill switch is the operational flag you reach for during an incident: wrap a risky or expensive dependency in a flag so it can be disabled instantly without a deploy. It is the human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; an operator flips the kill switch by hand. The default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only an explicit flip removes it. When it is killed, the page still renders - just without the recommendations - which is graceful degradation, the same idea as the load-shedding discussion in the Failure Modes appendix. + +## [271] APPENDIX N · FEATURE FLAGS · Flags over the patterns you already have (sldId 526) + +p. 242 + +a flag is a runtime control surface + +> Feature flags are not a new subsystem so much as a runtime control plane over patterns already in this book. A canary is a percentage flag deciding which build a request exercises, often paired with the L7 routing from the routing appendix. A strangler-fig cutover is a flag, or the proxy's percentage, steering a widening slice from the monolith to the extracted service. A saga step can be guarded by a flag to switch a new compensation path on cautiously. And an experiment is a sticky-bucketed variant flag with a metric attached. Recognising flags as the control surface for all of these keeps you from building a bespoke mechanism for each - the same decoupling, expressed four ways. + +## [272] APPENDIX N · FEATURE FLAGS · The flag lifecycle (sldId 527) + +p. 243 + +a release flag is born with a removal ticket + +> The failure mode of feature flags is not technical, it is organisational: flags that are never removed. Every flag is a branch in your code and a dimension in your test matrix, and a stale one is dead code, a hidden conditional, and a live risk surface that nobody remembers the meaning of. The discipline that keeps it healthy: give every release flag an owner and an expiry from the moment it is created, and treat removing it as part of finishing the feature, not optional cleanup. Long-lived ops and entitlement flags are exempt - they are permanent by design - which is exactly why distinguishing the four types matters. A short-lived flag that has been at 100 percent for two weeks should be deleted along with the branch it guarded. + +## [273] APPENDIX N · FEATURE FLAGS · Testing, observing, and anti-patterns (sldId 528) + +p. 244 + + - Test both sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag. + - Put the resolved variant on your telemetry - a span attribute and a metric label - so a latency or error change attributes to a specific flag and cohort. +- Anti-patterns: flags as a config store; release flags that never die; stacks of flags whose combinations no one reasons about; experiments without sticky assignment; business logic buried in flag rules. + +> A flag multiplies the states your system can be in, so test both sides of every flag you depend on; a test suite that only exercises the default path will not catch a bug behind the flag. Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them explicitly. Observability ties flags back to the Observability section: record the resolved variant as a span attribute and a metric label, so a latency or error change can be attributed to a specific flag and cohort. The recurring anti-patterns: using flags as a general configuration store - they are for release control, not for tuning timeouts; letting release flags become permanent; stacking many flags whose combinations no one reasons about; running experiments without sticky assignment, which makes the data meaningless; and putting real business logic inside flag rules instead of in code. + +## [274] APPENDIX N · FEATURE FLAGS · Take-aways (sldId 529) + +p. 245 + +- Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. +- Distinguish the four flag types - they want different owners, defaults, and lifecycles. +- Evaluate through a vendor-neutral SDK (OpenFeature) against an OSS backend (flagd), always with a safe default. +- Keep targeting and rollout in configuration, not code, and make percentage rollouts sticky. +- Treat release flags as debt with a removal ticket; put the resolved variant on your telemetry. + +References: OpenFeature spec & flagd docs (CNCF) · Hodgson, “Feature Toggles” · Accelerate / DORA · Unleash (OSS alternative). + +> Close on the imperatives. Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. Distinguish the four flag types, because they want different owners, defaults, and lifecycles. Evaluate through a vendor-neutral SDK against an open-source backend, always with a safe default so a flag outage cannot take the app down. Keep targeting and rollout rules in configuration, not code, and make percentage rollouts sticky. Treat release flags as debt with a removal ticket attached, and put the resolved variant on your telemetry so you can attribute a regression to a cohort. The canonical references are the OpenFeature specification and the flagd documentation, Pete Hodgson's Feature Toggles article for the taxonomy of flag types, and the Accelerate and DORA research for the deploy-versus-release distinction; Unleash is a capable open-source alternative backend behind the same OpenFeature SDK. + +## [275] API Testing with Newman (sldId 530) + +O + +Postman collections as an automated gate on Docker Engine + +> This appendix turns the Postman collections the team already pokes at by hand into an automated test gate, using Newman, Postman's command-line runner. It is a deep-dive you keep open when you are wiring API tests into CI. The key idea that makes it cheap: Newman tests the API as a black box over HTTP, so one collection is the conformance suite for every implementation in the book, and the only language-specific step is bringing the service under test up. The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid. + +## [276] APPENDIX O · API TESTING · Why Newman (sldId 531) + +p. 246 + +the headless runner turns a Postman collection into CI-readable results + +> A Postman collection is an executable contract, not just a saved list of requests. Newman runs it without the GUI and emits machine-readable results: a cli reporter for a human watching, a junit reporter that CI turns into a test report, and an html reporter to share. There is no new test framework to learn - the tests are the JavaScript assertions already supported inside Postman, and Newman is the bridge from works-when-I-click-it to verified-on-every-pull-request. The same file serves three audiences: a developer in Postman, a reviewer reading the tests, and a pipeline failing the build when an assertion breaks. + +## [277] APPENDIX O · API TESTING · Anatomy of a collection (sldId 532) + +p. 247 + +folders of requests, each with assertions; the environment supplies the variables + +> A collection nests folders and requests; each request can carry a pre-request script, the HTTP call itself, and a block of tests that run against the response. The tests are small JavaScript blocks using Postman's pm API - asserting the status, the response time, the shape of the body. Environment variables supply the parts that change between runs, like the base URL and an auth token, resolved at run time. Storing a value from one response - a new order id - in a variable lets a later request chain off it, which is how a collection models a whole flow rather than isolated calls. + +## [278] APPENDIX O · API TESTING · Tests live in the request (sldId 533) + +p. 248 + +[chip] json · Postman + +{% raw %} +```json +{ + "name": "Create order", + "request": { + "method": "POST", + "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }], + "url": "{{base_url}}/orders", + "body": { "mode": "raw", "raw": "{\"sku\":\"A-1\",\"qty\":2}" } + }, + "event": [{ + "listen": "test", + "script": { "exec": [ + "pm.test('201 Created', () => pm.response.to.have.status(201));", + "pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));", + "const body = pm.response.json();", + "pm.test('returns an id', () => pm.expect(body).to.have.property('id'));", + "pm.collectionVariables.set('order_id', body.id);" + ] } + }] +} +``` +{% endraw %} + +Assertions run after the response; the saved order_id chains into later requests. + +> Here is a request that creates an order and asserts on the result. The test block asserts a 201 Created, parses the body, asserts it carries an id, and stores that id in a collection variable so a later request - fetch, cancel, or pay for the order - can use it. Beyond status codes, a test can validate the response shape against a JSON Schema, which is how you turn the metadata and error-format work from earlier appendices into an enforced contract: assert that an error body matches the Problem Details schema rather than checking exact strings, so the test stays stable as data changes but still catches a removed field or a wrong type. + +## [279] APPENDIX O · API TESTING · One collection, six backends (sldId 534) + +p. 249 + +Newman tests over HTTP, so one collection validates every implementation + +> Because Newman exercises the API as a black box over HTTP, the collection does not know or care which language implements the service. The same collection is the conformance suite for every implementation in this book - point it at whichever service is running and the assertions are identical. The collection is the API specification made executable. Bringing the service under test up is the only language-specific step; the Newman command that follows is the same everywhere. + +## [280] APPENDIX O · API TESTING · Launch the service under test (sldId 535) + +p. 250 + +[chip] bash · .NET + +```bash +# .NET — run the service, then the same collection +dotnet run & # http://localhost:8080 +newman run orders.postman_collection.json -e local.postman_environment.json +``` + +Only this step is language-specific; the Newman command is identical for all six. + +> The only per-language step is starting the service. Each implementation exposes the same HTTP API on the same port, so the base URL in the environment file is identical and the Newman command that follows does not change. That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it. In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop. + +## [281] APPENDIX O · API TESTING · Running Newman & shaping output (sldId 536) + +p. 251 + +[chip] bash + +{% raw %} +```bash +# install once (Node.js): +# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1 +newman run orders.postman_collection.json \ + --environment local.postman_environment.json \ + --reporters cli,junit,htmlextra \ + --reporter-junit-export results/newman.xml \ + --timeout-request 5000 \ + --bail +# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure + +# data-driven: one iteration per CSV row; the columns become {{variables}} +newman run orders.postman_collection.json -e local.postman_environment.json \ + --iteration-data cases.csv +``` +{% endraw %} + +Reporters target the audience; --bail gates fast; --iteration-data drives edge cases. + +> The runner takes the collection and an environment file, and you choose reporters for the audience: cli for a human, junit for the XML CI turns into a report, htmlextra for a shareable page. --bail stops at the first failed assertion, which is what you usually want in a gate; drop it to see every failure in one run. A single set of requests can be run repeatedly over a table of inputs with --iteration-data, which is how you cover boundary values, bad input, and edge cases without duplicating requests - and the expected status can travel in the data too, so one request becomes a table of positive and negative cases. + +## [282] APPENDIX O · API TESTING · Data-driven tests (sldId 537) + +p. 252 + +one request set, many inputs — boundary values and negative cases from a table + +> Data-driven testing runs the same requests once per row of a CSV. The columns become variables in the requests, and a column can carry the expected status so a single assertion checks status against the row's expectation. This is how a handful of requests becomes broad coverage: the happy path, a zero-quantity order that must be rejected with a 422, an over-stock order that must also be rejected - all without copy-pasting requests. Keeping the cases in data rather than in duplicated requests is what makes the suite maintainable as the API grows. + +## [283] APPENDIX O · API TESTING · Newman in CI (sldId 538) + +p. 253 + +[chip] yaml · GitHub Actions + +```yaml +# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js) +jobs: + api-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: docker compose up -d --build --wait # order, payment, inventory, ... + - run: ./scripts/wait-for-ready.sh # poll /readyz before testing + - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1 + - run: | + newman run orders.postman_collection.json \ + -e ci.postman_environment.json \ + --reporters cli,junit \ + --reporter-junit-export results/newman.xml + - uses: actions/upload-artifact@v7 # keep the report even on failure + if: always() + with: { name: newman-results, path: results/ } +``` + +Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build. + +> Newman exits non-zero when any assertion fails, which is all a pipeline needs to gate a merge. The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them. The readiness poll matters: start testing only once the services report ready, using the readiness endpoint from the Observability and Graceful Shutdown material, or the first requests race the boot and fail spuriously. Upload the junit report as an artifact even on failure so the failing assertion is visible in the run. + +## [284] APPENDIX O · API TESTING · The gate on Docker Engine (sldId 539) + +p. 254 + +the same stack you run locally, gating every pull request + +> The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks. The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock. + +## [285] APPENDIX O · API TESTING · Where Newman fits (sldId 540) + +p. 255 + +the API/contract tier: on top of unit tests, under a thin end-to-end layer + +> Newman does not replace unit tests; it is the layer on top of them. It owns the API/contract tier: real HTTP against a running service, fast enough to run on every pull request. Keep the base of fast unit tests broad, use Newman for the contract and integration behaviour that only shows up over real HTTP, and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system. Pushing everything into Newman makes a slow, brittle suite; ignoring it leaves the contract untested. Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material. + +## [286] APPENDIX O · API TESTING · Take-aways (sldId 541) + +p. 256 + +- Treat the Postman collection as the executable specification - Newman runs it headlessly to guard every change. +- One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box. +- Validate response shape against a schema, not just status codes; drive edge cases from data, not duplicated requests. +- Assert the failure paths - 401, 403, 409/422, 429, idempotency - as carefully as the happy path. +- Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier. + +References: Postman docs & Newman CLI docs (reporters, --iteration-data, --bail) · Postman Learning Center (pm API, JSON-schema validation). + +> Close on the imperatives. Treat the Postman collection as the executable specification of your API, and let Newman run it headlessly so the same assertions guard every change. Because Newman tests over HTTP, one collection is the conformance suite for all six implementations. Validate response shape against a schema, not just status codes; drive edge cases from data rather than duplicated requests; and assert the failure paths as carefully as the happy path. Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer. The references are the Postman documentation and the Newman command-line documentation, and the Postman Learning Center guides on writing tests with the pm API and on JSON-schema validation. + +## [287] Appendix P · .NET on Linux (sldId 542) + +P + +Runtime, Kestrel, and Containers — what changes when Windows goes away. + +> Appendix P is for the audience moving from .NET Framework on Windows: what the runtime does on Linux, what replaces IIS, and what ships in the container. Four parts: the platform and the language, compilation and the runtime, Kestrel, and the container image. +> +> It is reference material; pull it up when someone asks what changes when Windows goes away. + +## [288] APPENDIX P · RUNTIME · C# is a language. .NET is a platform. (sldId 543) + +C# is a statically-typed, garbage-collected language — one of several that target .NET. F# and VB.NET are the others. All three compile to the same intermediate representation. + +.NET (since .NET 5) is the platform: CoreCLR runtime, Base Class Library (System.*, Microsoft.Extensions.*), the Roslyn compiler, NuGet, and the dotnet CLI. The platform is cross-platform. + +When this book says “.NET 10,” it means the platform. When it says “C#,” it means the language. A .csproj targets a .NET version (net10.0), not a C# version, because the platform version determines what APIs are available. + +The distinction matters because conflating them causes architectural confusion — the runtime is language-agnostic. + +p. 257 + +> C# is the language, .NET is the platform. C#, F# and VB.NET all compile to the same intermediate language; the platform is the CoreCLR runtime, the Base Class Library, the Roslyn compiler, NuGet and the dotnet CLI, cross-platform since .NET 5. +> +> A .csproj targets a platform version, net10.0, not a language version, because the platform version decides which APIs exist. The examples target .NET 10.0.12 with SDK 10.0.112, and the C# version follows from the SDK. Keeping the two words apart avoids design confusion: the runtime is language-agnostic. + +## [289] APPENDIX P · COMPILATION · CIL: what dotnet build produces (sldId 544) + +Roslyn compiles C# to CIL bytecode — RyuJIT compiles CIL to native code method-at-a-time at runtime. + +p. 258 + +> The compilation pipeline. Roslyn compiles C# to CIL bytecode in an assembly; dotnet build and dotnet publish produce .dll files, not native executables. At run time RyuJIT compiles each method to native x86-64 or ARM64 code on its first call, and tiered compilation recompiles hot methods with more optimization. +> +> The practical consequence: start-up pays a JIT cost, and steady-state code runs as native code. NativeAOT trades that start-up cost for restrictions on dynamic code; the CoreCLR slide covers it. + +## [290] APPENDIX P · RUNTIME · CoreCLR on Linux: libcoreclr.so (sldId 545) + +JIT compilation (RyuJIT) — CIL to native x86-64 or ARM64, one method at a time, first call only. + +Garbage collection — generational, concurrent GC. Server GC mode (default in ASP.NET Core) allocates one heap per CPU core. + +Type system — reflection, generics, interface dispatch. + +Threading — maps .NET threads to pthreads; Task and async/await run on the thread pool. + +P/Invoke — call native C libraries directly (libpq, librdkafka, openssl). + +NativeAOT alternative: ahead-of-time compilation to a single native binary. Faster startup, less memory, but no Reflection.Emit — JIT is the default for full library compatibility. + +After JIT, a .NET method is indistinguishable from one compiled by GCC. The runtime stays for GC and exceptions, but the hot path is native code. + +p. 259 + +> On Linux the runtime is libcoreclr.so, a native shared library. It owns JIT compilation, garbage collection (generational and concurrent; ASP.NET Core defaults to Server GC with one heap per core), the type system, threading (managed threads map to pthreads, and async work runs on the thread pool) and P/Invoke into native libraries such as libpq, librdkafka (which Confluent.Kafka wraps) and OpenSSL. +> +> NativeAOT compiles ahead of time to one native binary: faster start-up and less memory, but no runtime code generation, so some reflection-heavy libraries do not work. JIT stays the default for full library compatibility. After the JIT, a hot .NET method is native code like any other; the runtime remains for GC and exceptions. + +## [291] APPENDIX P · KESTREL · Kestrel: the web server that replaced IIS (sldId 546) + +Built into ASP.NET Core — no IIS, no nginx needed. Listens on TCP, handles TLS via OpenSSL, parses HTTP, runs the middleware pipeline. + +p. 260 + +> Kestrel is the web server built into ASP.NET Core. It listens on a TCP socket, handles TLS through OpenSSL on Linux, parses HTTP/1.1, HTTP/2 and HTTP/3, and hands each request to the middleware pipeline. There is no IIS and no w3wp.exe worker process. +> +> Walk the figure from the socket to your endpoint: connection, protocol parsing, middleware (routing, authentication, health checks, OpenTelemetry), then the handler. Every example in the deck runs on Kestrel inside the container. + +## [292] APPENDIX P · KESTREL · Why not nginx or Apache in front? (sldId 547) + +In Kubernetes, the service mesh handles TLS termination, load balancing, and retries. Kestrel handles everything else. + +Adding nginx as a sidecar adds latency, memory, and operational complexity for features Kestrel already provides. + +Every example in this book: builder.WebHost.UseUrls("http://+:8080") + +For gRPC (HTTP/2 only): builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(50051, o => o.Protocols = HttpProtocols.Http2)) + +Port 8080 because ports below 1024 require root, which containers should not run as. + +Kestrel supports HTTP/1.1, HTTP/2, HTTP/3 natively — gRPC, WebSockets, and REST share the same host. + +p. 261 + +> Teams moving from IIS often put nginx or Apache in front of Kestrel out of habit. On Kubernetes the service mesh or the ingress already terminates TLS, balances load and retries, so a reverse proxy sidecar adds latency, memory and another component to operate for features Kestrel and the platform already provide. +> +> The examples bind with builder.WebHost.UseUrls("http://+:8080"), and gRPC services listen on an HTTP/2-only endpoint configured through ConfigureKestrel. Port 8080 because ports under 1024 need root, and the containers run as a non-root user. REST, gRPC and WebSockets share one Kestrel host. + +## [293] APPENDIX P · CONTAINERS · Multi-stage container build (sldId 548) + +SDK compiles (∼780 MB image), only the runtime + app DLLs ship (∼200 MB image). The SDK never reaches production. + +p. 262 + +> The multi-stage Containerfile. The build stage uses the SDK image, ubi9/dotnet-100 (about 780 MB), to restore and publish; the runtime stage uses ubi9/dotnet-100-aspnet (about 170 MB) and copies only the published output, owned by UID 1001 with group-0 permissions. The final image is about 200 MB, and the SDK, the NuGet cache and the source never reach production. +> +> Restore and publish run in one RUN step with a BuildKit cache mount for NuGet packages, so rebuilds are fast and a stale layer cannot point at packages that are gone. What to show: docker image inspect on an example image and the number of layers. + +## [294] APPENDIX P · CONTAINERS · Red Hat UBI: what ships to production (sldId 549) + +UBI9 base (∼70 MB) — glibc, openssl, ca-certificates, tzdata, libicu. + +ASP.NET Core runtime (∼100 MB) — libcoreclr.so, libclrjit.so, BCL assemblies, ASP.NET shared framework. + +Your application (∼5–80 MB) — compiled .dll files, NuGet package DLLs, appsettings.json. + +Not in the image: SDK, MSBuild, Roslyn, NuGet, source code, obj/ and bin/ intermediates, test projects. + +UBI images are freely redistributable, based on RHEL 9, and run on any Kubernetes cluster. Red Hat publishes no UBI 10 .NET image, so .NET stays on UBI 9 while the other languages use UBI 10. They contain the same .NET runtime bits as Microsoft’s images — the difference is the base OS layer. + +ubi9/dotnet-100:9.8-1791421718 (SDK, build only) → ubi9/dotnet-100-aspnet:9.8-1791363520 (runtime, ships), both from registry.access.redhat.com. + +p. 263 + +> What ships in the runtime image, layer by layer: the UBI 9 base (glibc, OpenSSL, CA certificates, tzdata, libicu), the ASP.NET Core runtime (libcoreclr.so, the JIT, the BCL and the ASP.NET shared framework), and your application's assemblies and configuration. The SDK, MSBuild, Roslyn, sources and test projects are not in it. +> +> The examples pin ubi9/dotnet-100:9.8-1791421718 for the build and ubi9/dotnet-100-aspnet:9.8-1791363520 for the runtime. They stay on UBI 9 because Red Hat publishes no UBI 10 .NET image; every other language in the deck builds on UBI 10. UBI images are freely redistributable, run on any Kubernetes cluster, and carry the same .NET runtime as Microsoft's images; the difference is the base OS layer. + +## [295] APPENDIX P · LIFECYCLE · Container runtime lifecycle (sldId 550) + +1. CoreCLR initializes — loads libcoreclr.so, starts the GC, initializes the thread pool. +2. Host builder runs — registers services (DI), configures logging, sets up OpenTelemetry. +3. Kestrel binds to port 8080 — begins accepting HTTP connections. +4. Application is ready — Kubernetes starts sending liveness and readiness probes. +5. On shutdown, SIGTERM arrives — .NET host catches it and begins graceful shutdown: stops accepting new requests, drains in-flight, flushes telemetry, disposes services. + +The entire lifecycle is the same as any Linux process. No IIS app pool recycle, no w3wp.exe, no application domain. + +The container is the process boundary — SIGTERM is your shutdown signal, HostOptions.ShutdownTimeout is your budget. + +p. 264 + +> The life of a .NET container, in order: CoreCLR loads and starts the GC and the thread pool; the host builder registers services, logging and OpenTelemetry; Kestrel binds port 8080; Kubernetes starts its probes. On SIGTERM the host stops accepting requests, drains in-flight work, flushes telemetry and disposes services, within HostOptions.ShutdownTimeout. +> +> It is an ordinary Linux process: no application-pool recycling, no w3wp.exe, no application domains. The container is the process boundary, SIGTERM is the shutdown signal, and the grace period is the budget, which Appendix H covers in detail. + +## [296] APPENDIX P · TAKE-AWAYS · Take-aways (sldId 551) + +C# compiles to CIL bytecode, not native code. The CoreCLR JIT (RyuJIT) compiles to native at runtime, method-at-a-time. After that first call, performance is identical to C or C++. + +Kestrel is the built-in web server — it handles TLS (via OpenSSL), HTTP/1.1+2+3, gRPC, and WebSockets natively. No reverse proxy layer is needed inside the Pod. + +Multi-stage Containerfiles separate the SDK (∼780 MB) from the runtime (∼170 MB). Only the published DLLs and the ASP.NET runtime ship to production. + +Red Hat UBI 9 images (no UBI 10 .NET image exists) are the same .NET runtime on a RHEL 9 base — freely redistributable, and run on any Kubernetes cluster. + +References: .NET runtime architecture (docs.microsoft.com) · Kestrel web server (ASP.NET Core docs) · Red Hat UBI .NET images (access.redhat.com) + +p. 265 + +> The four points to keep. C# compiles to CIL and the JIT produces native code method by method, so steady-state performance is native. Kestrel is the web server, with TLS through OpenSSL and HTTP/1.1, HTTP/2, HTTP/3, gRPC and WebSockets, and needs no reverse proxy inside the Pod. +> +> Multi-stage builds keep the SDK out of the shipped image. The Red Hat UBI 9 images carry the same .NET runtime on a RHEL 9 base, freely redistributable and run on any Kubernetes cluster; .NET stays on UBI 9 because no UBI 10 .NET image exists yet. + +## [297] Appendix Q · Blazor Server + SignalR (sldId 552) + +Q + +Real-time .NET — server-rendered UI, push updates, and the Redis backplane. + +> Appendix Q covers real-time .NET: Blazor Server for server-rendered UI, SignalR for push, and the Redis backplane that lets several Pods serve one audience. It builds on the WebSocket scaling problem from Appendix C and is backed by Example 30, an order dashboard that updates live when an order is placed. + +## [298] APPENDIX Q · BLAZOR SERVER · What Blazor Server is (sldId 553) + +Blazor Server is a hosting model where all C# component logic runs on the server. The browser runs a thin JavaScript runtime that sends UI events (clicks, keystrokes) over a persistent WebSocket and applies DOM diffs returned by the server. + +The WebSocket is a SignalR “circuit” — one per browser tab. The server keeps a component tree in memory for each circuit, re-renders on state change, and sends only the diff (a binary serialized RenderBatch). + +No WASM, no client-side .NET. The trade-off: latency per interaction (each event round-trips), but zero download size and full access to server-side resources (databases, internal APIs, secrets). + +Blazor Server requires a persistent WebSocket per tab — the scaling challenge from Appendix C applies directly. + +p. 266 + +> Blazor Server runs all component logic on the server. The browser loads a thin JavaScript runtime that sends UI events over a persistent connection and applies the DOM diffs the server sends back. +> +> Each browser tab is one SignalR circuit, and the server keeps that tab's component tree in memory, re-renders on state change and sends only the diff. There is no WebAssembly download and full access to server-side resources; the cost is a round trip per interaction and memory per connected tab. Because every tab holds a persistent connection, the Pod-pinning problem from Appendix C applies directly. + +## [299] APPENDIX Q · SIGNALR · SignalR: the transport underneath (sldId 554) + +SignalR is ASP.NET Core’s real-time communication library. It abstracts WebSockets (preferred), Server-Sent Events, and long-polling behind a single Hub API. + +A Hub is a server-side class with methods that clients can call (client → server), and the server can invoke methods on connected clients (server → client). Groups let you broadcast to subsets of connections. + +Blazor Server uses SignalR internally for its circuit, but you can also use SignalR directly in any ASP.NET Core app for push notifications, live feeds, or collaborative editing — independent of Blazor. + +The Hub pattern: clients call server methods by name, server invokes client methods by name. No manual frame parsing, no custom protocol — SignalR handles serialization (JSON or MessagePack), reconnection, and connection lifecycle. + +SignalR is not Blazor-specific — it is a general-purpose real-time library that Blazor Server happens to use as its transport. + +p. 267 + +> SignalR is ASP.NET Core's real-time library. It negotiates WebSockets first, with Server-Sent Events and long polling as fallbacks, behind one Hub API. Clients call server methods by name, the server invokes client methods by name, and groups broadcast to subsets of connections. Serialization (JSON or MessagePack), reconnection and the connection lifecycle are handled for you. +> +> Blazor Server uses SignalR for its circuit, but SignalR stands on its own: push notifications, live feeds and collaborative editing in any ASP.NET Core app. Example 30 uses it both ways. + +## [300] APPENDIX Q · SCALING · Scaling with the Redis backplane (sldId 555) + +The same per-pod pinning problem from Appendix C appears here: a SignalR connection is pinned to the pod that accepted it. If pod A calls Clients.Group("dashboard").SendAsync(), only clients connected to pod A receive the message. + +SignalR’s built-in Redis backplane solves this. One line of configuration, AddSignalR().AddStackExchangeRedis(redisConn), and every hub invocation is published to Redis Pub/Sub. All pods subscribe, so all connected clients receive the update. + +builder.Services.AddSignalR().AddStackExchangeRedis(redisConn) — that is the entire backplane setup. + +p. 268 + +> The Pod-pinning problem again: a SignalR connection stays on the Pod that accepted it, so a broadcast from Pod A reaches only Pod A's clients. The Redis backplane fixes it: AddSignalR().AddStackExchangeRedis(...) publishes every hub invocation to Redis pub/sub, every Pod subscribes, and each delivers to its own connections. +> +> Example 30 runs two dashboard instances, on ports 8080 and 8081, against one Redis, and verify.sh checks that an order pushed through one instance reaches a client connected to the other, including after one instance restarts. Redis 8 is used under its AGPLv3 open-source option. + +## [301] APPENDIX Q · CODE · The order dashboard: how the pieces connect (sldId 556) + +OrderHub.cs — a SignalR Hub with a JoinDashboard method. Clients call it to join the “dashboard” group. The hub itself has no push logic — it is just a group-management entry point. + +POST /orders — the REST endpoint creates an order in Postgres, then calls hub.Clients.Group("dashboard").SendAsync("OrderPlaced", order). This pushes the order to every connected dashboard client. + +Dashboard.razor — a Blazor Server component. On initialization, it connects a HubConnection to /hubs/orders, calls JoinDashboard, and registers a handler for “OrderPlaced” that prepends the order to the list and calls StateHasChanged(). + +The result: POST an order via curl, and the Blazor dashboard updates in real time with no polling. The SignalR circuit carries both the Blazor DOM diffs and the explicit OrderPlaced messages. + +The example runs on docker compose: two Blazor app instances (ports 8080 and 8081) on one Redis backplane, plus Postgres and the LGTM stack. + +p. 269 + +> How Example 30 fits together. OrderHub has one method, JoinDashboard, which adds the caller to the dashboard group; the hub holds no push logic. POST /orders stores the order in Postgres and calls Clients.Group("dashboard").SendAsync("OrderPlaced", order) through IHubContext. Dashboard.razor connects a HubConnection to /hubs/orders, joins the group, and on OrderPlaced prepends the order and re-renders. +> +> What to show: open the dashboard on port 8080 and on port 8081, place an order with curl, and watch both update with no polling. Fallback: run verify.sh, which checks the health endpoints, the orders API, the SignalR negotiate endpoint and cross-instance delivery. + +## [302] APPENDIX Q · COMPARISON · When to use what (sldId 557) + +Raw WebSockets (Appendix C): full control, cross-language, works anywhere. Use when you need a custom binary protocol (protobuf frames), or when clients are not .NET. + +SignalR: managed reconnection, groups, Hub RPC pattern, MessagePack serialization. Use when the server is ASP.NET Core and you want push without managing WebSocket lifecycle. + +Blazor Server: server-rendered UI with no client-side framework. Use for internal tools, dashboards, admin panels — where the user count is low and server-side access matters more than offline support. + +Blazor WebAssembly: client-side .NET in the browser. Use when you need offline support or want to reduce server load. Not covered here — the scaling model is different (no persistent circuit). + +Choose the lowest abstraction that meets your requirements — raw WS for cross-language, SignalR for .NET push, Blazor Server for .NET UI. + +p. 270 + +> Choose the lowest abstraction that meets the requirement. Raw WebSockets, as in Appendix C: full control, any client language, custom binary framing such as protobuf. SignalR: managed reconnection, groups, hub RPC and MessagePack when the server is ASP.NET Core and you want push without owning the WebSocket lifecycle. +> +> Blazor Server: server-rendered UI with no client framework, for internal tools, dashboards and admin consoles where user counts are modest and server-side access matters more than offline use. Blazor WebAssembly runs .NET in the browser, holds no persistent circuit and scales like static content; it is not covered here. + +## [303] APPENDIX Q · TAKE-AWAYS · Take-aways (sldId 558) + +Blazor Server runs all UI logic on the server — the browser is a thin terminal connected by a SignalR circuit. No WASM download, full server access, but every interaction round-trips. + +SignalR abstracts WebSockets behind a Hub API with groups, automatic reconnection, and serialization. It is a general-purpose real-time library, not Blazor-specific. + +The Redis backplane (AddStackExchangeRedis) lets multiple pods broadcast to all connected clients — the same per-pod pinning solution from Appendix C, built into SignalR. + +For internal dashboards with low user counts and high data freshness, Blazor Server + SignalR is a pragmatic choice. For public-facing, high-scale, or cross-language scenarios, use raw WebSockets. + +References: ASP.NET Core Blazor hosting models (docs.microsoft.com) · SignalR documentation · Appendix C: WebSocket scaling + +p. 271 + +> The take-aways. Blazor Server runs the UI on the server and the browser is a thin terminal on a SignalR circuit: no download, full server access, a round trip per interaction. SignalR is a general real-time library, not only Blazor's transport. +> +> The Redis backplane lets several Pods broadcast to every connected client, the same answer to Pod pinning as in Appendix C, built into SignalR. For internal dashboards with modest user counts, Blazor Server with SignalR is a pragmatic choice; for public, high-scale or cross-language clients, use raw WebSockets. + +## [304] Example 05 on minikube (sldId 559) + +R + +The same services and the same verify.sh on a single-node Kubernetes cluster + +> Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod. +> +> The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results. + +## [305] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 560) + +p. 272 + +- Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress. +- Scope: Example 05 (event-driven fan-out), three services, all six languages, one node. +- One Kafka broker, one Postgres instance, no service mesh: a local topology, not production. +- The contract carries over: Service names, the Secret, environment variables, the health endpoint. +- The same verify.sh runs against compose and the cluster: ten checks per language. + +Site: Appendix R · Code: examples/05-event-driven/k8s/ + +> What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it. +> +> What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged. +> +> The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec. + +## [306] APPENDIX R · MINIKUBE · The minikube runtime (sldId 561) + +p. 273 + +- Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5. +- 8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services. +- NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation. +- No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. +- Every command passes --context cndp-k8s; setup-profile.sh restores the prior context. + +minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 + +> minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version. +> +> Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1. +> +> The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag. + +## [307] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 562) + +p. 274 + +The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec + +> The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret. +> +> From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs. + +## [308] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 563) + +p. 275 + +- kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1). +- postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw. +- Credential → Secret cndp-postgres-app, read through secretKeyRef. +- build: → docker build, then minikube image load; imagePullPolicy: Never. +- healthcheck → startup and readiness probes on the order-service /healthz. +- ports: 8080:8080 → NodePort 30080. + +The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses. + +> Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts. +> +> The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing. + +## [309] APPENDIX R · MINIKUBE · One overlay per language (sldId 564) + +p. 276 + +[chip] yaml · kustomize + +```yaml +# k8s/overlays/dotnet/kustomization.yaml (excerpt) +images: + - name: order-service + newName: localhost/cndp-05/dotnet-order-service + newTag: v1 + # shipping-consumer and notification-consumer map the same way +# appended to each Deployment's environment by a JSON patch +- name: DATABASE_URL + value: "Host=cndp-postgres-rw;Port=5432;Database=appdb;\ + Username=$(PG_USER);Password=$(PG_PASSWORD)" +- name: KAFKA_BOOTSTRAP + value: "cndp-kafka-bootstrap:9092" +# order-service only +- name: SERVICE_VERSION + value: "1.0.0" +``` + +base/ is language-neutral; each overlay maps the images and appends the variables its language reads. + +> The .NET overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service. +> +> Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base. + +## [310] APPENDIX R · MINIKUBE · Images built on the host (sldId 565) + +p. 277 + +docker build on the host, minikube image load into the node; pods never pull + +> The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load. +> +> The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest. + +## [311] APPENDIX R · MINIKUBE · Run it (sldId 566) + +p. 278 + +[chip] bash · minikube + +```bash +# from examples/05-event-driven/, with no other profile or container running +k8s/setup-profile.sh # create cndp-k8s; check the published ports +k8s/build-images.sh dotnet # docker build, then minikube image load +k8s/deploy.sh dotnet # Strimzi, Kafka, Postgres, LGTM, then the overlay +RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh + +# all six languages, a fresh namespace for each +OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh + +# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile +k8s/teardown.sh --operator +minikube stop -p cndp-k8s +``` + +With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged. + +> The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three .NET images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay. +> +> verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0. +> +> What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images. + +## [312] APPENDIX R · MINIKUBE · Recorded results (sldId 567) + +p. 279 + +- 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). +- Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s. +- Tempo received traces from order-service, shipping-consumer and notification-consumer in every language. +- shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset. +- Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them. + +Times cover the image build and load, the deploy, the checks and the namespace teardown. + +> The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language. +> +> The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster. diff --git a/lgtm-presentation/text/Designing-Cloud-Native-APIs-Go.md b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Go.md new file mode 100644 index 00000000..7ce7a744 --- /dev/null +++ b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Go.md @@ -0,0 +1,4031 @@ +# Designing-Cloud-Native-APIs-Go + +Source: Designing-Cloud-Native-APIs-Go.pptx · 281 slides + +## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native APIs (sldId 256) + +From principles to a running system on Kubernetes — communication, composition, data, events, and the platform that governs them. + +Robert Sedor + +Chief Architect, Application Development + +> Welcome and framing. This is a working architect's tour of designing cloud-native APIs — not a survey of buzzwords. Set expectations: we build one realistic system (five services and a gateway) and re-examine it from twelve angles, then the appendices go deep on protocols, versioning, WebSockets, sagas, errors, caching, failure modes and API testing, and run Example 05 on minikube. Tell the audience the whole deck is a reference they can keep. Mention the running stack up front: Go 1.27.2 with net/http, gRPC, GraphQL, Kafka, Kubernetes. + +## [002] AGENDA · Agenda (sldId 257) + +- Setting up · Patterns in the age of AI +- 01 · Cloud-Native Principles +- 02 · Communications +- 03 · Composition +- 04 · Data +- 05 · Event-Driven +- 06 · Stream Processing +- 07 · Workflows & Jobs +- 08 · API Management +- 09 · API Registry +- 10 · API Metadata +- 11 · Observability +- 12 · Security +- 13 · Anti-Patterns +- Appendices A–D · Protocols, Versioning, WebSockets, Sagas +- Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing +- Appendices K–L · Monolith → Microservices, Caching Patterns +- Appendices M–O · Failure modes, Feature Flags, API Testing +- Appendix R · Example 05 on minikube +- Glossary of acronyms + +p. 1 + +> The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns. +> +> The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck. +> +> Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order. + +## [003] OVERVIEW · What we'll build, and the order we'll build it (sldId 258) + +- Foundations — cloud-native principles and the canonical system we carry throughout +- Communications — REST, gRPC, GraphQL, and synchronous vs. asynchronous +- Composition & Data — gateways, federation, database-per-service, outbox, sagas +- Event-driven & Stream processing — the log, schemas, derived streams, KEDA scaling +- Workflows & Jobs — orchestration, choreography, batch and scheduled work +- The API platform — gateway, registry (Apicurio), metadata (OpenMetadata), observability +- Anti-patterns — and the heuristics that keep you out of them + +p. 2 + +> Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close. Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to. For a 90-minute talk, cover the main twelve; for the full three hours, work through the appendices too. + +## [004] SETTING UP · Why patterns matter more in the age of AI (sldId 259) + +- AI tooling (Claude, Claude Code, frontier models) turns intent into code at a rate that was not possible before. The bottleneck shifted: it's no longer typing, it's knowing what to ask for and what to accept. +- Patterns are your vocabulary at three layers: design patterns (factory, strategy, observer), architecture patterns (hexagonal, microservices, event-driven), system design patterns (sagas, caching, strangler fig — the scope of this deck). +- Without that vocabulary you can't specify precisely, can't evaluate what comes back, can't tell good code from plausible-looking code, or notice when an anti-pattern is dressed as a solution. The AI is fluent; you still need to be literate. +- All architecture is the least-worst decision — every choice trades something away. +- All management is risk management — you manage the distribution of failures, not their existence. +- There is no such thing as a free lunch — every pattern has a cost; ask whether you can afford it. +- There is no problem which cannot be made worse — the wrong fix amplifies the original pain. + +p. 3 + +> Open the deck by naming the elephant. AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows. They produce code fluently. From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run. The risk is also new: a developer who doesn't have the vocabulary to evaluate what comes back is rolling dice in production. The patterns in this deck are not nostalgic computer-science trivia; they are the literacy that lets you direct AI tooling instead of being directed by it. Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing). The four maxims are the philosophical spine. ‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit. ‘Risk management’: you manage the distribution of failures, not their existence. ‘No free lunch’: every pattern (cache, saga, eventing, decomposition) has a cost — a stale read window, a compensation path, a coordination tax, an operational complexity. The question is whether you are buying the right thing for what you are paying. ‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap. The closing line is the audience take-home: AI is the amplifier; the patterns are the literacy that decides which direction it amplifies. + +## [005] SETTING UP · Three layers of patterns literacy (sldId 260) + +p. 4 + +> The visual companion to the three layers of patterns literacy. Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch). The arrows up the middle show how each lower layer informs the next: you can't reason about a saga (system layer) without knowing what a state machine and a compensation are (architecture / design layer); you can't reason about content-based routing without understanding the strategy pattern. The blue caption in the middle is the through-line: knowing this vocabulary is what lets you ask AI tooling for the right thing, recognise what it produces, and judge why it is or isn't fit. The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles. Make the audience comfortable with the maxims, especially the last one (no problem cannot be made worse) — it's the warning at the heart of using AI tooling well. + +## [006] THE RUNNING EXAMPLE · One system, carried end to end (sldId 261) + +We will design every concern against these five services and a gateway — the same example, re-examined from each angle. + +p. 5 + +> This is the single most important slide to anchor on — every later example refers back to it. Walk each service: order-service owns the order lifecycle and exposes REST at the edge; inventory, payment and shipping are internal, reached by gRPC and contributing to GraphQL reads; notification-service is Kafka-only, consuming order.placed. The graphql-gateway composes reads across them. Emphasize that each service exposes only the protocols that fit its role — not a uniform surface. Promise the audience we'll return to this picture a dozen times. + +## [007] Cloud-Native Principles (sldId 262) + +01 + +Properties first, tools second. + +> Transition into section one. The core message: cloud-native is not a stack you buy, it is a set of runtime properties your service must exhibit. Tools (Kubernetes, Istio, KEDA) exist to make those properties cheap to obtain — but if your service isn't written to lean on them, no platform can rescue it. + +## [008] CLOUD-NATIVE PRINCIPLES · Cloud-native is a set of runtime properties (sldId 263) + +If your API isn't disposable, observable, resilient, elastic, automatable and loosely coupled, the platform can't help it. + +p. 6 + +> Six pillars. Spend a sentence on each: disposable (12-factor, fast start, clean stop, no local state), observable (traces/metrics/logs, health probes), resilient (timeouts, retries, circuit breakers, graceful degradation), elastic (scale to load and to zero), automatable (declarative config, GitOps, self-healing), loosely coupled (contracts not calls, async where it fits). The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties. + +## [009] CLOUD-NATIVE PRINCIPLES · The platform makes the properties the default (sldId 264) + +- Kubernetes gives you the disposable process model: liveness/readiness probes, rolling deploys, self-healing. +- KEDA (and the http-add-on) make elasticity real — scale on lag or RPS, all the way to zero. +- Istio supplies resilience and security at the wire: timeouts, retries, circuit breaking, mTLS. +- OpenTelemetry + Tempo / Mimir / Prometheus / Grafana / Kiali make every request observable. +- Your job is to write services that lean on these — not to reimplement them per service. + +p. 7 + +> The punchline is the bold line: your job is to lean on the platform, not reimplement it. Concretely — don't write your own retry library when Istio does it at the wire; don't build a bespoke autoscaler when KEDA scales on lag and RPS; don't hand-roll metrics plumbing when OpenTelemetry plus the LGTM stack gives it to you. Every line of resilience code you write per service is a line you maintain forever and that drifts between teams. The platform makes the good behaviour the default. + +## [010] CLOUD-NATIVE PRINCIPLES · Design-first: the contract precedes the code (sldId 265) + +- Agree the interface before implementing it — OpenAPI for REST, .proto for gRPC, AsyncAPI for events. +- The contract is a versioned artifact in the registry, gated in CI, owned by the producing domain. +- Consumers code against the contract and generated stubs, never against your database or internals. +- This is what makes services independently deployable — the promise the contract encodes is the boundary. + - Tooling: Go modules for reproducible builds; net/http with a codegen step emits OpenAPI; buf / protoc for gRPC; gqlgen generates Go resolvers from the SDL. + +p. 8 + +> Design-first is the cultural prerequisite for everything that follows. The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI. Consumers code against the contract and generated stubs, never against your database. Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary. If you skip this, you get distributed coupling that's worse than a monolith because it fails over the network. + +## [011] CLOUD-NATIVE PRINCIPLES · The twelve-factor app, revisited (sldId 266) + +- The twelve-factor app (2011) is the original recipe for software that's disposable, scalable and portable — the discipline beneath the runtime properties we just saw. +- It predates Kubernetes but maps onto it almost one-to-one: most factors are now things the platform provides, not code you write. +- So read the factors by ownership — a few you still author (config from the environment, statelessness, clean start/stop, logs as streams) and the rest the platform hands you (scaling, build/release/run, attached backing services). +- In a microservices, event-driven system they get richer: “backing services” means Postgres, Kafka and the schema registry as swappable URLs; “logs” becomes correlated traces, metrics and logs. +- Treat it as a pre-flight check: “will this survive the platform?” The twelve factors are listed and mapped to where each lives in our architecture. + +p. 9 + +> Frame the twelve-factor app for the audience: it's the 2011 Heroku methodology that first codified what we now call cloud-native, and it's worth revisiting because it predates Kubernetes yet maps onto it almost perfectly. The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services). In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability. End by teeing up the list and the mapping diagrams. + +## [012] CLOUD-NATIVE PRINCIPLES · What the twelve factors are (sldId 267) + +The original 2011 checklist — most are now platform concerns; only a few are yours to write. + +p. 10 + +> A lot of people nod along to ‘twelve-factor’ without being able to name the twelve, so here they are at a glance, one line each. Don't read them aloud — let people scan. Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams). That sets up the map of each factor onto our architecture. + +## [013] CLOUD-NATIVE PRINCIPLES · Twelve factors, mapped to our system (sldId 268) + +Most factors are now platform defaults you adopt; a few are discipline you still write. + +p. 11 + +> This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building. Read it left to right: the twelve factors cluster into five things they ask of you, and each maps to a concrete piece of our stack and the section that covers it. Build & ship (codebase, dependencies, build/release/run, parity) becomes one repo, one image per release, the same image everywhere (§1). Configure becomes environment config (§1). Run on the platform (stateless processes, concurrency, disposability, port binding) becomes stateless pods, KEDA scaling, fast start/stop, and services behind Istio (§1, 2, 6). Attach resources becomes Postgres, Kafka and Apicurio as swappable URLs (§4–5). Operate (logs, admin processes) becomes OpenTelemetry into the LGTM stack and admin work as Jobs/CronJobs (§7, 11). The caption is the message: most of these are now defaults you adopt, not code you write — which is exactly what 'cloud-native' buys you. + +## [014] CLOUD-NATIVE PRINCIPLES · A 12-factor net/http service, ready for the platform (sldId 269) + +[chip] go · net/http + +```go +// main.go (excerpt), examples/01-cloud-native-principles/go: config from env, two probes +func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(HealthResponse{Status: "ok"}) // liveness: the process is up +} +func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + w.Header().Set("Content-Type", "application/json") + if err := s.pool.Ping(ctx); err != nil { // readiness: the database answers + w.WriteHeader(http.StatusServiceUnavailable) // 503: out of rotation, not restarted + json.NewEncoder(w).Encode(ReadinessResponse{Status: "down", + Checks: map[string]string{"database": "unreachable"}}) + return + } + json.NewEncoder(w).Encode(ReadinessResponse{Status: "ready", + Checks: map[string]string{"database": "ok"}}) +} +// main(): SERVICE_VERSION and DATABASE_URL come from the environment (factor III); the mux +// serves /, /healthz, /readyz and /orders on :8080 (factor VII, port binding) +``` + +Probes, env-config and an explicit version are the price of admission for a Kubernetes Deployment. + +p. 12 + +> Walk the code from Example 01: configuration comes from the environment with os.Getenv (factor III), never from a baked-in file: SERVICE_VERSION names the version the image reports, and DATABASE_URL points the pgx pool at Postgres. The router is the standard library's ServeMux, so there is no router dependency. +> +> The two probes are the contract with Kubernetes: /healthz is liveness (is the process up? if not, restart it) and never checks a dependency; /readyz is readiness (can it serve?): it pings the database with a two-second timeout and, when that fails, answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. Getting liveness and readiness wrong is a common cause of cascading restarts, so stress the distinction. + +## [015] Communications (sldId 270) + +02 + +Match the interaction style to the call. + +> Section two. The thesis: there is no single right protocol — there is a right protocol per interaction. Resist the urge to standardize on one wire for everything. The section gives a decision rule and then shows each protocol in code against the running system. + +## [016] COMMUNICATIONS · Pick the protocol for the interaction (sldId 271) + +REST for reach, gRPC for internal speed, GraphQL for client-driven reads, async for decoupled facts. + +p. 13 + +> Four interaction styles, four defaults. REST for the public/edge surface where reach and caching matter; gRPC for internal service-to-service where latency and streaming matter; GraphQL for client-driven reads that compose many sources in one round trip; async events for decoupled facts that fan out. Most real systems use all four — the skill is matching each call to the right one. Note the sync/async chips at the bottom. This slide is the map for the whole section. + +## [017] COMMUNICATIONS · REST at the edge with net/http (sldId 272) + +[chip] go · net/http + +```go +// orders.go — net/http (Go 1.22+ ServeMux); validated input, 201 on create +func (s *Server) place(w http.ResponseWriter, r *http.Request) { + var in OrderIn // request contract, validated before logic + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || + in.SKU == "" || in.Quantity < 1 { + http.Error(w, "invalid order", http.StatusBadRequest) + return + } + o, err := s.orders.Create(r.Context(), in) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + s.events.Publish(r.Context(), "order.placed", o) // made safe by the outbox + w.Header().Set("Location", "/orders/"+o.ID) + writeJSON(w, http.StatusCreated, o) // 201, not 200 +} +// routes: s.mux.HandleFunc("POST /orders", s.place); "GET /orders" pages by cursor +``` + +Typed request contract, validation before logic, 201 with a Location header: the edge surface other teams depend on. + +p. 14 + +> This is the edge surface. Point out four things teams routinely get wrong: (1) resource-shaped URLs, nouns not verbs, registered with Go 1.22 method-aware patterns such as "POST /orders"; (2) the request decoded into a typed struct and validated, so bad input never reaches the logic; (3) correct status codes: 201 on create with a Location header, not 200; (4) cursor pagination on the listing, not offset, because offset breaks under concurrent writes and slows down at depth. +> +> The handler publishes order.placed after the insert; the Data section makes that publish safe with the outbox. The OpenAPI document for this surface is what we register and gate. Test it with curl and Postman. + +## [018] COMMUNICATIONS · gRPC for internal service-to-service calls (sldId 273) + +[chip] protobuf + go · grpc + +```protobuf+go +// inventory.proto — the contract, registered in Apicurio +syntax = "proto3"; +service Inventory { + rpc ReserveStock (ReserveRequest) returns (ReserveReply); +} +message ReserveRequest { string sku = 1; int32 quantity = 2; } +message ReserveReply { bool reserved = 1; int32 remaining = 2; } +// inventory_server.go — embed the generated UnimplementedInventoryServer base +type inventoryServer struct { pb.UnimplementedInventoryServer; stock *Stock } +func (s *inventoryServer) ReserveStock( + ctx context.Context, req *pb.ReserveRequest) (*pb.ReserveReply, error) { + remaining := s.stock.Reserve(req.GetSku(), req.GetQuantity()) // generated getters + return &pb.ReserveReply{Reserved: remaining >= 0, Remaining: max(remaining, 0)}, nil +} +``` + +Schema-first, binary, multiplexed over HTTP/2 — the right default for chatty internal paths. Test with ghz. + +p. 15 + +> gRPC for the internal paths. The .proto is the contract, and it lives in the registry just like OpenAPI. The field numbers (=1, =2) are the wire contract; Appendix B returns to why. protoc-gen-go and protoc-gen-go-grpc generate the message types and the server interface, so both sides share types and nobody parses JSON by hand. +> +> The server embeds the generated UnimplementedInventoryServer, which keeps it compiling when the service gains methods, and reads fields through the generated getters. gRPC is binary and multiplexed over HTTP/2, so it is fast and supports streaming: the default for chatty service-to-service calls where you control both ends. Load-test it with ghz. + +## [019] COMMUNICATIONS · Synchronous coupling vs. asynchronous facts (sldId 274) + +A synchronous call shares fate; an event shares a fact. Reach for async wherever immediacy isn't required. + +p. 16 + +> The central trade-off of the whole talk in one picture. Top row: a synchronous call couples caller to callee in time and availability — if payment-service is down, the order request fails too, and availability multiplies downward across a chain. Bottom row: emitting order.placed as a fact lets the order succeed immediately; consumers catch up from the log if they were down. The rule: reach for async wherever immediacy isn't strictly required. This sets up the entire event-driven section later. + +## [020] COMMUNICATIONS · The mesh handles the wire, not your code (sldId 275) + +Istio sidecars give you mTLS, retries, timeouts and telemetry — uniformly, without per-service code. + +p. 17 + +> Service mesh in one picture. Your app speaks plain HTTP or gRPC to localhost; the Istio sidecar (Envoy) intercepts and adds mTLS, retries, timeouts and telemetry on the wire — uniformly, for every service, with zero application code. istiod is the control plane distributing policy and identity (SPIFFE). The key insight ties back to principles: resilience and security become platform defaults, not per-service reimplementations. The configuration itself is an Istio VirtualService. + +## [021] COMMUNICATIONS · Resilience as configuration, not per-call code (sldId 276) + +[chip] yaml · istio + +```yaml +# Istio: bounded retries and timeout for inventory-service +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: inventory } +spec: + hosts: [inventory] + http: + - timeout: 2s + retries: + attempts: 2 # bounded — never an unbounded retry storm + perTryTimeout: 800ms + retryOn: connect-failure,unavailable + route: + - destination: { host: inventory } +``` + +Bounded retries with backoff live in the mesh. Unbounded client retries are how an incident becomes an outage. + +p. 18 + +> Resilience as configuration. This VirtualService gives inventory a 2-second timeout, two retry attempts with an 800ms per-try timeout, retrying only on connect-failure and unavailable. The critical word is bounded — attempts: 2, not infinite. Tell the war story: unbounded client-side retries are how a brief blip becomes a full outage, because every caller piles on the struggling service (a retry storm). Putting this in the mesh means it's consistent across every service and tunable without redeploying code. + +## [022] Composition (sldId 277) + +03 + +Aggregate at the edge, not inside services. + +> Section three. The question: when a client needs data from several services, where does the joining happen? The answer that keeps services decoupled: at the edge, in a gateway or BFF — never by services calling each other in deep chains. That chaining is how you accidentally rebuild a monolith over the network. + +## [023] COMPOSITION · One query, many services (sldId 278) + +The gateway presents one schema and fans out to REST and gRPC back-ends, then joins and shapes the result. + +p. 19 + +> GraphQL federation against the running system. The gateway presents one schema to clients and fans out to REST and gRPC back-ends, then joins and shapes the result. The client makes one round trip and asks for exactly the fields it needs. The services stay unaware of each other; the gateway owns the composition. Contrast this with services calling services, which we'll warn against on the patterns slide. The resolver code shows how the gateway composes the calls. + +## [024] COMPOSITION · A composing resolver in gqlgen (sldId 279) + +[chip] go · gqlgen + +```go +func (r *queryResolver) Orders(ctx context.Context, limit *int) ([]*model.Order, error) { + var orders []*model.Order // REST call to order-api + if err := r.getJSON(ctx, r.OrderAPIURL+"/orders", &orders); err != nil { return nil, err } + r.attachStock(ctx, orders) // stock only when the query selects it + return orders, nil +} +func (r *Resolver) attachStock(ctx context.Context, orders []*model.Order) { + if !slices.Contains(graphql.CollectAllFields(ctx), "stock") { return } + stock, _ := r.stockFor(ctx, skusOf(orders)) // one batched gRPC call, no N+1 + for _, o := range orders { s := int(stock[o.Sku]); o.Stock = &s } +} +``` + +The gateway owns aggregation: orders come from order-api over REST; stock is fetched only when selected, in one batched gRPC call. + +p. 20 + +> The resolver makes federation concrete. gqlgen is schema-first: it generates the resolver interfaces from graph/schema.graphqls, and we implement them. The orders query calls order-api over REST. The Order.stock field is filled only when the query selects it, and one GetStockBatch gRPC call to inventory-service covers every order in the response. +> +> That batching is the answer to the N+1 problem: naive per-field resolution would make one inventory call per order. The services stay unaware of each other; the gateway owns the composition, and the schema's SDL is what we export to the registry. + +## [025] COMPOSITION · Three ways to compose — and where each belongs (sldId 280) + +Push aggregation to a gateway or BFF. Chaining calls inside services rebuilds the monolith over the network. + +p. 21 + +> Three composition patterns: an API gateway (one composed schema for everyone), a backend-for-frontend (a tailored aggregate per client type — web vs mobile), and GraphQL federation (a distributed graph). All three push aggregation outward to the edge. The anti-pattern to call out explicitly: service-to-service call chains, where order calls inventory calls pricing calls... — synchronous, deeply coupled, and impossible to reason about. That rebuilds the monolith with network latency between the pieces. + +## [026] Data (sldId 281) + +04 + +Own your data; share it through contracts. + +> Section four, and the hardest discipline to hold. The rule: each service owns its data and shares it only through APIs and events — never through a shared database or by reaching into another service's tables. This is what makes independent deployment and independent scaling real. The slides cover ownership, the dual-write trap, and the saga. + +## [027] DATA · A database per service (sldId 282) + +Each service owns its schema and storage engine; no other service touches its tables directly. + +p. 22 + +> Database-per-service. Each service owns its schema and even its storage engine — order and payment on Postgres, something else on a document store if that fits. No other service touches those tables directly; the only doors in are the API and the events. The hard part is social, not technical: someone will always want to 'just join across two services' databases' for a report. That shortcut is the single most common way microservice independence dies. Reporting needs go through the metadata/lineage layer or a dedicated read model, not direct table access. + +## [028] DATA · CQRS — Command Query Responsibility Segregation (sldId 283) + +Separate the write model from the read model. They have different shapes, scales and even different stores. + +p. 23 + +> CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises for invariants and consistency: a normalised schema in a transactional store (Postgres, MySQL). The read side optimises for query shape: denormalised wide tables, an Elasticsearch index, a Mongo document, or whatever fits the question being asked. The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern. The cost: eventual consistency, a small lag between the write committing and the read model catching up. The benefit: each side can scale, schema-evolve and even be stored independently. Use this pattern when reads and writes have different shapes and access patterns. Avoid it when a single store handles both; CQRS adds operational complexity that isn't free. The classic motivating case: a service that takes thousands of writes per second but is read by analytics, dashboards and search — the write model serves the writes, the read model serves the rest. CQRS pairs naturally with event sourcing on the write side and event streaming for projections; the Event-Driven section draws both distinctions. + +## [029] DATA · CQRS in practice (sldId 284) + +[chip] go · net/http + pgx + franz-go + +```go +// Write side — one transaction, an outbox row, no broker call +func createOrder(ctx context.Context, in OrderIn) (Order, error) { + var order Order + err := withTx(ctx, writeDB, func(tx pgx.Tx) (err error) { // begin/commit/rollback + if order, err = insertOrder(ctx, tx, in); err != nil { return err } + return insertOutbox(ctx, tx, "order.placed", order) // same txn — atomic + }) + return order, err // commit → CDC publishes +} +// Read model is a separate denormalised store; a projection keeps it current: +// Projection — consumes order events, upserts the read model (idempotent) +for { // subscribes: order.placed + cl.PollFetches(ctx).EachRecord(func(rec *kgo.Record) { + if err := upsertSummary(ctx, readDB, deserialize(rec.Value)); err != nil { + slog.Error("projection", "err", err) + } + }) + cl.CommitUncommittedOffsets(ctx) // at-least-once delivery +} +``` + +p. 24 + +> The Go shape. The write side is a normal POST handler in an existing transaction (we already saw the outbox pattern). The read side is a separate GET that queries a denormalised 'order_summary' table that holds the JOINed, aggregated view the UI needs. The projection (bottom of the slide) is a Kafka consumer that subscribes to order events and upserts into the read model — the upsert must be idempotent because Kafka delivery is at-least-once. The read-model table is denormalised, not 3NF: it holds customer_name (denormalised from customers), line_count (aggregated from order_lines), and any other field the query needs without a JOIN. Different teams can own the read side and the write side. Different scaling: the read store can be scaled horizontally (read replicas, search index) without affecting writes. CQRS adds complexity: use it when the read and write shapes have diverged, not as a default. + +## [030] DATA · Log tailing — the database commit log is the event stream (sldId 285) + +Debezium tails the WAL and publishes every committed row as a Kafka event. No dual-write. + +p. 25 + +> The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC. The application writes ordinary SQL. The database commits and the change lands in the write-ahead log (WAL on Postgres, redo log on Oracle, binlog on MySQL, transaction log on SQL Server). Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved. The application doesn't know Debezium exists. There is no dual-write — the commit is the single source of truth, and the event is a strictly downstream consequence. This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use. Debezium supports Postgres, MySQL, MariaDB, SQL Server, Oracle, MongoDB, Db2, Cassandra, and Vitess out of the box. Run mode: deploy as Kafka Connect workers, or use Debezium Server (standalone) if you don't want a Kafka Connect cluster. WAL = Write-Ahead Log: the database's commit-ordered durable log that already exists for replication and crash recovery; Debezium just subscribes to it. + +## [031] DATA · Dual-write is unsafe — use the outbox (sldId 286) + +Write state and the outbox row in one transaction; CDC relays the committed fact to Kafka. + +p. 26 + +> This is the slide that prevents a class of production bugs. The dual-write problem: if you write to your database and then publish to Kafka as two separate operations, a crash between them leaves you inconsistent — the order exists but the event was never sent, or vice versa. The fix is the transactional outbox: write the state change and an outbox row in one local transaction, then a change-data-capture tool (Debezium) tails the database log and relays the committed row to Kafka. The database commit is the single source of truth. The outbox write itself is one database transaction, shown in code. + +## [032] DATA · The outbox write — one transaction, no lost events (sldId 287) + +[chip] go · pgx + Debezium + +```go +// orders.go — pgx; the state change and the outbox row commit in one local txn +func (s *Orders) Create(ctx context.Context, in OrderIn) (Order, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { return Order{}, fmt.Errorf("begin: %w", err) } + defer tx.Rollback(ctx) // no-op after Commit; rolls back on any early return + o, err := insertOrder(ctx, tx, in) + if err != nil { return Order{}, err } + if err := insertOutbox(ctx, tx, "order.placed", o.ID, toJSON(o)); err != nil { + return Order{}, err // same txn: atomic with the state change + } + if err := tx.Commit(ctx); err != nil { return Order{}, fmt.Errorf("commit: %w", err) } + return o, nil // Debezium tails the WAL and publishes; no broker on the request path +} +``` + +The database commit is the source of truth. The outbox + CDC turns it into a guaranteed, ordered event. + +p. 27 + +> The whole mechanism is the single transaction: pool.Begin, then tx.Commit, with defer tx.Rollback for every early return. The order insert and the outbox insert commit together or not at all. +> +> There is no broker call inside the request path. If the broker is down, the order still commits and the event ships when CDC catches up, so request latency and availability no longer depend on Kafka. Debezium tails the write-ahead log and publishes outbox rows in commit order, which gives a guaranteed, ordered event stream. The saga appendix uses the same idea: the state transition and the outbound command commit together. + +## [033] DATA · Consistency without distributed transactions: the saga (sldId 288) + +A chain of local transactions; failures trigger compensating actions, never a cross-service rollback. + +p. 28 + +> The saga is how you get consistency across services without a distributed transaction (there is no two-phase commit here, and you don't want one). It's a sequence of local transactions, each emitting the event that triggers the next: order created, payment taken, stock reserved, shipment booked. When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order). Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics. + +## [034] Event-Driven (sldId 289) + +05 + +Emit facts; let consumers decide. + +> Section five — the asynchronous backbone we kept pointing at. The mental shift: a producer emits a fact about something that happened (order.placed) and does not know or care who consumes it. Consumers decide what to do. This inverts the dependency direction, and it is what decouples services in time and ownership. + +## [035] EVENT-DRIVEN · The event backbone — one fact, many reactions (sldId 290) + +order.placed fans out to consumers the producer never names. Add a consumer without touching the producer. + +p. 29 + +> order.placed fans out to payment, shipping and notification — and the producer names none of them. The power move: you can add a new consumer (say, a fraud-check service) without touching or redeploying the producer. Contrast with the synchronous world, where adding a dependency means changing the caller. This is the open/closed principle at the architecture level. Kafka (run by Strimzi on Kubernetes) is the backbone; Pulsar is an alternative with similar semantics. + +## [036] EVENT-DRIVEN · Producing and consuming with franz-go (sldId 291) + +[chip] go · franz-go + +```go +// producer — order-service: one client per service, keyed by order ID +func publish(ctx context.Context, cl *kgo.Client, o Order) error { + rec := &kgo.Record{Topic: "order.placed", Key: []byte(o.ID), Value: serialize(o)} + return cl.ProduceSync(ctx, rec).FirstErr() // acks from all in-sync replicas +} +// consumer — notification-consumer, Kafka-only by design +cl, err := kgo.NewClient( + kgo.SeedBrokers(os.Getenv("KAFKA_BOOTSTRAP")), + kgo.ConsumerGroup("notification-group"), // offset tracked per group + kgo.ConsumeTopics("order.placed"), + kgo.DisableAutoCommit(), // commit after the side effect +) +if err != nil { return err } +for { + fs := cl.PollFetches(ctx) + fs.EachRecord(func(r *kgo.Record) { notify(ctx, r) }) // idempotent insert + if err := cl.CommitUncommittedOffsets(ctx); err != nil { // at-least-once + slog.Error("commit failed", "err", err) + } +} +``` + +Commit offsets after the side-effect, and make handlers idempotent — at-least-once delivery is the contract. + +p. 30 + +> Two things to drill home. First, auto-commit is disabled (kgo.DisableAutoCommit) and the offsets are committed after the side effect, here the notification insert. Commit first and crash, and the work is lost. +> +> Second, because the commit comes after the work, a crash mid-batch means the records are delivered again, so delivery is at-least-once and handlers must be idempotent. The example's consumer inserts with ON CONFLICT (order_id) DO NOTHING, so a duplicate is a no-op. Exactly-once in practice means at-least-once plus idempotency. The record key is the order ID, which keeps every event for one order on one partition, in order. + +## [037] EVENT-DRIVEN · Schemas are the contract — the registry enforces them (sldId 292) + +Apicurio holds versioned schemas; compatibility is checked at publish time so breaking changes are rejected early. + +p. 31 + +> Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate. The schema (Avro, Protobuf or JSON Schema) lives in Apicurio. Producers and consumers fetch it; compatibility is checked at publish time so a breaking change is rejected before it ever reaches the topic. This is what makes adding a consumer safe. We come back to event versioning rules in Appendix B. + +## [038] EVENT-DRIVEN · Event sourcing vs. event streaming (sldId 293) + +Two patterns, often confused. Sourcing: the events are the state. Streaming: events are the transport. + +p. 32 + +> The vocabulary clarification most teams need before they go further with events. Event sourcing means events are the database — there is no 'current state' row anywhere; current state is computed by folding the entire event history. Adding a credit to an account isn't UPDATE balance SET balance = balance + 100; it's INSERT INTO events VALUES ('credit', 100), and reading the balance means summing every event for that account (with snapshots for performance). Strong fit for audit-heavy systems (banking, healthcare records, regulatory), temporal queries ("what was the balance on March 3rd?"), and any domain where the history is itself a primary asset. Tools: Marten (Postgres-backed, .NET), EventStoreDB, Axon (JVM). Event streaming means events are how facts move between services — each service still keeps its current state in a normal database, but emits events ("order.placed", "payment.processed") that other services subscribe to. The event is a transport mechanism, not a storage mechanism. Strong fit for integration, fan-out, decoupling. Tools: Kafka, Pulsar, NATS JetStream. These are not mutually exclusive. A common combination is full CQRS: the write side is event-sourced (so you have an audit trail of state changes), and the read-side projections are fed by streaming those events to consumers that build denormalised views. The acid test: is the event itself the source of truth (sourcing), or is it a propagated fact about a change that happened in a store (streaming)? In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core. + +## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) + +Different jobs, often together: substrate (Kafka, Pulsar), compute (Flink), movement (NiFi, Debezium). + +p. 33 + +> Each of the four tools does a different job. Kafka is the substrate: a partitioned, ordered, durable log. Consumers track their own offsets and can replay. It's the de facto event spine for cloud-native architectures; Strimzi runs it natively on Kubernetes. Pulsar is also substrate, in the same broad category as Kafka but with native pub/sub plus queue semantics in the same product, tiered storage that pushes cold data to cheap object stores automatically, and multi-tenancy built into the broker. Pulsar has a Kafka-protocol shim so consumers can speak Kafka while writing to Pulsar. Flink is compute: not a transport layer, but a stateful stream-processing engine. Flink sits on top of Kafka or Pulsar, consumes streams, applies operators (windowed aggregations, joins, filters, stateful transformations) with exactly-once semantics via checkpointing, and produces new streams. Alternatives: Kafka Streams (lighter, JVM-only, embedded library), Spark Structured Streaming (heavier, batch-mindset). NiFi is movement: a visual dataflow / ETL tool with 200+ source-and-sink connectors and back-pressure built in. Think of it as the answer to 'I need to get data from SFTP to Kafka to an object store with these transforms in the middle' — you draw the flow in the UI rather than writing a pipeline. Debezium (covered in the Data section) is the CDC variant of movement. Together they compose: typical pipeline is source DB → Debezium or NiFi → Kafka or Pulsar → Flink for windowed aggregations → sink. The mistake is treating these as competitors. They complement each other; pick by the question your team is asking: durably hold events (substrate), compute over them (Flink), move them around (NiFi/Debezium). + +## [040] EVENT-DRIVEN · The log is the source of truth (sldId 295) + +Consumers track their own offset; replay is just rewinding. Kafka (Strimzi) and Pulsar are logs, not queues. + +p. 34 + +> The mental model correction most people need: Kafka is a log, not a queue. Messages are retained after they are read, and each consumer group tracks its own offset. That means replay is just rewinding an offset: a new consumer can process all of history, and a buggy consumer can be fixed and replayed. This is the foundation for event sourcing and for rebuilding stream-processing state, the subject of stream processing. Strimzi runs Kafka on Kubernetes; Pulsar offers similar log semantics with tiered storage. + +## [041] Stream Processing (sldId 296) + +06 + +Streams in, derived streams out. + +> Section six builds directly on the log. Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams. This is where real-time analytics, materialized views and complex event reactions live. + +## [042] STREAM PROCESSING · Derive new streams from streams (sldId 297) + +A streaming app is a topology of sources, stateful processors and sinks — joins, windows and aggregates. + +p. 35 + +> A stream-processing app is a topology: sources (input topics), stateful processors (filter, join, window, aggregate) and sinks (output topics or stores). The output of one topology is just another stream that other services consume. Emphasize stateful — unlike a simple consumer, these processors keep local state (running totals, window buffers) that must survive restarts. That state-management problem is what the next code slide and KEDA slide address. + +## [043] STREAM PROCESSING · A windowed aggregation with goka streams (sldId 298) + +[chip] go · goka + +```go +graph := goka.DefineGroup("stream-processor", + goka.Input("order.placed", new(codec.Bytes), rekey), // stage 1: re-key + goka.Output("stream-processor-by-merchant", rekeyedCodec{}), + goka.Input("stream-processor-by-merchant", rekeyedCodec{}, aggregate), + goka.Output("revenue.by-merchant", new(codec.Bytes)), + goka.Persist(stateCodec{}), // the group table, backed by a changelog topic +) +func aggregate(ctx goka.Context, msg any) { + in := msg.(*rekeyed) // {ts, total} for one order + state := loadState(ctx) // this merchant's windows + start := (in.TS / 1000 / windowSeconds) * windowSeconds // tumbling-window floor + cur := state.add(start, in.Total) // count + running total + ctx.SetValue(state) // goka writes the changelog + ctx.Emit(revenue, ctx.Key(), revenueJSON(ctx.Key(), start, cur)) // revenue.by-merchant +} +``` + +Window state lives in a changelog-backed group table, so a restart replays the changelog. The output is itself a stream. + +p. 36 + +> Stream processing in Go with goka. A goka group is a Kafka consumer group with a table: local state per key, backed by a changelog topic, so a pod that dies rebuilds its state by replaying the changelog. That is fault-tolerant local state without an external database in the hot path. +> +> Stage one re-keys each order.placed record by merchant; stage two folds the order into that merchant's tumbling windows (the floor of the event time to the window size) and emits every update to revenue.by-merchant. goka has no windowing DSL, so the window is explicit code. The output topic is itself a stream other services consume. Kafka Streams and Flink are the JVM equivalents if asked. + +## [044] STREAM PROCESSING · KEDA scales workers on the signal that matters (sldId 299) + +Scale Kafka workers on consumer lag and HTTP services on request rate — both down to zero when idle. + +p. 37 + +> KEDA closes the elasticity loop. The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU. KEDA reads that lag and scales workers up, and down to zero when there's no lag. For synchronous HTTP services, the KEDA http-add-on scales on request concurrency/rate. One mechanism, two signals. Scale-to-zero is a real cost lever for spiky workloads. The KEDA configuration covers both: lag for events and RPS for HTTP. + +## [045] STREAM PROCESSING · Scale on lag (events) and on RPS (HTTP) (sldId 300) + +[chip] yaml · KEDA + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: { name: payment-consumer } +spec: + scaleTargetRef: { name: payment-consumer } + minReplicaCount: 0 + maxReplicaCount: 20 + triggers: + - type: kafka + metadata: { bootstrapServers: "cndp-kafka-bootstrap:9092", topic: order.placed, + consumerGroup: payment, lagThreshold: "100" } +--- +# HTTP: scale the Go service on concurrent requests (KEDA http-add-on) +apiVersion: http.keda.sh/v1alpha1 +kind: HTTPScaledObject +metadata: { name: order-service } +spec: + hosts: [orders.example.com] + scaleTargetRef: { name: order-service, service: order-service, port: 8080 } + replicas: { min: 0, max: 30 } +``` + +One mechanism, two signals: queue depth for async workers, concurrency for synchronous APIs. + +p. 38 + +> Two manifests, one idea. The ScaledObject scales payment-consumer on Kafka lag for the order.placed topic, with minReplicaCount: 0 — true scale to zero when the topic is quiet, springing back when lag builds. The HTTPScaledObject does the same for the synchronous order-service based on request load via the http-add-on. The lagThreshold of 100 is the tuning knob: how many messages of backlog before adding a worker. Point out that scale-to-zero plus a cold-start budget is the trade-off to discuss with the audience for latency-sensitive paths. + +## [046] Workflows and Jobs (sldId 301) + +07 + +Not every unit of work is a request. + +> Section seven. A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work. Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes. + +## [047] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 302) + +A coordinator that commands keeps the flow in one place; events that trigger each other decouple but hide the flow. + +p. 39 + +> The two ways to coordinate a multi-step process. Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service. Choreography: each service reacts to the previous one's event with no central brain — maximally decoupled, but the end-to-end flow is emergent and hard to see. Neither is universally right; choose by the selection criteria in the comparison. This connects straight to the saga discussion and Appendix D. + +## [048] WORKFLOWS AND JOBS · Choosing between the two (sldId 303) + +Orchestration + +- One coordinator owns the saga and its state +- Flow is explicit and centrally observable +- One place to reason about, change and audit +- Risk: the orchestrator becomes a god-service +- Good for complex, long-lived business processes + +Choreography + +- Each service reacts to the previous event +- Maximally decoupled; no central bottleneck +- Flow is emergent — harder to see end-to-end +- Risk: nobody owns the overall process +- Good for simple, additive event reactions + +p. 40 + +> The decision heuristic: use orchestration for complex, long-lived business processes where you need auditability and a clear owner (order fulfillment, loan approval). Use choreography for simple, additive reactions where decoupling matters more than visibility (send a welcome email when a user signs up). The failure modes are mirror images: orchestration risks a god-service; choreography risks nobody owning the process and no one able to answer 'where is order 42 stuck?'. Observability (section 11) is what makes choreography survivable. + +## [049] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 304) + +Long, batch or periodic work belongs in Jobs/CronJobs or a scaled queue worker — never on the request path. + +p. 41 + +> Where non-request work lives on Kubernetes. A Job runs a task to completion (a data migration, a nightly export). A CronJob schedules Jobs (the reconciliation that runs at 2am). A queue worker continuously drains a topic and is scaled by KEDA on lag. The anti-pattern: doing long or heavy work inside an HTTP handler, blocking the request and timing out the client. The rule of thumb on screen — long, batch or periodic work never belongs on the request path. Hand it to a Job or a worker and return immediately, ideally with a status URL the client can poll. + +## [050] API Management (sldId 305) + +08 + +Treat the API surface as a platform. + +> Section eight zooms out from individual services to the API surface as a whole. Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability). The next three sections each take one of these in depth. + +## [051] API MANAGEMENT · Four cooperating planes (sldId 306) + +Gateway and mesh at runtime; registry for contracts; metadata for discovery; observability for signals. + +p. 42 + +> The four planes, and they map exactly to the rest of the platform sections. Runtime plane: the gateway and Istio mesh handle traffic, auth, rate-limiting and resilience. Contract plane: the registry (section 9) holds every API and event schema, versioned and gated. Discovery plane: metadata/catalog (section 10) lets people find and trust APIs. Signal plane: observability (section 11) tells you what is happening. The point: these are distinct responsibilities with distinct owners, not one monolithic 'API gateway product' that tries to do everything. + +## [052] API MANAGEMENT · Versioning and evolution are part of management (sldId 307) + +- Evolve additively — new optional fields and endpoints; never repurpose or remove without a new version. +- Carry a major version in the path (/v2) or media type; deprecate with headers and a sunset date. +- The registry's compatibility rules (BACKWARD / FORWARD / FULL) enforce this mechanically at publish time. +- Run old and new side by side; migrate consumers off the old contract before you retire it. +- Lifecycle: design → register → publish → consume → deprecate → retire — each step has an owner and a gate. + +p. 43 + +> Versioning is a management concern, not an afterthought. The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period. When you must break, carry a major version (in the path or media type) and run old and new side by side with a published sunset date, migrating consumers off before retiring. The registry's compatibility rules enforce the additive discipline mechanically at publish time. The bold lifecycle line is the takeaway: every contract moves design → register → publish → consume → deprecate → retire, and each transition has an owner and a gate. Appendix B drills into how REST, gRPC and GraphQL each handle this differently. + +## [053] API Registry (sldId 308) + +09 + +One versioned home for every contract. + +> Section nine. If contracts are the boundary, they need one authoritative home. The registry is where every OpenAPI spec, .proto, AsyncAPI doc and event schema lives — versioned, compatibility-checked, and gated in CI. This is the difference between 'we have contracts somewhere in git' and 'contracts are enforced before code ships'. + +## [054] API REGISTRY · Apicurio: contracts, versioned and gated (sldId 309) + +OpenAPI, AsyncAPI, proto and Avro/JSON schemas live in one registry with compatibility rules and CI gating. + +p. 44 + +> Apicurio is the concrete registry in this stack. Every contract type lands here: REST OpenAPI, GraphQL SDL, gRPC .proto, and event schemas (Avro/JSON). Each artifact has versions and a compatibility rule (BACKWARD, FORWARD, FULL). At publish time the registry checks the new version against the rule and rejects incompatible changes. Producers register; consumers fetch and generate stubs. The serde libraries for Kafka pull schemas from here by ID at runtime. A CI gate turns this into an automatic guardrail. + +## [055] API REGISTRY · Gate breaking changes in CI (sldId 310) + +[chip] go · net/http + +```go +// registry_client.go — register a new version; the registry enforces the rule +const registry = "http://apicurio.registry.svc/apis/registry/v3" +func publish(group, artifactID, schema string) error { + body, _ := json.Marshal(map[string]any{"content": map[string]string{ // v3 CreateVersion + "content": schema, "contentType": "application/json"}}) + url := fmt.Sprintf("%s/groups/%s/artifacts/%s/versions", registry, group, artifactID) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { return err } + defer resp.Body.Close() + if resp.StatusCode == http.StatusBadRequest { // 400 = compatibility rule violated + return errors.New("incompatible schema change: blocking the merge") + } + if resp.StatusCode >= 300 { return fmt.Errorf("registry: %s", resp.Status) } + return nil // 200 = compatible, new version created +} +// rule set once per artifact, e.g. BACKWARD: new consumers can read old data +``` + +The CI job fails the build on an incompatible change — the contract is enforced before code ships, not after. + +p. 45 + +> This is the guardrail that makes the contract story enforceable. The CI job posts the new schema version to Apicurio; if it violates the artifact's compatibility rule, Apicurio 3.3.3 answers 400 with a RuleViolationException and the job fails the build, blocking the merge. A compatible schema gets 200 and a new version. +> +> The contract is enforced before code ships, not discovered broken in production by a downstream team. The rule (for example BACKWARD: new consumers can read old data) is set once per artifact and then applies to every change. This is design-first from section 1, mechanized. + +## [056] API Metadata (sldId 311) + +10 + +If they can't find it, it doesn't exist. + +> Section ten. A contract that's enforced but undiscoverable is half useless. Metadata is the discovery and trust layer: a searchable catalog of every API, topic and table, with ownership, lineage and quality signals. If a consumer can't find your API and trust it, they'll build their own or screen-scrape — and your boundary erodes. + +## [057] API METADATA · Discovery, lineage and ownership (sldId 312) + +OpenMetadata ingests from services, the registry and Kafka to build a searchable catalog with lineage. + +p. 46 + +> OpenMetadata is the catalog in this stack. It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog. The differentiator over a wiki is automated lineage: it knows that this topic feeds that table feeds this API, so it can answer 'if I change this field, who breaks?' before you change it. Ingestion is a scheduled connector, so the catalog stays current without manual upkeep. What consumers get from the catalog is the point of running it. + +## [058] API METADATA · What the catalog gives every consumer (sldId 313) + +- Discovery — search across APIs, topics and tables; find the owning team and how to get access. +- Trust — published SLOs, data-quality test results, freshness, and a tier/criticality rating. +- Lineage — column- and topic-level lineage answers 'if I change this, who breaks?' before you change it. +- Ownership & governance — tags, classifications (PII), and accountable owners attached to every asset. + - Ingestion runs as a scheduled connector against OpenAPI specs, the registry, Kafka and Postgres. + +p. 47 + +> Four concrete payoffs for consumers. Discovery: search across APIs, topics and tables, and find the owning team and how to get access — no more asking around in Slack. Trust: published SLOs, data-quality results, freshness and a criticality tier tell you whether to depend on it. Lineage: column- and topic-level impact analysis answers the change-safety question proactively. Ownership and governance: tags, PII classifications and accountable owners on every asset — which is also what your compliance team needs. The throughline of sections 9–10: a contract is only as good as your ability to find it, trust it, and know who owns it. + +## [059] Observability (sldId 314) + +11 + +Instrument once; correlate everything. + +> Section eleven, and the one that makes everything else operable. In a distributed system you cannot debug by reading one log file — a single user action touches five services. Observability is how you reconstruct that story. The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them. + +## [060] OBSERVABILITY · Three signals, one correlated story (sldId 315) + +OpenTelemetry emits traces, metrics and logs; the LGTM-style stack stores them and Grafana joins them. + +p. 48 + +> Three signals, each answering a different question. Traces: where did the time go and which service failed in this one request? Metrics: what are the aggregate rates, errors and latencies over time? Logs: what exactly happened in this component? OpenTelemetry is the vendor-neutral way to emit all three from your code. The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them. The magic is the shared trace_id linking a metric spike to the exact traces and logs behind it. The instrumentation comes first, then the trace. + +## [061] OBSERVABILITY · The signals in depth — traces, metrics, logs, baggage, profiles (sldId 316) + +Three classic pillars plus two that matter more every year. OpenTelemetry emits all five; one trace_id correlates them. + +p. 49 + +> Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer where the time went and which hop failed in one request. Metrics answer the trend question — aggregate rates, errors, latencies over time (the RED method: Rate, Errors, Duration; the USE method: Utilisation, Saturation, Errors). Logs answer what exactly happened in one component. Baggage is the signal most people miss — it's key-value context (tenant_id, user_tier, feature_flag) that propagates along the whole trace, so a value set at the edge is readable in a Kafka consumer three hops downstream. Profiles are the newest signal — continuous CPU / memory / lock sampling per service, answering which function is burning the CPU (Pyroscope, Parca, Grafana Phlare). The correlation bar at the bottom is the whole point: traces, metrics, logs, baggage and profiles all share trace_id + span_id + resource attributes (service.name, k8s.pod.name, deployment.version), so you can pivot — a metric spike leads to an exemplar trace, which leads to its logs, which leads to its CPU profile. OpenTelemetry is the vendor-neutral SDK and wire format for all five; traces/metrics/logs are GA, baggage is stable, profiling is the newest (the OTel profiling signal reached the ecosystem in 2024). + +## [062] OBSERVABILITY · What each signal answers — and when to reach for it (sldId 317) + +- Traces — 'where did the time go in this request, which hop failed?' Reach for it when debugging latency or a specific failed request. Stored in Tempo / Jaeger. +- Metrics — 'what is the aggregate rate / error / latency trend, is it getting worse?' Reach for it for dashboards, alerting, capacity. RED (Rate, Errors, Duration) for request-driven services; USE (Utilisation, Saturation, Errors) for resources. Stored in Prometheus / Mimir. +- Logs — 'what exactly happened in this component at this instant?' Reach for it for detail the trace doesn't carry. Structured (JSON), keyed by trace_id. Stored in Loki / ELK. +- Baggage — 'carry a value (tenant_id, user_tier) along the whole request so downstream services and their telemetry can use it.' Set once at the edge, readable everywhere downstream. W3C Baggage spec. +- Profiles — 'which function is burning CPU / allocating memory / holding locks, continuously, in production?' Reach for it for the latency the trace shows but doesn't explain. Pyroscope / Parca. + - The discipline: emit traces + metrics + logs always; add baggage when downstream needs request-scoped context; add profiling when you have CPU/memory mysteries a trace can't resolve. + +p. 50 + +> Walk each signal and the question it answers — the framing 'what question does this answer' is more useful to an engineer than 'what is this signal.' Traces are per-request; metrics are aggregate; logs are detail. The two newer ones deserve emphasis. Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant. Profiling is the frontier: continuous profilers (Pyroscope, Parca, Grafana Phlare) run in production at low overhead and answer the 'the trace says this span took 200ms but why' question by showing the actual call stack burning the CPU. The sub-bullet is the practical guidance on which to adopt when. + +## [063] OBSERVABILITY · Auto-injection vs. instrumentation — three distinct things (sldId 318) + +- Manual instrumentation — you write the spans. tracer.Start(...), span.SetAttributes(...), span.AddEvent(...). Needed for business operations the libraries can't know about ('reserve_stock', 'score_fraud'). The most signal per line, the most effort. +- Auto-instrumentation — a library / agent wraps known frameworks for you. HTTP servers and clients, gRPC, JDBC / database drivers, Kafka, Redis — each becomes spans with zero code change. Covers the plumbing; misses your domain. +- Auto-injection — the platform puts the auto-instrumentation agent into the pod for you. The OpenTelemetry Operator watches for an annotation (instrumentation.opentelemetry.io/inject) and mutates the pod at admission — no agent in your image, no Dockerfile change, no redeploy of the app to add tracing. +- How they layer: auto-injection delivers the agent → the agent provides auto-instrumentation of frameworks → you add manual spans for business logic on top. All three coexist in the same trace. + - Trade-off: auto-instrumentation is convenient but can be noisy (every framework call becomes a span) and slightly slower. Manual is precise but is work you own. Most teams: auto for the plumbing, manual for the domain, injection to roll it out fleet-wide. + +p. 51 + +> Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate. Manual: you call the tracer API yourself; necessary for domain operations. Auto-instrumentation: the OTel instrumentation libraries (or a language agent) wrap known frameworks: your HTTP handler, your DB driver, your Kafka client all emit spans without you touching them. Auto-injection: a Kubernetes-level mechanism; the OpenTelemetry Operator sees a pod annotation and injects the language agent + config as an init container / env vars at admission time, so you don't even put the agent in your image. Per-language specifics: Go: no language agent or -javaagent-style attach; you wrap explicitly (otelhttp for handlers, otelpgx for the DB, an otel wrapper on the Kafka client) and add manual spans, while the OTel Operator can still inject collector config. Python: the 'opentelemetry-instrument' wrapper, or the operator injects it; instrumentation packages per library. Java/Quarkus: the OTel Java agent (-javaagent) auto-instruments everything; Quarkus also has build-time OTel extension. Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource. The bold line is the mental model: injection delivers the agent, the agent instruments frameworks, you add manual spans on top — they layer into one trace. + +## [064] OBSERVABILITY · Instrument net/http with OpenTelemetry (sldId 319) + +[chip] go · OpenTelemetry + +```go +// otel.go — init once in main(); otelhttp wraps the mux, add custom spans +func main() { + shutdown := initTracer(context.Background()) // OTLP exporter + batch processor + defer shutdown() + mux := http.NewServeMux() + mux.HandleFunc("POST /orders", placeOrder) + _ = http.ListenAndServe(":8080", otelhttp.NewHandler(mux, "order-service")) // span/request +} +var tracer = otel.Tracer("order-service") +func placeOrder(w http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(r.Context(), "reserve-stock") // custom span + defer span.End() + inventory.ReserveStock(ctx, body.SKU, body.Quantity) + // trace_id flows: REST -> gRPC -> Kafka -> consumer +} +``` + +The OTEL Collector exports traces to Tempo, metrics to Mimir/Prometheus, logs to Loki — all keyed by trace_id. + +p. 52 + +> How little code this takes. Go has no agent that attaches at runtime, so instrumentation is explicit: otelhttp.NewHandler wraps the mux and turns every request into a server span, otelgrpc does the same for gRPC, and initTracer sets up the OTLP exporter and the W3C propagator once in main. +> +> You hand-write spans only for business operations worth measuring, such as reserve-stock. The trace_id flows from REST to gRPC to Kafka and into the consumer: the producer injects the trace context into the record headers with the propagator, and the consumer extracts it and starts its span as a child. The Collector ships everything to Tempo, Mimir and Loki, keyed by that trace_id. + +## [065] OBSERVABILITY · How correlation works — context propagation (sldId 320) + +The trace context rides in-band with the request (W3C traceparent). The same trace_id at every hop is what stitches the story. + +p. 53 + +> Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling with the request, in-band, at every hop. The W3C traceparent header is the standard: version-traceid-parentspanid-flags. The trace_id (16 bytes) is constant across every hop of the request — that constancy is literally what stitches the distributed story into one trace. The span_id is new per operation, and each span records its parent span_id, which is how the backend rebuilds the tree. The flags byte carries the sampling decision (01 = sampled) so every hop honours the same decision. Alongside traceparent rides tracestate (vendor-specific) and baggage (your key-values). The transport differs per hop but the mechanism is identical: HTTP uses the traceparent header, gRPC uses metadata, Kafka uses message headers. The SDK injects the context on the way out and extracts it on the way in — automatically, which is why auto-instrumentation 'just works' across services. This propagated context is exactly why the same trace_id shows up on the trace waterfall, on every structured log line, and as an exemplar on your metrics — they all read it from the same in-band context. + +## [066] OBSERVABILITY · Baggage in practice — carry request context downstream (sldId 321) + +[chip] go · OpenTelemetry baggage + +```go +// set baggage once at the edge; it rides every downstream hop on the context +func withTenant(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m, _ := baggage.NewMember("tenant.id", r.Header.Get("X-Tenant")) + b, _ := baggage.New(m) + next.ServeHTTP(w, r.WithContext(baggage.ContextWithBaggage(r.Context(), b))) + }) +} +// read it anywhere downstream, even in a Kafka consumer three hops away +func handleEvent(ctx context.Context, rec *kgo.Record) { + tenant := baggage.FromContext(ctx).Member("tenant.id").Value() + trace.SpanFromContext(ctx).SetAttributes( + attribute.String("tenant.id", tenant)) // pin to the span: filter traces by tenant +} +``` + +Set once at the edge; read anywhere downstream. Pin it to the span to filter traces by tenant.id. + +p. 54 + +> Baggage is the W3C-standard way to carry request-scoped key-values along the whole trace. The middleware sets tenant.id at the edge; with the baggage propagator registered next to the trace-context propagator, the OTel SDK serialises it into the baggage header and every downstream HTTP, gRPC or Kafka call made with that context carries it. +> +> Three hops later a Kafka consumer reads it with baggage.FromContext without anyone passing it explicitly. The common idiom is to also set it as a span attribute so it is queryable in the trace UI: show all slow traces for tenant acme. Caution: baggage travels on the wire to every downstream service, including third parties, so never put secrets or PII in it. + +## [067] OBSERVABILITY · A single trace stitches the whole request (sldId 322) + +One trace_id propagates from the edge REST call through gRPC, Kafka and the consumer — five services, one story. + +p. 55 + +> This is what the instrumentation buys you, visually. One trace_id propagates from the edge REST POST through the gRPC ReserveStock call, onto the published order.placed event, and into the consumer that sends the notification. Five services, one readable waterfall. Point at the time axis: you can see exactly which span dominated latency. That same trace_id appears as an exemplar on your metrics and on every log line, so a latency alert is one click from the offending trace and its logs. This is what makes the choreographed, event-driven design from earlier sections debuggable. + +## [068] OBSERVABILITY · Sampling — head vs. tail (sldId 323) + +You can't store every trace at scale. Head decides at the start (cheap, blind); tail decides at the end (smart, costly). + +p. 56 + +> The cost-control reality: at scale you cannot store every trace, so you sample. The key question is when the keep/drop decision is made. Head sampling decides at the start, in the SDK, before the trace even completes — typically 'keep 1 in N' (probabilistic) or 'keep N per second' (rate-limiting). It's cheap and low-overhead, but it is blind at decision time: it can't keep a trace because it errored or was slow, because it decides before knowing. Tail sampling decides at the end, in the Collector, after the whole trace has been seen — the Collector buffers spans, waits for the trace to complete, then applies rules: keep all errors, keep all traces over 500ms, keep rare routes, sample the boring successful ones at 1%. Tail is smart but costs memory (buffering in-flight traces) and CPU (the Collector does real work). The strategies, named: probabilistic (keep X%), rate-limiting (cap per second), parent-based (follow the upstream's decision so a trace isn't half-sampled), and tail rules (error / latency / attribute based). The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate. Two rules to drill home: always sample by trace, not by span (a half-sampled trace is worse than no trace), and propagate the sampling flag (the 01 in traceparent) so every service agrees. Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy. + +## [069] OBSERVABILITY · Choosing a sampling strategy (sldId 324) + +- Probabilistic (head) — keep a fixed percentage (e.g. 10%). Simplest; predictable volume; blind to errors. Good baseline for high-traffic, low-stakes services. +- Rate-limiting (head) — keep at most N traces/sec per service. Protects your backend from traffic spikes; good when load is bursty. +- Parent-based (head) — honour the upstream service's sampling decision (carried in the traceparent flags). Essential in distributed systems: it stops a trace being sampled at service A but dropped at service B, which would give you broken half-traces. +- Tail-based (Collector) — buffer the whole trace, then keep by rule: all errors, all traces over a latency threshold, rare routes, plus a small % of the rest. The gold standard for keeping what matters, at the cost of Collector memory + CPU. +- Recommended production setup: parent-based head sampling at a modest baseline (1-10%) for representativeness, plus tail sampling at the Collector to guarantee every error and slow trace is kept. + - Don't sample metrics the way you sample traces (metrics aggregate everything). Keep logs in full with retention limits, or sample them on their own policy keyed by trace_id so a kept trace keeps its logs. + +p. 57 + +> The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-limiting is better when traffic is bursty because it caps absolute volume. Parent-based is non-negotiable in a distributed system: without it, service A samples a trace but service B independently drops it, and you get a broken trace with holes — worse than no trace. Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident. The cost is the Collector must buffer in-flight traces (memory) and evaluate rules (CPU), and it must see all spans of a trace (which constrains how you load-balance the Collector — use a trace-ID-aware load balancer). The recommended setup combines head (for a cheap representative baseline) and tail (for guaranteed capture of the interesting traces). The sub-bullet is the reminder: traces, metrics and logs are sampled differently — you never sample a metric counter, and logs typically follow their own retention or are sampled to match their parent trace. + +## [070] OBSERVABILITY · Prove it with simple tools before you ship (sldId 325) + +- curl / Postman — exercise REST contracts and inspect status codes, headers and pagination. +- hey — quick HTTP load to watch latency, error rate and the KEDA http-add-on scale the service. +- ghz — load-test gRPC endpoints and confirm timeouts and retries behave under pressure. +- GraphQL Playground — explore the composed schema and watch the gateway fan out in Tempo. +- Kiali — read the live service-mesh topology, mTLS status and request flow during a load test. + +p. 58 + +> Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure. GraphQL Playground explores the composed schema and, with tracing on, shows the gateway fanning out in Tempo. Kiali visualizes the live mesh: topology, mTLS status and real-time request flow during a load test. The message: prove the behaviour with simple tools before you ship, not after an incident. + +## [071] Security (sldId 326) + +12 + +Defence in depth, in layers. + +> Section 12 — security. The Anti-Patterns section that used to be 12 has shifted to 13. We organise this section around two ideas: the 4 C's of cloud-native security (concentric layers, each protecting what is inside it), and the seven patterns that you'll meet over and over in practice. The bias of this section: security is not a single product or a single team. It's a property of how the system is designed and run, owned at every layer. Each pattern here exists because no single layer is enough on its own. + +## [072] SECURITY · The 4 C's of cloud-native security (sldId 327) + +Cloud → Cluster → Container → Code. Concentric layers, each layer protects what is inside. + +p. 59 + +> The mental model from the CNCF security paper. Four concentric layers of responsibility, each enforcing controls appropriate to its scope. Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin. Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE. Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform. Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams. The point of the concentric metaphor: a weakness in any layer leaks into the inner ones. Strong code on a weak container is still vulnerable. Strong container on a weak cluster is still vulnerable. We design controls at all four. The 4 C's table lists each layer's controls and its owner. + +## [073] SECURITY · The 4 C's — what each layer enforces, and who owns it (sldId 328) + +- Cloud — IAM (principles of least privilege), VPC isolation, KMS for keys, audit logging, region & data-residency controls. Owner: platform / cloud admin. +- Cluster — Kubernetes RBAC, NetworkPolicy (default-deny + targeted allows), Pod Security Standards (restricted profile), admission control (OPA Gatekeeper / Kyverno), secret encryption at rest, etcd encryption. Owner: platform / SRE. +- Container — minimal base images (distroless, UBI-minimal), non-root users, read-only root filesystems, image signing (Sigstore / Cosign), vulnerability scanning (Trivy, Grype) on every build, secret mounting via projected volumes (never bake secrets into images). Owner: app team + platform. +- Code — secure defaults (parameterised queries, output encoding), input validation at the trust boundary, dependency scanning (Dependabot, Snyk, OSV-Scanner), secrets handling (no plaintext in source, no logging secrets), CSRF / SSRF / XXE awareness, threat modeling. Owner: app team. +- Cross-cutting: observability (audit who did what when), incident response runbooks, threat modeling per service, regular dependency updates. + - The pattern: a control at the wrong layer is the most common source of false confidence. Network ACLs cannot save you from an SQL injection. Code-level input validation cannot save you from a compromised image. Defence in depth is real. + +p. 60 + +> Walk each layer. The Cloud layer is where you control the perimeter and the identities and the keys; failures here often look like 'someone left a storage bucket open to the internet' or 'an over-permissive IAM role enabled lateral movement to another account.' The Cluster layer is the Kubernetes operating system for your workloads; failures look like 'the developer Pod could reach the database Pod' (no NetworkPolicy) or 'a privileged container was admitted' (no Pod Security Standards). The Container layer is about the image and runtime; failures look like 'a CVE in the base image' or 'the container ran as root and got out via a kernel exploit' or 'a secret was in the image.' Code is where most developer focus already lives. The sub-bullet is the take-home: a control at the wrong layer is false confidence. The pattern catalog holds seven patterns that show up across these four layers. + +## [074] SECURITY · Seven patterns to know (sldId 329) + +- Sidecar — move security concerns out of the app into a companion process. mTLS, JWT, audit, rate-limiting belong there. +- Valet Key — hand the client a short-lived signed token to access a resource directly. Less app proxying, less attack surface. +- Strangler Fig (for security modernisation) — encapsulate legacy auth behind an API gateway, replace pieces over time. +- Zero-Trust / Identity-Aware Proxy — every request authenticated and authorised. Perimeter trust is dead. +- Policy-as-Code — security gates declared in Git, evaluated at admission and at build. No human runs a checklist. +- Bulkhead — isolate resources so one failure (or compromise) cannot starve or cascade into others. +- Claim-Check — send a reference, not the payload. Keeps PII out of message bus history and limits the blast radius if a topic leaks. + - The seven appear in different layers and combine. Each slide that follows shows the pattern, then implementation — platform config where it's mostly YAML, code where it's mostly code. + +p. 61 + +> The roadmap for the next fourteen slides. These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation). You'll see each pattern with a diagram explaining how it works, then either a YAML configuration block (for the platform-heavy patterns) or language-specific code (for the code-heavy patterns). Use the sub-bullet to set audience expectation that the section alternates between pattern-and-implementation pairs. + +## [075] SECURITY · SIDECAR · Sidecar pattern — push security out of the app (sldId 330) + +mTLS, JWT validation, rate limiting, audit logging — a companion process in the same Pod handles them all. + +p. 62 + +> The sidecar pattern — a separate container running in the same Pod as the application, sharing localhost. The sidecar handles cross-cutting security concerns: terminates mTLS, validates JWTs, rate-limits, audits, enforces policy. The app receives only authenticated, authorised requests with trusted headers attached (X-Forwarded-Client-Cert, the validated JWT claims, the request ID). Why this matters: one security policy applied uniformly across every workload regardless of language. Same Envoy sidecar protects your Java app, your Python app, your Go app. App teams write business code, not crypto code. The sidecar is enforced by the platform (Istio injects them automatically) so it can't be disabled by accident. Examples in the field: Istio Envoy sidecars (the dominant choice), Linkerd2-proxy, OPA Sidecar for policy decisions, Cilium L7 with per-endpoint policy. + +## [076] SECURITY · SIDECAR · Reading the sidecar's trusted headers (sldId 331) + +- The sidecar handles mTLS, JWT validation, and policy decisions — the app trusts the headers the sidecar attaches and reads them as plain values. +- X-Forwarded-Client-Cert — the validated client identity from mTLS. Includes the SPIFFE ID (spiffe://cluster.local/ns/orders/sa/order-service) or the X.509 Subject. Set only by the sidecar; the app must reject requests that don't have it. +- JWT claims — the sidecar validates the signature, then passes the claims forward as a header or sets request.auth.claims via an envoy filter. The app reads sub, aud, scope, custom claims; it never re-validates the signature. +- Request ID — the sidecar attaches X-Request-Id for traceability. Log it; propagate it to downstream calls; correlate it with audit logs. +- Mode: only trust these headers when the sidecar is in front. In direct-to-Pod traffic (port-forward, debugging, mistakes), they could be spoofed. Use a NetworkPolicy to ensure traffic only enters via the sidecar's mTLS port. + - Examples: Istio Envoy sidecars (auto-injected), Linkerd2-proxy, OPA Sidecar (policy decisions), Cilium L7 policy. License: all Apache 2.0. + +p. 63 + +> The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches. The most common failure mode is an app that ignores X-Forwarded-Client-Cert and falls back to 'allow if you got here.' Make the headers required, not optional. The second-most-common failure mode is forgetting that traffic can sometimes bypass the sidecar (port-forward for debugging, host networking, direct pod-to-pod calls within the same node). NetworkPolicy is what closes that gap — only allow inbound traffic on the sidecar's mTLS port, never the app's container port directly. With both the sidecar and the NetworkPolicy, the app can fully trust the headers and become much simpler — no crypto code, no JWT parsing library, just header reads. + +## [077] SECURITY · VALET KEY · Valet key pattern — short-lived signed token, direct access (sldId 332) + +App mints a scoped, time-bound token; client uses it directly against the resource. App never proxies the bytes. + +p. 64 + +> The valet key pattern: instead of streaming a large file through your application, the application mints a scoped, time-bound, operation-restricted token (a 'valet key') and the client uses it to interact directly with the storage system. Pros: dramatically less data through the application (memory, bandwidth, latency); no long-lived credentials at the client; no app-side credential to leak; the token can expire and be revoked. Cons: requires the storage side to validate tokens; the token's permissions and TTL must be carefully scoped — a leaked token still has its window. Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape. The code shows the pattern in the service's language. + +## [078] SECURITY · VALET KEY · Valet key — scoped, time-bound, op-restricted (sldId 333) + +[chip] go · net/http + golang-jwt v5 + +```go +// main.go (excerpt), examples/12-security/go: the valet key is an HS256 JWT (golang-jwt v5) +type valetClaims struct { + Resource string `json:"res"` + Operation string `json:"op"` + jwt.RegisteredClaims +} +func mintValet(resource, operation string) map[string]any { + expires := time.Now().Add(5 * time.Minute) // time-bound + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, valetClaims{ + Resource: resource, Operation: operation, // one object, one operation + RegisteredClaims: jwt.RegisteredClaims{Issuer: "order-service", + ExpiresAt: jwt.NewNumericDate(expires), IssuedAt: jwt.NewNumericDate(time.Now())}, + }).SignedString([]byte(valetSecret)) + if err != nil { slog.Error("sign valet key", "err", err) } + return map[string]any{"resource": resource, "operation": operation, + "expires": expires.Unix(), "token": token} +} +// verifyValet: HS256 only, exp required, issuer checked; then res, op and exp must match +parsed, err := jwt.ParseWithClaims(token, &claims, + func(*jwt.Token) (any, error) { return []byte(valetSecret), nil }, + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithExpirationRequired(), jwt.WithIssuer("order-service")) +``` + +p. 65 + +> The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HS256 JWT signed with golang-jwt v5: the res and op claims carry the scope, exp the expiry, iss the issuer. POST /valet-key returns the resource, the operation, the expiry and the token. +> +> The verifier (GET /verify-valet in the example, the storage gateway in a real system) parses with WithValidMethods so only HS256 is accepted (no alg=none, no algorithm swap), requires exp, checks the issuer, then compares res, op and exp with the request. Any mismatch is a 403. +> +> The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth. + +## [079] SECURITY · STRANGLER FIG · Strangler Fig for security modernisation (sldId 334) + +- Use case: encapsulate legacy authentication (LDAP-only, HTTP Basic, hardcoded session cookies) behind a modern API gateway, then replace the legacy auth path one service at a time. +- Step 0: introduce the gateway in front of the monolith. Clients now hit the gateway, which forwards to the legacy auth. Day-zero behaviour unchanged. +- Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Dex), and translates to whatever the legacy expects; it speaks both languages. +- Step 2: per route, switch the gateway from 'forward legacy headers' to 'forward only the JWT claims to the new service'. Legacy auth is no longer in the path for that route. +- Step 3: when no routes still need legacy auth, remove it. +- Bounded blast radius: each route's auth migration is a config change in the gateway, reversible without a release. + - See Appendix K for the general strangler-fig family (identify → move → redirect, with proxy, with shared database, with content-based routing). Same mechanics, applied to auth instead of business logic. + +p. 66 + +> The strangler fig was covered as a general pattern in Appendix K — here it shows up specifically for security modernisation, which is a common need: most enterprises have legacy auth (LDAP-only, custom SSO, NTLM, hardcoded API keys) buried in critical systems, and a 'big bang' replacement is too risky. The gateway becomes the bilingual translator: it validates modern JWTs from your IdP at the edge and translates to whatever the legacy backend expects (X-Remote-User header, basic auth, NTLM, custom session cookie). Per route, you migrate when the new path is verified — if anything breaks, flip the gateway config back to forwarding legacy headers. The sub-bullet points to Appendix K for the full mechanics; this slide is the application of those mechanics to a specific security need. + +## [080] SECURITY · ZERO-TRUST · Zero-trust / Identity-Aware Proxy (sldId 335) + +Every request authenticated and authorised, on every hop. Perimeter trust is dead. SPIFFE inside, IAP at the edge. + +p. 67 + +> The model: there is no trusted network. Every request, from any source, to any service, on every hop — authenticated and authorised. The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance. The new model (bottom): the user authenticates to an IAP (Identity-Aware Proxy) which evaluates identity, device posture, location, MFA. The IAP then forwards to a policy engine (OPA, Cedar) which returns an allow/deny per-request. The downstream service still authenticates the caller (mTLS + SPIFFE ID) and re-authorises against policy. Inside the cluster: mTLS via SPIFFE identity (every workload has an X.509 cert identifying its service account), AuthorizationPolicy per workload restricting which other workloads can call which endpoints. A breach in one service gives you nothing because the next call still has to prove identity and pass policy. + +## [081] SECURITY · ZERO-TRUST · Implementing zero-trust (sldId 336) + +- Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — they terminate TLS, validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT the downstream service sees in X-Forwarded-* headers. +- Inside the cluster (mTLS): Istio + SPIFFE assigns every workload an X.509 cert identifying its service account (spiffe://cluster.local/ns/orders/sa/order-service). Every service-to-service call is mutually authenticated automatically. +- Authorization: Istio AuthorizationPolicy declares 'order-service can call payment-service /charge but nothing else, only when carrying a valid customer JWT.' Per-workload, fine-grained, declarative. +- Policy decisions: OPA / Cedar evaluate context (user, time, device, target, action) and return allow/deny. Out-of-process so policy can change without redeploying every service. +- App-side discipline: read the validated peer identity from X-Forwarded-Client-Cert; never accept calls that arrive without it; log every authorization decision. + - License notes: Istio, SPIFFE/SPIRE, OPA, Cedar — all open source (Apache 2.0 or similar). Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too. + +p. 68 + +> Concrete implementations to ground the model. The edge IAP is what your users hit — they authenticate against your IdP (Keycloak, or Dex federating an existing directory), the IAP issues a session, every request from then on carries identity. Inside the cluster, SPIFFE is the service-identity mechanism that makes mTLS work without manual cert management — it's what gives every Pod a verifiable identity. Istio's AuthorizationPolicy is the declarative layer on top: 'who can call what.' OPA / Cedar is the more flexible policy engine when your decision logic is complex (per-tenant, per-time-of-day, per-data-classification). The bold line is the app-side discipline: don't make these external policies redundant by accepting unauthenticated calls in your code; use the peer identity from the headers as a required input. + +## [082] SECURITY · POLICY-AS-CODE · Policy-as-Code — security gates in Git (sldId 337) + +OPA Gatekeeper / Kyverno enforce declarative policies at admission. CI runs the same policies pre-merge. + +p. 69 + +> Security policies as code: declarative, version-controlled, tested, applied automatically. The flow on the diagram: a developer commits manifests; CI runs conftest or Kyverno against the manifests pre-merge (catches violations before they reach the cluster); on `kubectl apply` (or ArgoCD / Flux), the Kubernetes API server calls the admission controller (OPA Gatekeeper / Kyverno) which evaluates the ConstraintTemplate / ClusterPolicy against the incoming object. Non-conformant resources are rejected — they never become running Pods. The policy file lives in Git, reviewed like code, applied by the cluster. No human runs a checklist; consistency is enforced uniformly. Example policies: images must come from approved registries, no privileged containers, no host network, no host path mounts, all containers have resource limits, all images are signed. + +## [083] SECURITY · POLICY-AS-CODE · OPA Gatekeeper ConstraintTemplate (Rego) (sldId 338) + +[chip] yaml · OPA Gatekeeper (language-neutral) + +```yaml +# ConstraintTemplate — reusable policy logic in Rego +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8srequiresignedimages +spec: + # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array) + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiresignedimages + violation[{"msg": msg}] { + container := input.review.object.spec.containers[_] + not startswith(container.image, input.parameters.allowedRegistries[_]) + msg := sprintf("image %v not from an allowed registry", [container.image]) + } +--- +# Constraint — applies the policy to namespaces +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sRequireSignedImages +metadata: { name: must-be-signed } +spec: + match: + kinds: [{apiGroups: [""], kinds: ["Pod"]}] + excludedNamespaces: ["kube-system", "gatekeeper-system"] + parameters: + allowedRegistries: ["registry.acme.com/", "quay.io/redhat/"] +``` + +p. 70 + +> The two-piece pattern: ConstraintTemplate defines the logic (in Rego, OPA's policy language), Constraint is the instance that applies the logic to specific resources with specific parameters. The Rego in violation[{msg}] reads as 'for every container in the Pod, if its image doesn't start with any of the allowed registries, emit a violation with this message.' Gatekeeper translates that into an admission webhook that rejects non-conformant Pods before they're scheduled. The Constraint binds the template to the kinds (Pods) and namespaces (everything except system namespaces), with the allowedRegistries list as the parameter. Lives in Git, applied via `kubectl apply` (or ArgoCD), versioned and reviewed like code. An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic. + +## [084] SECURITY · BULKHEAD · Bulkhead — isolate resources, bound the blast radius (sldId 339) + +Per-tenant / per-call / per-priority pools. One tenant's storm cannot starve others. One compromise cannot cascade. + +p. 71 + +> Bulkhead: borrowed from naval architecture, where watertight compartments stop one breach from sinking the ship. In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. The left side of the diagram (red) shows the default: one shared pool means a single tenant's traffic storm starves everyone else's. The right side: separate pools, each with its own bounded size. A storm from tenant A consumes tenant A's pool and rejects further A requests — tenants B and background workloads keep running. Why this matters for security as well as resilience: a compromised tenant or a compromised endpoint can be used to mount a denial-of-service against the shared system; bulkheads cap that to its own pool. Implementations: Resilience4j Bulkhead (JVM), Polly (.NET), buffered channels per tenant (Go), Istio per-destination connection pools at the mesh layer. + +## [085] SECURITY · BULKHEAD · Per-tenant bulkheads with semaphores (sldId 340) + +[chip] go · buffered channel + +```go +// per-tenant bulkhead: a buffered channel per tenant; A's storm can't drain B's capacity +var bulkheads sync.Map // tenant -> chan struct{} +func tenantSem(tenant string) chan struct{} { + v, _ := bulkheads.LoadOrStore(tenant, make(chan struct{}, 5)) // 5 per tenant + return v.(chan struct{}) +} +func handle[T any](tenant string, work func() (T, error)) (T, error) { + sem := tenantSem(tenant) + sem <- struct{}{} // blocks only this tenant when full + defer func() { <-sem }() + return work() +} +// fail fast instead of queueing: select on sem <- struct{}{} and a timer, answer 503 +``` + +p. 72 + +> A bulkhead per tenant. Each tenant gets its own buffered channel used as a semaphore: a request takes a slot before the work and gives it back afterwards, so at most 5 requests per tenant run at once. A storm from tenant A fills A's channel and blocks only A; tenant B's capacity is untouched. +> +> In production, prefer failing fast to queueing: select on the send and a short timer, and answer 503 when the slot does not free up, so callers back off instead of piling up. For socket-level isolation, give each tenant its own http.Client with a bounded Transport (MaxConnsPerHost). For hard isolation, run per-tenant replicas: Kubernetes-level bulkheading. + +## [086] SECURITY · CLAIM-CHECK · Claim-check — send a reference, not the payload (sldId 341) + +Large or sensitive payload goes to a secure store; the message bus carries only an opaque token to fetch it. + +p. 73 + +> The claim-check pattern: a producer puts the (large or sensitive) payload into a secure object store and publishes only a claim ticket (an opaque token plus a fetch URI) to the message bus. The consumer receives the ticket, fetches the payload from the store with the token. Two big security wins: PII never sits in Kafka's topic history (which is replicated, retained, and possibly visible to operators and other tools); and the token can expire and be revoked, while a Kafka message cannot. The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security. Compare to the write-around caching pattern: same shape, different motivation. Where: any flow where sensitive payloads (health records, financial documents, customer PII) cross messaging infrastructure that shouldn't see them; any flow where payloads are large enough that the bus would be the bottleneck. + +## [087] SECURITY · CLAIM-CHECK · Producer mints claim, consumer fetches payload (sldId 342) + +[chip] go · franz-go + golang-jwt v5 + +```go +// claim-check sketch: store is any encrypted object store; the token is the valet-key JWT +func publishWithClaim(ctx context.Context, cl *kgo.Client, orderID string, body []byte) error { + key := "payloads/" + orderID + if err := store.Put(ctx, key, body, true); err != nil { return err } // encrypted, ACL'd + claim, _ := json.Marshal(map[string]any{ + "order_id": orderID, + "uri": "https://objects.internal/" + key, + "token": mintValet(key, "GET")["token"], // GET only, five minutes + }) + return cl.ProduceSync(ctx, // only the claim goes on the topic + &kgo.Record{Topic: "order.documents", Key: []byte(orderID), Value: claim}).FirstErr() +} +func onClaim(ctx context.Context, r *kgo.Record) error { + var c Claim + if err := json.Unmarshal(r.Value, &c); err != nil { return err } + body, err := store.Get(ctx, c.URI, c.Token) // the store verifies the ticket + if err != nil { return err } + return handle(ctx, body) // PII never touched the topic +} +``` + +p. 74 + +> Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a ticket for that key (the golang-jwt valet key of Example 12: GET only, five minutes), and publishes only a small claim {order_id, uri, token} to order.documents with franz-go's ProduceSync. The PII is never on Kafka's wire. +> +> The consumer decodes the claim, fetches the payload with the token, processes it, then lets the offset commit (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give. +> +> Size the ticket's lifetime to the consumer's worst-case lag: five minutes fits the demo, not a backlog. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing. + +## [088] SECURITY · Choosing the right pattern (sldId 343) + +- Sidecar — when you want one security policy uniformly across every workload, regardless of language. Cost: an extra container per Pod (memory + startup time). +- Valet Key — when clients need direct access to a large or specific resource (file upload, image download). Cost: storage system must validate tokens (most do). +- Strangler Fig (auth) — when you have legacy authentication you can't change all at once. Cost: a gateway running translation logic during the transition. +- Zero-Trust / IAP — the default model for any system with users outside your immediate control. Cost: mTLS everywhere has real CPU and operational overhead. +- Policy-as-Code — always. Always. The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno. +- Bulkhead — any multi-tenant system, any system with mixed-priority workloads. Cost: pools are extra moving parts to size and tune. +- Claim-Check — when payloads carry PII or are large enough to bloat the message bus. Cost: an extra fetch hop and the store-and-bus must agree on token semantics. +- Most production systems combine all of these, not as a checklist but as a decision per concern. + +p. 75 + +> Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a few MB of memory, a couple hundred ms of startup); for most systems the security uniformity makes it worth that. Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry. Strangler fig (auth) is a deliberate transition strategy; the gateway running translation is the cost. Zero-trust / mTLS everywhere has real CPU overhead (TLS handshakes are not free) — mesh implementations like Istio amortise it well but it's not zero. Policy-as-code's cost is the upfront investment in writing policies; the ongoing cost is near zero. Bulkheads are extra moving parts to tune. Claim-check adds a hop. The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist. + +## [089] SECURITY · Take-aways and references (sldId 344) + +- Defence in depth across the 4 C's: cloud, cluster, container, code. A control at the wrong layer is false confidence. +- Identity is the new perimeter. mTLS + SPIFFE inside; IAP / OIDC at the edge. Every request authenticated and authorised, on every hop. +- Policy in Git, enforced by the cluster. OPA Gatekeeper or Kyverno at admission; conftest in CI for early feedback. +- PII never crosses your message bus in cleartext. Claim-check or end-to-end encrypt. +- Isolate by tenant, by call, by priority. Bulkheads bound the blast radius of both incidents and outages. + - References: NIST SP 800-204A (microservices security); CNCF Cloud Native Security Whitepaper; OWASP API Security Top 10; the OpenSSF SLSA framework; Istio AuthorizationPolicy docs; OPA Gatekeeper library; OWASP Microservices Security Cheat Sheet. + - Open-source pillars: Istio (Apache 2.0), SPIFFE/SPIRE (Apache 2.0), OPA / Gatekeeper (Apache 2.0), Kyverno (Apache 2.0), Sigstore (Apache 2.0), Falco (Apache 2.0), Trivy (Apache 2.0). + +p. 76 + +> Close with the take-aways the audience should remember. Defence in depth across the 4 C's is the philosophical anchor. Identity-as-perimeter is the operational anchor. Policy-in-Git is the automation anchor. The PII-never-in-cleartext and bulkhead lines are the specific applications. The reference list is what you'd recommend for further reading — NIST 800-204A is the canonical microservices security paper; CNCF's Cloud Native Security Whitepaper organises the same material; OWASP API Top 10 is the application-layer reference; SLSA covers supply-chain security (signing, provenance, build integrity). The open-source list emphasises that the entire security stack is buildable with Apache 2.0 components. + +## [090] Anti-Patterns (sldId 345) + +13 + +Most outages re-couple what you separated. + +> The final main section, and the most memorable framing of the whole talk: nearly every cloud-native failure is coupling sneaking back in. You did the work to separate things; entropy and deadlines pull them back together. This section names the specific ways that happens and gives heuristics to catch it early. + +## [091] ANTI-PATTERNS · How cloud-native API systems go wrong (sldId 346) + +Each anti-pattern reintroduces the coupling the architecture worked to remove. + +p. 77 + +> Walk the grid briefly — most of these are callbacks to earlier sections. Distributed monolith (services that must deploy together), shared database (two services on one table), chatty calls (deep synchronous chains), no contract/leaky internal models (DB rows as the public API), missing observability (debugging by guesswork), retry storms (no backoff). The unifying diagnosis is on screen: each one reintroduces the coupling the architecture worked to remove. If you only remember one slide for code review, it's this one. The anti-pattern heuristics turn these into positive rules. + +## [092] ANTI-PATTERNS · The heuristics that keep you out of them (sldId 347) + +- If two services must deploy together, they are one service — merge them or fix the contract. +- One writer per dataset; everyone else reads through the API or subscribes to its events. +- Push aggregation to a gateway/BFF; forbid service-to-service call chains deeper than one hop. +- No contract in the registry, no merge — and every retry has a timeout and a bound. +- If you can't trace it, you can't run it: observability is a launch requirement, not a follow-up. + +p. 78 + +> These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or fix the contract. (2) One writer per dataset; everyone else reads through the API or subscribes to events — no shared tables. (3) Push aggregation to a gateway or BFF and forbid synchronous call chains deeper than one hop. (4) No contract in the registry, no merge — and every retry has a timeout and a bound. (5) If you can't trace it, you can't run it — observability is a launch requirement, not a follow-up. Offer these as a checklist the team can literally paste into a PR template. + +## [093] IN CLOSING · The whole talk, in five lines (sldId 348) + +- Cloud-native is a set of properties; the platform exists to make them the default. +- Match each interaction to its style (REST, gRPC, GraphQL or async) and compose at the edge. +- Own your data; share it as contracts and facts, never as shared tables. +- Make the contract a governed artifact: registry to enforce it, metadata to find it, observability to run it. +- Every anti-pattern is coupling sneaking back in. Design to keep things apart. + +p. 79 + +> The recap — read these five as the entire talk compressed. If the audience remembers nothing else: cloud-native is properties not tools; match the protocol to the interaction and compose at the edge; own your data and share it as contracts and facts; make the contract a governed artifact (registry, metadata, observability); and watch for coupling sneaking back in. Pause here before References and Q&A. If it's the long-form session, this is the natural point to say 'and now the appendices go deeper on four things people always ask about' and continue. + +## [094] REFERENCES · The shoulders this stands on (sldId 349) + +- Cloud-Native Patterns and Architecture +- Building Microservices, 2nd ed. — Sam Newman (O'Reilly) +- Designing Data-Intensive Applications, 2nd ed. — Martin Kleppmann & Chris Riccomini (O'Reilly) +- Building Event-Driven Microservices — Adam Bellemare (O'Reilly) +- Cloud Native Patterns — Cornelia Davis (Manning) +- Patterns of Distributed Systems — Unmesh Joshi (Addison-Wesley) +- Architecture Modernization — Nick Tune & Jean-Georges Perrin (Manning) +- Cloud Native Anti-Patterns — Gerald Bachlmayr +- Principles of Web API Design — James Higginbotham (Addison-Wesley) +- Patterns for API Design — Olaf Zimmermann et al. (Addison-Wesley) +- API Design Patterns — JJ Geewax (Manning) +- Learning Domain-Driven Design — Vlad Khononov (O'Reilly) +- Balancing Coupling in Software Design — Vlad Khononov (Addison-Wesley) + +p. 80 + +> The reading list this talk stands on. Call out a couple of pairings: Newman's Building Microservices for the boundaries and integration story; Kleppmann's Designing Data-Intensive Applications for the data and consistency foundations behind the outbox and saga; Bellemare for the event-driven backbone; and the API-design trio (Higginbotham, Zimmermann et al., Geewax) for contract and versioning depth. The slides and these references are enough to build the system, not only to discuss it. + +## [095] Thank you (sldId 350) + +Questions? + +> End of the main talk. Thank the audience and open for questions. The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session. They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation. + +## [096] REFERENCE · Glossary of acronyms (1 of 2) — protocols, data, events (sldId 351) + +- Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface). +- Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue, where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe). +- Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log: the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used: a cache eviction policy); SETEX (the Redis "SET with EXpiry" command: sets a key and its TTL atomically); ORM (Object-Relational Mapping); pgx (Go's Postgres driver); struct tags (Go's JSON field mapping, not an acronym). +- Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service: a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD, which translates between models; ACL also means Access Control List in security); Saga (not an acronym: a sequence of local transactions with compensating actions). + +p. 81 + +> Glossary part 1, the protocol/data/event vocabulary. Call out the ones most likely to trip people up: gRPC is recursively 'gRPC Remote Procedure Call' but practically it's Google's high-performance RPC over HTTP/2 with Protocol Buffers. mTLS (mutual TLS) is the one people often gloss — ordinary TLS authenticates the server to the client; mutual TLS also authenticates the client to the server, which is the basis of zero-trust service identity. The data row has the SETEX and TTL the audience asked about explicitly: TTL is the expiry duration on a cached key; SETEX is the Redis command that sets a value and its TTL in one atomic operation (SETEX key seconds value). LRU is the eviction policy that drops the least-recently-touched key when the cache is full. CDC and WAL go together — CDC tails the WAL. Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List. + +## [097] REFERENCE · Glossary of acronyms (2 of 2) — platform, security, telemetry (sldId 352) + +- Kubernetes & cloud — K8s (Kubernetes); RBAC (Role-Based Access Control); HPA (Horizontal Pod Autoscaler); KEDA (Kubernetes Event-Driven Autoscaling); PSS (Pod Security Standards); CRD (Custom Resource Definition); IAM (Identity & Access Management); KMS (Key Management Service); VPC (Virtual Private Cloud); CNI (Container Network Interface). +- Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials). +- Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency). +- Runtime & build — net/http (Go's standard-library HTTP server); goroutine (a lightweight thread scheduled by the Go runtime); go.mod (module & dependency manifest); DI (Dependency Injection); DTO (Data Transfer Object); GC (Garbage Collection). + +p. 82 + +> Glossary part 2, the platform/security/observability vocabulary. The Kubernetes row: HPA scales on CPU/memory, KEDA scales on event sources (queue depth, Kafka lag, HTTP rate) — the distinction matters in the autoscaling discussion. PSS (Pod Security Standards) replaced the deprecated PodSecurityPolicy. The security row has the terms from section 12: SPIFFE/SPIRE give workloads cryptographic identities for mTLS; OPA is the policy engine behind policy-as-code; IAP is the zero-trust edge proxy; JWT/OIDC/OAuth are the token/identity standards; SLSA/CVE/SBOM are supply-chain security. The observability row has RED and USE, the two metrics methods worth memorising: RED (Rate, Errors, Duration) for services, USE (Utilisation, Saturation, Errors) for resources. p99 means the 99th-percentile latency — 99% of requests are faster than this; it's what you alert on, not the average. The runtime row is tailored to this deck's language. + +## [098] Appendix A · Protocols in Depth (sldId 353) + +A + +REST, gRPC and GraphQL: how they differ. + +> Appendix A. This is the deep dive behind the 'pick the protocol' slide from section 2. We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire. By the end the audience should be able to defend a protocol choice on technical grounds, not fashion. + +## [099] APPENDIX A · PROTOCOLS · Three paradigms and their mental models (sldId 354) + +REST models resources, gRPC models procedures, GraphQL models a typed graph — the same intent, three shapes. + +p. 83 + +> Start with the mental model, because it drives everything else. REST is resource-oriented: nouns and HTTP verbs, you transfer representations of state. gRPC is procedure-oriented: you call typed methods, it's behaviour not state, and the .proto is the contract. GraphQL is graph-oriented: one typed schema the client traverses, picking exactly the fields it wants. The same place_order intent takes three different shapes. Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction. + +## [100] APPENDIX A · PROTOCOLS · Over-fetching, under-fetching and round trips (sldId 355) + +REST trades round trips or bespoke endpoints for simplicity; GraphQL shapes one response; gRPC is a typed call. + +p. 84 + +> This is the practical difference clients feel. With REST you often need several round trips (order, then its items, then stock, then shipping) or you build a bespoke aggregate endpoint — and you still get fixed payload shapes that over- or under-fetch. GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three. The trade: GraphQL gives clients flexibility at the cost of gateway complexity; gRPC gives speed at the cost of rigidity; REST gives reach and simplicity at the cost of round trips. + +## [101] APPENDIX A · PROTOCOLS · The wire: transport, encoding and streaming (sldId 356) + +Reach and caching, or raw speed, or client-shaped responses — choose on the wire properties you need. + +p. 85 + +> What travels on the wire, and why it constrains your choice. REST: HTTP/1.1 or /2, JSON text, request/response, cacheable via standard HTTP, browser-native — maximum reach. gRPC: HTTP/2 only, protobuf binary, full uni- and bidirectional streaming, no HTTP caching, and it needs grpc-web to work from a browser. GraphQL: HTTP POST to a single URL, JSON, request/response plus subscriptions, caching is application-level (not HTTP), browser-native. The decision rule: if you need broad reach and HTTP caching, REST; if you need raw speed and streaming between services you control, gRPC; if you need client-shaped responses over many sources, GraphQL. + +## [102] APPENDIX A · PROTOCOLS · Choosing between them in practice (sldId 357) + +- Public or partner-facing, browser or third-party clients, caching matters → REST. +- Internal service-to-service, low latency, high call volume, streaming → gRPC. +- Aggregating many back-ends for varied clients that each want different fields → GraphQL at the gateway. +- A fact that many consumers react to, decoupled in time → none of these; emit an event. +- Real systems use all four; standardizing on one wire for everything is itself an anti-pattern. + +p. 86 + +> The decision guide. Public/partner/browser with caching → REST. Internal, hot, streaming → gRPC. Client-driven aggregation across back-ends → GraphQL at the gateway. A fact many consumers react to → don't make it a call at all, emit an event. The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done. + +## [103] APPENDIX A · PROTOCOLS · Common mistakes with each (sldId 358) + +- REST: verbs in URLs (/createOrder), 200-for-everything, offset pagination, and ignoring cache headers. +- gRPC: exposing it directly to browsers without grpc-web; treating it as REST-with-protobuf and ignoring streaming. +- GraphQL: the N+1 resolver explosion; no query depth/complexity limits; using it for simple single-resource reads. +- All three: leaking internal database models straight onto the wire, so you can never evolve them. +- Async: using a queue when you needed a log (no replay), or a request when you needed a fact. + +p. 87 + +> The failure modes, drawn from real review comments. REST: verbs in URLs, returning 200 with an error body, offset pagination that breaks under load, and throwing away free HTTP caching. gRPC: trying to call it straight from a browser without grpc-web, or using it as 'REST with protobuf' and never touching its streaming superpower. GraphQL: the classic N+1 resolver explosion (fix with DataLoader batching), no depth or complexity limits so a malicious query melts the gateway, and reaching for it when a plain GET would do. The universal one is the last bold-adjacent: leaking your database schema onto the wire kills your ability to evolve — the same leaky-internal-models anti-pattern from section 12. + +## [104] Appendix B · Versioning & Evolution (sldId 359) + +B + +REST vs RESTful, proto breaking changes, GraphQL evolution. + +> Appendix B answers the question every team fights about: how do we version this without breaking everyone? The answer differs sharply by protocol. REST has several strategies with real trade-offs; gRPC has precise wire-compatibility rules; GraphQL rejects versioning entirely in favour of evolution. We'll see each, with code. + +## [105] APPENDIX B · VERSIONING · Four ways to version a REST API (sldId 360) + +URI versioning is common and visible; media-type versioning keeps URLs stable — the more RESTful choice. + +p. 88 + +> REST has four strategies, ordered from coarse to fine. URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation. Query param: simple to default but muddies caches. Custom header: keeps URLs clean but is invisible and often forgotten. Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach. In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct. Know which you're choosing and why. The media-type versioning code shows how. + +## [106] APPENDIX B · VERSIONING · Media-type versioning with net/http (sldId 361) + +[chip] go · net/http + +```go +const ( + v1 = "application/vnd.acme.order+json" // default / legacy + v2 = "application/vnd.acme.order.v2+json" // adds "currency" +) +// one stable URL — the resource; the representation is negotiated via Accept +func getOrder(w http.ResponseWriter, r *http.Request) error { + order, err := orders.Get(r.Context(), r.PathValue("oid")) + if err != nil { return err } + switch r.Header.Get("Accept") { + case v2: // negotiate the representation + w.Header().Set("Content-Type", v2) + return json.NewEncoder(w).Encode(order.withCurrency()) + default: // v1 shape, unchanged + w.Header().Set("Content-Type", v1) + return json.NewEncoder(w).Encode(order.v1()) + } +} +// the URL never changes; clients opt into a representation via Accept +``` + +The resource URL is stable; the representation is negotiated. New clients ask for v2; old clients keep getting v1. + +p. 89 + +> This makes 'RESTful versioning' concrete. There's one URL for the order resource — it never changes. The client signals which representation it wants via the Accept header; the server negotiates and responds with the matching Content-Type. New clients ask for the v2 media type and get the new currency field; old clients send nothing, default to v1, and are completely unaffected. Contrast with /v2/orders, where you've now got two URLs for the same underlying thing. Mention that you'd normally structure this with response models per version rather than building maps by hand, but the negotiation mechanism is the point. + +## [107] APPENDIX B · VERSIONING · gRPC / protobuf: what is a breaking change (sldId 362) + +Field numbers are the contract — names and order are not. Never reuse or renumber a field. + +p. 90 + +> gRPC's rules are precise because the wire format is positional. Safe, wire-compatible changes: add a field with a new number, add an rpc method, add an enum value, rename a field (the number is what matters, not the name), mark fields deprecated, and reserve removed tags. Breaking changes: change a field's number or type, reuse a removed number, remove a field without reserving its tag, rename or move a service or package, or change the request/response message type. The one people miss is on screen at the bottom: changing a method between unary and any streaming mode changes the HTTP/2 framing and is always breaking. The discipline: field numbers are forever; never reuse or renumber. Code shows safe evolution next. + +## [108] APPENDIX B · VERSIONING · Evolving a .proto safely (sldId 363) + +[chip] protobuf + +```protobuf +syntax = "proto3"; +message Order { + string id = 1; + string sku = 2; + int32 qty = 3; + reserved 4; // tag 4 once held 'notes' — never reuse it + reserved "notes"; + string currency = 5; // safe: brand-new field, new tag number + PaymentState state = 6; // safe: new field; old clients ignore it +} +enum PaymentState { PENDING = 0; PAID = 1; REFUNDED = 2; } // 0 must exist +// breaking (don't): qty -> int64, or moving 'sku' to tag 7, or reusing tag 4 +``` + +Add with new tags, reserve what you remove, and never change an existing field's number or type. + +p. 91 + +> Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire. We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work. Note the enum must keep a zero value as its default. The comment at the bottom lists the things that would break the wire: widening qty's type, moving an existing field to a new tag, or reusing a reserved tag. Apicurio's compatibility check (section 9) enforces exactly these rules in CI. + +## [109] APPENDIX B · VERSIONING · GraphQL versions by evolution, not by number (sldId 364) + +There is no /v2 in GraphQL: add fields, mark old ones @deprecated, watch usage, then retire. + +p. 92 + +> GraphQL takes a completely different stance: there are no versions. There's one schema that evolves continuously. The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero. The insight: GraphQL pushes you toward data-driven deprecation instead of big-bang version cutovers. Code shows the @deprecated directive next. + +## [110] APPENDIX B · VERSIONING · Deprecating a field in gqlgen (sldId 365) + +[chip] graphql · gqlgen + +```graphql +# schema.graphqls — additive change + @deprecated; no /v2 endpoint ever exists +type Order { + id: ID! + total: Float @deprecated(reason: "Use totalMinor (integer cents) instead.") + totalMinor: Int! # the replacement field, added additively +} +# gqlgen still generates and serves the deprecated field's resolver. +# Clients see @deprecated in introspection and tooling. +# Field-usage metrics show when total has zero callers; only then remove it. +``` + +Additive change plus @deprecated plus usage metrics replaces versioning entirely. + +p. 93 + +> The mechanics in gqlgen. We add totalMinor additively and mark the old total field with @deprecated and a reason. The field keeps working, nothing breaks, but the deprecation shows in introspection and in tools such as GraphiQL, nudging client developers. gqlgen keeps generating and serving the resolver for the deprecated field. +> +> The operational piece: instrument field-level usage on the server, and remove total only when its usage drops to zero. That is the GraphQL approach: evolution governed by data, with the breaking removal deferred until it is provably safe. Compare REST's media types and gRPC's tag rules: each protocol answers the same problem differently. + +## [111] APPENDIX B · VERSIONING · Principles that cut across all three (sldId 366) + +- Additive change is always safe; removal and mutation are where you break people. +- Make compatibility mechanical — the registry's BACKWARD/FORWARD/FULL rules, checked in CI, not by reviewers' memory. +- Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. +- Run old and new in parallel and migrate consumers before retiring — retirement is the only step that can break. +- Version data the same way you version APIs: event schemas in the registry follow the identical rules. + +p. 94 + +> Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break people — so design to add. Make compatibility mechanical: the registry's rules checked in CI, never a reviewer remembering. Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing. Run old and new in parallel and migrate before you retire, because retirement is the only inherently breaking move. And the bold line that connects this back to the event-driven section: your event schemas version by exactly the same rules — the registry treats an Avro schema and an OpenAPI spec identically. Versioning data and versioning APIs are the same discipline. + +## [112] Appendix C · WebSockets at Scale (sldId 367) + +C + +Go (coder/websocket), pod pinning, the backplane, binary framing, reconnection. + +> Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes. WebSockets are long-lived, stateful and pinned to a pod — the opposite of the stateless request model the platform assumes. We'll build one in net/http, see why it fights scaling, fix it with a backplane, make it fast and versioned with protobuf, and make it survivable with reconnection. + +## [113] APPENDIX C · WEBSOCKETS · A WebSocket endpoint in Go (coder/websocket) (sldId 368) + +[chip] go · coder/websocket + +```go +// ws.go — coder/websocket; per-pod state, the scaling problem starts here +var clients sync.Map // user ID -> *websocket.Conn: this pod's connections, not shared +func wsHandler(w http.ResponseWriter, r *http.Request) { + userID := r.PathValue("user_id") // 1.22+ path wildcard + c, err := websocket.Accept(w, r, nil) + if err != nil { return } + defer c.CloseNow() + clients.Store(userID, c) // this pod now owns this connection + defer clients.Delete(userID) + for { // full-duplex, long-lived + var msg Message + if err := wsjson.Read(r.Context(), c, &msg); err != nil { + return // disconnect + } + handle(r.Context(), userID, msg) + } +} +``` + +clients is per-pod state: that one map is the whole scaling problem. + +p. 95 + +> With coder/websocket the endpoint is short: accept the upgrade, then loop reading and handling messages, full duplex, until the client disconnects. Point at the clients map: it lives in this pod's memory. +> +> That one design fact drives the scaling discussion. The connection, and the knowledge of who is connected, live on exactly one pod. Everything stateless about HTTP, where any replica can serve any request, no longer holds. The scaling slides in this appendix show what that does to a Deployment. + +## [114] APPENDIX C · WEBSOCKETS · Why WebSockets fight Kubernetes scaling (sldId 369) + +A long-lived connection pins to one pod; new pods get no existing connections and scale-down drops them. + +p. 96 + +> Here's the collision with Kubernetes. A WebSocket connection, once established through the load balancer, is pinned to one pod for its entire lifetime. Two problems follow. Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have. Scale-down and rollouts: terminating a pod severs every connection it was holding, so a routine deploy disconnects thousands of users at once. Stateless HTTP shrugs both off; WebSockets don't. The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling. + +## [115] APPENDIX C · WEBSOCKETS · The scaling problem, stated precisely (sldId 370) + +- Pinning: a connection is bound to one pod; the LB can't move it without dropping it. +- Uneven load: new replicas only receive new connections, so existing hotspots persist. +- Rollouts hurt: every deploy or scale-down severs the connections on the affected pods. +- Cross-pod delivery: to push to user X you must reach whichever pod holds X — which you don't track. +- Connection limits & memory: each socket is held-open state; pods cap out on file descriptors, not CPU. + +p. 97 + +> Name the five distinct problems so the audience can recognize them. Pinning: the LB can't migrate a live socket. Uneven load: scaling adds capacity for new connections but doesn't rebalance existing ones. Rollouts hurt: every deploy is a mass disconnect event — you need graceful drain and client reconnection. Cross-pod delivery: to push a message to user X you must route to the specific pod holding X's socket, and nothing tracks that by default. And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too. The pub/sub backplane solves or eases every one of these. + +## [116] APPENDIX C · WEBSOCKETS · Scaling out: a pub/sub backplane (sldId 371) + +Each pod owns only its own sockets; a Redis or Kafka backplane fans a message out to whichever pod holds the client. + +p. 98 + +> The pattern that makes WebSockets horizontally scalable. Each pod still owns only its own sockets, which is fine, but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay. When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that holds X's socket delivers it. Now scale-up and zero-downtime rollouts work again, because no pod is the single home of a conversation — connections can land anywhere and still receive everything. Redis is the common default; Kafka if you already run it and want the durability. + +## [117] APPENDIX C · WEBSOCKETS · Binary framing with protobuf — fast and versioned (sldId 372) + +[chip] protobuf + go · coder/websocket + +```protobuf+go +// envelope.proto — version the message, not the URL +message Envelope { + uint32 version = 1; // bump for incompatible payload changes + string type = 2; // "OrderPlaced", "OrderCancelled", ... + bytes payload = 3; // the protobuf-encoded body for type + uint64 seq = 4; // monotonic per connection: drives resume +} +func push(ctx context.Context, c *websocket.Conn, seq uint64, ev *pb.OrderPlaced) error { + p, _ := proto.Marshal(ev) // nested protobuf + out, _ := proto.Marshal(&pb.Envelope{Version: 2, Seq: seq, Type: "OrderPlaced", Payload: p}) + return c.Write(ctx, websocket.MessageBinary, out) // one binary frame +} +``` + +Protobuf frames are smaller and faster than JSON; the versioned envelope and seq number enable evolution and resume. + +p. 99 + +> Two wins in one slide. Performance: protobuf frames written with websocket.MessageBinary are smaller and cheaper to parse than JSON text, which matters when pushing high-frequency updates to many sockets. +> +> Versioning: a WebSocket message has no URL to hang a version on, so you version the envelope. The version field lets the client detect an incompatible payload and renegotiate or reconnect; the type field dispatches to the right protobuf message; and a monotonic seq per connection is what makes reconnection and replay possible, the subject of the failure-handling slide. One envelope answers both how to make it fast and how to version a socket message. + +## [118] APPENDIX C · WEBSOCKETS · Failure handling: resume, don't restart (sldId 373) + +Heartbeats detect dead links; the client reconnects with backoff and resumes from the last acknowledged sequence. + +p. 100 + +> The cloud-native posture: treat every connection as disposable and design for its loss. Heartbeats (ping/pong) detect a dead link quickly — TCP alone can hang for minutes. When the connection drops, the client reconnects with exponential backoff plus jitter (so ten thousand clients don't reconnect in lockstep and hammer your pods after a rollout). On reconnect, the client sends its last-seen sequence number (last_seq = 1042); the server replays only the events the client missed (1043 onward), and because each frame carries a seq, the client can dedupe — so the whole thing is at-least-once and idempotent. The principle on screen: resume, don't restart. A reconnect should be invisible to the user, not a full state reload. + +## [119] APPENDIX C · WEBSOCKETS · A cloud-native WebSocket checklist (sldId 374) + +- Externalize fan-out to a backplane (Redis or Kafka); never assume one pod holds the client. +- Scale and trigger on connection count and memory, not CPU; set realistic per-pod connection caps. +- Drain gracefully on shutdown: stop accepting, signal clients to reconnect, let backoff spread the herd. +- Heartbeat to detect dead links; reconnect with exponential backoff + jitter; resume via a per-connection seq. +- Use binary frames with a versioned envelope; authenticate on connect and re-check on resume. + +p. 101 + +> The operational checklist to leave on screen. Externalize fan-out to a backplane so no pod is special. Scale and set your KEDA/HPA trigger on connection count and memory, not CPU, and cap connections per pod realistically. Drain gracefully on shutdown — stop accepting, tell clients to reconnect, and let backoff-with-jitter spread the reconnect storm across your rollout. Heartbeat to detect dead links fast; reconnect with backoff and jitter; resume from a per-connection sequence number so the user never notices. And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere. Do these and WebSockets are just another well-behaved cloud-native workload. + +## [120] Appendix D · Saga State & Compensation (sldId 375) + +D + +Persisting state, compensating cleanly, and the step-B-needs-A's-data problem. + +> Appendix D is the deep mechanics behind the saga slide in the Data section. Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them. + +## [121] APPENDIX D · SAGAS · Where a Go saga keeps its state (sldId 376) + +Memory won't survive a pod restart — persist every step transition in a DB, an event log, or a workflow engine. + +p. 102 + +> First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-flight saga with no idea what to compensate. Three real options. A DB-backed state machine: one row per saga instance holding its status and a context blob; you own the transitions — simplest, and great for most cases. Event-sourced: append each step as an event and rebuild state by replay — full audit history, fits naturally with the Kafka log. A workflow engine like Temporal or Camunda: durable execution with retries and timers built in — most power, least code, but a new dependency to run. The cross-cutting rule on screen: the state transition and the outbound command must commit together (the outbox pattern again), or you get lost or duplicated steps. + +## [122] APPENDIX D · SAGAS · A persisted saga state machine (sldId 377) + +[chip] go · pgx + +```go +// saga_orchestrator.go — DB-backed state machine; advance one step per call +var steps = []string{"charge_payment", "reserve_stock", "book_shipping"} +func advance(ctx context.Context, pool *pgxpool.Pool, sagaID string) error { + tx, err := pool.Begin(ctx) // state + command commit together + if err != nil { return err } + defer tx.Rollback(ctx) + s, err := getSagaForUpdate(ctx, tx, sagaID) // SELECT ... FOR UPDATE (row lock) + if err != nil || s.Status != "RUNNING" { return err } // idempotent: already done + step := steps[s.StepIndex] + result, err := invoke(ctx, step, s.Context) // call the owning service + if err != nil { return markCompensating(ctx, tx, s) } // the unwinder takes over + s.Context[step] = result // accumulate output into the context + s.StepIndex++ + if s.StepIndex == len(steps) { s.Status = "COMPLETED" } + if err := updateSaga(ctx, tx, s); err != nil { return err } + return tx.Commit(ctx) // durable after every step +} +``` + +One locked row per saga, advanced one durable step at a time. A crash just means 'load the row and call advance again.' + +p. 103 + +> This is the DB-backed option made concrete, and the default choice for a bounded saga. Key ideas: one row per saga, selected FOR UPDATE so two workers can't advance the same saga concurrently. The status guard makes advance() idempotent — calling it on a finished saga is a no-op, which matters because retries and redeliveries will call it again. Each successful step accumulates its result into the context blob, which is what lets a later step read data an earlier step produced, and bumps the step index; the row is saved durably after every step inside the same transaction as the work record. Recovery needs no special path: after a crash, a sweeper loads RUNNING sagas and calls advance() again. No special crash logic. + +## [123] APPENDIX D · SAGAS · Compensating transactions unwind a failed saga (sldId 378) + +Each committed step has an inverse; on failure they run in reverse order — there is no cross-service rollback. + +p. 104 + +> Compensation is how a saga 'rolls back' when there's no actual rollback available across services. Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release. When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first. Two things that bite people, both on screen: compensations must themselves be idempotent and durable, because a crash mid-unwind has to resume cleanly; and a compensation can itself fail, so it needs its own retries. The saga ends in a persisted COMPENSATED state with a full audit trail of what was done and undone. + +## [124] APPENDIX D · SAGAS · Running compensations in reverse (sldId 379) + +[chip] go · pgx + +```go +var compensations = map[string]string{ // inverse for each forward step + "charge_payment": "refund_payment", + "reserve_stock": "release_stock", + "book_shipping": "cancel_shipping", +} +func compensate(ctx context.Context, sagaID string) error { + return withTx(ctx, db, func(tx pgx.Tx) error { + s, err := getSagaForUpdate(ctx, tx, sagaID) + if err != nil { return err } + for i := s.StepIndex - 1; i >= 0; i-- { // undo completed steps, newest first + step := steps[i] + key := fmt.Sprintf("%s:%s", sagaID, step) // idempotency key — safe retry + if err := invokeC(ctx, compensations[step], s.Context, key); err != nil { + return err + } + } + s.Status = "COMPENSATED" + return saveSaga(ctx, tx, s) + }) +} +``` + +Compensate only completed steps, in reverse, with an idempotency key so a retry never double-refunds. + +p. 105 + +> The compensation logic. A map gives each forward step its semantic inverse. The loop walks from StepIndex - 1 down to 0: only the steps that completed, newest first, and invokes each compensation. The saga ends as COMPENSATED. +> +> The idempotency key built from the saga ID and the step is what prevents the worst outcome: a retry refunding a customer twice. The compensating service dedupes on that key. A production version also tracks per-step compensation status, so a compensation that crashes halfway resumes in the right place instead of re-running finished ones; the idempotency key makes even a naive re-run safe. This is why compensations are designed up front, as part of each step, not added afterwards. + +## [125] APPENDIX D · SAGAS · When step B needs data only step A produced (sldId 380) + +Don't make B call A. Persist A's output into the saga context and pass it forward; on resume, read it back. + +p. 106 + +> This case is a common source of bad designs. The orchestrator calls service A, then service B — but B needs a value that only A produced (say A returns a reservation_id and a warehouse region that B requires to proceed). The tempting wrong answer is to have B call A directly — now you've created a hidden synchronous dependency between B and A, exactly the chatty coupling the whole architecture fights. The right answer is on screen: the orchestrator owns a saga context (a 'blackboard'), persists A's output into it after step A, and passes the needed values into B's call. B never knows A exists. And note this is already in our state-machine code — that 's.context[step] = result' line is exactly this. + +## [126] APPENDIX D · SAGAS · Carrying context forward — and resuming after a crash (sldId 381) + +[chip] go · pgx + +```go +// After A runs, its output is in s.Context["reserve_stock"], e.g.: +// {"reservation_id": "r-77", "region": "eu-1"} +func invoke(ctx context.Context, step string, sctx Context) (any, error) { + switch step { + case "book_shipping": + res := sctx["reserve_stock"].(map[string]any) // B reads A's output from context + return shipping.Book(ctx, BookReq{ + ReservationID: res["reservation_id"].(string), // data only A produced + Region: res["region"].(string), + }) + } + return nil, nil +} +// Crash between A and B? The sweeper reloads the saga row, sees StepIndex points +// at book_shipping with A's result already in Context, and calls advance() — B +// proceeds with A's data. A is never re-called; nothing is lost. +``` + +B reads A's result from the persisted context, never from A. Resume = reload the row and continue at the next step. + +p. 107 + +> This closes the loop and answers the resume question directly. Because step A persisted its output into the saga context (committed durably with the state transition), step B reads reservation_id and region out of context: it calls shipping.Book with data that originated in A, but it never calls A. Now the crash scenario you raised: the orchestrator dies between A completing and B starting. On restart, the sweeper reloads the saga row; StepIndex already points at book_shipping and the context already contains A's result, both committed before the crash. It calls advance(), B runs with A's data, and A is never re-invoked — no lost data, no duplicate reservation. The persisted context is simultaneously the answer to 'how does B get A's data' and 'how do I resume cleanly' — one mechanism, both problems. That's the payoff of persisting state every step. + +## [127] Appendix E · API Error Handling (sldId 382) + +E + +One discipline, four wire formats. + +> Appendix E. Error handling is where cloud-native systems fall apart, more often than the happy path. The thesis: agree one error contract (a stable machine code, a safe human message, a correlation id, a retryable flag, field details) and then map that same model onto REST, gRPC, Kafka and GraphQL. We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it. + +## [128] APPENDIX E · ERRORS · One error model, four wire formats (sldId 383) + +The same five facts in every error; only the encoding changes per protocol. + +p. 108 + +> Start with what every error must carry, regardless of protocol: a stable machine-readable code clients can branch on (not a localized string), a human-readable message safe to show, a correlation/trace id that ties the error to the distributed trace from section 11, a retryable flag (with retry-after when known), and structured field-level details. The four protocols just encode those same five facts differently — REST as problem+json, gRPC as status+details, Kafka via headers and a DLQ record, GraphQL as an errors[] entry with extensions. Agree this once as a shared contract; never leak stack traces or internal type names to callers. + +## [129] APPENDIX E · ERRORS · REST: status, then Problem Details (sldId 384) + +The status code is the category; the problem+json body carries the machine code and specifics. + +p. 109 + +> REST has two layers. First the HTTP status class, which is the category and drives client behaviour: 4xx means the client must fix the request and should not blindly retry; 429 and 503 mean 'busy' and must be paired with a Retry-After header so clients back off; 5xx means a server fault that idempotent calls may retry with backoff. Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId. Status for the category, body for the specifics. + +## [130] APPENDIX E · ERRORS · REST errors with net/http (sldId 385) + +[chip] go · net/http + +```go +// one central place: domain error → RFC 9457 problem+json +type StockError struct{ Detail string } +func (e StockError) Error() string { return e.Detail } +func writeProblem(w http.ResponseWriter, r *http.Request, err error) { + var se StockError + if !errors.As(err, &se) { // domain → wire, one mapping + http.Error(w, "internal", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/problem+json") // RFC 9457 + w.WriteHeader(http.StatusConflict) // 409 + _ = json.NewEncoder(w).Encode(map[string]any{ + "type": "https://errors.acme.io/stock", "title": "Insufficient stock", + "status": 409, "detail": se.Detail, + "code": "STOCK_UNAVAILABLE", "traceId": traceID(r.Context()), // stable code, trace + }) +} +``` + +Map every domain error to problem+json in one handler — stable code, safe message, trace id; never a raw stack trace. + +p. 110 + +> One function maps domain errors to the wire: domain code returns a typed error, errors.As recognises it, and writeProblem renders RFC 9457 problem+json, so no endpoint invents its own error JSON. Anything unrecognised becomes a plain 500 with no detail. +> +> The media type is application/problem+json, the status matches the category (409, a conflict the client can act on), and the body carries the stable code and the traceId for correlation. What is absent by design: stack traces, type names and SQL, which leak internals and widen the attack surface. + +## [131] APPENDIX E · ERRORS · gRPC: status codes + rich details (sldId 386) + +A small fixed set of canonical codes, plus structured google.rpc.Status details in the trailers. + +p. 111 + +> gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it. INVALID_ARGUMENT and NOT_FOUND are client bugs: don't retry. FAILED_PRECONDITION and ALREADY_EXISTS are state issues: don't retry. UNAVAILABLE and ABORTED are transient: retry with backoff. RESOURCE_EXHAUSTED means you're being rate-limited — honour the RetryInfo delay. DEADLINE_EXCEEDED means tune the timeout. Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else. + +## [132] APPENDIX E · ERRORS · gRPC errors with rich status details (sldId 387) + +[chip] protobuf + go · grpc + +```protobuf+go +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + epb "google.golang.org/genproto/googleapis/rpc/errdetails" +) +func (s *server) ReserveStock(ctx context.Context, + req *pb.ReserveRequest) (*pb.ReserveReply, error) { + if inventoryDown { + st := status.New(codes.Unavailable, "inventory temporarily down") // canonical + st, _ = st.WithDetails( + &epb.ErrorInfo{Reason: "STOCK_DOWN", Domain: "inventory"}, // machine + &epb.RetryInfo{RetryDelay: durationpb.New(2 * time.Second)}, // back off 2s + ) + return nil, st.Err() + } + return reserve(ctx, req) +} +``` + +Pick the canonical code (UNAVAILABLE = retryable) and attach ErrorInfo + RetryInfo so clients back off correctly. + +p. 112 + +> The rich-error model in Go: status.New with a canonical code, then WithDetails attaches google.rpc details from the errdetails package. Unavailable tells the client and the mesh the failure is transient and retryable; ErrorInfo carries a machine reason and domain; RetryInfo tells the client how long to wait. st.Err() returns an error whose details travel in the trailers. +> +> The discipline: do not use Internal for everything. The code is how automated clients decide whether and when to retry, so choosing it well prevents both lost work and retry storms. + +## [133] APPENDIX E · ERRORS · Kafka: retry topics and a dead-letter queue (sldId 388) + +Commit the offset, route the failed record aside, and keep consuming — a poison record never blocks the partition. + +p. 113 + +> Kafka error handling has a failure mode unique to logs: a single poison record can block the whole partition forever if you keep retrying it in place, because the offset never advances — head-of-line blocking that stalls every record behind it. The fix is to move failures aside: try a bounded number of in-place retries, then route the record to a delayed-retry topic (reprocessed after a delay without blocking the live stream), and after N attempts send it to a dead-letter queue for humans or tooling. You commit the offset either way, so the main consumer keeps making progress. The DLQ record carries the original payload plus error and trace headers. + +## [134] APPENDIX E · ERRORS · Kafka retry + DLQ with franz-go (sldId 389) + +[chip] go · franz-go + +{% raw %} +```go +func process(ctx context.Context, cl *kgo.Client, r *kgo.Record) error { + if err := handle(r); err == nil { return nil } // success + attempts := headerInt(r, "retries") + out := &kgo.Record{Value: r.Value} + if attempts < maxRetries { + out.Topic = "orders.retry.30s" // delayed retry + out.Headers = []kgo.RecordHeader{{Key: "retries", Value: itob(attempts + 1)}} + } else { + out.Topic = "orders.DLQ" // give up → DLQ + out.Headers = []kgo.RecordHeader{{Key: "error", Value: []byte("poison")}, + {Key: "traceId", Value: []byte(traceID(ctx))}} + } + return cl.ProduceSync(ctx, out).FirstErr() // then commit, so the partition keeps moving +} +``` +{% endraw %} + +Retry a bounded number of times, then DLQ — and always commit, so one poison record can't stall the partition. + +p. 114 + +> Bounded in-place retries, then a delayed-retry topic, then a dead-letter queue. The retries header counts the attempts and grows on each hop through orders.retry.30s; after maxRetries the record goes to orders.DLQ with its payload, an error header and a traceId header for investigation. +> +> The caller commits the offset in every case, success, retry or DLQ, so one poison record cannot stall the partition. This is the manual version of what frameworks provide declaratively. Make handle idempotent, because with at-least-once delivery a retried record may already have been partly applied. + +## [135] APPENDIX E · ERRORS · GraphQL: partial success with errors[] (sldId 390) + +One HTTP 200; the failed field is null and explained in errors[] with a machine code in extensions. + +p. 115 + +> GraphQL breaks the one-request-one-status assumption: a single query can touch many resolvers, so the response is almost always HTTP 200 and carries both a data object and an errors array. When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId. The rest of the data still returns. The anti-pattern to call out: blanket-failing the whole query (or returning a 500) when only one field broke — that throws away useful data the client already asked for. Partial success is a feature; let the client decide whether to retry just the failed field. + +## [136] APPENDIX E · ERRORS · GraphQL field errors with gqlgen (sldId 391) + +[chip] go · gqlgen + +```go +// gqlgen: an error from one field's resolver nulls that field only; siblings resolve +func (r *orderResolver) Stock(ctx context.Context, obj *Order) (*int, error) { + n, err := r.Inventory.Remaining(ctx, obj.Sku) + if err != nil { + return nil, &gqlerror.Error{ + Message: "inventory unavailable", // safe message + Path: graphql.GetPath(ctx), // the field + Extensions: map[string]any{ + "code": "UNAVAILABLE", // machine code + "retryable": true, + "traceId": traceID(ctx), + }, + } + } + return &n, nil +} +``` + +Return a gqlerror from the field resolver: gqlgen nulls that field and adds an errors[] entry with your extensions; the rest of data returns. + +p. 116 + +> Returning an error from a single field's resolver is the right move: gqlgen sets that field to null and appends an entry to errors[] with the message, the path and your extensions, while every other field in the query resolves normally. The extensions object carries the shared error contract in GraphQL: code, retryable, traceId. +> +> Do not let a failure escape to the operation level and fail the whole response; that is the blanket-failure anti-pattern. The client sees the order and its total, sees stock is null, reads extensions.code, and decides whether to retry only stock. + +## [137] APPENDIX E · ERRORS · How a retry storm forms — and how to stop it (sldId 392) + +Naive retries multiply load on a struggling service; bounded, spaced, circuit-broken retries break the feedback loop. + +p. 117 + +> This is the failure mode that turns a brief blip into a full outage. The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries. It's a positive feedback loop, and naive retry logic is the amplifier. The controls that break it, bottom of the slide: bound every call with a timeout; cap retries to a small number and only for idempotent calls; space them with exponential backoff plus jitter (so clients don't retry in lockstep); add a circuit breaker to fail fast while the dependency is down; enforce a retry budget (cap retries as a percentage of traffic); and shed load early with 429/UNAVAILABLE rather than queueing work you can't do. The Istio config from section 2 provides several of these at the mesh layer without code changes. + +## [138] APPENDIX E · ERRORS · Error-handling heuristics across all four (sldId 393) + +- One contract, four encodings: a stable code, a safe message, a trace id, a retryable flag, field details — everywhere. +- The category lives in the protocol: HTTP status, gRPC code, GraphQL errors[] entry, Kafka DLQ vs retry topic. +- Make retryability explicit and honour it: Retry-After, RetryInfo, extensions.retryable — don't make clients guess. +- Never leak internals: no stack traces, SQL, or class names on the wire; map them to a code and log the detail server-side. +- Bound every retry (timeout, small max, backoff + jitter, circuit breaker, retry budget) and make handlers idempotent. + +p. 118 + +> The cross-cutting checklist to leave on screen. One contract expressed four ways — the same five facts whether it's problem+json, google.rpc.Status, a GraphQL extensions object, or a Kafka DLQ header. Let each protocol carry the category natively rather than inventing your own. Make retryability explicit and machine-readable so clients and the mesh back off correctly instead of guessing. Never leak internals — that's both a security issue and a coupling issue. And the bold line is the one that prevents outages: every retry is bounded and spaced, protected by a circuit breaker and a retry budget, and every handler is idempotent so a redelivered or retried request is safe. Get these right and your failure paths become as boring as your happy paths. + +## [139] Appendix F · DDD & Hexagonal Architecture (sldId 394) + +F + +Where the boundaries and the contracts come from. + +> Appendix F. The whole talk assumes you've drawn good service boundaries and know what each API should say — this is where those come from. Domain-Driven Design gives you the strategic tools to place boundaries (subdomains, bounded contexts) and the tactical tools to model what's inside (aggregates, domain events); hexagonal architecture keeps the four protocols we've discussed as mere adapters around a clean domain core. Together they answer: where does an API boundary go, what should its contract say, and how do I keep REST/gRPC/GraphQL/Kafka out of my business logic. The reference is Vlad Khononov's Learning Domain-Driven Design. + +## [140] APPENDIX F · DDD · Why DDD belongs in an API talk (sldId 395) + +- An API is a contract between two bounded contexts — so “where's the boundary?” and “what does the contract say?” are DDD questions before they're API questions. +- Strategic DDD places the boundaries: subdomains (core, supporting, generic) and bounded contexts, each with its own model and ubiquitous language. +- Tactical DDD models what's inside: aggregates (consistency boundaries), entities, value objects and domain events — which become your resources and your Kafka events. +- Hexagonal architecture keeps it clean: the domain core is isolated, and REST, gRPC, GraphQL and Kafka are just adapters plugged into ports. +- Get the boundaries right and the APIs almost design themselves; get them wrong and no amount of REST polish will save you. + +p. 119 + +> Set up why a DDD appendix belongs in an API talk. The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries. Distinguish strategic DDD (where the lines go) from tactical DDD (how you model inside a line), and preview that hexagonal architecture is what stops the four protocols from contaminating the domain. The closing line is the one to land: boundaries dominate outcomes far more than protocol polish. + +## [141] APPENDIX F · DDD · Strategic DDD: subdomains & bounded contexts (sldId 396) + +Place boundaries by business value and rate of change; each context owns one model and exposes it through an API. + +p. 120 + +> Strategic DDD first. Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications). Each subdomain becomes one or more bounded contexts — a boundary inside which one model and one ubiquitous language are consistent. The punchline here is the bottom box: that boundary is exactly where the API sits, and the contract translates between two contexts' models rather than sharing internals. Map it to our system: order and pricing are core, inventory and shipping supporting, auth and notification generic. + +## [142] APPENDIX F · DDD · A bounded context is a service-and-API boundary (sldId 397) + +- A bounded context is the scope within which one model and one ubiquitous language stay consistent — the natural unit for a service and its API. +- The same word means different things across contexts: an “order” in fulfillment is not an “order” in billing. Don't force one shared model across the system. +- The context's published API is its only public surface; the schema, storage and internal types stay private — this is the “no leaky internal models” rule from anti-patterns, restated. +- Put boundaries where the business naturally splits and where a team can own them end-to-end (Conway's law) — that's where the integration cost is worth paying. + +p. 121 + +> Make the bounded context concrete as the service/API boundary. The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead. Tie back explicitly to the anti-patterns section: 'no leaky internal models' is just 'keep the bounded context's internals private.' And invoke Conway's law: boundaries that don't match team ownership generate constant cross-team coordination. + +## [143] APPENDIX F · DDD · Context mapping: how contexts integrate (sldId 398) + +The relationship patterns between contexts, and which ones your API implements. + +p. 122 + +> Context mapping catalogues the ways two contexts relate. Walk the starred ones because they're what APIs implement: an Open-Host Service is a context that, because many others integrate with it, publishes a stable public API; the Published Language is the shared, versioned schema that API speaks; and an Anticorruption Layer is the translation you build when consuming someone else's context so their model doesn't leak into yours. The others are trade-offs: Conformist means you just accept the upstream model; Shared Kernel means you share a model and therefore change in lockstep — high coupling, use sparingly; Partnership means two contexts co-evolve; Separate Ways means don't integrate. The starred three are the ones to design explicitly. + +## [144] APPENDIX F · DDD · Open-Host Service & Published Language (sldId 399) + +- Open-Host Service: a context integrated by many publishes a stable, documented API as its public protocol — that's your REST / gRPC / GraphQL surface. +- Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry. +- Anticorruption Layer: when you consume another context, translate their model into yours at the edge so their concepts never leak into your domain. +- Conformist is the cheap opposite — accept their model wholesale and inherit their changes; choose an ACL instead when the upstream model is messy or volatile. +- So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer. + +p. 123 + +> This slide does the real work of connecting DDD vocabulary to the platform we built. Open-Host Service = the public API; Published Language = the schema in the registry; Anticorruption Layer = the translation at your edge. The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory. Emphasize the ACL-vs-Conformist choice as a real decision: conform when the upstream is clean and stable, build an ACL when it's messy or changes a lot, because the ACL localizes the blast radius of their changes to one translation layer. + +## [145] APPENDIX F · HEXAGONAL · Hexagonal architecture: ports & adapters (sldId 400) + +The domain core is isolated; REST, gRPC, GraphQL and Kafka are just adapters that plug into ports. + +p. 124 + +> This is the architectural payoff of the whole protocols discussion. The domain core in the middle holds entities, aggregates and domain logic and depends on nothing — no framework, no I/O. It declares ports: inbound ports (what it can be asked to do) and outbound ports (what it needs from the world). Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port. Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports. The arrows all point inward: adapters depend on the domain, never the reverse. The consequence we care about: the four protocols we spent the talk on are interchangeable adapters; you can add gRPC alongside REST, or a Kafka consumer, without touching a line of domain logic. + +## [146] APPENDIX F · HEXAGONAL · Ports & adapters in code (sldId 401) + +[chip] go · ports & adapters + +```go +// domain/ports.go — the domain declares the small interfaces it needs +type OrderRepository interface{ Save(ctx context.Context, order *Order) error } // + finders +type EventPublisher interface{ Publish(event OrderPlaced) } +// domain/service.go — pure application logic: no framework, no I/O +type PlaceOrder struct { + repo OrderRepository + pub EventPublisher +} +func (s *PlaceOrder) Execute(ctx context.Context, cmd PlaceOrderCmd) (*Order, error) { + if cmd.SKU == "" || cmd.Quantity <= 0 { return nil, fmt.Errorf("invalid order") } + order := &Order{ID: newUUID(), SKU: cmd.SKU, Quantity: cmd.Quantity, Status: "placed"} + if err := s.repo.Save(ctx, order); err != nil { return nil, err } + s.pub.Publish(OrderPlaced{OrderID: order.ID, SKU: order.SKU, Quantity: order.Quantity}) + return order, nil +} +``` + +The domain depends only on ports it declares; REST/gRPC/Kafka are adapters wired in at the edge. Swap or add one without touching PlaceOrder. + +p. 125 + +> Walk the two blocks. Ports: the domain declares small Go interfaces for what it needs, a repository and an event publisher, and depends on nothing else. The application service PlaceOrder is pure: it enforces the invariant, creates the order, saves it through the repository port and publishes OrderPlaced through the publisher port. It never imports net/http, pgx or Kafka. +> +> The adapters live outside the domain: a Postgres repository and a publisher on the outbound side; an HTTP handler and a CLI on the driving side, each turning its input into a PlaceOrderCmd and calling Execute. The example runs the same use case from both the HTTP server and the CLI. Dependency inversion serves the boundary: protocols are replaceable, the domain is stable. + +## [147] APPENDIX F · DDD · Aggregates and domain events (sldId 402) + +- An aggregate is a cluster of objects treated as one consistency unit — the boundary inside which invariants hold and a single transaction commits. It's usually what sits behind one API resource. +- That makes the aggregate the natural transaction boundary, and therefore the outbox boundary: the state change and the domain event commit together (section 4). +- Domain events (“OrderPlaced”) are first-class in DDD; they become the Kafka events other contexts react to (section 5) — choreography between bounded contexts. +- Keep aggregates small: one aggregate per transaction, reference others by id, and let events connect them — which is exactly the saga story in Appendix D. + +p. 126 + +> Tactical DDD, and it ties three earlier sections together. The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource. Because it's the transaction boundary, it's also the outbox boundary from section 4: state plus event commit atomically. Domain events are the DDD concept that becomes your Kafka topic payloads, enabling choreography across contexts (section 5). And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D). This slide is where DDD stops being abstract and visibly underpins the data, event and saga designs. + +## [148] APPENDIX F · DDD · How DDD maps to our API stack (sldId 403) + +- Bounded context → a service and its API boundary. +- Open-Host Service / Published Language → the REST/gRPC/GraphQL contract + the Apicurio registry. +- Anticorruption Layer → model translation at the gateway / consumer edge. +- Aggregate → an API resource and the transaction/outbox boundary behind it. +- Domain event → a Kafka event other contexts consume. + - Reference: Vlad Khononov — Learning Domain-Driven Design (O'Reilly, 2021). + +p. 127 + +> The summary map — leave it up while you recap. Every DDD concept on the left has shown up concretely on the right somewhere in the talk: contexts are services, the OHS/Published Language is the registry-backed contract, the ACL is edge translation, aggregates are resources and outbox boundaries, domain events are Kafka events. The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on. Point people to Khononov's Learning Domain-Driven Design for the full treatment. + +## [149] Appendix G · Balancing Coupling (sldId 404) + +G + +Coupling isn't the enemy — imbalance is. + +> Appendix G. We've thrown around “coupled” and “decoupled” throughout as if loose is always better. This sharpens it, using Vlad Khononov's Balancing Coupling model. The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it. Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together. This gives precise language for why a shared database is bad but a registry-backed API contract is fine, and it ties every anti-pattern from section 12 back to one model. + +## [150] APPENDIX G · COUPLING · Coupling is necessary — balance it, don't eliminate it (sldId 405) + +- Components must share some knowledge to collaborate; a system with zero coupling also has zero behaviour. “Loosely coupled” is too blunt a goal. +- Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility. +- The headline: a coupling is only painful when strength, distance and volatility are all high at once — reduce any one and the pain drops. +- This is why “just split it into microservices” can backfire: spreading strong, volatile coupling across a network maximises all three dimensions — a distributed monolith. + +p. 128 + +> Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real skill is balancing it. Credit Khononov's Balancing Coupling model and stress that it measures the connection between components, not the components, along three dimensions. The headline sentence is the whole model in one line — pain needs all three of strength, distance and volatility to be high. The bold line is the cautionary tale that resonates with anyone burned by microservices: splitting strongly- and volatile-coupled code across a network doesn't remove the coupling, it just adds distance to it — the distributed monolith. + +## [151] APPENDIX G · COUPLING · Coupling has three dimensions (sldId 406) + +Integration strength × distance × volatility — a coupling only hurts when all three are high. + +p. 129 + +> The model in one picture. Integration strength is how much knowledge two parts share — the more they share, the more likely a change in one forces a change in the other. Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary. Volatility is how often the coupled thing changes. The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile. A strong, distant coupling on something that never changes costs you nothing. That's why all three dimensions have to be considered together rather than chasing “lose coupling” in the abstract. + +## [152] APPENDIX G · COUPLING · The three dimensions, in practice (sldId 407) + +- Integration strength: how much knowledge two parts share — from intrusive (private internals) down to contract (an explicit interface). Less shared knowledge = looser coupling. +- Distance: how far apart they sit — method, class, module, service, system. Greater distance raises the cost of any change that crosses the boundary. +- Volatility: how often the coupled thing changes. Khononov derives it from DDD subdomain type — core is volatile, generic is stable. +- Modularity = strength XOR distance: keep strong coupling local, and make anything distant a weak (contract) coupling. Being both strong and distant is the trap. + +p. 130 + +> Unpack each dimension with a concrete handle. Strength is a ladder (intrusive to contract). Distance is intuitive — the further apart, the more it costs to change across the seam. Volatility is the clever part of the model: you can't know the future, but you can predict change-rate from DDD subdomain type (core changes a lot, generic barely changes), which connects this appendix straight back to Appendix F. The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith. + +## [153] APPENDIX G · COUPLING · Integration strength: a ladder, worst to best (sldId 408) + +From intrusive (shared internals) to contract (a versioned schema) — across distance, aim for the bottom rung. + +p. 131 + +> Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the classic case is two services on one database, where either can break the other invisibly. Functional coupling: they share business logic or rules and must change together for functional reasons. Model coupling: they share a data model or DTOs — a shared library of types. Contract coupling: they integrate only through an explicit, stable, versioned contract — and that's our REST, gRPC, GraphQL and Kafka schemas in the registry. Lower on the ladder means less shared knowledge and looser coupling. The rule that matters: the greater the distance between two components, the further down this ladder you need to be. + +## [154] APPENDIX G · COUPLING · Strength in our system (sldId 409) + +- Intrusive (worst): two services sharing one database, or reaching into another's internals — any change can break the other invisibly. This is the shared-database anti-pattern. +- Functional / Model: sharing business logic or a model — e.g. a shared library of types. Fine within a context; risky across teams because it forces lockstep change. +- Contract (best across distance): integrate only through an explicit, versioned schema — our REST/gRPC/GraphQL APIs and Kafka event schemas, governed by the registry. +- The rule: the greater the distance, the weaker the coupling must be. Across services that means contract coupling — never shared state. + +p. 132 + +> Map the ladder onto decisions in our system. The shared database is intrusive coupling and it's the worst precisely because the integration strength is maximal and invisible — neither side knows what the other depends on. Shared libraries are model/functional coupling: perfectly fine inside one bounded context or team, dangerous when they force two independently-deployed teams to release together. Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance. The bold rule is the actionable takeaway and it's exactly why every cross-service integration in the talk went through a registered contract rather than a shared table. + +## [155] APPENDIX G · COUPLING · Balancing coupling: strength vs. distance (sldId 410) + +Strong or distant, rarely both — and put any unavoidable strong-and-distant coupling where volatility is low. + +p. 133 + +> This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module — perfectly healthy, keep it local. Low strength and distant is healthy microservices — services integrating through contracts. Low strength and close is needless indirection — you've split things that could just be merged. And the danger quadrant, top-right, is high strength at high distance: the distributed monolith, global complexity, where one change ripples across services and teams. The right-hand panel adds the third dimension: even a top-right coupling is tolerable if it never changes, but on volatile core-domain code it's where slow delivery and outages come from. So the formula: balance equals strength-XOR-distance, OR low volatility. + +## [156] APPENDIX G · COUPLING · Distance and volatility (sldId 411) + +- Distance is the cost multiplier: a change contained in one module is cheap; the same change spanning two services and two teams is slow and expensive. +- Volatility is the trigger: a strong, distant coupling on code that never changes is harmless; on volatile core-domain code it's where outages and slow delivery originate. +- Predict volatility from the domain: core subdomains change often — keep their couplings weak and local; generic subdomains are stable, so you can afford more coupling. +- Balance = (strength XOR distance) OR low volatility — put any unavoidable strong-and-distant coupling where the code rarely changes. + +p. 134 + +> Deepen distance and volatility. Distance is why the same logical change can be a one-line edit or a multi-team, multi-deploy project — it multiplies cost. Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt. The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas. The bold formula is Khononov's balance condition — worth writing on a whiteboard in a design review. + +## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldId 412) + +- Every protocol choice we've made is a coupling choice: REST, gRPC, GraphQL and events are all contract coupling — the loosest kind that still lets services collaborate. +- The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration strength on its consumers. +- Connascence (Page-Jones) is a finer lens on the same idea — API versioning is managing connascence of name and position in your schemas (why protobuf field numbers matter). +- Every anti-pattern from section 12 is an imbalance: shared DB = intrusive at distance; chatty sync chains = strong + distant + volatile; leaky models = model coupling across a boundary. + +p. 135 + +> Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing contract coupling because it's the loosest that still works across distance. The registry's compatibility gate is, in this language, a guard against a producer silently increasing integration strength on consumers — that's what a breaking change is. Mention connascence as the finer-grained classical model (Page-Jones) that versioning manages — protobuf field numbers are connascence of position made explicit and stable. The bold line re-diagnoses the anti-patterns section through the coupling model, which is a satisfying way to show these aren't separate lists of rules but one underlying principle. + +## [158] APPENDIX G · COUPLING · Heuristics for balanced coupling (sldId 413) + +- Don't chase zero coupling — chase balance. Strong coupling is fine when it's local and stable. +- As distance grows, drive strength down: across services, integrate only through versioned contracts, never shared state. +- Keep strong, volatile couplings inside a single bounded context or aggregate, where distance is small. +- Use DDD subdomain types to predict volatility, and put your weakest couplings around the parts that change most. + - References: Vlad Khononov — Learning Domain-Driven Design (O'Reilly) and Balancing Coupling in Software Design (Addison-Wesley). + +p. 136 + +> The closing heuristics — the slide to leave up. Don't eliminate coupling, balance it; lower strength as distance rises; keep strong-and-volatile couplings local (inside an aggregate or context); and use subdomain types to predict volatility so your loosest seams are around your most-changing code. These five lines are a usable design-review checklist. Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices. + +## [159] Appendix H · Graceful Shutdown (sldId 414) + +H + +Disposability, kept as a promise. + +> Appendix H. This is the operational flip side of factor 9 (disposability) and the cloud-native pillars: pods are killed and replaced constantly, so a service has to be able to go away cleanly. Ungraceful exits are the source of the 5xx blips users see during otherwise-healthy rolling deploys. The thesis: graceful shutdown isn't one setting, it's a short protocol between your app and Kubernetes — stop new traffic, finish in-flight work, drain consumers and sockets, then exit before the grace period runs out. + +## [160] APPENDIX H · SHUTDOWN · Disposability is a promise you have to keep (sldId 415) + +- Pods are cattle: every deploy, scale-down, autoscale event or reschedule kills and replaces them — often many times a day. +- An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout. +- Graceful shutdown is the flip side of factor 9, disposability: stop taking new work, finish what you accepted, release resources, then exit. +- It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in. + +p. 137 + +> Set up the appendix by connecting it to two earlier ideas: factor 9 (disposability) from the twelve-factor arc, and the resilience pillar. The motivating story is the one every team has lived: a rolling deploy that looks healthy in tests but throws a scatter of 5xx errors in production because pods were torn down mid-request. Stress that this is not about a magic flag — it's a sequence the app and Kubernetes perform together, on a clock. This appendix walks that sequence, then shows the code on both sides of it. + +## [161] APPENDIX H · SHUTDOWN · What happens when Kubernetes stops a pod (sldId 416) + +SIGTERM starts a race; readiness-down plus a short preStop keep new traffic off until you've drained. + +p. 138 + +> Walk the two lanes. Top, Kubernetes: it sends SIGTERM to PID 1, starts the terminationGracePeriodSeconds clock, and will SIGKILL you if you overrun. The catch is the red box — removing the pod from the Service endpoints is asynchronous (kube-proxy, kubelet, the mesh all have to catch up), so for a moment after SIGTERM traffic can still arrive. Bottom, your pod: the response is to flip readiness DOWN immediately (which starts the deregistration) and run a short preStop sleep to cover the lag, then stop accepting, drain in-flight work, stop consumers and close resources, and exit — ideally well before the grace period ends. The red arrows are the whole point: readiness + preStop are what keep new traffic off your dying pod during the race. + +## [162] APPENDIX H · SHUTDOWN · The contract with Kubernetes (sldId 417) + +- SIGTERM is the signal: Kubernetes sends it to PID 1, then waits terminationGracePeriodSeconds (default 30s) before SIGKILL. +- Readiness DOWN is how you stop new traffic — the pod leaves the Service endpoints but keeps running to finish in-flight work. +- There's a race: endpoint removal is asynchronous, so traffic can still arrive briefly after SIGTERM — a short preStop sleep covers the lag. +- Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down. +- Set the grace period larger than your longest acceptable in-flight operation — too short cuts work off, too long stalls deploys. + +p. 139 + +> These five points are the rules of the protocol. The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining). The asynchronous-deregistration race is the subtle one — it's why a preStop sleep exists at all; it's not about doing work, it's about waiting for the rest of the cluster to stop routing to you. And the sizing rule on the grace period is the practical lever: it must exceed your real worst-case in-flight operation, with headroom, or you'll be SIGKILLed mid-request. + +## [163] APPENDIX H · SHUTDOWN · Graceful means something different per workload (sldId 418) + +Only HTTP drains automatically; Kafka consumers, WebSockets and jobs each need their own drain. + +p. 140 + +> The trap is assuming 'graceful shutdown' is one thing. Only HTTP request draining tends to be automatic (the server stops accepting and finishes in-flight). Everything else you wire up yourself in your shutdown hook. Kafka consumers: stop polling, finish the records already in hand, commit offsets, and leave the group so the rebalance is clean. WebSockets (from Appendix C): you can't finish a long-lived connection, so send a close/go-away and rely on client reconnection plus the per-connection seq and backplane. Jobs and schedulers: stop starting new work and either let running jobs finish or checkpoint so they resume after restart. The green footer is the safety net under all of it — because a hard SIGKILL is always possible, idempotent handlers make a redelivered request or message harmless. + +## [164] APPENDIX H · SHUTDOWN · Graceful shutdown in Go (net/http) (sldId 419) + +[chip] go · net/http + +```go +// main.go (excerpt), examples/21-graceful-shutdown/go: one SIGTERM drives the drain +const shutdownTimeout = 20 * time.Second // drain delay + this stays under the 30s grace +func main() { + // ... pgx pool, s := &server{pool: pool}; GET /readyz answers 503 once s.shuttingDown + httpServer := &http.Server{Addr: ":8080", Handler: mux} + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + go httpServer.ListenAndServe() + <-ctx.Done() // 1. the single SIGTERM + stop() // a later signal terminates at once + s.shuttingDown.Store(true) // readiness fails first, the server keeps serving + time.Sleep(drainDelay()) // 2. DRAIN_DELAY_SECONDS (5s): endpoints update + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() // 3. stop accepting, finish in-flight requests + if err := httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("shutdown incomplete", "err", err, "in_flight", s.inFlight.Load()) + } +} // 4. deferred pool.Close(), exit 0 +``` + +One SIGTERM: readiness fails first, the service keeps serving for DRAIN_DELAY_SECONDS, then Server.Shutdown drains within 20 seconds. + +p. 141 + +> Four steps, all started by one SIGTERM, the only signal Kubernetes sends before SIGKILL. (1) signal.NotifyContext turns SIGTERM into a cancelled context; main calls stop() so that a later signal terminates at once, and flips the atomic.Bool behind /readyz, so readiness answers 503 and the Service stops sending new traffic. (2) The service keeps serving for DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) while the endpoint removal propagates. +> +> (3) http.Server.Shutdown stops accepting connections and waits for in-flight requests, bounded by a 20-second context so a stuck request cannot hang the rollout; delay plus drain stays inside the 30-second grace period. (4) The deferred pool.Close() runs and the process exits 0; a service with a franz-go client closes it here, which commits offsets and leaves the consumer group. Pair it with the preStop hook and the grace period on the Kubernetes side. + +## [165] APPENDIX H · SHUTDOWN · The Kubernetes side: probes, preStop, grace period (sldId 420) + +[chip] yaml · Kubernetes + +```yaml +# deployment.yaml — give the pod time to deregister and drain +spec: + terminationGracePeriodSeconds: 30 # >= preStop + drain + buffer + containers: + - name: order-service + readinessProbe: # flips the pod out of the Service + httpGet: { path: /readyz, port: 8080 } + periodSeconds: 5 + failureThreshold: 2 # ~10s to stop routing after DOWN + livenessProbe: # keeps answering during the drain + httpGet: { path: /healthz, port: 8080 } + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] # cover async endpoint removal +``` + +Readiness removes you from the Service; preStop covers the dereg lag; terminationGracePeriodSeconds must exceed your full drain or you get SIGKILLed mid-flight. + +p. 142 + +> The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover. +> +> Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages. +> +> The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number. + +## [166] APPENDIX H · SHUTDOWN · Beyond HTTP: messages, sockets, jobs (sldId 421) + +- Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook. +- Kafka consumers: stop polling, finish the records in hand, commit offsets, and leave the group so the rebalance is clean (ties to Event-Driven). +- WebSockets: send a close / go-away, let clients reconnect to another pod, and lean on the per-connection seq + backplane from Appendix C. +- Jobs and schedulers: don't start new work; let running jobs finish or checkpoint so they resume after restart. +- A hard SIGKILL is always possible — idempotent handlers make a redelivered request or message safe, which is your real safety net. + +p. 143 + +> Reinforce the per-workload point from the diagram with the concrete drain steps. The Kafka sequence is the one to emphasize because it connects to the at-least-once delivery discussion from the Event-Driven section: committing offsets on the way out minimizes redelivery, and leaving the group makes the rebalance fast. WebSockets can't be 'finished,' so graceful there means handing the client off, which is exactly what the Appendix C reconnection design enables. The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare. + +## [167] APPENDIX H · SHUTDOWN · Graceful shutdown checklist (sldId 422) + +- Handle SIGTERM; flip readiness DOWN first so the Service stops routing before you stop accepting. +- Add a short preStop sleep to cover asynchronous endpoint removal; keep liveness UP throughout the drain. +- Drain in-flight HTTP, commit consumer offsets, close sockets and pools — all bounded by terminationGracePeriodSeconds. +- Make the work idempotent so the inevitable occasional SIGKILL is survivable. +- Test it under a real SIGTERM during a rolling deploy — not just a happy-path integration test. + - References: Quarkus lifecycle guide & M. Eisele, “Quarkus Graceful Shutdown” (2026); Go net/http — Server.Shutdown(ctx). + +p. 144 + +> The leave-up summary. Read it as an ordered runbook: readiness first, preStop to cover the race, then the actual drains, all inside the grace budget. The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event. And the bold final point is the one teams skip: graceful shutdown that's never tested under a real SIGTERM during a rollout is just hopeful configuration. Encourage a small script (or a chaos test) that kills a pod mid-request and asserts the client still got an answer — exactly how the referenced Quarkus write-up proves its recipe. + +## [168] Appendix I · L7 Routing & Traffic Management (sldId 423) + +I + +Software-defined, north/south and east/west. + +> Appendix I. This one is large enough to be its own talk — the user explicitly suggested it could be cut later for that. The thesis: L7 routing isn't one box in a rack any more; it's a four-layer stack of software (edge, gateway, mesh, in-app) that makes decisions on every HTTP and gRPC envelope crossing the network, north/south and east/west. We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with net/http + gopher-lua, then circle back to east/west, where most routing decisions happen. + +## [169] APPENDIX I · L7 ROUTING · L7 routing: software, not silicon (sldId 424) + +- The hardware load balancer used to be where routing decisions lived. In a cloud-native system that work has moved into software — programmable, observable, and runnable anywhere. +- L7 means application layer: the router sees the HTTP or gRPC envelope (host, path, method, headers, cookies, gRPC service.method) and can route on any of it. +- It lives in four layers — edge / ingress, API gateway, service mesh, and (sometimes) inside the app itself. Each makes a different type of L7 decision. +- It runs both directions: north/south (clients into your system) and east/west (service to service). Most L7 decisions happen on internal traffic. +- The non-hardware stack is open source and composable: HAProxy, Nginx, Envoy, Istio, Linkerd, Kong, Traefik, Go (net/http), gopher-lua, Open Policy Agent — plus a YAML or two. + +p. 145 + +> Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else. The four-layer mental model (edge, gateway, mesh, in-app) is the backbone of the whole appendix — we'll touch each layer in turn. Stress north/south plus east/west early; people default to thinking about ingress, but inside a microservices system the volume of east/west calls dwarfs north/south, and that's where mTLS, retries and locality routing earn their keep. Name the open-source pieces explicitly so the audience knows the talk is about real, free, runnable software. + +## [170] APPENDIX I · L7 ROUTING · L4 vs L7: what each layer can decide on (sldId 425) + +L4 sees connections; L7 sees the conversation — paths, headers, methods, cookies, gRPC service.method. + +p. 146 + +> Anchor the discussion in the OSI model just enough to be useful. L4 is the layer of IPs, ports and TCP — a classic load balancer can pick a backend by 5-tuple and hash-balance traffic, but it cannot read URLs, headers, cookies, methods or gRPC metadata. L7 sees the full HTTP/gRPC envelope: host, path, headers, cookies, JWT claims, query parameters, methods, and the gRPC service.method pair. That's the entire menu of inputs the routing patterns in this appendix use to make routing decisions. The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower). + +## [171] APPENDIX I · L7 ROUTING · The L7 capability menu (sldId 426) + +- Routing rules: path / host / header / method / cookie / gRPC method → backend. +- TLS termination: decrypt at the edge, re-encrypt (or use mTLS) inward. +- Traffic management: canary, blue/green, A/B, header-based, geo, shadow. +- Payload modification: inject / strip headers, rewrite URLs, transform bodies. +- Resilience: timeouts, retries with backoff + jitter, circuit breakers, outlier ejection — the same toolkit from Appendix E, applied at the network. +- Cross-cutting: authn/authz, rate limiting, WAF, request mirroring, full traces and metrics on every hop. + +p. 147 + +> Read this as the menu of L7 features you can mix and match across layers. The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section. Most of these features used to be hardware appliances; now they're an annotation or a YAML block away. The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification. + +## [172] APPENDIX I · L7 ROUTING · Where L7 lives: the layered stack (sldId 427) + +Four layers, north/south at the top, east/west at the bottom, each making a different type of L7 decision. + +p. 148 + +> This is the backbone diagram for the whole appendix. North/south traffic (clients into the system) enters through the edge/ingress layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API gateway (Kong, Istio gateway, Spring Cloud Gateway), which handles product-level concerns like auth, rate limiting and transformation. Once inside the cluster, east/west traffic is governed by the service mesh (Istio + Envoy sidecars, or Linkerd) — every internal call gets mTLS, retries, outlier detection and locality-aware load balancing. Finally, when L7 decisions need to consider business rules the platform doesn't know about, you push routing into the app itself (net/http + gopher-lua + franz-go). Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them. + +## [173] APPENDIX I · L7 ROUTING · A request's L7 path (sldId 428) + +One HTTP request is a chain of L7 decisions made by four independent components. + +p. 149 + +> Walk a single request left to right. It starts at the client — say a browser or another service. The edge load balancer terminates TLS, does host/path routing and maybe WAF; the API gateway authenticates the caller, applies rate limits and may transform; the mesh sidecar adds mTLS for the cluster's internal hop, applies retries and locality routing; and finally the service can do its own in-app routing if the business rules demand it. Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end. This is the request lifecycle from section 11, viewed through the L7 lens. + +## [174] APPENDIX I · L7 ROUTING · Open-source L7 building blocks (sldId 429) + +- HAProxy: fast, battle-tested L7 LB; ACL-driven config; classic ingress. +- Nginx: ubiquitous web server / LB; location-block routing; widely used as ingress controller. +- Envoy: the modern L7 proxy; HTTP/2, gRPC, dynamic config; the sidecar of choice. +- Istio: a control plane built on Envoy; CRDs for routing, security, observability across the mesh. +- Kong, Traefik, Spring Cloud Gateway: opinionated API gateways with plugin ecosystems. +- Go (net/http) + gopher-lua: when the routing logic is a business rule, push it into the app — embed a Lua ruleset in the service. + +p. 150 + +> Differentiate the pieces so the audience knows when to reach for which. HAProxy and Nginx are the workhorses of the edge — mature, fast, predictable. Envoy is the modern choice for sidecars because it speaks HTTP/2 and gRPC natively and supports dynamic configuration via xDS. Istio (and Linkerd) wrap Envoy in a control plane and give you the mesh CRDs (VirtualService, DestinationRule). Kong, Traefik and Spring Cloud Gateway are API gateways with plugin ecosystems and a more product-aware feature set. The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster. + +## [175] APPENDIX I · L7 ROUTING · Content-based routing with HAProxy (sldId 430) + +[chip] haproxy.cfg + +```haproxy.cfg +frontend http_in + bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 # TLS here; h2 for gRPC + acl is_api path_beg /api/ + acl is_grpc req.hdr(content-type) -m beg application/grpc + acl is_internal req.hdr(x-internal) -m str 1 + acl is_canary hdr(cookie) -m sub canary=on + use_backend grpc_pool if is_grpc # by content-type + use_backend order_v2_pool if is_canary # by cookie + use_backend order_int_pool if is_internal # by header + use_backend order_pool if is_api # by path + default_backend web_pool +backend order_v2_pool + balance roundrobin + server v2a 10.1.0.10:8080 check + server v2b 10.1.0.11:8080 check +``` + +ACLs read the L7 envelope (path, header, cookie, content-type) and use_backend dispatches — classic, fast, predictable. + +p. 151 + +> HAProxy is the lingua franca of L7 LBs. The pattern is always the same: declare ACLs that read parts of the L7 envelope (path_beg for URL prefixes, hdr/req.hdr for headers, content-type for the body type), then use_backend dispatches based on those ACLs in order. This single frontend shows four different content-based routing decisions — by content-type (gRPC traffic goes to a gRPC pool), by cookie (canary users), by header (internal users), by path. Notice TLS terminates at this frontend; everything inside is plaintext within the cluster (or re-encrypted via mTLS at the mesh layer). HAProxy's reload semantics are graceful by default — it can drain old workers while new ones take traffic, which matters for Appendix H's shutdown discipline. + +## [176] APPENDIX I · L7 ROUTING · Content-based routing with Nginx (sldId 431) + +[chip] nginx.conf + +```nginx.conf +# map directive — declarative routing table on a header value +map $http_x_app_version $upstream_pool { + default order_v1; + "v2" order_v2; + "v2beta" order_v2_beta; +} +server { + listen 443 ssl; http2 on; + ssl_certificate /etc/nginx/certs/tls.crt; + ssl_certificate_key /etc/nginx/certs/tls.key; + location /api/orders/ { # path-based routing + proxy_pass http://$upstream_pool; # picked by the map + proxy_set_header X-Request-Id $request_id; + } + location ~ ^/api/grpc/ { # gRPC traffic + grpc_pass grpc://orders_grpc_pool; + } +} +``` + +map turns a header value into an upstream pool name; location blocks dispatch by path and protocol — HTTP and gRPC side by side. + +p. 152 + +> Nginx's content-based routing is built around location blocks and the map directive. The map is the elegant bit: it converts a header value into a variable, which then drives proxy_pass — you express the routing table declaratively, then point a single location at the variable. Location blocks split HTTP from gRPC (grpc_pass) so you can run both protocols through one server. The X-Request-Id injection is a tiny example of payload modification — the L7 router can inject correlation headers so downstream traces can be stitched together. Nginx as an ingress controller in Kubernetes (the ingress-nginx project) is one of the most-deployed L7 routers in the world. + +## [177] APPENDIX I · L7 ROUTING · Content-based routing in the mesh: Istio VirtualService (sldId 432) + +[chip] yaml · Istio VirtualService + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: # gRPC by service.method + - uri: { prefix: /acme.orders.v1.OrderService/ } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by header + - headers: { x-internal: { exact: "1" } } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by cookie + - headers: { cookie: { regex: ".*canary=on.*" } } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] # default +``` + +Match by URI prefix (gRPC service.method), header, or cookie — same L7 inputs, expressed as Kubernetes CRDs. + +p. 153 + +> The same content-based routing primitives, but expressed as Kubernetes CRDs that the mesh control plane (Istio) compiles down to Envoy sidecar configuration. Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie. The default route at the bottom is critical; without it, unmatched requests have nowhere to go. The CRD lives in Git, gets reviewed like any other change, and Istio reconfigures every sidecar in the cluster within seconds. This is the modern face of L7 routing — declarative, GitOps-managed, distributed across thousands of pods. + +## [178] APPENDIX I · L7 ROUTING · SSL / TLS termination at L7 (sldId 433) + +- Edge terminates TLS so the L7 router can read and route on the request — you cannot inspect headers or paths through an encrypted connection. +- Re-encrypt or mTLS inward: terminate at the edge, then either plaintext within the cluster (rare today) or mTLS via the mesh (the modern default). +- Certificate management: cert-manager + Let's Encrypt for public certs; SPIFFE / SDS for service identity inside the mesh; Istio rotates pod certs automatically. +- TLS passthrough (no termination) when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only. +- Cost: TLS handshakes are real CPU. Modern stacks use session resumption, TLS 1.3 and HTTP/2 to amortise the cost across many requests. + +p. 154 + +> Cover the three modes of TLS at the edge: terminate-and-decrypt (the common case, lets you do L7 things), terminate-and-re-encrypt (most secure default in a mesh, since mTLS inside is automatic), and passthrough (when you must not see the payload, e.g. client mTLS for regulated traffic — route by SNI only). cert-manager is the standard cluster-side answer for public certificates; SPIFFE-style identity and Envoy's SDS handle service-to-service certs automatically inside the mesh. Mention the CPU cost briefly so the L7 latency budget makes sense — a TLS handshake is milliseconds, and you want to amortise it via session resumption, TLS 1.3 and HTTP/2 multiplexing. + +## [179] APPENDIX I · L7 ROUTING · Sticky sessions: cookie vs IP-hash vs none (sldId 434) + +Convenient for legacy state, hostile to elastic scaling — prefer stateless behind a JWT. + +p. 155 + +> Three modes worth knowing about. No affinity is the default and what you want — any pod gets any request, load is even, and client state lives somewhere else (Redis, the JWT, the database). Cookie-based stickiness is what you fall back to when something stateful (a shopping cart in memory, a legacy session) has to stay with one pod; the LB sets a cookie on the first response and uses it to route subsequent requests to the same pod. It works behind any L7 LB but ties the user's session to one pod's lifetime — restart that pod, lose the session. IP-hash hashes the source IP to a pod; it needs no cookies but is fragile to NAT and mobile networks where many users share an IP, leading to skewed load. The red footer is the real cost: stickiness fights elastic scaling, hurts pod restarts and breaks scale-to-zero. Use it only for a known constraint, never as a default. + +## [180] APPENDIX I · L7 ROUTING · Sticky sessions in practice (sldId 435) + +- When you need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod. +- Cookie-based is almost always the right choice over IP-hash: it survives NAT and lets clients explicitly drop the cookie. +- What stickiness breaks: even load balancing, fast pod restarts, scale-to-zero, blue/green flips, and a clean Appendix H graceful shutdown for any stateful pod. +- The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable. + +p. 156 + +> Make stickiness a last resort, not a first reach. Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it. The list of things stickiness breaks is the real argument against it: it actively fights the entire cloud-native delivery model — elastic scaling, fast restarts, graceful shutdown. The bold final point is the architectural fix: externalise the state. Redis for session data, JWT claims for user identity, the WebSocket backplane from Appendix C for fan-out. With that done, your LB is back to no-affinity and the platform works the way it wants to. + +## [181] APPENDIX I · L7 ROUTING · Intelligent traffic steering (sldId 436) + +Six familiar shapes — each is a header or weight rule away. + +p. 157 + +> Six patterns that all share the same L7 primitives. Canary: 90/10 weighted routing to a new version, watching metrics and ramping. Blue/green: two identical environments, traffic flipped atomically (and rolled back atomically). A/B test: a stable cohort maps to a variant via a user identifier so the same user always sees the same variant — critical for valid measurement. Header-based: dark launches and internal-only previews by routing on x-internal or similar. Geo / latency: send EU users to EU pods for RTT and data residency. Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load. The point is they're not different things mechanically; they're the same routing rule expressed with different inputs and weights. The difficult part is the deploy discipline around them: alerts that trigger an automatic rollback, gradual ramps, and the metrics that tell you the canary is bad before users do. + +## [182] APPENDIX I · L7 ROUTING · Canary release: weighted Istio routing (sldId 437) + +[chip] yaml · Istio canary + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - route: + - destination: { host: orders, subset: v1 } + weight: 90 # 90% on the stable version + - destination: { host: orders, subset: v2 } + weight: 10 # 10% on the canary +--- +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: orders } +spec: + host: orders + subsets: + - name: v1 + labels: { version: v1 } + - name: v2 + labels: { version: v2 } +``` + +VirtualService splits traffic by weight; DestinationRule defines the subsets by pod labels — the canonical canary release pattern. + +p. 158 + +> The canonical Istio canary. Two CRDs working together: DestinationRule defines subsets by pod label (version=v1 and version=v2), and VirtualService routes by weight to those subsets. Changing the weights in Git and reconciling the cluster ramps the canary — typically through 10%, 25%, 50%, 100% over hours or days, with metric gates between each step. If error rate or latency on v2 crosses a threshold, an automated controller (Flagger, Argo Rollouts) flips the weights back to 100/0 in seconds. The same weighted-routing primitive does shadow traffic, geo balance, and blue/green flips — the only thing that changes is which subsets you target and what the weights are. + +## [183] APPENDIX I · L7 ROUTING · Header-based steering: dark launches (sldId 438) + +[chip] yaml · Istio header-based + +```yaml +# route internal beta users to v2; everyone else stays on v1 +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: + - headers: + x-acme-beta: { exact: "true" } # set by your auth proxy + route: [{ destination: { host: orders, subset: v2 } }] + - match: + - headers: + user-agent: { regex: ".*AcmeInternal.*" } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] +``` + +First match wins; an internal-only header (set upstream by auth) hands real production traffic to v2 — with zero blast radius. + +p. 159 + +> Header-based steering is how you do dark launches and betas safely. An upstream identity proxy sets an x-acme-beta header for opted-in users, and the VirtualService routes those users to v2 — in real production traffic, on real data, but with zero exposure to anyone outside the beta. First match wins, so order the rules from most-specific to least, ending with the catch-all default. This is also how you do internal-only previews (route by an internal user-agent string) and per-tenant feature gates (route by an x-tenant-id). Combined with weighted routing, you can do 'roll the canary forward only for the beta cohort,' which is the most surgical release pattern in the toolbox. + +## [184] APPENDIX I · L7 ROUTING · Payload modifications (sldId 439) + +- Header injection: X-Request-Id, traceparent, X-Forwarded-For, identity claims — propagate context downstream so the trace stitches together. +- Header stripping: drop Authorization or PII headers before forwarding to a less-trusted backend; strip Server / X-Powered-By outbound. +- URL rewrites: /api/v1/orders → /orders internally; clean public URLs over messy service paths. +- Body transformation: rare but possible — protocol translation, redaction, format conversion (Envoy filters, Starlette middleware). +- Cost: body inspection means reading the full request into memory — the slowest L7 thing you can do. Use it sparingly, never on every request. + +p. 160 + +> Distinguish the cheap modifications from the expensive ones. Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers. Body transformation is the expensive one because it forces the router to buffer the full payload, which kills streaming, blows memory on large requests and can become a bottleneck under load. Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate). The bold point is the practical advice: if you can do it in the headers, do it in the headers. + +## [185] APPENDIX I · L7 ROUTING · Header injection and URL rewrites (sldId 440) + +[chip] yaml · Envoy / Istio + +```yaml +# Envoy / Istio: inject identity headers, strip secrets, rewrite paths +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders.acme.io] + http: + - match: [{ uri: { prefix: /api/v1/orders } }] + rewrite: + uri: /orders # external -> internal path + route: [{ destination: { host: orders } }] + headers: + request: + set: + x-client-ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" # caller address + x-peer-identity: "%DOWNSTREAM_PEER_URI_SAN%" + remove: [ authorization, cookie ] # don't leak inward + response: + remove: [ server, x-powered-by ] # don't leak outward +``` + +Rewrite paths, inject the caller address and identity, strip secrets — all declaratively in the VirtualService. + +p. 161 + +> Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders. +> +> The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none. +> +> The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved. + +## [186] APPENDIX I · L7 ROUTING · Beyond the edge: routing inside the app (sldId 441) + +- Sometimes the routing decision is a business rule the platform doesn't know about: route 'high-value-customer claims' here, 'European HL7 messages of type A03' there. +- Pushing those rules into HAProxy ACLs or VirtualService matches does not scale: domain experts own them, they change often, and they depend on the payload, not just the envelope. +- The pattern: an in-app router (a net/http middleware, a custom dispatcher, or a goka processor for streams) calls a rule engine (gopher-lua, Open Policy Agent) and routes on the result. +- It runs inside one service, behind the mesh — not on the network. The mesh still does mTLS, retries and locality; the rule engine just decides where to send the message next. + +p. 162 + +> Pivot from network-layer routing to in-app routing. The threshold is roughly: if the decision can be expressed in a few YAML lines and depends only on the envelope, do it at the mesh. If it depends on the payload, requires a decision table that domain experts edit, or involves complex conditions, push it into the app. The classic example (and the one the code models) is HL7 healthcare messages: route by patient class, encounter type, message subtype — dozens of intersecting conditions a non-developer needs to maintain. That belongs in a rule engine, not a VirtualService. Stress that this isn't an alternative to the mesh; it sits behind the mesh, complementing it. + +## [187] APPENDIX I · L7 ROUTING · Go + gopher-lua: routing by business rules (sldId 442) + +net/http is the HTTP front; gopher-lua (Lua) is the engine; franz-go does the dispatch. + +p. 163 + +> The in-app routing pattern in Go, composed from small pieces. Requests arrive over REST (or WebSocket, or Kafka) at a net/http handler, which gathers any extra context in an enrich step, asks the Lua ruleset for destinations through gopher-lua, and dispatches the result with a franz-go producer or an HTTP client. +> +> gopher-lua is a Lua virtual machine written in Go; the ruleset is a Lua script that domain experts edit. It is the same ruleset the C++ deck loads with sol2. The routing logic lives in the script, separate from the dispatcher, and changes without redeploying the service. + +## [188] APPENDIX I · L7 ROUTING · The rules, externalised: gopher-lua (Lua) (sldId 443) + +[chip] lua · gopher-lua + +```lua +-- orders_rules.lua — the externalised ruleset; Go loads it with gopher-lua +function route(order, ctx) + local dests = {} + if order.amount_cents > 50000 and ctx.tier == "VIP" then + table.insert(dests, "kafka:orders.priority"); table.insert(dests, "http:notify-vip") + end + if order.region == "EU" then table.insert(dests, "kafka:orders.eu") end + if #dests == 0 then table.insert(dests, "kafka:orders.default") end + return dests +end +``` + +Each rule is a small business statement: when X is true, route to Y. Owned and reviewed by domain experts. + +p. 164 + +> Read the rules as when-then statements. route receives the order and the enriched context and returns a list of destinations: high-value VIP orders go to the priority topic and a VIP notification, EU orders also go to the EU topic, and anything else goes to the default topic. +> +> The syntax is not the point. The point is ownership: domain experts, the people who define what VIP or high-value means, own this file and can change it without a developer touching the dispatcher. For larger decision tables, Open Policy Agent (Rego) is the alternative; the shape stays the same. + +## [189] APPENDIX I · L7 ROUTING · The dispatcher, in Go (sldId 444) + +[chip] go · gopher-lua + franz-go + +```go +// dispatcher.go — net/http + franz-go + gopher-lua (loads the same rules.lua) +func (s *Server) route(w http.ResponseWriter, r *http.Request) { + order := parse(r) + rc := enrich(r.Context(), order) // gather context (DB / HTTP) + for _, dest := range s.router.evaluate(r.Context(), order, rc) { // Lua route() + proto, target, _ := strings.Cut(dest, ":") + switch proto { + case "kafka": + s.kafka.Produce(r.Context(), record(target, order), nil) + case "http": + s.http.Post(r.Context(), target, order) + } + } + writeJSON(w, http.StatusOK, map[string]any{"routed_to": "ok"}) +} +// evaluate: a pooled *lua.LState per call (a state is not goroutine-safe), unsafe +// libraries removed, a 100 ms context bound, the default topic on any error +``` + +Enrich → evaluate → dispatch — the same content-based-router pattern the EIP literature describes, in plain Go. + +p. 165 + +> The dispatcher stays small. enrich gathers any extra context the rules need, such as a database lookup or a call to a customer service. evaluate runs route() from the Lua ruleset in a pooled gopher-lua state, because a state is not safe for concurrent use; the sandbox opens only the base, table, string and math libraries, and a 100-millisecond context bound stops a runaway script. +> +> Each destination carries a protocol prefix, kafka: or http:, so one ruleset drives several transports. For event-shaped flows, put the same evaluate call in a franz-go consumer loop instead of the HTTP handler; the ruleset and the dispatch stay the same. + +## [190] APPENDIX I · L7 ROUTING · Every L7 hop costs time (sldId 445) + +Typical p50/p99 added per stage — the budget you spend before your service even runs. + +p. 166 + +> Set expectations with the numbers. Each L7 capability has a typical cost: TLS termination is sub-2ms, path/header routing is sub-1ms, auth/JWT validation is a few ms, WAF a few more, mesh sidecar another millisecond per hop, rate-limit Redis lookup is a couple of ms. Body inspection is the outlier — it can be 5 to 50 ms depending on size, which is why we say to use it sparingly. The numbers are typical, not authoritative; profile your own stack. The right-side card has the working heuristic: stay under ~10ms total L7 on hot paths. With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency. The retry-storm discipline from Appendix E applies here too — timeouts must account for the total L7 budget plus the service's own work, or retries will pile up. + +## [191] APPENDIX I · L7 ROUTING · L7 pitfalls and how to avoid them (sldId 446) + +- Too many hops: edge → gateway → ingress → mesh sidecar in → mesh sidecar out adds up fast. Collapse layers that aren't earning their place. +- Regex-heavy ACLs / matches: each regex evaluation is real CPU. Prefer exact / prefix matches; use regex only when needed. +- Sticky sessions break elasticity without any error: the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone. +- Body inspection on every request: blows memory, kills streaming. If you must transform bodies, do it on a small percentage or just at the edge. +- Sync auth lookups on the hot path: cache validated tokens, validate JWT signatures locally, never do a per-request DB lookup for identity. +- Misaligned timeouts and retries between layers — a 30s gateway timeout + 3 mesh retries with 10s each = a retry storm at the bottom (see Appendix E). + +p. 167 + +> Run quickly through the failure modes. The hop-count one is the most common: every layer feels free until you measure p99 — then you realise the request crossed seven proxies. The regex point is a perf footgun many people miss. The sticky-session one ties back to the sticky-sessions comparison. Body inspection is the latency-budget killer. The auth-lookup one is the most common cause of cascading slowdowns under load. And the timeout-misalignment is the gateway-to-mesh-to-service version of the retry storm we covered in Appendix E — the lesson is identical: bound retries, align timeouts top-down, give the deepest layer the strictest budget. + +## [192] APPENDIX I · L7 ROUTING · East / West: L7 between services, inside the mesh (sldId 447) + +Every service-to-service call is L7-routable through the sidecars. + +p. 168 + +> Most L7 decisions in a microservices system don't happen at the edge — they happen east/west, between services, every time service A calls service B. The mesh's sidecar pair handles that traffic: mTLS between services (encryption and identity), HTTP/2 multiplexing, native gRPC support, retries with backoff and jitter, outlier detection (eject misbehaving pods automatically), locality-aware load balancing (prefer same-zone pods to keep RTT low), header-based routing and canary weights for east/west versions, plus rich per-call traces and metrics. The headline is the second box: all of that, per call, with no app code. Application services keep talking plain HTTP or gRPC; the sidecars make it secure, reliable and observable in flight. This is the modern answer to 'how do you build a microservices system that works in production.' + +## [193] APPENDIX I · L7 ROUTING · Why east/west L7 matters (sldId 448) + +- Internal traffic dominates: in a microservices system the volume of east/west calls dwarfs north/south by 10x or more. +- Security: mTLS everywhere by default — every internal call has an identity, encryption and an audit trail without per-service code. +- Resilience: timeouts, retries, circuit breakers and outlier ejection live in the sidecar, applied uniformly to every internal hop. +- Observability: every internal call gets a span, latency histogram and per-route success rate — the request lifecycle from section 11, populated automatically. +- Locality: prefer same-zone or same-region pods so internal RTT stays in single-digit milliseconds, and graceful failover when a zone goes. +- Routing decisions: header-based canaries and weighted releases work east/west too — you can canary a backend service that has no external traffic at all. + +p. 169 + +> Hammer the volume point first: most production L7 routing happens between services, not at the edge. The security argument is the one that justifies the mesh on its own — mTLS everywhere is otherwise hard to retrofit. The resilience and observability arguments are the operational ones; you stop writing the same boilerplate in every service. Locality is the perf argument: with the right Istio config, two pods in the same zone talk over a few milliseconds; cross-region traffic gets degraded gracefully. And the last point is non-obvious but important: canary releases of a deeply internal service (say, a payments calculation engine that no client touches directly) are possible because the mesh can split east/west traffic by weight — you cannot canary internal services through an edge LB. + +## [194] APPENDIX I · L7 ROUTING · Istio for east/west: DestinationRule + VirtualService (sldId 449) + +[chip] yaml · Istio east/west + +```yaml +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: payments } +spec: + host: payments.acme.svc.cluster.local + trafficPolicy: + # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10 + outlierDetection: # eject bad pods + consecutive5xxErrors: 5 + interval: 30s + baseEjectionTime: 30s + # ... loadBalancer: localityLbSetting enabled (prefer same zone) + subsets: + - { name: v1, labels: { version: v1 } } + - { name: v2, labels: { version: v2 } } +--- +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: payments } +spec: + hosts: [payments.acme.svc.cluster.local] # internal DNS — east/west + http: + - route: + - { destination: { host: payments, subset: v1 }, weight: 95 } + - { destination: { host: payments, subset: v2 }, weight: 5 } +``` + +Same CRDs as north/south, but pointed at an internal host — outlier detection, locality LB, canary weights, all between services with no app code. + +p. 170 + +> Same Istio primitives as the weighted canary, but applied to an internal service (note the cluster.local host). DestinationRule sets up the connection pool (HTTP/2 multiplexing for gRPC), enables outlier detection (5 consecutive 5xxs in 30s ejects the pod), enables locality-aware load balancing (same-zone preferred), and defines version subsets. VirtualService weights east/west traffic 95/5 between v1 and v2 — a canary release of a backend service that no external client knows exists. This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars. + +## [195] APPENDIX I · L7 ROUTING · gRPC east/west specifics (sldId 450) + +- gRPC is HTTP/2 under the hood, so it works natively in any L7 router that speaks HTTP/2 (Envoy, Istio, modern HAProxy/Nginx). +- Route by gRPC service.method: the URI path is /package.Service/Method — use a uri prefix match in Istio, grpc_pass in Nginx, or path_beg in HAProxy. +- Header-based balancing matters more for gRPC: connections are long-lived and multiplexed, so least-request or per-request load balancing beats round-robin. +- Streaming RPCs (server-streaming, client-streaming, bidi): retries are dangerous if the stream is non-idempotent — cap retries to UNARY calls in the mesh policy. +- Trailers carry status: a 200 + a non-OK trailer is the gRPC equivalent of a 5xx — outlier detection has to be configured to read trailers, not just the status line. + +p. 171 + +> gRPC has a few sharp edges of its own. Route on the gRPC URI prefix to do per-method canaries. Use least-request (or random-3-choices) load balancing instead of round-robin because gRPC connections are long-lived — a few long-running streams can starve other backends if you round-robin connections instead of requests. Be careful with retries on streaming RPCs: the mesh can retry a UNARY call safely if it's idempotent, but retrying a half-completed streaming RPC will replay half the work — Istio lets you scope retries to specific gRPC status codes and request kinds. And the trailer point is subtle: gRPC returns its real status in HTTP trailers (a 200 with a non-OK gRPC status is a failure), so outlier detection has to know to read those, or it will think a failing service is healthy. + +## [196] APPENDIX I · L7 ROUTING · When to use which L7 layer (sldId 451) + +- Edge / ingress: TLS termination, host/path routing, WAF, public-facing rate limits. Anything north/south that affects all clients. +- API gateway: cross-cutting product policy — auth, per-consumer rate limits, request transformation, monetisation, key management. +- Service mesh: east/west routing, mTLS, retries, outlier detection, canaries between internal services. The default home for service-to-service L7. +- In-app router (Go + gopher-lua): when routing depends on business rules, payload content, or decisions that domain experts must own. +- Rule of thumb: prefer the lowest layer that can fully express the decision, and never push business rules into mesh CRDs. + +p. 172 + +> This is the decision guide to leave on screen for a beat. The mental model: each layer has a job it's good at, and pushing work to the wrong layer is where complexity comes from. Edge/ingress for the things every client crosses. API gateway for product-shaped policy. Mesh for east/west and everything internal. In-app for business rules. The bold rule of thumb prevents the common antipattern of growing increasingly baroque Istio VirtualService configs to encode rules that domain experts need to maintain — if it's a business rule, it belongs in a rule engine inside a service, not in a network CRD. + +## [197] APPENDIX I · L7 ROUTING · L7 routing checklist (sldId 452) + +- Pick one ingress, one gateway, one mesh — don't run three competing L7 layers because each team had a favourite. +- TLS terminates at the edge; mTLS between services in the mesh; cert rotation is automatic (cert-manager + SDS), not a quarterly outage. +- Routing rules live in Git, reviewed like code; GitOps reconciles them; rollback is a git revert. +- Every L7 layer emits traces and metrics — sample the spans, alert on per-route SLOs, watch for retry budget exhaustion. +- Stickiness is an exception, not a default; business rules belong in a rule engine inside a service, not in network YAML. +- Test under load and under failure: bring a pod down, eject a zone, ramp a canary, verify the mesh does what the YAML says. + - References: HAProxy / Nginx / Envoy docs; Istio traffic management guide; gopher-lua; Open Policy Agent (OPA) and Rego docs; Go net/http middleware docs. + +p. 173 + +> Leave the checklist up at the end. The first point is the most common organisational failure — a cluster ends up running three ingress controllers because three teams each picked one. Pick one and standardise. The cert-rotation point is the one teams underestimate — expired certs are still a top-3 outage cause in many shops. GitOps for routing is the operational discipline that makes the whole thing reviewable. Observability and the load/failure-test point are non-negotiable. The references sub-bullet is the reading list: the gopher-lua project on PyPI, Open Policy Agent (OPA) docs for declarative policy, and the net/http and net/http middleware guides. + +## [198] Appendix K · Monolith to Microservices (sldId 453) + +K + +Patterns that survive the migration with the system still running. + +> Appendix K covers the migration patterns. The thesis: you don't decompose a monolith by stopping it and rewriting; you decompose it incrementally, one bounded asset at a time, with the system serving users the whole way through. We'll walk through the monolith as the starting point, modular monoliths as a frequently-correct destination, the strangler-fig family of patterns for progressive extraction, content-based routing for fine-grained cutover, and the decorating collaborator pattern for wrapping legacy code you can't touch. Code in this appendix is net/http + net/http, but the patterns are language-neutral. + +## [199] APPENDIX K · MONOLITH · A monolith is a unit of deployment (sldId 454) + +One process, one binary, one heartbeat. All modules live or die together. Often the right starting point — don't be ashamed of it. + +p. 174 + +> Define the term before fixing it. A monolith is not a code-organisation problem — it's a deployment-organisation property. Even a well-organised codebase with clear modules, layers, and tests is a monolith if it ships as one binary on one heartbeat. The defining property is on the slide: change anywhere, redeploy everything; bug in Reporting can take down Payments; one scaling unit for workloads that scale differently. But the second italic line is what to leave audience with: monoliths are not always wrong. For new products, small teams, or anything that fits in one team's head, they're often the simplest and fastest path to value. Premature decomposition is its own anti-pattern. The conversation is: when does this monolith start hurting more than it helps? — and that's when these patterns earn their keep. + +## [200] APPENDIX K · MODULAR MONOLITH · The modular monolith — often the right destination (sldId 455) + +Still one deployable, but with strict in-process module boundaries enforced by build tooling — not by gentlemen's agreement. + +p. 175 + +> The intermediate pattern that's often the right destination, not a stepping stone. Modules within one process have clear public APIs (PlaceOrder, ReserveStock, ChargeCard) and private internals that other modules cannot reach. Enforcement is the key bit: in Python, this is import-linter or modulegraph rules in CI that fail the build if Orders.internals is imported outside the orders package; in larger codebases you might split into separate sub-packages or even separate Go modules, each with its own go.mod. Modules can share a database, but each owns a schema and no cross-schema joins are allowed (also enforced in CI by query analysis or just convention). Why this is often the destination: you get most of the benefits of microservices (clear boundaries, replaceable modules, independent reasoning) at a fraction of the operational cost (one deployment, one log stream, one debugger session). If you need to split later for different scaling profiles, teams or release cadences, you have a small refactor, not a rewrite. The cost of being wrong about decomposition is also much lower: rename the public API, move the file, ship. + +## [201] APPENDIX K · DECOMPOSED DBS · Modular monolith with decomposed databases (sldId 456) + +Data ownership becomes per-module — the hardest part of the split, done while still one process. The next step is mechanical. + +p. 176 + +> Splitting the database is the hard part of decomposition: schema dependencies, cross-table joins, and reporting queries all create coupling that stays invisible until you try to move data. The insight of this pattern: do that work while you're still one process. Each module gets its own Postgres / Mongo / whatever, its own connection pool, its own migration history. Cross-module data is now async or event-driven — you replace a JOIN with a service call or an event subscription. Same discipline microservices require, but with one operational target instead of N. Once this is done, promoting a module to a separate service is a refactor of the deployment topology, not a data-migration project. (Many teams discover at this point that they don't need separate services: the modular monolith with decomposed data is enough.) Practical tip: start with the highest-coupling module first; the second hardest is the database that the reporting layer joins across, so do that one early. + +## [202] APPENDIX K · STRANGLER FIG · Identify → move → redirect (sldId 457) + +Named after the strangler fig vine: grow the new system around the old until the old can be removed. + +p. 177 + +> The Martin Fowler strangler-fig pattern, named for the vine that grows around its host tree until the host is gone and the vine stands on its own. The three steps are the unit of work you repeat. Identify: pick a bounded asset — not 'the orders subsystem,' but 'order creation' or 'order status query' specifically. The smaller and more bounded, the safer the move. Move: build the new service alongside the monolith, replicate the behaviour, test it in parallel without production traffic. REDIRECT: switch callers to the new service. Each cycle is independently reversible — if the new service is wrong, redirect back and try again. There are three ways to do the redirect: via a proxy, via a proxy plus partial routing, or via a shared database. The proxy is the foundation for the rest; almost everything starts with that step. + +## [203] APPENDIX K · STRANGLER FIG · With a proxy — step zero of every migration (sldId 458) + +Introduce the routing layer first, before extracting anything. Then redirection becomes a config change, not a release. + +p. 178 + +> The pre-condition for incremental migration: clients hit the proxy, the proxy forwards to the monolith. Day zero, behaviour is unchanged. But you now have the lever — the proxy is where you'll later route 1% of /orders/* traffic to the extracted service, then 5%, then 100%. Without this step, every cutover requires coordinated client releases. With it, every cutover is a proxy config change. As a bonus, the proxy is also the right place for TLS termination, auth, rate-limiting, and observability — things you wanted regardless. For Python shops this is typically nginx or Envoy as a Kubernetes Service ahead of the app Pods; you could also use Traefik or HAProxy. The strangler-fig literature calls this an 'event interception' — you intercept the request before it gets to the legacy system, giving yourself a place to redirect later. + +## [204] APPENDIX K · STRANGLER FIG · With proxy + redirection (sldId 459) + +The proxy splits traffic between the new service and the shrinking monolith. Roll forward by widening the route; roll back by narrowing. + +p. 179 + +> Now the proxy does the routing work: by path (/orders/* → new service), by header (X-Tenant: acme → new service), by percentage (1% of all traffic → new service), or by feature flag (users in the beta group → new service). The new service handles its slice; the monolith handles everything else. Clients still see one URL, one auth boundary, one TLS cert. Roll forward by widening the new-service route in the proxy config; roll back by narrowing it. The new service can fail completely and the monolith picks up — the bounded blast radius is the entire point of this pattern. Production observability matters here: instrument both paths so you can verify the new service is producing the same outcomes as the monolith before you widen the route. For Python deployments this is typically encoded in nginx server blocks, Envoy route configs, or Istio VirtualService rules — same patterns from Appendix I, used for migration instead of canary release. + +## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) + +Process boundary first, data boundary later. Useful intermediate state, not a destination. Schema coupling will bite eventually. + +p. 180 + +> Sometimes the data split is too risky to do at the same time as the process split. In that case you let the new service and the monolith share the database for a while. Both apps read and write the same tables; the new service owns the API but not the data. This pattern is useful for two cases: extracting reads first (the new service is a read replica with a nicer API), and as a stepping stone when the data migration is a separate project. But this is not a destination: every schema change now needs both apps to agree, and schema migrations become a coordination tax that grows with every team that owns part of the same database. The bold red caption is the rule: this is a transition state. The next step is to give the new service its own store, which means CDC out of the monolith's DB into the new service's DB, or dual-writing during a brief sync window, or event-sourcing the data into the new service. + +## [206] APPENDIX K · ROUTING · Content-based routing during migration (sldId 461) + +[chip] go · net/http + +```go +// content_router — route by request content during cutover (net/http middleware) +func contentRouter(next http.Handler) http.Handler { + client := &http.Client{Timeout: 5 * time.Second} // reused: warm connection pool + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/orders") { next.ServeHTTP(w, r); return } + body, _ := io.ReadAll(r.Body) + var p struct{ Tenant, Region string } + _ = json.Unmarshal(body, &p) // decide by content, not URL + upstream := monolith // default: legacy + switch { + case p.Tenant == "acme": + upstream = newSvc // cut Acme over + case p.Region == "EU": + upstream = euSvc // regional service + } + forward(w, r, client, upstream, body) // hand the consumed bytes through + }) +} +``` + +Reads the body, decides by content (tenant / region / feature flag), forwards to monolith or new service. + +p. 181 + +> The middleware shape. Reading the body costs CPU on every routed request, and once it is consumed you must hand the bytes through to the upstream, which is what forward does. The decision is by payload content: tenant, region, feature flag, user segment, not the URL, which stays the same. +> +> That is what makes the pattern stronger than path-based proxying: move one tenant at a time to the new service, watch it under live traffic, then widen. The http.Client is created once and reused, so the connection pool stays warm. Behind Envoy or Istio you would express the same logic as a Lua or Wasm filter; the middleware shows the shape. The body parse is the cost: negligible for a migration of days, worth measuring for one of months. + +## [207] APPENDIX K · DECORATOR · The decorating collaborator (sldId 462) + +The new service wraps the legacy one, adds capability, then becomes the API. Useful when you can't modify the legacy code. + +p. 182 + +> Different shape from the proxy patterns: the decorator owns behaviour, not just routing. The new service sits in front of the legacy API and adds cross-cutting capability: caching, audit, validation, enrichment, fan-out to Kafka, rate-limiting, metrics. The legacy service is unaware it has been wrapped. Clients now talk to the decorator's API, which delegates to the legacy service for the underlying work but contributes everything else. Over time, the decorator can take ownership of more behaviour — once the decorator owns enough, the legacy service can be replaced without clients noticing. This is particularly useful when the legacy system is a 3rd-party product, a vendor system, or a piece of code so fragile that touching it is forbidden. The decorator is a real microservice (with its own data, its own deploy, its own scaling) — not just a router. A common Go implementation: a net/http service that proxies to a SOAP or legacy REST service while emitting Kafka events and caching responses. + +## [208] APPENDIX K · DECORATOR · A decorating collaborator in Go (sldId 463) + +[chip] go · net/http + go-redis + franz-go + +```go +// wraps a legacy /orders API and adds caching + event emission. Legacy unchanged. +func getOrder(w http.ResponseWriter, r *http.Request) error { + oid := r.PathValue("oid") + if b, err := cache.Get(r.Context(), "order:"+oid).Bytes(); err == nil { + return writeRaw(w, b) // 1. cache hit + } + body, err := legacy.Get(r.Context(), "/orders/"+oid) // 2. legacy call + if err != nil { return err } + cache.Set(r.Context(), "order:"+oid, body, 60*time.Second) + return writeRaw(w, body) +} +func placeOrder(w http.ResponseWriter, r *http.Request) error { + body, err := legacy.Post(r.Context(), "/orders", r.Body) // 1. legacy first + if err != nil { return err } + cl.Produce(r.Context(), &kgo.Record{Topic: "order.placed", Value: body}, nil) // 2. emit + cache.Del(r.Context(), "order:"+orderID(body)) // 3. invalidate + return writeRaw(w, body) +} +``` + +The decorator adds caching, event emission and (if you wire it up) auditing — capabilities the legacy service can't add on its own. + +p. 183 + +> Tight, complete. The decorator is a real net/http app. The GET path adds a Redis cache with a 60-second TTL — a capability the legacy service didn't have. The POST path delegates the actual write to the legacy service (it's still the source of truth), then emits a Kafka event so downstream systems can subscribe to the new event stream, then invalidates the cache. The legacy service has no idea it's been wrapped; clients have no idea they're talking to a decorator. Over time you can move more behaviour into the decorator: validation rules, idempotency keys, dedup, multi-source reads. When you're ready to retire the legacy service, you implement the order storage inside the decorator (or in a clean new service it calls) and stop forwarding — the API stays the same for clients. This is also the right shape for adding capabilities your vendor can't provide, such as PII redaction, retention policies or multi-region failover, to a system you can't modify. The cost is one extra hop in the request path; the benefit is owning behaviour you couldn't own before. + +## [209] APPENDIX K · DECISIONS · Choosing the right migration pattern (sldId 464) + +- Modular monolith first, microservices later — the cheapest decomposition is often inside one process. Many systems never need to leave this state. +- Decompose the database before decomposing the deployment. The data split is the hard part; doing it in one process is much cheaper than coordinating across services. +- Always introduce the proxy first — the routing layer is what makes incremental cutover possible. No proxy = no incremental migration. +- Strangler with shared database is a transition state. Plan its end before you start it. Schema coupling is debt that accrues. +- Content-based routing when you need to cut over by tenant / region / segment instead of by URL. Costs CPU at the proxy; buys precise risk control. +- Decorating collaborator when you need to add behaviour to a system you can't modify. Most useful for vendor / 3rd-party / legacy-mainframe wrappers. +- Reversibility is the design property to optimise for. Every migration step should be undo-able by a config change, not a release. + +p. 184 + +> Close the appendix with the decision rules. The first point is the most important and most often skipped: a modular monolith is frequently a fine destination. Microservices have real operational cost, and not every system warrants paying it. The second point is the most underrated: the database split is where projects get stuck for years — do it early, while still one process, when it's cheaper to redo if you got the boundaries wrong. The third point is the platform discipline: no proxy, no incremental migration; every cutover is a coordinated client release; every rollback is the same. The fourth point is the schedule discipline: shared-database strangler is OK as a means but never as an end — if you don't plan its dissolution, it becomes the new monolith. The fifth and sixth are about choosing the right pattern: content-based routing for fine-grained progress, decorator for capability addition. The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release. Migrations fail when they reach an irreversible step too early. + +## [210] Appendix L · Caching patterns (sldId 465) + +L + +Six patterns for keeping a cache useful and your data correct. + +> Appendix L is the caching reference. Five reasons to cache, then six patterns to do it well. The patterns differ in who reads/writes the cache, when, and who owns the consistency story. We use Redis as the canonical cache because it's the default open-source choice on Kubernetes — same patterns apply to in-memory caches, Memcached, or any KV store. CDN and HTTP-layer caching is mentioned in passing but isn't this appendix's focus. + +## [211] APPENDIX L · WHY CACHE · Five things a cache earns you (sldId 466) + +- Application performance — a cache hit is microseconds; a database round trip is milliseconds. The hot path becomes ~100x faster on hits. +- Backend load — caching shields the database from repeated reads of unchanged data. Read-heavy workloads can offload 80-95% of queries with a sensible TTL. +- Throughput — the same Pod fleet serves dramatically more requests because it spends less time waiting on the DB. Vertical scaling without buying anything. +- Predictable performance — p99 latency stops being a function of DB load. Tail latencies flatten because the hot path doesn't contend on the same lock-bound resource as everyone else. +- Database cost — fewer queries means smaller (or fewer) DB instances, smaller IOPS budgets, less replication bandwidth. Cache is almost always cheaper than the DB tier it offloads. + - Trade-off: every cache adds a consistency story, an invalidation story, and a failure-mode story. Pick the pattern that matches the consistency you need from the six patterns in this appendix. + +p. 185 + +> Five concrete reasons that justify the caching investment. Start with application performance — the user-facing speedup is the visible win. Backend load matters more in steady state: shielding the database is what lets your DB tier stay smaller and cheaper than your app tier. Throughput follows from both — the same Pods serve more requests when they're not blocked on I/O. Predictable performance is the operational win that is hard to measure but real: a cache layer flattens p99 because most requests don't contend for DB locks. Database cost is the financial win that often pays for the entire caching infrastructure. The sub-bullet at the end is what trips teams up: every cache adds complexity. This appendix covers the six standard patterns; pick the one that matches the consistency story you need, not the one that sounds best. + +## [212] APPENDIX L · CACHE-ASIDE · Cache-aside (look-aside) — the default (sldId 467) + +The application reads cache, falls back to DB, populates cache. Explicit and simple; the most common pattern. + +p. 186 + +> The pattern everyone starts with. The application is in charge: try the cache first, on a miss read the database, populate the cache on the way back, return the value. Every other pattern in this appendix differs from this one in who does the work — cache-aside is the baseline where the app does it all. Pros: explicit, debuggable, no magic. The cache is just a key-value store; the app decides what to cache and when. Cons: every read has the if/else; cache miss costs two round trips (cache check + DB read); cache logic is repeated across services if you don't extract it. The TTL is the consistency knob — longer = fewer DB hits, more stale-read risk. Use this when you want explicit control and your reads dominate writes. The cache-aside code is two functions; the discipline is to remember to invalidate on writes (covered in write-through and write-around). + +## [213] APPENDIX L · CACHE-ASIDE · Cache-aside in Go with Redis (sldId 468) + +[chip] go · go-redis + pgx + +```go +const ttl = 60 * time.Second +func getProduct(w http.ResponseWriter, r *http.Request) error { + key := "product:" + r.PathValue("pid") + if b, err := cache.Get(r.Context(), key).Bytes(); err == nil { + return writeRaw(w, b) // 1. cache hit + } + var p Product // 2. DB on miss + err := db.QueryRow(r.Context(), + "SELECT id, name, price_cents FROM products WHERE id=$1", + r.PathValue("pid")).Scan(&p.ID, &p.Name, &p.PriceCents) + if errors.Is(err, pgx.ErrNoRows) { return notFound() } + if err != nil { return err } + b, _ := json.Marshal(p) + cache.Set(r.Context(), key, b, ttl) // 3. populate + return writeRaw(w, b) +} +// PUT handler: UPDATE the DB, then cache.Del(key) — invalidate on write +``` + +Try cache; on miss, hit DB and populate. Invalidate on write. Three operations — the rest is conventions about TTL and naming. + +p. 187 + +> The minimal cache-aside implementation. Three things to point out. First, the connection pool: a go-redis client is a pool, created once and shared by every request; never create a client per request. Second, the key shape: product:{pid} is a namespace convention; entity:type:id lets you SCAN and delete by pattern for a mass invalidation. +> +> Third, invalidate on write: without the delete in the PUT handler, the cache serves stale data for the full TTL. The choice is invalidate (this code) or set the new value (write-through). Treat a Redis error on Get as a miss, so a Redis outage degrades to the database instead of failing reads, and log it. For high-cardinality keys, set a maxmemory policy (allkeys-lru is typical) so the cache cannot grow without bound. + +## [214] APPENDIX L · READ-THROUGH · Read-through — the cache fetches on miss (sldId 469) + +Application sees one interface; the cache library handles DB fallback transparently. + +p. 188 + +> The variation where the cache library, not the application, calls the database on a miss. The app calls cache.get() and gets the value back — doesn't know or care whether the cache had it or had to fetch. Implementation requires a cache library that supports loaders (typically a callback you register at cache creation time). Pros: cleaner application code, single read interface. Cons: less control over what triggers a DB hit; the cache library must be told how to read the underlying source. For Go with Redis specifically, go-redis gives you no loader out of the box — you wrap it. With generics a small ReadThrough[T] wrapper is usually the cleanest option; eko/gocache and jellydator/ttlcache offer higher-level abstractions if you prefer a dependency. Use this pattern when read paths are repetitive across the codebase and you want to eliminate the if/else from every caller. + +## [215] APPENDIX L · READ-THROUGH · Read-through in Go: a generic loader wrapper (sldId 470) + +[chip] go · go-redis (generics) + +```go +// generic read-through: callers see one Get(); the miss path is invisible +type ReadThrough[T any] struct { + rdb *redis.Client + loader func(context.Context, string) (T, error) // called on cache miss + ttl time.Duration + prefix string +} +func (rt ReadThrough[T]) Get(ctx context.Context, id string) (T, error) { + var v T + key := rt.prefix + id + if b, err := rt.rdb.Get(ctx, key).Bytes(); err == nil { + return v, json.Unmarshal(b, &v) // hit + } + v, err := rt.loader(ctx, id) // miss → loader fetches from DB + if err != nil { return v, err } + b, _ := json.Marshal(v) + rt.rdb.Set(ctx, key, b, rt.ttl) // populate + return v, nil +} +// wire once: products := ReadThrough[Product]{rdb, loadProduct, 60*time.Second, "product:"} +``` + +The loader is registered once; every caller sees one Get method. The cache miss path is invisible. + +p. 189 + +> The wrapper is small but the discipline is significant. Callers can't accidentally bypass the cache or forget to populate it — they call products.Get(ctx, pid) and get the right behaviour. Different entity types get different ReadThrough instances with different loaders and TTLs. For libraries that give you this out of the box: eko/gocache offers a chained-cache abstraction and jellydator/ttlcache gives generic in-memory TTL caches — but with generics the hand-rolled wrapper on this slide is often clearer than a dependency. The trade vs cache-aside: you trade a few lines of if/else per caller for a wrapper layer that callers must use. In a large codebase that's worth it; in a small one it's overengineering. Note: a loader-style cache makes thundering-herd protection easier — add a Redis lock in the loader so only one caller does the DB fetch when many callers miss the same key at once (the 'cache stampede' problem). + +## [216] APPENDIX L · WRITE-THROUGH · Write-through — cache and DB written synchronously (sldId 471) + +Every write hits both the cache and the DB. Cache is never stale; writes pay both latencies. + +p. 190 + +> When stale reads are not acceptable, write-through forces every write to update both stores atomically (or as close to atomically as you can manage). The application calls cache.set(...) and db.update(...) and both must succeed before the response returns. Pros: a read immediately after a write sees the new value; the cache is never stale for the writer's view. Cons: write latency is the sum of both stores; you have to handle the case where the cache write succeeds but the DB write fails (or vice versa) — there's no two-phase commit in this world. The usual discipline: write the DB first, write the cache second, accept that a failure between them means the cache may briefly hold the new value while the DB doesn't (or vice versa, depending on your order). Pair with a short TTL so any inconsistency self-heals. + +## [217] APPENDIX L · WRITE-THROUGH · Write-through in Go (sldId 472) + +[chip] go · go-redis + pgx + +```go +func updateProduct(ctx context.Context, p Product) error { + // 1. write the DB first — it's the source of truth + if _, err := db.Exec(ctx, + "UPDATE products SET name=$1, price_cents=$2 WHERE id=$3", + p.Name, p.PriceCents, p.ID); err != nil { + return err + } + // 2. write the cache second — set, don't invalidate (read back the canonical row) + var c Product + if err := db.QueryRow(ctx, "SELECT * FROM products WHERE id=$1", p.ID). + Scan(&c.ID, &c.Name, &c.PriceCents); err != nil { + return err + } + b, _ := json.Marshal(c) + return cache.Set(ctx, "product:"+p.ID, b, ttl).Err() +} +// Faster: cache the request body directly — but it may diverge from the DB if +// there are server-side defaults, triggers, or computed columns. +``` + +DB first (source of truth), cache second (read-back the canonical row, or trust the body for speed). + +p. 191 + +> Two flavours of write-through. The first reads back the row from the DB after update and caches that row — the canonical value, including any server-side defaults, triggers, or computed columns. The second writes the request body directly to the cache, which is faster (one less DB round trip) but the cached value may diverge from the DB if the DB applies server-side modifications you didn't replicate. Pick by your DB schema's complexity. Either way, the DB write goes first because the DB is the source of truth — if the cache write fails, you've lost cache freshness but not data. If the DB write fails, you return an error and don't touch the cache. The TTL is your safety net: even if cache and DB diverge briefly, they'll re-converge when the cache entry expires and the next read re-populates from the DB. + +## [218] APPENDIX L · WRITE-AROUND · Write-around — writes skip the cache (sldId 473) + +Writes go to the DB only. The next read populates the cache lazily. Good for write-heavy, low-re-read workloads. + +p. 192 + +> When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful: most cached entries expire before being read again. Write-around solves this: write the DB, leave the cache alone. The next reader pays the cache-miss cost and populates the cache. Pros: no cache pollution by write-only data; the cache holds only what is being read. Cons: the first read after every write is slow (cache miss); reads of recently-written data are slower than under cache-aside. Pair with cache-aside or read-through on the read side. Common use: any 'append-only' or 'mostly-written' workload — events, log lines, audit entries, IoT telemetry where you read summaries, not individual entries. + +## [219] APPENDIX L · WRITE-AROUND · Write-around in Go (sldId 474) + +[chip] go · go-redis + pgx + +```go +// append-heavy data (events, logs): writes skip the cache; first read populates it +func recordEvent(ctx context.Context, e Event) error { + _, err := db.Exec(ctx, + "INSERT INTO events (id, type, payload, ts) VALUES ($1,$2,$3,NOW())", + e.ID, e.Type, e.Payload) + return err // no cache write: most events are never re-read +} +// read side is plain cache-aside (the next read populates it) +func getEvent(ctx context.Context, eid string) (Event, error) { + key := "event:" + eid + if b, err := cache.Get(ctx, key).Bytes(); err == nil { return unmarshalEvent(b) } + var e Event + err := db.QueryRow(ctx, "SELECT * FROM events WHERE id=$1", eid). + Scan(&e.ID, &e.Type, &e.Payload) + if errors.Is(err, pgx.ErrNoRows) { return e, notFound() } + b, _ := json.Marshal(e) + cache.Set(ctx, key, b, 60*time.Second) + return e, err +} +``` + +Writes skip the cache entirely; the first read populates it. Avoids cache thrash from one-time writes. + +p. 193 + +> Two write variants and the standard cache-aside read for context. The pure write-around just inserts to the DB — the cache isn't touched at all. The defensive variant deletes any sibling entry in case some concurrent process populated the cache between the start of the write and its completion — cheap insurance for high-contention paths. The read side is plain cache-aside. The pattern shines for workloads where the write-to-re-read ratio is high: a security event log might see 10,000 writes a minute and 100 reads a day for active alerts — caching every write would just churn entries. Compare to a product catalog where every read is a probable cache hit — there, write-through makes more sense because you do want the writer's update to appear immediately in subsequent reads. + +## [220] APPENDIX L · WRITE-BACK · Write-back (write-behind) — fast writes, eventual durability (sldId 475) + +Writes return immediately after touching the cache; a background flusher persists to the DB asynchronously. + +p. 194 + +> The fastest-write pattern, with the most caveats. The client writes to the cache and gets an immediate ack — the cache is the source of truth for that window. A background process drains the cache to the DB, batching writes for efficiency. Pros: write latency equals cache latency — microseconds, not milliseconds; massive throughput on write-heavy workloads. Cons: a cache crash before the flusher catches up loses unflushed writes; the cache must be configured for persistence (Redis AOF or RDB snapshots), and even then you might lose seconds of data. Reads from outside the cache (analytics, BI) lag by the flush interval. Use this pattern only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've worked out what 'lost the last 5 seconds of writes' means for your business. Common: real-time gaming state, IoT telemetry with a downstream aggregator, write-heavy buffering before a slower backing store. + +## [221] APPENDIX L · WRITE-BACK · Write-back with a background flusher in Go (sldId 476) + +[chip] go · go-redis + goroutine + +```go +// main.go (excerpt), examples/25-caching/go: write-back +func handleWriteBackPut(w http.ResponseWriter, r *http.Request) { + key := "metric:" + r.PathValue("id") // body decoded into parsed, id added: data + safeSet(r.Context(), key, string(data), 0) // 1. cache only, no TTL + rdb.SAdd(r.Context(), "metric:dirty", key) // mark for flush + writeJSON(w, 202, parsed) // 202: queued, not yet durable +} +func flusher(ctx context.Context) { // 2. drain the dirty set to the DB + failures := 0 + for { + wait := flushPeriod // 1s, doubling to 8s after failures + if failures > 0 { wait = min(flushPeriod< Write-back has more moving parts than the other patterns; the slide shows the shape of Example 25. (1) PUT /write-back/metrics/{id} stores the metric in Redis with no TTL, adds the key to a 'dirty set', which tells the flusher which keys still have to reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) The flusher runs as a background goroutine: it SPOPs up to flushBatch dirty keys and upserts each one (ON CONFLICT (id) DO UPDATE), so a key flushed twice stays one row. SPOP is atomic, so two flusher instances each get a distinct subset of keys and the flusher scales out. (3) A key whose upsert fails is re-queued: HIncrBy counts its attempts in metric:attempts and it goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in the cache and the failure in the log. The wait between cycles doubles after each failed one, from one second to at most eight. Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale. + +## [222] APPENDIX L · REFRESH-AHEAD · Refresh-ahead — keep hot keys warm (sldId 477) + +A background task refreshes hot keys before their TTL expires. Users never see a cold miss on popular items. + +p. 196 + +> The pattern for eliminating the 'cache miss latency cliff' on popular keys. Plain cache-aside has a sharp behaviour: while a key is cached, reads are fast; the moment its TTL expires, the next reader pays the slow DB-fetch path. For homepage data, top-products, frequently-queried entities, that latency spike is user-visible. Refresh-ahead pre-empts this: a background task watches the cache (or maintains a set of 'hot' keys) and refreshes their values from the DB before the TTL fires. Users always read warm cache. Pros: no user-visible misses on the keys you mark as hot. Cons: extra DB load for keys that may not be requested again before expiry; you need a way to identify which keys are hot. Use this when miss latency matters more than DB throughput on the affected keys. + +## [223] APPENDIX L · REFRESH-AHEAD · Refresh-ahead in Go (sldId 478) + +[chip] go · go-redis + goroutine + +```go +const (ttlS = 60 * time.Second; refreshBefore = 10 * time.Second) +func getProduct(ctx context.Context, pid string) (Product, error) { + cache.ZAdd(ctx, "product:hot", // touch the hot-list + redis.Z{Score: float64(time.Now().Unix()), Member: pid}) + if b, err := cache.Get(ctx, "product:"+pid).Bytes(); err == nil { return unmarshal(b) } + return loadAndCache(ctx, pid) // miss: load + populate +} +// background goroutine refreshes hot keys before their TTL expires +func refresher(ctx context.Context) { + for t := time.NewTicker(5 * time.Second); ; <-t.C { + cutoff := float64(time.Now().Add(-5 * time.Minute).Unix()) + cache.ZRemRangeByScore(ctx, "product:hot", "0", ftoa(cutoff)) // drop cold keys + hot, _ := cache.ZRange(ctx, "product:hot", 0, -1).Result() + for _, pid := range hot { + if d, _ := cache.TTL(ctx, "product:"+pid).Result(); d > 0 && d < refreshBefore { + loadAndCache(ctx, pid) // refresh window + } + } + } +} +``` + +Track 'hot' keys in a Redis sorted set scored by last-access time. Refresh those keys when their TTL falls under a threshold. + +p. 197 + +> The implementation uses a Redis sorted set (ZADD) to track which keys are 'hot' — scored by the last-access timestamp. The background refresher periodically checks each hot key's TTL; when it falls under refreshBefore, the refresher re-reads the DB and re-populates the cache before the user-facing path would see a miss. Keys not accessed in the last 5 minutes are removed from the hot set (so we don't refresh products no one is asking for). Two tuning knobs: refreshBefore (how early to refresh; longer = more safety, more DB load) and the hot-key window (how recent counts as hot). Variants: instead of tracking hot keys explicitly, you can use Redis's keyspace notifications to subscribe to TTL-expiry events and refresh reactively — cleaner but more setup. This pattern shines for read-heavy workloads where a small set of keys serves most traffic (the 80/20 product page, the top tweets, the trending articles) and miss latency is user-visible. For low-skew read patterns where every key is equally likely, refresh-ahead is wasted effort. + +## [224] APPENDIX L · ANTI-PATTERNS · When not to cache, and what to watch for (sldId 479) + +- Don't cache by default. Caching adds a consistency story, an invalidation story, and a failure-mode story. If reads are already fast, just don't add a cache — the cheapest cache is no cache. +- Don't cache rarely-read data. Caching write-once-read-never data thrashes your cache and gains nothing. Profile your read patterns first. +- Don't cache without a TTL. Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable. Always set an explicit TTL or maxmemory-policy on Redis (allkeys-lru typical). +- Avoid cache stampedes (thundering herd): when a hot key expires and many concurrent readers all miss at once, they all hit the DB simultaneously. Mitigate with per-key locks in the loader, or with refresh-ahead, or by jittering TTLs slightly per-entry. +- Watch for cache aside + write-around without invalidation — the write goes to the DB, but a stale cached value can persist for the full TTL. Invalidate on every write, or use a shorter TTL. +- Treat the cache as a performance optimisation, never as the source of truth. A cache outage must mean slower reads, not data loss — always have a DB fallback path. Test this by stopping Redis in staging. + +p. 198 + +> The six things that go wrong with caching. The first point is the most important and most often missed: caching is a complexity tax, only worth paying when reads hurt. The second is the write-around lesson stated as a rule: profile first. The third is operational: an unbounded Redis with no eviction policy will OOM-kill itself; allkeys-lru is the typical default. The fourth is the cache stampede problem: hot key expires → 100 concurrent readers all miss → 100 simultaneous DB reads → DB tips over. Mitigations: distributed lock per key (only one fetches, the rest wait); refresh-ahead; TTL jitter (add random ms to TTL so similar keys don't expire together). The fifth is the most common stale-data bug in production. The bold final line is the architecturally important one: the cache is never the source of truth. If Redis dies, you must serve slower reads, not return errors. Build the DB fallback path; test it by killing Redis in staging. + +## [225] APPENDIX L · DECISIONS · Choosing the right caching pattern (sldId 480) + +- Cache-aside — the default. Use when you want explicit control and reads dominate writes. The pattern everyone should know first. +- Read-through — use when the read path is repetitive across the codebase and you want to factor out the if/else. Same semantics as cache-aside; cleaner code. +- Write-through — use when zero stale-read risk after a write matters more than write latency. Read-heavy with infrequent writes is the sweet spot. +- Write-around — use when writes far outnumber re-reads. Avoids cache pollution by write-once data. +- Write-back — use only when extreme write throughput is the constraint, eventual durability is acceptable and your cache is configured for persistence. +- Refresh-ahead — use when a small set of hot keys serves most traffic and miss latency is unacceptable. Adds DB load for the hot set; eliminates user-visible misses on it. +- Most production systems use cache-aside for 95% of cases, with refresh-ahead on a small set of identified hot keys and write-through where stale-read is forbidden. Mix patterns; don't pick one as the rule. + +p. 199 + +> The decision rules to leave audience with. Cache-aside is the default and the right pattern for most reads. Read-through is the same semantics with cleaner code if you have a wrapper library. Write-through trades write latency for read consistency, which pays off when stale reads after writes are harmful (financial, healthcare, regulatory). Write-around is the right pattern for telemetry / events / logs / any append-heavy workload. Write-back is the niche extreme-throughput pattern, with serious caveats. Refresh-ahead is the hot-key pattern. The bold final line is the take-home: don't pick one pattern as 'the' cache strategy. Use cache-aside as the default, layer refresh-ahead on the hot keys you identify, use write-through where consistency demands it, write-around for telemetry. Different read/write profiles want different patterns; matching them is the engineering work. + +## [226] Appendix M · Failure modes (sldId 481) + +M + +Where it all breaks — and the patterns that hold. + +> Appendix M is the synthesis appendix — it brings together error handling, graceful shutdown, observability and the resilience patterns from across the deck, and points them at the specific ways distributed systems fail. If you can only take one slide from this appendix, take the matrix at the end: it maps each failure mode to the patterns that address it. This appendix references section 11 (Observability), section 12 (Security — bulkheads), and Appendix H (Graceful shutdown); it is the place where those threads are tied together. If this runs long in your session, it stands alone and can be presented on its own. + +## [227] APPENDIX M · Distributed systems fail differently (sldId 482) + +- In a single process, an operation succeeds or it throws. Across a network, a call can succeed, fail, hang forever, succeed-but-the-response-is-lost, or succeed-slowly — and you often cannot tell which. +- Partial failure is the defining trait: some dependencies are up, some down, some degraded, all at the same time. There is no global 'the system is up'. +- Failures are not independent: one slow component can exhaust a caller's resources and take down healthy services around it. Failure spreads. +- Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and what users experience diverge. +- The goal is not zero failures; that is impossible. The goal is to bound the blast radius and recover automatically. All management is risk management. + - This appendix: six failure modes, the defensive patterns that address them (with code), Istio's mesh-level role, graceful shutdown, how to observe failures, and a mapping matrix. + +p. 200 + +> Set the frame. The single most important conceptual shift for developers moving from monoliths is partial failure: in a monolith a function call either returns or throws, synchronously, in-process. Across a network there is a whole spectrum of failure, including the worst kind — the call that hangs, or the one that succeeded on the server but whose response was lost, leaving the client unsure whether to retry. Emphasise that failures are correlated (one failure causes others) and that some failures are actively deceptive (gray failures). Land the bold point hard: we are not trying to prevent failure, we are trying to bound it and recover. This reframes everything that follows from 'how do we stop failures' to 'how do we contain and survive them.' + +## [228] APPENDIX M · A taxonomy of failure modes (sldId 483) + +Network failures, component failures, load failures — and the defensive toolkit that addresses them. + +p. 201 + +> The map for the appendix. Three families. NETWORK failures: partition (nodes alive but unreachable) and split-brain (both sides act as leader). COMPONENT failures: partial (some deps fail) and gray (some degrade and are perceived differently by different observers). LOAD failures: amplification (a failure feeds on itself) and cascading (failure spreads to neighbours). Underneath sits the defensive toolkit — timeouts and deadlines, retry with backoff and jitter, circuit breakers, bulkheads (section 12), load shedding, fallback/degradation, quorum/consensus, Istio mesh policies, graceful shutdown (Appendix H), and observability (section 11). Every pattern addresses specific failure modes; the closing matrix maps which to which. Reconnect to the section-1 maxim: all management is risk management — you manage the distribution of failures, not their existence. + +## [229] APPENDIX M · NETWORK · Network partition (sldId 484) + +Nodes are alive and healthy; they cannot reach each other. CAP forces a choice: consistency or availability. + +p. 202 + +> See the diagram notes. The key teaching point: a partition is not a crash. Every node is healthy and running; the network between them is the thing that failed. This is what makes partitions so confusing to debug — every individual component looks fine. CAP theorem becomes concrete here: while partitioned, you choose to stay consistent (the minority side refuses writes, sacrificing availability) or available (both sides accept writes, sacrificing consistency, reconciling later). Most data systems let you tune this. Kubernetes itself uses etcd, which chooses consistency (the minority side of a control-plane partition stops accepting writes). Address with quorum/consensus, idempotent writes (so a retried write after heal is safe), and partition-tolerant design. + +## [230] APPENDIX M · NETWORK · Split-brain (sldId 485) + +A partition where both sides think they are the leader and both accept writes. State diverges; the worst partition outcome. + +p. 203 + +> See the diagram notes. Split-brain is the specific catastrophic outcome of a partition in a system that elects a leader or primary. Both sides lose contact, each assumes the other died, each promotes itself, and now you have two primaries accepting conflicting writes. On heal you have divergent state and no clean way to merge. The prevention is quorum: an action requires a majority (N/2 + 1) of nodes to agree, so a minority partition mathematically cannot act — it knows it is the minority and steps down. This is why cluster sizes are odd (3, 5, 7): to always have a clear majority. Fencing tokens (monotonically increasing IDs that let resources reject a stale leader's writes) and leases are complementary. etcd/Raft give the Kubernetes control plane this property out of the box. + +## [231] APPENDIX M · COMPONENT · Partial failure (sldId 486) + +The defining trait of distributed systems: some dependencies fail while others keep working. Handle each leg. + +p. 204 + +> See the diagram notes. This is the slide for developers moving from monoliths. In a monolith, the order service and the inventory service are function calls in the same process — if one is 'down' the whole process is down. Distributed, the order service calls inventory (healthy), payment (down), and shipping (slow) and must make a per-dependency decision for each: use the healthy result, fall back or fail for the down one, time out and fall back for the slow one. The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller. Address with per-call timeouts, per-dependency circuit breakers, fallbacks, and bulkheads. The next several slides are exactly these patterns, with code. + +## [232] APPENDIX M · COMPONENT · Gray failure (sldId 487) + +The insidious one: a component is degraded and is perceived differently by different observers. Liveness is green; users suffer. + +p. 205 + +> See the diagram notes. Gray failure is the failure mode that defeats naive health checking. The classic shape: a node is slow or dropping a fraction of requests, but its liveness probe (GET /healthz returning 200 from a trivial handler) still passes, so Kubernetes keeps it in rotation and the load balancer keeps sending it traffic. The system believes it is healthy; users experience errors and latency. This is 'differential observability' — the observer that matters (the client) sees something different from the observer that decides (the probe). The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims. This ties directly to section 11. + +## [233] APPENDIX M · LOAD · Amplification loops & retry storms (sldId 488) + +A failure triggers retries, which add load, which cause more failures. Backoff, jitter and retry budgets break the loop. + +p. 206 + +> See the diagram notes. Amplification is a positive feedback loop: the system's own defensive behaviour (retrying) becomes the attack. Two compounding effects. First, retry multiplication across layers: if the gateway retries 3x, and it calls a service that retries 3x, which calls a service that retries 3x, one user request becomes 27 requests to the bottom of the stack. Second, synchronisation: every client that failed at the same moment retries at the same moment, creating a thundering herd. The result can be metastable — the system stays collapsed even after the original trigger is gone, because the retry load alone sustains the failure. Breaking it: exponential backoff with jitter (de-synchronise), retry budgets (cap retries to a small percentage of live traffic so they can never multiply unboundedly), retry at exactly one layer, and circuit breakers to stop the doomed calls entirely. This is the failure mode most often caused by well-intentioned resilience code. + +## [234] APPENDIX M · LOAD · Cascading failure (sldId 489) + +One component's failure overloads its neighbours, which fail in turn — the domino effect up the call graph. + +p. 207 + +> See the diagram notes. Cascading failure is how a single slow dependency takes down an entire system. The mechanism: a slow database causes the calling service's request threads (or async tasks, or connection pool) to fill up waiting; with its resources exhausted, that service now fails or slows for all callers, not just the ones touching the database; its callers' resources then fill up waiting on it; and so on up the graph until the user-facing edge is down. The amplifiers are synchronous call chains and unbounded resources (unbounded queues, unbounded thread pools, no timeouts). Each defensive pattern breaks one link: timeouts stop threads blocking forever; bulkheads isolate the pool so the database calls can't consume all of it; circuit breakers stop calling the slow dependency entirely; load shedding rejects excess work at the edge before it propagates. This is the failure mode bulkheads (section 12) were invented for. + +## [235] APPENDIX M · The defensive toolkit — patterns and what they buy (sldId 490) + +- Timeout + deadline propagation — never wait forever; pass the remaining budget downstream so nobody does doomed work. Defends: partial, cascading. +- Retry with backoff + jitter — ride out transient failures without building a storm; only for idempotent calls; use a retry budget. Defends: partial, amplification (if done right). +- Circuit breaker — stop calling a failing dependency; fail fast; test for recovery. Defends: cascading, amplification, gray. +- Bulkhead (§12) — isolate resources by tenant/call/priority so one failure can't starve the rest. Defends: partial, cascading. +- Load shedding — when overloaded, reject excess early (503) rather than collapsing. Defends: amplification, cascading. +- Quorum / consensus — majority-required action; the only real answer to split-brain. Defends: partition, split-brain. +- Fallback / graceful degradation — return a useful degraded response (cache, default, queue-for-later) instead of an error. Defends: partial, gray. +- Istio mesh policies + graceful shutdown + observability: the platform layer underneath all of these patterns. Each one gets code, and a matrix maps patterns to modes. + +p. 208 + +> The toolkit overview before the deep dives. Each line names a pattern and the failure modes it defends against — this is the verbal version of the closing matrix. The thing to stress: no pattern is sufficient alone, and several patterns interact (retry without a circuit breaker amplifies; a circuit breaker without a fallback just fails faster; bulkheads without timeouts still let threads block within a pool). Production resilience is the composition of several of these, tuned together. Each gets its own diagram and per-language code so developers can implement them; the bold line flags that the platform-level pieces (Istio, shutdown, observability) sit underneath the application-level patterns. + +## [236] APPENDIX M · TIMEOUTS · Timeouts + deadline propagation (sldId 491) + +Every remote call needs a timeout; the remaining budget should travel downstream so nobody does doomed work. + +p. 209 + +> See the diagram notes. Two ideas. First, every remote call must have a timeout — the default in many HTTP clients is infinite, which is how a single slow dependency hangs a whole service. Second, deadline propagation: the original deadline (say 1000ms) travels with the request, and each hop subtracts the time it has used and passes the remainder downstream. When the remaining budget is too small to plausibly finish, the downstream fails immediately rather than starting work that will be thrown away. gRPC does this natively with deadlines; for HTTP you pass a deadline header and honour it. The waste this prevents is real: without it, your database spends 500ms on a query for a request the user abandoned 450ms ago, and that wasted work is exactly what tips an overloaded system over. The code example shows the language-specific mechanics. + +## [237] APPENDIX M · TIMEOUTS · Timeouts + deadlines (sldId 492) + +[chip] go · net/http + context + +```go +// every client has an explicit timeout; context carries the end-to-end deadline +var client = &http.Client{Timeout: 2 * time.Second} +func charge(ctx context.Context, order Order) (Receipt, error) { + if d, ok := ctx.Deadline(); ok && time.Until(d) < 50*time.Millisecond { + return Receipt{}, ErrDeadline // too little budget — fail fast + } + req, _ := http.NewRequestWithContext(ctx, "POST", "/charge", body(order)) + req.Header.Set("X-Deadline-Ms", msLeft(ctx)) // propagate the budget downstream + resp, err := client.Do(req) + if err != nil { return Receipt{}, status504("downstream timed out") } + defer resp.Body.Close() + return decodeReceipt(resp) +} +``` + +p. 210 + +> Two things: the client is constructed with an explicit timeout (net/http defaults to no timeout, which is a classic foot-gun), and the handler honours an inbound deadline, refusing to start work it cannot finish and propagating the remaining budget downstream in a header. A context.WithTimeout on the handler is the total-time bound. gRPC propagates the deadline by default as the call deadline; for HTTP you carry it in a header convention like X-Deadline-Ms and honour it at each hop. + +## [238] APPENDIX M · RETRY · Retry with exponential backoff + jitter (sldId 493) + +Retry transient failures — but spread retries with exponential backoff + jitter, cap them, and only for idempotent calls. + +p. 211 + +> See the diagram notes. Retry is the most abused resilience pattern because the naive version (retry immediately, a fixed number of times, at every layer) is precisely how you build the amplification storm from the amplification-loop discussion. The correct version has four properties: exponential backoff (each retry waits longer: 100ms, 200ms, 400ms), jitter (add randomness so clients don't synchronise), a cap on both attempts and total time, and a retry budget (retries may never exceed, say, 10% of live traffic). And it only applies to idempotent operations — retrying a non-idempotent POST without an idempotency key can double-charge a customer. Always pair retry with a circuit breaker so that once a dependency is clearly down, you stop retrying entirely. The code example shows the idiomatic library for each language. + +## [239] APPENDIX M · RETRY · Retry with backoff + jitter (sldId 494) + +[chip] go · cenkalti/backoff v7 + +```go +// retry with exponential backoff + jitter, idempotent calls only (cenkalti/backoff v7) +func getQuote(ctx context.Context, symbol string) (Quote, error) { + bo := backoff.NewExponentialBackOff() // backoff + jitter built in + bo.InitialInterval = 100 * time.Millisecond + bo.MaxInterval = 2 * time.Second // capped + // Retry stops on success, a backoff.Permanent error, a cancelled ctx or the try cap + return backoff.Retry(ctx, func() (Quote, error) { + return client.Get(ctx, "/quote/"+symbol) // GET is idempotent: safe to retry + }, backoff.WithBackOff(bo), backoff.WithMaxTries(4)) +} +// don't retry a non-idempotent POST unless it carries an idempotency key; +// pair with a circuit breaker so a down dependency stops retries entirely. +``` + +p. 212 + +> cenkalti/backoff v7 is the Go retry library. The policy encodes the four properties that matter: retry only transient failures (return backoff.Permanent for a 4xx or a business error), exponential backoff with jitter, a hard cap on attempts (WithMaxTries), and the final error returned to the caller. Retry also stops when the context is cancelled. +> +> The two comments carry the discipline: retry only idempotent operations, a GET or a POST with an idempotency key, and pair the retry with a circuit breaker so a dependency that is clearly down stops being hammered. + +## [240] APPENDIX M · CIRCUIT BREAKER · Circuit breaker — the state machine (sldId 495) + +Closed → Open (fail fast) → Half-Open (test) → Closed. Stops doomed calls; gives the dependency room to recover. + +p. 213 + +> See the diagram notes. The circuit breaker is the pattern that most directly stops cascading and amplification. Three states. CLOSED: calls pass through, failures are counted over a rolling window. When the failure ratio crosses the threshold, the breaker trips to OPEN: calls fail immediately without even attempting the downstream, which both gives a fast answer to the caller and gives the struggling dependency room to recover instead of being hammered. After a reset timeout the breaker goes HALF-OPEN: it allows a few trial calls; if they succeed it closes (recovered), if they fail it re-opens. Crucial details: one breaker per dependency (never a single global breaker), and pair it with a fallback so that an open breaker degrades gracefully rather than just erroring faster. Libraries differ per language; the code example shows each. + +## [241] APPENDIX M · CIRCUIT BREAKER · Circuit breaker + fallback (sldId 496) + +[chip] go · sony/gobreaker v2 + +```go +// one breaker per dependency, never one global breaker (sony/gobreaker v2, generic) +var paymentCB = gobreaker.NewCircuitBreaker[Receipt](gobreaker.Settings{ + Name: "payment", + Timeout: 30 * time.Second, // stay open 30 s, then half-open to test recovery + ReadyToTrip: func(c gobreaker.Counts) bool { return c.ConsecutiveFailures >= 5 }, +}) +func charge(ctx context.Context, order Order) (Receipt, error) { + r, err := paymentCB.Execute(func() (Receipt, error) { + return client.Post(ctx, "/charge", order) // skipped while the breaker is open + }) + if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) { + _ = outbox.Enqueue(ctx, order) // graceful fallback + return Receipt{Status: "pending", ID: order.ID}, nil + } + return r, err +} +``` + +p. 214 + +> sony/gobreaker v2 is a small, generic circuit-breaker library: NewCircuitBreaker[Receipt] makes Execute return a typed Receipt. The breaker is keyed per dependency, payment here, never one global breaker. It opens after five consecutive failures and stays open for 30 seconds before letting a trial request through. +> +> While it is open, Execute returns ErrOpenState at once without making the call, which is the fail-fast behaviour that stops a cascade; in the half-open state, extra calls get ErrTooManyRequests. Those two errors take the fallback: enqueue the order in the outbox and return a pending status instead of an error, which is graceful degradation. + +## [242] APPENDIX M · BULKHEAD · Bulkhead — isolate so one failure can't sink the rest (sldId 497) + +- Covered in depth in section 12 (Security) as a blast-radius pattern; it is equally a resilience pattern, so it appears here in the failure-mode context. +- Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. A storm in one pool exhausts only that pool. +- Against cascading failure: if database calls have their own bounded pool, a slow database exhausts that pool and returns errors for DB-backed calls — but the service's other endpoints keep serving from their own pools. +- Against partial failure: a single misbehaving tenant or dependency is contained to its own compartment instead of consuming the shared resource. +- Implementation (from §12): golang.org/x/sync/semaphore plus per-tenant http.Client transports — lightweight in-process bulkheads paired with per-call timeouts. At the mesh: Istio DestinationRule connectionPool gives the same isolation at the network layer. + - Pair with timeouts: a bulkhead bounds how many calls run concurrently, but without a timeout those calls can still block forever and keep the bulkhead full. The two patterns need each other. + +p. 215 + +> Bulkhead got full treatment in section 12, so here we just re-frame it in the failure-mode context and cross-reference. The key insight to land: a bulkhead bounds concurrency, but concurrency-bounding alone doesn't help if the bounded calls block forever — you must combine bulkhead (limit how many) with timeout (limit how long). Point back to section 12 for the per-language code (we don't repeat it here to save slides), and forward to the Istio slide for mesh-level bulkheading via connectionPool limits. + +## [243] APPENDIX M · SHEDDING & FALLBACK · Load shedding and graceful degradation (sldId 498) + +- Load shedding — when you are over capacity, reject excess work immediately (HTTP 503 + Retry-After) at the edge, before it consumes resources. A system that sheds 20% of load stays up for the other 80%; a system that accepts everything collapses for 100%. +- Shed by priority: drop health-check-style or low-value traffic first, protect revenue-critical paths. Adaptive concurrency limits (e.g. Netflix's, or Envoy's) shed automatically based on observed latency. +- Graceful degradation — when a dependency is unavailable, return a useful reduced response instead of an error: serve stale cache, omit the personalised section, queue the write for later, show a default recommendation. +- Degradation turns a hard failure into a soft one the user may barely notice. A product page that can't reach the reviews service should still show the product — just without reviews. +- Both are the application-level complement to the circuit breaker: the breaker decides to stop calling; the fallback decides what to return instead; load shedding decides what not to accept in the first place. + - Reference §11: you cannot shed or degrade intelligently without observability — you shed based on measured saturation, and you alert when degradation rates climb. + +p. 216 + +> Two related patterns that often get skipped. Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses. The maths is simple: if you are at 120% of capacity and accept everything, everything is slow and timing out (0% goodput); if you shed the excess 20%, the 80% you accept is served well. Adaptive limiters do this automatically based on latency. Graceful degradation is the product-level decision about what to return when you can't return everything — it requires the product/design team's input on what's essential vs nice-to-have. The bold line ties shedding, breakers and fallback together as a system: stop accepting (shed), stop calling (break), return something useful (fall back). + +## [244] APPENDIX M · ISTIO · Istio — resilience at the mesh layer (sldId 499) + +Timeouts, retries, outlier detection (gray-failure ejection) and circuit breaking — uniform, config-driven, no app code. + +p. 217 + +> See the diagram notes. The service mesh gives you a large slice of the resilience toolkit without writing code in every service, in every language. VirtualService configures per-route timeouts and retries (with Envoy adding backoff and jitter automatically, and retryOn conditions controlling which failures are retried). DestinationRule configures connectionPool limits (mesh-level bulkheading) and outlierDetection — which is the mesh's answer to gray failure: it watches each host, and when one returns consecutive errors it ejects that host from the load-balancing pool for a cooldown period, regardless of what the host's own health check claims. That is exactly the gray-failure defence the health-check can't provide. The important discipline: don't double up. If Istio is retrying, don't also retry in the app (that's amplification). Decide where each concern lives — typically: timeouts and outlier detection at the mesh, business-aware retries and fallbacks in the app. + +## [245] APPENDIX M · SHUTDOWN · Graceful shutdown (see App. H) (sldId 500) + +[chip] go · net/http + +```go +// main.go (excerpt), examples/21-graceful-shutdown/go: shutdown is a failure mode +func (s *server) readyz(w http.ResponseWriter, r *http.Request) { + if s.shuttingDown.Load() { // liveness (/healthz) stays 200 + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]any{"ready": false, "reason": "shutting down"}) + return + } + // ... ping the pool: 503 "db unreachable", else {"ready": true} +} +func main() { + // ... pool, mux, httpServer; ctx, stop := signal.NotifyContext(..., SIGTERM, SIGINT) + <-ctx.Done() // SIGTERM: terminationGracePeriod has started + stop() + s.shuttingDown.Store(true) // 1. readiness starts failing + time.Sleep(drainDelay()) // 2. let the LB stop routing to us (5s) + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + httpServer.Shutdown(shutdownCtx) // 3. finish in-flight work (20s budget) +} // 4. deferred pool.Close(), exit 0 +``` + +p. 218 + +> The shutdown sequence that prevents dropped requests during a rolling deploy, from Example 21. One SIGTERM cancels the signal context: (1) the shuttingDown flag flips so /readyz returns 503 and Kubernetes stops sending new traffic, while /healthz stays 200; (2) the service keeps serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) Server.Shutdown stops accepting and finishes in-flight requests within a 20-second budget; (4) the deferred pool close runs and the process exits 0. The ordering matters: stop taking new work before draining existing work, and close pools last. This is the application half; Appendix H covers the Kubernetes half (preStop hook, terminationGracePeriodSeconds). Together they give zero-downtime rollouts. + +## [246] APPENDIX M · OBSERVE · Observing failure modes (see §11) (sldId 501) + +- Network partition — watch peer-connectivity and cross-zone error metrics; alert on quorum-loss events from etcd / your datastore. +- Split-brain — alert on more-than-one-leader conditions, divergent-replica metrics, and fencing-token rejections in logs. +- Partial failure — RED metrics per dependency (rate, errors, duration), not just per service. A spike in errors to one downstream is the early signal. +- Gray failure — the hard one: compare health-check status against real-traffic SLOs. Latency percentiles (p99) diverging from the average, and Istio outlier-ejection counts, are your tells. +- Amplification — watch request-rate against user-action-rate (if requests >> actions, retries are multiplying), retry-count metrics, and goodput falling while load rises. +- Cascading — a distributed trace shows the error spreading service-to-service; saturation metrics (thread pools, queue depth, connection pools) climb together across services. + - The unifying tool is the trace_id from §11: when an alert fires, pivot from the metric to an exemplar trace to its logs to find which leg failed first and how it spread. + +p. 219 + +> Tie the appendix back to section 11. Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop. The most important specific guidance: measure RED per dependency (so partial failures are visible), compare health-check status to real-traffic SLOs (so gray failures are visible), and watch request-rate vs user-action-rate (so amplification is visible). The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response. + +## [247] APPENDIX M · Which pattern addresses which failure mode (sldId 502) + +Read a row to defend a failure mode; read a column to see what a pattern buys you. Production layers several. + +p. 220 + +> The synthesis slide — if the audience keeps one thing, keep this. Reading across a row tells you the portfolio of patterns that defend a given failure mode (e.g. cascading failure is addressed by timeouts, circuit breakers, bulkheads, load shedding, and graceful shutdown together). Reading down a column tells you what a given pattern buys you across failure modes. Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule. Second, almost every other cell is about assembling a portfolio — there is no single pattern that handles partial, gray, amplification and cascading failures; you layer timeouts, retries-with-budget, breakers, bulkheads, shedding and fallbacks, and you observe the result. Defence in depth, exactly as in the security section, applied to reliability. + +## [248] APPENDIX M · Take-aways and references (sldId 503) + +- Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often infinite. +- Retry only idempotent calls, with backoff + jitter + a budget, at one layer, always paired with a circuit breaker. Naive retries cause the outages they were meant to prevent. +- Bound the blast radius: bulkheads isolate, circuit breakers stop the bleeding, load shedding protects capacity, fallbacks keep the user served. +- Quorum is the only cure for split-brain. Let etcd / Raft / your datastore handle consensus; don't invent your own. +- Push uniform concerns (timeouts, outlier detection, mesh bulkheads) to Istio; keep business-aware concerns (fallbacks, idempotency) in the app. Don't double-retry. +- Observe from the client's vantage point (RED per dependency, SLOs, outlier ejections) — gray failures are invisible to naive health checks. Drain on shutdown (App. H). + - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkhead origin); the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling); Istio traffic-management docs; the metastable-failures research (Bronson et al., 2021). + +p. 221 + +> Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: design for partial failure, timeout everything, retry carefully, bound the blast radius, use quorum for consensus, split concerns between mesh and app, and observe from the client's perspective. The references are the canonical sources: the Google SRE book's cascading-failures and overload chapters are the single best free resource; Michael Nygard's 'Release It!' is where the circuit breaker and bulkhead patterns were named for software; the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol; and the metastable failures paper (Bronson et al.) is the modern formalisation of amplification loops. Encourage the audience to read the SRE cascading-failures chapter as the next step. + +## [249] Feature Flags (sldId 504) + +N + +Progressive delivery, decoupled from deploy — OpenFeature + flagd + +> This appendix adds the runtime switch that separates shipping code from releasing a feature. It is a deep-dive you keep open when you are about to roll something out gradually. The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, like every other example in the book. The arc: why deploy is not release, the four kinds of flag, how evaluation works, the code to evaluate and target and roll out, flags as a control plane over patterns we already covered, and the lifecycle discipline that keeps flags from becoming debt. + +## [250] APPENDIX N · FEATURE FLAGS · Deploy is not release (sldId 505) + +p. 222 + +the artifact ships dark; releasing is a runtime flag flip — and so is rollback + +> The reframe that makes everything else work: deploying an artifact and releasing a feature are different events, and a flag is what splits them. The artifact ships to production with the new path turned off - a non-event, visible only in the deploy log. Releasing is then a runtime flag flip: on for one percent, then a wider cohort, then everyone, each step reversible by narrowing the flag rather than rolling back a deployment. This is what lets a team merge to main many times a day yet release on its own cadence, and it is the mechanism behind every canary and every safe cutover in the book. Rollback stops being a redeploy and becomes a config change measured in seconds. + +## [251] APPENDIX N · FEATURE FLAGS · Four kinds of flag (sldId 506) + +p. 223 + +by lifetime and owner — each wants different defaults and lifecycle + +> Feature flag covers four different tools, and conflating them is the root of most flag messes. They differ along two axes - how long the flag lives, and who decides to flip it. Release flags are temporary and developer-owned: they ship new code dark, ramp it, and are then removed. Experiment or A/B flags are temporary and data-owned: they split traffic across variants so a metric can be measured, and they require sticky assignment. Ops or kill-switch flags are long-lived and SRE-owned: a permanent control to disable a feature or dependency fast under load or incident. Permission or entitlement flags are long-lived and product-owned: they gate functionality by plan, tier, or region. The defaults differ too - a release flag fails off, so half-shipped code stays dark, while a kill-switch usually fails on, so the feature survives a flag-system outage. + +## [252] APPENDIX N · FEATURE FLAGS · Evaluating a flag: OpenFeature + flagd (sldId 507) + +p. 224 + +local evaluation, vendor-neutral SDK, fail-safe to the default + +> Evaluation should not couple your code to a vendor. OpenFeature is the CNCF vendor-neutral API standard - one SDK surface across languages - and flagd is its lightweight open-source backend that evaluates flags from a file, ConfigMap, or synced source, in-cluster. Together they keep the whole feature-flag story open-source and running on plain Kubernetes, with no managed SaaS, and swapping flagd for another provider later is a configuration change, not a code change. Two properties matter most. First, evaluation returns the default if the provider is unreachable - a flag-system outage must never take the application down. Second, the SDK is vendor-neutral: the app calls getBooleanValue with a flag key, a default, and an evaluation context, and never names the backend. The context carries the targeting inputs - a stable key plus attributes like plan and region - that the rules act on. + +## [253] APPENDIX N · FEATURE FLAGS · Evaluating a flag (sldId 508) + +p. 225 + +[chip] go · OpenFeature + flagd + +```go +func setupFlags() error { // OpenFeature Go SDK + flagd provider, once at startup + provider, err := flagd.NewProvider(flagd.WithHost("flagd"), flagd.WithPort(8013)) + if err != nil { return err } + return openfeature.SetProviderAndWait(provider) +} +var flags = openfeature.NewClient("checkout") +func checkout(w http.ResponseWriter, r *http.Request) { + cart := parseCart(r) + evalCtx := openfeature.NewEvaluationContext(r.Header.Get("X-User"), // sticky key + map[string]any{"plan": cart.Plan, "region": cart.Region}) + useNew, _ := flags.BooleanValue(r.Context(), "new-checkout", false, evalCtx) // default off + if useNew { + newCheckout(w, r, cart) + return + } + legacyCheckout(w, r, cart) +} +``` + +The same one line of code serves an on/off, a per-plan entitlement, or a 25% rollout. + +> The provider is registered once at startup; from then on the application asks the client for a value. The crucial thing to notice is that the application code is the same one line regardless of whether the flag is a simple on/off, a per-plan entitlement, or a 5 percent rollout. All of that lives in the flag's configuration, not the code. The default is what gets returned if flagd is unreachable, so the new path stays dark on a flag-system outage rather than erroring. The targeting key is the user id, which is what makes a percentage rollout sticky. Across every language the shape is identical: register a flagd provider, get a client, build an evaluation context with a targeting key and attributes, and call getBooleanValue with a safe default. + +## [254] APPENDIX N · FEATURE FLAGS · Targeting and rollout live in config (sldId 509) + +p. 226 + +[chip] json · flagd + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "==": [ {"var": "plan"}, "enterprise" ] }, "on", + { "fractional": [ {"var": "targetingKey"}, + ["on", 25], ["off", 75] ] } + ] + } + } + } +} +``` + +Enterprise plans always on (entitlement); everyone else split 25/75 (sticky rollout) — no code change. + +> Because the rules live in flagd, changing who sees a feature never touches the application. A flag definition is a set of variants, a default, and a targeting expression. Two operators do most of the work: a conditional for segmentation - here, the enterprise plan always gets the new checkout, an entitlement - and the fractional operator for percentage rollouts, here splitting everyone else 25/75. The fractional operator buckets on the targeting key, which is what gives sticky assignment: the same user hashes to the same bucket every time. Ramping the rollout is editing the 25 upward; rolling back is editing it down - both take effect in the running fleet within flagd's sync interval, with no redeploy. A kill switch is the same file: set a flag's state to DISABLED and it stops evaluating entirely. + +## [255] APPENDIX N · FEATURE FLAGS · Percentage rollout via sticky bucketing (sldId 510) + +p. 227 + +consistent per-user assignment; instant ramp and rollback + +> Sticky bucketing matters for two reasons. For a rollout, it prevents flicker - a user who got the new checkout on their first visit keeps getting it, rather than bouncing between variants on refresh. For an experiment, it is a correctness requirement: a user must stay in one arm of the test for the measurement to mean anything. The mechanism is simple - hash a stable key, the user id, into a bucket from 0 to 99, and compare it to the rollout threshold. Because the hash is stable, the same user always lands in the same bucket. Ramping the rollout is moving the threshold up; rolling back is moving it down, and both take effect in the running fleet within the sync interval with no deploy on the path of either. + +## [256] APPENDIX N · FEATURE FLAGS · A kill switch (sldId 511) + +p. 228 + +[chip] go · OpenFeature + +```go +// ops kill-switch — default true (fail open): on until an operator kills it +func recommend(ctx context.Context, userID string) (Recommendations, error) { + on, _ := flags.BooleanValue(ctx, "recommendations-enabled", true, evalCtx(userID)) + if !on { + return Recommendations{}, nil // degrade — still serve the page + } + return recommendationClient.Fetch(ctx, userID) // pairs with a breaker +} +// default true = fail open: only a deliberate flip disables it. +// the default flips with the flag type. +``` + +Default true = fail open: only an operator flipping the flag disables it. The default flips with the flag type. + +> A kill switch is the operational flag you reach for during an incident: wrap a risky or expensive dependency in a flag so it can be disabled instantly without a deploy. It is the human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; an operator flips the kill switch by hand. The default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only an explicit flip removes it. When it is killed, the page still renders - just without the recommendations - which is graceful degradation, the same idea as the load-shedding discussion in the Failure Modes appendix. + +## [257] APPENDIX N · FEATURE FLAGS · Flags over the patterns you already have (sldId 512) + +p. 229 + +a flag is a runtime control surface + +> Feature flags are not a new subsystem so much as a runtime control plane over patterns already in this book. A canary is a percentage flag deciding which build a request exercises, often paired with the L7 routing from the routing appendix. A strangler-fig cutover is a flag, or the proxy's percentage, steering a widening slice from the monolith to the extracted service. A saga step can be guarded by a flag to switch a new compensation path on cautiously. And an experiment is a sticky-bucketed variant flag with a metric attached. Recognising flags as the control surface for all of these keeps you from building a bespoke mechanism for each - the same decoupling, expressed four ways. + +## [258] APPENDIX N · FEATURE FLAGS · The flag lifecycle (sldId 513) + +p. 230 + +a release flag is born with a removal ticket + +> The failure mode of feature flags is not technical, it is organisational: flags that are never removed. Every flag is a branch in your code and a dimension in your test matrix, and a stale one is dead code, a hidden conditional, and a live risk surface that nobody remembers the meaning of. The discipline that keeps it healthy: give every release flag an owner and an expiry from the moment it is created, and treat removing it as part of finishing the feature, not optional cleanup. Long-lived ops and entitlement flags are exempt - they are permanent by design - which is exactly why distinguishing the four types matters. A short-lived flag that has been at 100 percent for two weeks should be deleted along with the branch it guarded. + +## [259] APPENDIX N · FEATURE FLAGS · Testing, observing, and anti-patterns (sldId 514) + +p. 231 + + - Test both sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag. + - Put the resolved variant on your telemetry - a span attribute and a metric label - so a latency or error change attributes to a specific flag and cohort. +- Anti-patterns: flags as a config store; release flags that never die; stacks of flags whose combinations no one reasons about; experiments without sticky assignment; business logic buried in flag rules. + +> A flag multiplies the states your system can be in, so test both sides of every flag you depend on; a test suite that only exercises the default path will not catch a bug behind the flag. Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them explicitly. Observability ties flags back to the Observability section: record the resolved variant as a span attribute and a metric label, so a latency or error change can be attributed to a specific flag and cohort. The recurring anti-patterns: using flags as a general configuration store - they are for release control, not for tuning timeouts; letting release flags become permanent; stacking many flags whose combinations no one reasons about; running experiments without sticky assignment, which makes the data meaningless; and putting real business logic inside flag rules instead of in code. + +## [260] APPENDIX N · FEATURE FLAGS · Take-aways (sldId 515) + +p. 232 + +- Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. +- Distinguish the four flag types - they want different owners, defaults, and lifecycles. +- Evaluate through a vendor-neutral SDK (OpenFeature) against an OSS backend (flagd), always with a safe default. +- Keep targeting and rollout in configuration, not code, and make percentage rollouts sticky. +- Treat release flags as debt with a removal ticket; put the resolved variant on your telemetry. + +References: OpenFeature spec & flagd docs (CNCF) · Hodgson, “Feature Toggles” · Accelerate / DORA · Unleash (OSS alternative). + +> Close on the imperatives. Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. Distinguish the four flag types, because they want different owners, defaults, and lifecycles. Evaluate through a vendor-neutral SDK against an open-source backend, always with a safe default so a flag outage cannot take the app down. Keep targeting and rollout rules in configuration, not code, and make percentage rollouts sticky. Treat release flags as debt with a removal ticket attached, and put the resolved variant on your telemetry so you can attribute a regression to a cohort. The canonical references are the OpenFeature specification and the flagd documentation, Pete Hodgson's Feature Toggles article for the taxonomy of flag types, and the Accelerate and DORA research for the deploy-versus-release distinction; Unleash is a capable open-source alternative backend behind the same OpenFeature SDK. + +## [261] API Testing with Newman (sldId 516) + +O + +Postman collections as an automated gate on Docker Engine + +> This appendix turns the Postman collections the team already pokes at by hand into an automated test gate, using Newman, Postman's command-line runner. It is a deep-dive you keep open when you are wiring API tests into CI. The key idea that makes it cheap: Newman tests the API as a black box over HTTP, so one collection is the conformance suite for every implementation in the book, and the only language-specific step is bringing the service under test up. The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid. + +## [262] APPENDIX O · API TESTING · Why Newman (sldId 517) + +p. 233 + +the headless runner turns a Postman collection into CI-readable results + +> A Postman collection is an executable contract, not just a saved list of requests. Newman runs it without the GUI and emits machine-readable results: a cli reporter for a human watching, a junit reporter that CI turns into a test report, and an html reporter to share. There is no new test framework to learn - the tests are the JavaScript assertions already supported inside Postman, and Newman is the bridge from works-when-I-click-it to verified-on-every-pull-request. The same file serves three audiences: a developer in Postman, a reviewer reading the tests, and a pipeline failing the build when an assertion breaks. + +## [263] APPENDIX O · API TESTING · Anatomy of a collection (sldId 518) + +p. 234 + +folders of requests, each with assertions; the environment supplies the variables + +> A collection nests folders and requests; each request can carry a pre-request script, the HTTP call itself, and a block of tests that run against the response. The tests are small JavaScript blocks using Postman's pm API - asserting the status, the response time, the shape of the body. Environment variables supply the parts that change between runs, like the base URL and an auth token, resolved at run time. Storing a value from one response - a new order id - in a variable lets a later request chain off it, which is how a collection models a whole flow rather than isolated calls. + +## [264] APPENDIX O · API TESTING · Tests live in the request (sldId 519) + +p. 235 + +[chip] json · Postman + +{% raw %} +```json +{ + "name": "Create order", + "request": { + "method": "POST", + "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }], + "url": "{{base_url}}/orders", + "body": { "mode": "raw", "raw": "{\"sku\":\"A-1\",\"qty\":2}" } + }, + "event": [{ + "listen": "test", + "script": { "exec": [ + "pm.test('201 Created', () => pm.response.to.have.status(201));", + "pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));", + "const body = pm.response.json();", + "pm.test('returns an id', () => pm.expect(body).to.have.property('id'));", + "pm.collectionVariables.set('order_id', body.id);" + ] } + }] +} +``` +{% endraw %} + +Assertions run after the response; the saved order_id chains into later requests. + +> Here is a request that creates an order and asserts on the result. The test block asserts a 201 Created, parses the body, asserts it carries an id, and stores that id in a collection variable so a later request - fetch, cancel, or pay for the order - can use it. Beyond status codes, a test can validate the response shape against a JSON Schema, which is how you turn the metadata and error-format work from earlier appendices into an enforced contract: assert that an error body matches the Problem Details schema rather than checking exact strings, so the test stays stable as data changes but still catches a removed field or a wrong type. + +## [265] APPENDIX O · API TESTING · One collection, six backends (sldId 520) + +p. 236 + +Newman tests over HTTP, so one collection validates every implementation + +> Because Newman exercises the API as a black box over HTTP, the collection does not know or care which language implements the service. The same collection is the conformance suite for every implementation in this book - point it at whichever service is running and the assertions are identical. The collection is the API specification made executable. Bringing the service under test up is the only language-specific step; the Newman command that follows is the same everywhere. + +## [266] APPENDIX O · API TESTING · Launch the service under test (sldId 521) + +p. 237 + +[chip] bash · Go + +```bash +# Go — run the service, then the same collection +go run ./cmd/order-service & # http://localhost:8080 +newman run orders.postman_collection.json -e local.postman_environment.json +``` + +Only this step is language-specific; the Newman command is identical for all six. + +> The only per-language step is starting the service. Each implementation exposes the same HTTP API on the same port, so the base URL in the environment file is identical and the Newman command that follows does not change. That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it. In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop. + +## [267] APPENDIX O · API TESTING · Running Newman & shaping output (sldId 522) + +p. 238 + +[chip] bash + +{% raw %} +```bash +# install once (Node.js): +# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1 +newman run orders.postman_collection.json \ + --environment local.postman_environment.json \ + --reporters cli,junit,htmlextra \ + --reporter-junit-export results/newman.xml \ + --timeout-request 5000 \ + --bail +# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure + +# data-driven: one iteration per CSV row; the columns become {{variables}} +newman run orders.postman_collection.json -e local.postman_environment.json \ + --iteration-data cases.csv +``` +{% endraw %} + +Reporters target the audience; --bail gates fast; --iteration-data drives edge cases. + +> The runner takes the collection and an environment file, and you choose reporters for the audience: cli for a human, junit for the XML CI turns into a report, htmlextra for a shareable page. --bail stops at the first failed assertion, which is what you usually want in a gate; drop it to see every failure in one run. A single set of requests can be run repeatedly over a table of inputs with --iteration-data, which is how you cover boundary values, bad input, and edge cases without duplicating requests - and the expected status can travel in the data too, so one request becomes a table of positive and negative cases. + +## [268] APPENDIX O · API TESTING · Data-driven tests (sldId 523) + +p. 239 + +one request set, many inputs — boundary values and negative cases from a table + +> Data-driven testing runs the same requests once per row of a CSV. The columns become variables in the requests, and a column can carry the expected status so a single assertion checks status against the row's expectation. This is how a handful of requests becomes broad coverage: the happy path, a zero-quantity order that must be rejected with a 422, an over-stock order that must also be rejected - all without copy-pasting requests. Keeping the cases in data rather than in duplicated requests is what makes the suite maintainable as the API grows. + +## [269] APPENDIX O · API TESTING · Newman in CI (sldId 524) + +p. 240 + +[chip] yaml · GitHub Actions + +```yaml +# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js) +jobs: + api-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: docker compose up -d --build --wait # order, payment, inventory, ... + - run: ./scripts/wait-for-ready.sh # poll /readyz before testing + - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1 + - run: | + newman run orders.postman_collection.json \ + -e ci.postman_environment.json \ + --reporters cli,junit \ + --reporter-junit-export results/newman.xml + - uses: actions/upload-artifact@v7 # keep the report even on failure + if: always() + with: { name: newman-results, path: results/ } +``` + +Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build. + +> Newman exits non-zero when any assertion fails, which is all a pipeline needs to gate a merge. The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them. The readiness poll matters: start testing only once the services report ready, using the readiness endpoint from the Observability and Graceful Shutdown material, or the first requests race the boot and fail spuriously. Upload the junit report as an artifact even on failure so the failing assertion is visible in the run. + +## [270] APPENDIX O · API TESTING · The gate on Docker Engine (sldId 525) + +p. 241 + +the same stack you run locally, gating every pull request + +> The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks. The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock. + +## [271] APPENDIX O · API TESTING · Where Newman fits (sldId 526) + +p. 242 + +the API/contract tier: on top of unit tests, under a thin end-to-end layer + +> Newman does not replace unit tests; it is the layer on top of them. It owns the API/contract tier: real HTTP against a running service, fast enough to run on every pull request. Keep the base of fast unit tests broad, use Newman for the contract and integration behaviour that only shows up over real HTTP, and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system. Pushing everything into Newman makes a slow, brittle suite; ignoring it leaves the contract untested. Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material. + +## [272] APPENDIX O · API TESTING · Take-aways (sldId 527) + +p. 243 + +- Treat the Postman collection as the executable specification - Newman runs it headlessly to guard every change. +- One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box. +- Validate response shape against a schema, not just status codes; drive edge cases from data, not duplicated requests. +- Assert the failure paths - 401, 403, 409/422, 429, idempotency - as carefully as the happy path. +- Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier. + +References: Postman docs & Newman CLI docs (reporters, --iteration-data, --bail) · Postman Learning Center (pm API, JSON-schema validation). + +> Close on the imperatives. Treat the Postman collection as the executable specification of your API, and let Newman run it headlessly so the same assertions guard every change. Because Newman tests over HTTP, one collection is the conformance suite for all six implementations. Validate response shape against a schema, not just status codes; drive edge cases from data rather than duplicated requests; and assert the failure paths as carefully as the happy path. Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer. The references are the Postman documentation and the Newman command-line documentation, and the Postman Learning Center guides on writing tests with the pm API and on JSON-schema validation. + +## [273] Example 05 on minikube (sldId 528) + +R + +The same services and the same verify.sh on a single-node Kubernetes cluster + +> Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod. +> +> The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results. + +## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) + +p. 244 + +- Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress. +- Scope: Example 05 (event-driven fan-out), three services, all six languages, one node. +- One Kafka broker, one Postgres instance, no service mesh: a local topology, not production. +- The contract carries over: Service names, the Secret, environment variables, the health endpoint. +- The same verify.sh runs against compose and the cluster: ten checks per language. + +Site: Appendix R · Code: examples/05-event-driven/k8s/ + +> What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it. +> +> What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged. +> +> The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec. + +## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) + +p. 245 + +- Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5. +- 8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services. +- NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation. +- No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. +- Every command passes --context cndp-k8s; setup-profile.sh restores the prior context. + +minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 + +> minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version. +> +> Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1. +> +> The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag. + +## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) + +p. 246 + +The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec + +> The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret. +> +> From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs. + +## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) + +p. 247 + +- kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1). +- postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw. +- Credential → Secret cndp-postgres-app, read through secretKeyRef. +- build: → docker build, then minikube image load; imagePullPolicy: Never. +- healthcheck → startup and readiness probes on the order-service /healthz. +- ports: 8080:8080 → NodePort 30080. + +The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses. + +> Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts. +> +> The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing. + +## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) + +p. 248 + +[chip] yaml · kustomize + +```yaml +# k8s/overlays/go/kustomization.yaml (excerpt) +images: + - name: order-service + newName: localhost/cndp-05/go-order-service + newTag: v1 + # shipping-consumer and notification-consumer map the same way +# appended to each Deployment's environment by a JSON patch +- name: DATABASE_URL + value: "postgres://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb" +- name: KAFKA_BOOTSTRAP + value: "cndp-kafka-bootstrap:9092" +# memory: requests 128Mi, limits 256Mi +``` + +base/ is language-neutral; each overlay maps the images and appends the variables its language reads. + +> The Go overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service. +> +> Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base. + +## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) + +p. 249 + +docker build on the host, minikube image load into the node; pods never pull + +> The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load. +> +> The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest. + +## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) + +p. 250 + +[chip] bash · minikube + +```bash +# from examples/05-event-driven/, with no other profile or container running +k8s/setup-profile.sh # create cndp-k8s; check the published ports +k8s/build-images.sh go # docker build, then minikube image load +k8s/deploy.sh go # Strimzi, Kafka, Postgres, LGTM, then the overlay +RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh +# all six languages, a fresh namespace for each +OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh +# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile +k8s/teardown.sh --operator +minikube stop -p cndp-k8s +``` + +With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged. + +> The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Go images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay. +> +> verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0. +> +> What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images. + +## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) + +p. 251 + +- 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). +- Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s. +- Tempo received traces from order-service, shipping-consumer and notification-consumer in every language. +- shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset. +- Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them. + +Times cover the image build and load, the deploy, the checks and the namespace teardown. + +> The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language. +> +> The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster. diff --git a/lgtm-presentation/text/Designing-Cloud-Native-APIs-Python.md b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Python.md new file mode 100644 index 00000000..e29ce5e3 --- /dev/null +++ b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Python.md @@ -0,0 +1,4002 @@ +# Designing-Cloud-Native-APIs-Python + +Source: Designing-Cloud-Native-APIs-Python.pptx · 281 slides + +## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native APIs (sldId 256) + +From principles to a running system on Kubernetes — communication, composition, data, events, and the platform that governs them. + +Robert Sedor + +Chief Architect, Application Development + +> Welcome and framing. This is a working architect's tour of designing cloud-native APIs — not a survey of buzzwords. Set expectations: we build one realistic system (five services and a gateway) and re-examine it from twelve angles, then the appendices go deep on protocols, versioning, WebSockets, sagas, errors, caching, failure modes and API testing, and run Example 05 on minikube. Tell the audience the whole deck is a reference they can keep. Mention the running stack up front: Python/FastAPI, gRPC, GraphQL, Kafka, Kubernetes. + +## [002] AGENDA · Agenda (sldId 257) + +- Setting up · Patterns in the age of AI +- 01 · Cloud-Native Principles +- 02 · Communications +- 03 · Composition +- 04 · Data +- 05 · Event-Driven +- 06 · Stream Processing +- 07 · Workflows & Jobs +- 08 · API Management +- 09 · API Registry +- 10 · API Metadata +- 11 · Observability +- 12 · Security +- 13 · Anti-Patterns +- Appendices A–D · Protocols, Versioning, WebSockets, Sagas +- Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing +- Appendices K–L · Monolith → Microservices, Caching Patterns +- Appendices M–O · Failure modes, Feature Flags, API Testing +- Appendix R · Example 05 on minikube +- Glossary of acronyms + +p. 1 + +> The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns. +> +> The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck. +> +> Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order. + +## [003] OVERVIEW · What we'll build, and the order we'll build it (sldId 258) + +- Foundations — cloud-native principles and the canonical system we carry throughout +- Communications — REST, gRPC, GraphQL, and synchronous vs. asynchronous +- Composition & Data — gateways, federation, database-per-service, outbox, sagas +- Event-driven & Stream processing — the log, schemas, derived streams, KEDA scaling +- Workflows & Jobs — orchestration, choreography, batch and scheduled work +- The API platform — gateway, registry (Apicurio), metadata (OpenMetadata), observability +- Anti-patterns — and the heuristics that keep you out of them + +p. 2 + +> Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close. Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to. For a 90-minute talk, cover the main twelve; for the full three hours, work through the appendices too. + +## [004] SETTING UP · Why patterns matter more in the age of AI (sldId 259) + +- AI tooling (Claude, Claude Code, frontier models) turns intent into code at a rate that was not possible before. The bottleneck shifted: it's no longer typing, it's knowing what to ask for and what to accept. +- Patterns are your vocabulary at three layers: design patterns (factory, strategy, observer), architecture patterns (hexagonal, microservices, event-driven), system design patterns (sagas, caching, strangler fig — the scope of this deck). +- Without that vocabulary you can't specify precisely, can't evaluate what comes back, can't tell good code from plausible-looking code, or notice when an anti-pattern is dressed as a solution. The AI is fluent; you still need to be literate. +- All architecture is the least-worst decision — every choice trades something away. +- All management is risk management — you manage the distribution of failures, not their existence. +- There is no such thing as a free lunch — every pattern has a cost; ask whether you can afford it. +- There is no problem which cannot be made worse — the wrong fix amplifies the original pain. + +p. 3 + +> Open the deck by naming the elephant. AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows. They produce code fluently. From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run. The risk is also new: a developer who doesn't have the vocabulary to evaluate what comes back is rolling dice in production. The patterns in this deck are not nostalgic computer-science trivia; they are the literacy that lets you direct AI tooling instead of being directed by it. Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing). The four maxims are the philosophical spine. ‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit. ‘Risk management’: you manage the distribution of failures, not their existence. ‘No free lunch’: every pattern (cache, saga, eventing, decomposition) has a cost — a stale read window, a compensation path, a coordination tax, an operational complexity. The question is whether you are buying the right thing for what you are paying. ‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap. The closing line is the audience take-home: AI is the amplifier; the patterns are the literacy that decides which direction it amplifies. + +## [005] SETTING UP · Three layers of patterns literacy (sldId 260) + +p. 4 + +> The visual companion to the three layers of patterns literacy. Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch). The arrows up the middle show how each lower layer informs the next: you can't reason about a saga (system layer) without knowing what a state machine and a compensation are (architecture / design layer); you can't reason about content-based routing without understanding the strategy pattern. The blue caption in the middle is the through-line: knowing this vocabulary is what lets you ask AI tooling for the right thing, recognise what it produces, and judge why it is or isn't fit. The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles. Make the audience comfortable with the maxims, especially the last one (no problem cannot be made worse) — it's the warning at the heart of using AI tooling well. + +## [006] THE RUNNING EXAMPLE · One system, carried end to end (sldId 261) + +We will design every concern against these five services and a gateway — the same example, re-examined from each angle. + +p. 5 + +> This is the single most important slide to anchor on — every later example refers back to it. Walk each service: order-service owns the order lifecycle and exposes REST at the edge; inventory, payment and shipping are internal, reached by gRPC and contributing to GraphQL reads; notification-service is Kafka-only, consuming order.placed. The graphql-gateway composes reads across them. Emphasize that each service exposes only the protocols that fit its role — not a uniform surface. Promise the audience we'll return to this picture a dozen times. + +## [007] Cloud-Native Principles (sldId 262) + +01 + +Properties first, tools second. + +> Transition into section one. The core message: cloud-native is not a stack you buy, it is a set of runtime properties your service must exhibit. Tools (Kubernetes, Istio, KEDA) exist to make those properties cheap to obtain — but if your service isn't written to lean on them, no platform can rescue it. + +## [008] CLOUD-NATIVE PRINCIPLES · Cloud-native is a set of runtime properties (sldId 263) + +If your API isn't disposable, observable, resilient, elastic, automatable and loosely coupled, the platform can't help it. + +p. 6 + +> Six pillars. Spend a sentence on each: disposable (12-factor, fast start, clean stop, no local state), observable (traces/metrics/logs, health probes), resilient (timeouts, retries, circuit breakers, graceful degradation), elastic (scale to load and to zero), automatable (declarative config, GitOps, self-healing), loosely coupled (contracts not calls, async where it fits). The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties. + +## [009] CLOUD-NATIVE PRINCIPLES · The platform makes the properties the default (sldId 264) + +- Kubernetes gives you the disposable process model: liveness/readiness probes, rolling deploys, self-healing. +- KEDA (and the http-add-on) make elasticity real — scale on lag or RPS, all the way to zero. +- Istio supplies resilience and security at the wire: timeouts, retries, circuit breaking, mTLS. +- OpenTelemetry + Tempo / Mimir / Prometheus / Grafana / Kiali make every request observable. +- Your job is to write services that lean on these — not to reimplement them per service. + +p. 7 + +> The punchline is the bold line: your job is to lean on the platform, not reimplement it. Concretely — don't write your own retry library when Istio does it at the wire; don't build a bespoke autoscaler when KEDA scales on lag and RPS; don't hand-roll metrics plumbing when OpenTelemetry plus the LGTM stack gives it to you. Every line of resilience code you write per service is a line you maintain forever and that drifts between teams. The platform makes the good behaviour the default. + +## [010] CLOUD-NATIVE PRINCIPLES · Design-first: the contract precedes the code (sldId 265) + +- Agree the interface before implementing it — OpenAPI for REST, .proto for gRPC, AsyncAPI for events. +- The contract is a versioned artifact in the registry, gated in CI, owned by the producing domain. +- Consumers code against the contract and generated stubs, never against your database or internals. +- This is what makes services independently deployable — the promise the contract encodes is the boundary. + - Tooling: pip with a fully pinned requirements.txt for reproducible Python builds; FastAPI emits OpenAPI; buf / protoc for gRPC; Strawberry exports SDL. + +p. 8 + +> Design-first is the cultural prerequisite for everything that follows. The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI. Consumers code against the contract and generated stubs, never against your database. Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary. If you skip this, you get distributed coupling that's worse than a monolith because it fails over the network. + +## [011] CLOUD-NATIVE PRINCIPLES · The twelve-factor app, revisited (sldId 266) + +- The twelve-factor app (2011) is the original recipe for software that's disposable, scalable and portable — the discipline beneath the runtime properties we just saw. +- It predates Kubernetes but maps onto it almost one-to-one: most factors are now things the platform provides, not code you write. +- So read the factors by ownership — a few you still author (config from the environment, statelessness, clean start/stop, logs as streams) and the rest the platform hands you (scaling, build/release/run, attached backing services). +- In a microservices, event-driven system they get richer: “backing services” means Postgres, Kafka and the schema registry as swappable URLs; “logs” becomes correlated traces, metrics and logs. +- Treat it as a pre-flight check: “will this survive the platform?” The twelve factors are listed and mapped to where each lives in our architecture. + +p. 9 + +> Frame the twelve-factor app for the audience: it's the 2011 Heroku methodology that first codified what we now call cloud-native, and it's worth revisiting because it predates Kubernetes yet maps onto it almost perfectly. The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services). In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability. End by teeing up the list and the mapping diagrams. + +## [012] CLOUD-NATIVE PRINCIPLES · What the twelve factors are (sldId 267) + +The original 2011 checklist — most are now platform concerns; only a few are yours to write. + +p. 10 + +> A lot of people nod along to ‘twelve-factor’ without being able to name the twelve, so here they are at a glance, one line each. Don't read them aloud — let people scan. Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams). That sets up the map of each factor onto our architecture. + +## [013] CLOUD-NATIVE PRINCIPLES · Twelve factors, mapped to our system (sldId 268) + +Most factors are now platform defaults you adopt; a few are discipline you still write. + +p. 11 + +> This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building. Read it left to right: the twelve factors cluster into five things they ask of you, and each maps to a concrete piece of our stack and the section that covers it. Build & ship (codebase, dependencies, build/release/run, parity) becomes one repo, one image per release, the same image everywhere (§1). Configure becomes environment config (§1). Run on the platform (stateless processes, concurrency, disposability, port binding) becomes stateless pods, KEDA scaling, fast start/stop, and services behind Istio (§1, 2, 6). Attach resources becomes Postgres, Kafka and Apicurio as swappable URLs (§4–5). Operate (logs, admin processes) becomes OpenTelemetry into the LGTM stack and admin work as Jobs/CronJobs (§7, 11). The caption is the message: most of these are now defaults you adopt, not code you write — which is exactly what 'cloud-native' buys you. + +## [014] CLOUD-NATIVE PRINCIPLES · A 12-factor FastAPI service, ready for the platform (sldId 269) + +[chip] python · FastAPI + +```python +# main.py (excerpt); requirements.txt pins every package with == +class Settings(BaseSettings): # config from env — factor III + database_url: str + kafka_bootstrap: str = "" + service_version: str = "0.0.0" +settings = Settings() +app = FastAPI(title="order-service", version=settings.service_version, lifespan=lifespan) +@app.get("/healthz") # liveness — the process is up +def healthz(): return {"status": "ok"} +@app.get("/readyz") # readiness — dependencies are reachable +async def readyz(): + try: + async with pool.acquire() as conn: + await conn.fetchval("SELECT 1") + except Exception: + return JSONResponse({"status": "down", "checks": {"database": "unreachable"}}, 503) + return {"status": "ready", "checks": {"database": "ok"}} +``` + +Probes, env-config and an explicit version are the price of admission for a Kubernetes Deployment. + +p. 12 + +> Walk the code: config comes from the environment through pydantic-settings (factor III), never from baked-in files, and requirements.txt pins every package with == so the image build is reproducible. The version on the FastAPI app comes from SERVICE_VERSION and shows up in OpenAPI and the registry. +> +> The two probes are the contract with Kubernetes. /healthz is liveness (is the process alive? if not, restart it) and never checks dependencies. /readyz is readiness (can it serve?): it runs SELECT 1 on the database pool and, when that fails, answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. Getting liveness and readiness wrong is a top cause of cascading restarts, so stress the distinction. + +## [015] Communications (sldId 270) + +02 + +Match the interaction style to the call. + +> Section two. The thesis: there is no single right protocol — there is a right protocol per interaction. Resist the urge to standardize on one wire for everything. The section gives a decision rule and then shows each protocol in code against the running system. + +## [016] COMMUNICATIONS · Pick the protocol for the interaction (sldId 271) + +REST for reach, gRPC for internal speed, GraphQL for client-driven reads, async for decoupled facts. + +p. 13 + +> Four interaction styles, four defaults. REST for the public/edge surface where reach and caching matter; gRPC for internal service-to-service where latency and streaming matter; GraphQL for client-driven reads that compose many sources in one round trip; async events for decoupled facts that fan out. Most real systems use all four — the skill is matching each call to the right one. Note the sync/async chips at the bottom. This slide is the map for the whole section. + +## [017] COMMUNICATIONS · REST at the edge with FastAPI (sldId 272) + +[chip] python · FastAPI + +```python +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel +class OrderIn(BaseModel): # request contract, validated by Pydantic + sku: str; quantity: int +class Order(BaseModel): + id: str; sku: str; quantity: int; status: str +@app.post("/orders", response_model=Order, status_code=201) +async def place_order(body: OrderIn) -> Order: + order = await orders.create(body) + await events.publish("order.placed", order) # emit the fact + return order +@app.get("/orders") # cursor pagination, not offset +async def list_orders(after: str | None = Query(None), limit: int = 50): + return await orders.page(after=after, limit=limit) +``` + +Resource-shaped URLs, validated contracts, correct status codes, cursor pagination — the edge surface other teams depend on. + +p. 14 + +> This is the edge surface. Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs); (2) request and response contracts validated by Pydantic so bad input never reaches your logic; (3) correct status codes — 201 on create, not 200; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth. FastAPI emits the OpenAPI spec from the handlers without extra code, and that spec is what we register and gate. Test this surface with curl and Postman. + +## [018] COMMUNICATIONS · gRPC for internal service-to-service calls (sldId 273) + +[chip] protobuf + python · grpc + +```protobuf+python +// inventory.proto — the contract, registered in Apicurio +syntax = "proto3"; +service Inventory { + rpc ReserveStock (ReserveRequest) returns (ReserveReply); +} +message ReserveRequest { string sku = 1; int32 quantity = 2; } +message ReserveReply { bool reserved = 1; int32 remaining = 2; } +# server.py — generated stubs from protoc / buf +class InventoryServicer(inventory_pb2_grpc.InventoryServicer): + def ReserveStock(self, request, context): + remaining = stock.reserve(request.sku, request.quantity) + return inventory_pb2.ReserveReply( + reserved=remaining >= 0, remaining=max(remaining, 0)) +``` + +Schema-first, binary, multiplexed over HTTP/2 — the right default for chatty internal paths. Test with ghz. + +p. 15 + +> gRPC for the internal paths. The .proto is the contract, and it lives in the registry like OpenAPI. Highlight that the field numbers (=1, =2) are the wire contract — we return to exactly why in Appendix B. Generated stubs (via protoc or buf) mean both sides share types; no hand-written JSON parsing. It's binary and multiplexed over HTTP/2, so it's fast and supports streaming. The right default for chatty service-to-service calls where you control both ends. Load-test it with ghz. + +## [019] COMMUNICATIONS · Synchronous coupling vs. asynchronous facts (sldId 274) + +A synchronous call shares fate; an event shares a fact. Reach for async wherever immediacy isn't required. + +p. 16 + +> The central trade-off of the whole talk in one picture. Top row: a synchronous call couples caller to callee in time and availability — if payment-service is down, the order request fails too, and availability multiplies downward across a chain. Bottom row: emitting order.placed as a fact lets the order succeed immediately; consumers catch up from the log if they were down. The rule: reach for async wherever immediacy isn't strictly required. This sets up the entire event-driven section later. + +## [020] COMMUNICATIONS · The mesh handles the wire, not your code (sldId 275) + +Istio sidecars give you mTLS, retries, timeouts and telemetry — uniformly, without per-service code. + +p. 17 + +> Service mesh in one picture. Your app speaks plain HTTP or gRPC to localhost; the Istio sidecar (Envoy) intercepts and adds mTLS, retries, timeouts and telemetry on the wire — uniformly, for every service, with zero application code. istiod is the control plane distributing policy and identity (SPIFFE). The key insight ties back to principles: resilience and security become platform defaults, not per-service reimplementations. The configuration itself is an Istio VirtualService. + +## [021] COMMUNICATIONS · Resilience as configuration, not per-call code (sldId 276) + +[chip] yaml · istio + +```yaml +# Istio: bounded retries and timeout for inventory-service +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: inventory } +spec: + hosts: [inventory] + http: + - timeout: 2s + retries: + attempts: 2 # bounded — never an unbounded retry storm + perTryTimeout: 800ms + retryOn: connect-failure,unavailable + route: + - destination: { host: inventory } +``` + +Bounded retries with backoff live in the mesh. Unbounded client retries are how an incident becomes an outage. + +p. 18 + +> Resilience as configuration. This VirtualService gives inventory a 2-second timeout, two retry attempts with an 800ms per-try timeout, retrying only on connect-failure and unavailable. The critical word is bounded — attempts: 2, not infinite. Tell the war story: unbounded client-side retries are how a brief blip becomes a full outage, because every caller piles on the struggling service (a retry storm). Putting this in the mesh means it's consistent across every service and tunable without redeploying code. + +## [022] Composition (sldId 277) + +03 + +Aggregate at the edge, not inside services. + +> Section three. The question: when a client needs data from several services, where does the joining happen? The answer that keeps services decoupled: at the edge, in a gateway or BFF — never by services calling each other in deep chains. That chaining is how you accidentally rebuild a monolith over the network. + +## [023] COMPOSITION · One query, many services (sldId 278) + +The gateway presents one schema and fans out to REST and gRPC back-ends, then joins and shapes the result. + +p. 19 + +> GraphQL federation against the running system. The gateway presents one schema to clients and fans out to REST and gRPC back-ends, then joins and shapes the result. The client makes one round trip and asks for exactly the fields it needs. The services stay unaware of each other; the gateway owns the composition. Contrast this with services calling services, which we'll warn against on the patterns slide. The resolver code shows how the gateway composes the calls. + +## [024] COMPOSITION · A composing resolver in Strawberry (sldId 279) + +[chip] python · Strawberry + +```python +import strawberry # modern, code-first GraphQL for Python +@strawberry.type +class Order: + id: str; sku: str; status: str + @strawberry.field # resolved on demand, from another service + async def stock(self) -> int: + reply = await inventory_client.reserve_stock_view(self.sku) # gRPC + return reply.remaining +@strawberry.type +class Query: + @strawberry.field + async def order(self, id: str) -> Order: + return await order_client.get(id) # REST call to order-service +schema = strawberry.Schema(query=Query) # SDL exported to the registry +``` + +The gateway owns aggregation; each field resolves against the owning service. Graphene is the alternative if you need its ecosystem. + +p. 20 + +> The resolver makes federation concrete. The Order type's stock field resolves on demand by calling inventory-service over gRPC; the top-level order query calls order-service over REST. Each field resolves against its owning service — the gateway is just the composition layer. Mention the N+1 risk: naive per-field resolution can explode into many back-end calls, so production gateways use DataLoader-style batching. Strawberry is the modern code-first choice; Graphene is the alternative if you need its broader ecosystem. The schema's SDL is exported to the registry. + +## [025] COMPOSITION · Three ways to compose — and where each belongs (sldId 280) + +Push aggregation to a gateway or BFF. Chaining calls inside services rebuilds the monolith over the network. + +p. 21 + +> Three composition patterns: an API gateway (one composed schema for everyone), a backend-for-frontend (a tailored aggregate per client type — web vs mobile), and GraphQL federation (a distributed graph). All three push aggregation outward to the edge. The anti-pattern to call out explicitly: service-to-service call chains, where order calls inventory calls pricing calls... — synchronous, deeply coupled, and impossible to reason about. That rebuilds the monolith with network latency between the pieces. + +## [026] Data (sldId 281) + +04 + +Own your data; share it through contracts. + +> Section four, and the hardest discipline to hold. The rule: each service owns its data and shares it only through APIs and events — never through a shared database or by reaching into another service's tables. This is what makes independent deployment and independent scaling real. The slides cover ownership, the dual-write trap, and the saga. + +## [027] DATA · A database per service (sldId 282) + +Each service owns its schema and storage engine; no other service touches its tables directly. + +p. 22 + +> Database-per-service. Each service owns its schema and even its storage engine — order and payment on Postgres, something else on a document store if that fits. No other service touches those tables directly; the only doors in are the API and the events. The hard part is social, not technical: someone will always want to 'just join across two services' databases' for a report. That shortcut is the single most common way microservice independence dies. Reporting needs go through the metadata/lineage layer or a dedicated read model, not direct table access. + +## [028] DATA · CQRS — Command Query Responsibility Segregation (sldId 283) + +Separate the write model from the read model. They have different shapes, scales and even different stores. + +p. 23 + +> CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises for invariants and consistency: a normalised schema in a transactional store (Postgres, MySQL). The read side optimises for query shape: denormalised wide tables, an Elasticsearch index, a Mongo document, or whatever fits the question being asked. The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern. The cost: eventual consistency, a small lag between the write committing and the read model catching up. The benefit: each side can scale, schema-evolve and even be stored independently. Use this pattern when reads and writes have different shapes and access patterns. Avoid it when a single store handles both; CQRS adds operational complexity that isn't free. The classic motivating case: a service that takes thousands of writes per second but is read by analytics, dashboards and search — the write model serves the writes, the read model serves the rest. CQRS pairs naturally with event sourcing on the write side and event streaming for projections; the Event-Driven section draws both distinctions. + +## [029] DATA · CQRS in practice (sldId 284) + +[chip] python · FastAPI + asyncpg + aiokafka + +```python +# Write side — command handler (one transaction, outbox row, no broker call) +@app.post("/orders") +async def create_order(body: OrderIn) -> Order: + async with db.transaction(): # one local transaction + order = await write_db.execute(insert_order_query, body) + await write_db.execute(insert_outbox_query, + topic="order.placed", payload=order.model_dump_json()) + return order # commit → CDC publishes the event +# Read side — query handler reads from a different, denormalised store +@app.get("/orders/{oid}/summary") +async def order_summary(oid: str) -> OrderSummary: + row = await read_db.fetch_one( + "SELECT * FROM order_summary WHERE order_id = $1", oid) + if not row: raise HTTPException(404) + return OrderSummary(**row) +# Projection — consumes events, upserts the read model (idempotent) +async for msg in consumer: # subscribes to order.placed + event = deserialize(msg.value) + await read_db.execute(upsert_order_summary, event) + await consumer.commit() # at-least-once delivery +``` + +p. 24 + +> The Python shape. The write side is a normal POST handler in an existing transaction (we already saw the outbox pattern). The read side is a separate GET that queries a denormalised 'order_summary' table that holds the JOINed, aggregated view the UI needs. The projection (bottom of the slide) is a Kafka consumer that subscribes to order events and upserts into the read model — the upsert must be idempotent because Kafka delivery is at-least-once. The read-model table is denormalised, not 3NF: it holds customer_name (denormalised from customers), line_count (aggregated from order_lines), and any other field the query needs without a JOIN. Different teams can own the read side and the write side. Different scaling: the read store can be scaled horizontally (read replicas, search index) without affecting writes. CQRS adds complexity: use it when the read and write shapes have diverged, not as a default. + +## [030] DATA · Log tailing — the database commit log is the event stream (sldId 285) + +Debezium tails the WAL and publishes every committed row as a Kafka event. No dual-write. + +p. 25 + +> The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC. The application writes ordinary SQL. The database commits and the change lands in the write-ahead log (WAL on Postgres, redo log on Oracle, binlog on MySQL, transaction log on SQL Server). Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved. The application doesn't know Debezium exists. There is no dual-write — the commit is the single source of truth, and the event is a strictly downstream consequence. This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use. Debezium supports Postgres, MySQL, MariaDB, SQL Server, Oracle, MongoDB, Db2, Cassandra, and Vitess out of the box. Run mode: deploy as Kafka Connect workers, or use Debezium Server (standalone) if you don't want a Kafka Connect cluster. WAL = Write-Ahead Log: the database's commit-ordered durable log that already exists for replication and crash recovery; Debezium just subscribes to it. + +## [031] DATA · Dual-write is unsafe — use the outbox (sldId 286) + +Write state and the outbox row in one transaction; CDC relays the committed fact to Kafka. + +p. 26 + +> This is the slide that prevents a class of production bugs. The dual-write problem: if you write to your database and then publish to Kafka as two separate operations, a crash between them leaves you inconsistent — the order exists but the event was never sent, or vice versa. The fix is the transactional outbox: write the state change and an outbox row in one local transaction, then a change-data-capture tool (Debezium) tails the database log and relays the committed row to Kafka. The database commit is the single source of truth. The outbox write itself is one database transaction, shown in code. + +## [032] DATA · The outbox write — one transaction, no lost events (sldId 287) + +[chip] python · asyncpg + Debezium + +```python +async def create(body: OrderIn) -> Order: + async with db.transaction(): # single local transaction + order = await db.execute(insert_order, body) + await db.execute( + insert_outbox, # same txn -> atomic with the state change + topic="order.placed", + key=order.id, + payload=order.model_dump_json(), + ) + return order # Debezium tails the WAL and publishes +# No await-kafka on the request path: if the broker is down, the order still +# commits and the event is delivered when CDC catches up. +``` + +The database commit is the source of truth. The outbox + CDC turns it into a guaranteed, ordered event. + +p. 27 + +> The whole trick is the single `async with db.transaction()` block: the order insert and the outbox insert commit together or not at all. The request path never awaits Kafka: if the broker is down, the order still commits and the event ships later when CDC catches up. This decouples your request latency and availability from Kafka's. Debezium tails the write-ahead log and publishes outbox rows in commit order, giving you a guaranteed, ordered event stream. This pattern shows up again implicitly in the saga appendix, where the state transition and outbound command must commit together. + +## [033] DATA · Consistency without distributed transactions: the saga (sldId 288) + +A chain of local transactions; failures trigger compensating actions, never a cross-service rollback. + +p. 28 + +> The saga is how you get consistency across services without a distributed transaction (there is no two-phase commit here, and you don't want one). It's a sequence of local transactions, each emitting the event that triggers the next: order created, payment taken, stock reserved, shipment booked. When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order). Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics. + +## [034] Event-Driven (sldId 289) + +05 + +Emit facts; let consumers decide. + +> Section five — the asynchronous backbone we kept pointing at. The mental shift: a producer emits a fact about something that happened (order.placed) and does not know or care who consumes it. Consumers decide what to do. This inverts the dependency direction, and it is what decouples services in time and ownership. + +## [035] EVENT-DRIVEN · The event backbone — one fact, many reactions (sldId 290) + +order.placed fans out to consumers the producer never names. Add a consumer without touching the producer. + +p. 29 + +> order.placed fans out to payment, shipping and notification — and the producer names none of them. The power move: you can add a new consumer (say, a fraud-check service) without touching or redeploying the producer. Contrast with the synchronous world, where adding a dependency means changing the caller. This is the open/closed principle at the architecture level. Kafka (run by Strimzi on Kubernetes) is the backbone; Pulsar is an alternative with similar semantics. + +## [036] EVENT-DRIVEN · Producing and consuming with aiokafka (sldId 291) + +[chip] python · aiokafka + +```python +from aiokafka import AIOKafkaProducer, AIOKafkaConsumer +# producer — order-service (called by the CDC relay or directly) +async def publish(topic: str, order: Order): + await producer.send_and_wait(topic, key=order.id.encode(), + value=serialize(order)) # schema-aware serde +# consumer — notification-service, Kafka-only by design +consumer = AIOKafkaConsumer("order.placed", + bootstrap_servers=settings.kafka_bootstrap, + group_id="notification-group", # offset tracked per group + enable_auto_commit=False) # commit after side-effect succeeds +async for msg in consumer: + await send_email(deserialize(msg.value)) + await consumer.commit() # at-least-once; handlers idempotent +``` + +Commit offsets after the side-effect, and make handlers idempotent — at-least-once delivery is the contract. + +p. 30 + +> Two things to drill home from this code. First, enable_auto_commit=False, and commit only once the side-effect (send_email) has succeeded: commit first and then crash, and the work is lost. Second, because we commit after, a crash mid-handler means the message is redelivered, so delivery is at-least-once and your handlers must be idempotent (dedupe by message key or an idempotency key). Exactly-once is a marketing term that mostly means at-least-once plus idempotency. The schema-aware serde line leads into schemas and the registry. + +## [037] EVENT-DRIVEN · Schemas are the contract — the registry enforces them (sldId 292) + +Apicurio holds versioned schemas; compatibility is checked at publish time so breaking changes are rejected early. + +p. 31 + +> Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate. The schema (Avro, Protobuf or JSON Schema) lives in Apicurio. Producers and consumers fetch it; compatibility is checked at publish time so a breaking change is rejected before it ever reaches the topic. This is what makes adding a consumer safe. We come back to event versioning rules in Appendix B. + +## [038] EVENT-DRIVEN · Event sourcing vs. event streaming (sldId 293) + +Two patterns, often confused. Sourcing: the events are the state. Streaming: events are the transport. + +p. 32 + +> The vocabulary clarification most teams need before they go further with events. Event sourcing means events are the database — there is no 'current state' row anywhere; current state is computed by folding the entire event history. Adding a credit to an account isn't UPDATE balance SET balance = balance + 100; it's INSERT INTO events VALUES ('credit', 100), and reading the balance means summing every event for that account (with snapshots for performance). Strong fit for audit-heavy systems (banking, healthcare records, regulatory), temporal queries ("what was the balance on March 3rd?"), and any domain where the history is itself a primary asset. Tools: Marten (Postgres-backed, .NET), EventStoreDB, Axon (JVM). Event streaming means events are how facts move between services — each service still keeps its current state in a normal database, but emits events ("order.placed", "payment.processed") that other services subscribe to. The event is a transport mechanism, not a storage mechanism. Strong fit for integration, fan-out, decoupling. Tools: Kafka, Pulsar, NATS JetStream. These are not mutually exclusive. A common combination is full CQRS: the write side is event-sourced (so you have an audit trail of state changes), and the read-side projections are fed by streaming those events to consumers that build denormalised views. The acid test: is the event itself the source of truth (sourcing), or is it a propagated fact about a change that happened in a store (streaming)? In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core. + +## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) + +Different jobs, often together: substrate (Kafka, Pulsar), compute (Flink), movement (NiFi, Debezium). + +p. 33 + +> Each of the four tools does a different job. Kafka is the substrate: a partitioned, ordered, durable log. Consumers track their own offsets and can replay. It's the de facto event spine for cloud-native architectures; Strimzi runs it natively on Kubernetes. Pulsar is also substrate, in the same broad category as Kafka but with native pub/sub plus queue semantics in the same product, tiered storage that pushes cold data to cheap object stores automatically, and multi-tenancy built into the broker. Pulsar has a Kafka-protocol shim so consumers can speak Kafka while writing to Pulsar. Flink is compute: not a transport layer, but a stateful stream-processing engine. Flink sits on top of Kafka or Pulsar, consumes streams, applies operators (windowed aggregations, joins, filters, stateful transformations) with exactly-once semantics via checkpointing, and produces new streams. Alternatives: Kafka Streams (lighter, JVM-only, embedded library), Spark Structured Streaming (heavier, batch-mindset). NiFi is movement: a visual dataflow / ETL tool with 200+ source-and-sink connectors and back-pressure built in. Think of it as the answer to 'I need to get data from SFTP to Kafka to an object store with these transforms in the middle' — you draw the flow in the UI rather than writing a pipeline. Debezium (covered in the Data section) is the CDC variant of movement. Together they compose: typical pipeline is source DB → Debezium or NiFi → Kafka or Pulsar → Flink for windowed aggregations → sink. The mistake is treating these as competitors. They complement each other; pick by the question your team is asking: durably hold events (substrate), compute over them (Flink), move them around (NiFi/Debezium). + +## [040] EVENT-DRIVEN · The log is the source of truth (sldId 295) + +Consumers track their own offset; replay is just rewinding. Kafka (Strimzi) and Pulsar are logs, not queues. + +p. 34 + +> The mental model correction most people need: Kafka is a log, not a queue. Messages are retained after they are read, and each consumer group tracks its own offset. That means replay is just rewinding an offset: a new consumer can process all of history, and a buggy consumer can be fixed and replayed. This is the foundation for event sourcing and for rebuilding stream-processing state, the subject of stream processing. Strimzi runs Kafka on Kubernetes; Pulsar offers similar log semantics with tiered storage. + +## [041] Stream Processing (sldId 296) + +06 + +Streams in, derived streams out. + +> Section six builds directly on the log. Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams. This is where real-time analytics, materialized views and complex event reactions live. + +## [042] STREAM PROCESSING · Derive new streams from streams (sldId 297) + +A streaming app is a topology of sources, stateful processors and sinks — joins, windows and aggregates. + +p. 35 + +> A stream-processing app is a topology: sources (input topics), stateful processors (filter, join, window, aggregate) and sinks (output topics or stores). The output of one topology is just another stream that other services consume. Emphasize stateful — unlike a simple consumer, these processors keep local state (running totals, window buffers) that must survive restarts. That state-management problem is what the next code slide and KEDA slide address. + +## [043] STREAM PROCESSING · A windowed aggregation with faust-streaming (sldId 298) + +[chip] python · faust-streaming + +```python +import faust # faust-streaming 0.15.3; the import name is faust +from datetime import timedelta +app = faust.App("stream-processor", broker="kafka://my-cluster-kafka-bootstrap", + store="memory://") # state is rebuilt from the changelog topic +orders_topic = app.topic("order.placed", value_type=Order) +revenue_topic = app.topic("revenue.by-merchant", value_type=Revenue) +# table = local, changelog-backed state: tumbling windows, no grace, by record time +revenue = app.Table("revenue", default=float) \ + .tumbling(300, expires=timedelta(seconds=300)) \ + .relative_to_stream() +@app.agent(orders_topic) +async def aggregate(stream): + async for order in stream.group_by(Order.merchant_id): # repartition by merchant + revenue[order.merchant_id] += order.total # stateful, per key and window + start = int(faust.current_event().message.timestamp // 300) * 300 + await revenue_topic.send(key=order.merchant_id, value=Revenue( + window_start=start, window_end=start + 300, merchant_id=order.merchant_id, + revenue=revenue[order.merchant_id].value())) # derived stream out +``` + +Local state + a changelog topic gives fault-tolerant aggregation. The output is itself a stream other services can consume. + +p. 36 + +> faust-streaming 0.15.3 is the maintained fork of Faust; the import name is still faust. The Table is the key idea: local state, a running revenue total per merchant and window, backed by a Kafka changelog topic, so when the pod dies the state rebuilds by replaying the changelog. The store is memory:// because every write is mirrored to the changelog: fault-tolerant local state without an external database in the hot path. +> +> The windows are tumbling five-minute windows with no grace, placed by each record's own timestamp (relative_to_stream). Every update goes to revenue.by-merchant, a derived stream other services consume. The example keeps the same contract as the Kafka Streams, Streamiz and goka versions; mention Flink as the cluster-scale alternative if asked. + +## [044] STREAM PROCESSING · KEDA scales workers on the signal that matters (sldId 299) + +Scale Kafka workers on consumer lag and HTTP services on request rate — both down to zero when idle. + +p. 37 + +> KEDA closes the elasticity loop. The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU. KEDA reads that lag and scales workers up, and down to zero when there's no lag. For synchronous HTTP services, the KEDA http-add-on scales on request concurrency/rate. One mechanism, two signals. Scale-to-zero is a real cost lever for spiky workloads. The KEDA configuration covers both: lag for events and RPS for HTTP. + +## [045] STREAM PROCESSING · Scale on lag (events) and on RPS (HTTP) (sldId 300) + +[chip] yaml · KEDA + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: { name: payment-consumer } +spec: + scaleTargetRef: { name: payment-consumer } + minReplicaCount: 0 + maxReplicaCount: 20 + triggers: + - type: kafka + metadata: { bootstrapServers: "cndp-kafka-bootstrap:9092", topic: order.placed, + consumerGroup: payment, lagThreshold: "100" } +--- +# HTTP: scale the FastAPI service on concurrent requests (KEDA http-add-on) +apiVersion: http.keda.sh/v1alpha1 +kind: HTTPScaledObject +metadata: { name: order-service } +spec: + hosts: [orders.example.com] + scaleTargetRef: { name: order-service, service: order-service, port: 8080 } + replicas: { min: 0, max: 30 } +``` + +One mechanism, two signals: queue depth for async workers, concurrency for synchronous APIs. + +p. 38 + +> Two manifests, one idea. The ScaledObject scales payment-consumer on Kafka lag for the order.placed topic, with minReplicaCount: 0 — true scale to zero when the topic is quiet, springing back when lag builds. The HTTPScaledObject does the same for the synchronous order-service based on request load via the http-add-on. The lagThreshold of 100 is the tuning knob: how many messages of backlog before adding a worker. Point out that scale-to-zero plus a cold-start budget is the trade-off to discuss with the audience for latency-sensitive paths. + +## [046] Workflows and Jobs (sldId 301) + +07 + +Not every unit of work is a request. + +> Section seven. A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work. Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes. + +## [047] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 302) + +A coordinator that commands keeps the flow in one place; events that trigger each other decouple but hide the flow. + +p. 39 + +> The two ways to coordinate a multi-step process. Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service. Choreography: each service reacts to the previous one's event with no central brain — maximally decoupled, but the end-to-end flow is emergent and hard to see. Neither is universally right; choose by the selection criteria in the comparison. This connects straight to the saga discussion and Appendix D. + +## [048] WORKFLOWS AND JOBS · Choosing between the two (sldId 303) + +Orchestration + +- One coordinator owns the saga and its state +- Flow is explicit and centrally observable +- One place to reason about, change and audit +- Risk: the orchestrator becomes a god-service +- Good for complex, long-lived business processes + +Choreography + +- Each service reacts to the previous event +- Maximally decoupled; no central bottleneck +- Flow is emergent — harder to see end-to-end +- Risk: nobody owns the overall process +- Good for simple, additive event reactions + +p. 40 + +> The decision heuristic: use orchestration for complex, long-lived business processes where you need auditability and a clear owner (order fulfillment, loan approval). Use choreography for simple, additive reactions where decoupling matters more than visibility (send a welcome email when a user signs up). The failure modes are mirror images: orchestration risks a god-service; choreography risks nobody owning the process and no one able to answer 'where is order 42 stuck?'. Observability (section 11) is what makes choreography survivable. + +## [049] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 304) + +Long, batch or periodic work belongs in Jobs/CronJobs or a scaled queue worker — never on the request path. + +p. 41 + +> Where non-request work lives on Kubernetes. A Job runs a task to completion (a data migration, a nightly export). A CronJob schedules Jobs (the reconciliation that runs at 2am). A queue worker continuously drains a topic and is scaled by KEDA on lag. The anti-pattern: doing long or heavy work inside an HTTP handler, blocking the request and timing out the client. The rule of thumb on screen — long, batch or periodic work never belongs on the request path. Hand it to a Job or a worker and return immediately, ideally with a status URL the client can poll. + +## [050] API Management (sldId 305) + +08 + +Treat the API surface as a platform. + +> Section eight zooms out from individual services to the API surface as a whole. Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability). The next three sections each take one of these in depth. + +## [051] API MANAGEMENT · Four cooperating planes (sldId 306) + +Gateway and mesh at runtime; registry for contracts; metadata for discovery; observability for signals. + +p. 42 + +> The four planes, and they map exactly to the rest of the platform sections. Runtime plane: the gateway and Istio mesh handle traffic, auth, rate-limiting and resilience. Contract plane: the registry (section 9) holds every API and event schema, versioned and gated. Discovery plane: metadata/catalog (section 10) lets people find and trust APIs. Signal plane: observability (section 11) tells you what is happening. The point: these are distinct responsibilities with distinct owners, not one monolithic 'API gateway product' that tries to do everything. + +## [052] API MANAGEMENT · Versioning and evolution are part of management (sldId 307) + +- Evolve additively — new optional fields and endpoints; never repurpose or remove without a new version. +- Carry a major version in the path (/v2) or media type; deprecate with headers and a sunset date. +- The registry's compatibility rules (BACKWARD / FORWARD / FULL) enforce this mechanically at publish time. +- Run old and new side by side; migrate consumers off the old contract before you retire it. +- Lifecycle: design → register → publish → consume → deprecate → retire — each step has an owner and a gate. + +p. 43 + +> Versioning is a management concern, not an afterthought. The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period. When you must break, carry a major version (in the path or media type) and run old and new side by side with a published sunset date, migrating consumers off before retiring. The registry's compatibility rules enforce the additive discipline mechanically at publish time. The bold lifecycle line is the takeaway: every contract moves design → register → publish → consume → deprecate → retire, and each transition has an owner and a gate. Appendix B drills into how REST, gRPC and GraphQL each handle this differently. + +## [053] API Registry (sldId 308) + +09 + +One versioned home for every contract. + +> Section nine. If contracts are the boundary, they need one authoritative home. The registry is where every OpenAPI spec, .proto, AsyncAPI doc and event schema lives — versioned, compatibility-checked, and gated in CI. This is the difference between 'we have contracts somewhere in git' and 'contracts are enforced before code ships'. + +## [054] API REGISTRY · Apicurio: contracts, versioned and gated (sldId 309) + +OpenAPI, AsyncAPI, proto and Avro/JSON schemas live in one registry with compatibility rules and CI gating. + +p. 44 + +> Apicurio is the concrete registry in this stack. Every contract type lands here: REST OpenAPI, GraphQL SDL, gRPC .proto, and event schemas (Avro/JSON). Each artifact has versions and a compatibility rule (BACKWARD, FORWARD, FULL). At publish time the registry checks the new version against the rule and rejects incompatible changes. Producers register; consumers fetch and generate stubs. The serde libraries for Kafka pull schemas from here by ID at runtime. A CI gate turns this into an automatic guardrail. + +## [055] API REGISTRY · Gate breaking changes in CI (sldId 310) + +[chip] python · Apicurio v3 + +```python +# registry_client.py — register a new version; the registry enforces the rule +import requests +REGISTRY = "http://apicurio.registry.svc/apis/registry/v3" +def publish(group: str, artifact_id: str, schema: str): + body = {"content": {"content": schema, # v3 CreateVersion body + "contentType": "application/json"}} + r = requests.post( + f"{REGISTRY}/groups/{group}/artifacts/{artifact_id}/versions", + json=body, timeout=10, + ) + if r.status_code == 400: # RuleViolationException: the rule rejected it + raise SystemExit("Incompatible schema change: blocking the merge") + r.raise_for_status() +# rule set once per artifact, e.g. BACKWARD: new consumers can read old data +``` + +The CI job fails the build on an incompatible change — the contract is enforced before code ships, not after. + +p. 45 + +> This is the guardrail that makes the whole contract story real. The CI pipeline posts the new schema version to Apicurio; if it violates the artifact's compatibility rule, Apicurio returns 400 and the script fails the build, blocking the merge. The point to make: the contract is enforced before code ships, not discovered broken in production by a downstream team. The rule (e.g. BACKWARD — new consumers can read old data) is set once per artifact and then enforced automatically forever. Tie it back: this is design-first from section 1, now mechanized. + +## [056] API Metadata (sldId 311) + +10 + +If they can't find it, it doesn't exist. + +> Section ten. A contract that's enforced but undiscoverable is half useless. Metadata is the discovery and trust layer: a searchable catalog of every API, topic and table, with ownership, lineage and quality signals. If a consumer can't find your API and trust it, they'll build their own or screen-scrape — and your boundary erodes. + +## [057] API METADATA · Discovery, lineage and ownership (sldId 312) + +OpenMetadata ingests from services, the registry and Kafka to build a searchable catalog with lineage. + +p. 46 + +> OpenMetadata is the catalog in this stack. It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog. The differentiator over a wiki is automated lineage: it knows that this topic feeds that table feeds this API, so it can answer 'if I change this field, who breaks?' before you change it. Ingestion is a scheduled connector, so the catalog stays current without manual upkeep. What consumers get from the catalog is the point of running it. + +## [058] API METADATA · What the catalog gives every consumer (sldId 313) + +- Discovery — search across APIs, topics and tables; find the owning team and how to get access. +- Trust — published SLOs, data-quality test results, freshness, and a tier/criticality rating. +- Lineage — column- and topic-level lineage answers 'if I change this, who breaks?' before you change it. +- Ownership & governance — tags, classifications (PII), and accountable owners attached to every asset. + - Ingestion runs as a scheduled connector against OpenAPI specs, the registry, Kafka and Postgres. + +p. 47 + +> Four concrete payoffs for consumers. Discovery: search across APIs, topics and tables, and find the owning team and how to get access — no more asking around in Slack. Trust: published SLOs, data-quality results, freshness and a criticality tier tell you whether to depend on it. Lineage: column- and topic-level impact analysis answers the change-safety question proactively. Ownership and governance: tags, PII classifications and accountable owners on every asset — which is also what your compliance team needs. The throughline of sections 9–10: a contract is only as good as your ability to find it, trust it, and know who owns it. + +## [059] Observability (sldId 314) + +11 + +Instrument once; correlate everything. + +> Section eleven, and the one that makes everything else operable. In a distributed system you cannot debug by reading one log file — a single user action touches five services. Observability is how you reconstruct that story. The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them. + +## [060] OBSERVABILITY · Three signals, one correlated story (sldId 315) + +OpenTelemetry emits traces, metrics and logs; the LGTM-style stack stores them and Grafana joins them. + +p. 48 + +> Three signals, each answering a different question. Traces: where did the time go and which service failed in this one request? Metrics: what are the aggregate rates, errors and latencies over time? Logs: what exactly happened in this component? OpenTelemetry is the vendor-neutral way to emit all three from your code. The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them. The magic is the shared trace_id linking a metric spike to the exact traces and logs behind it. The instrumentation comes first, then the trace. + +## [061] OBSERVABILITY · The signals in depth — traces, metrics, logs, baggage, profiles (sldId 316) + +Three classic pillars plus two that matter more every year. OpenTelemetry emits all five; one trace_id correlates them. + +p. 49 + +> Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer where the time went and which hop failed in one request. Metrics answer the trend question — aggregate rates, errors, latencies over time (the RED method: Rate, Errors, Duration; the USE method: Utilisation, Saturation, Errors). Logs answer what exactly happened in one component. Baggage is the signal most people miss — it's key-value context (tenant_id, user_tier, feature_flag) that propagates along the whole trace, so a value set at the edge is readable in a Kafka consumer three hops downstream. Profiles are the newest signal — continuous CPU / memory / lock sampling per service, answering which function is burning the CPU (Pyroscope, Parca, Grafana Phlare). The correlation bar at the bottom is the whole point: traces, metrics, logs, baggage and profiles all share trace_id + span_id + resource attributes (service.name, k8s.pod.name, deployment.version), so you can pivot — a metric spike leads to an exemplar trace, which leads to its logs, which leads to its CPU profile. OpenTelemetry is the vendor-neutral SDK and wire format for all five; traces/metrics/logs are GA, baggage is stable, profiling is the newest (the OTel profiling signal reached the ecosystem in 2024). + +## [062] OBSERVABILITY · What each signal answers — and when to reach for it (sldId 317) + +- Traces — 'where did the time go in this request, which hop failed?' Reach for it when debugging latency or a specific failed request. Stored in Tempo / Jaeger. +- Metrics — 'what is the aggregate rate / error / latency trend, is it getting worse?' Reach for it for dashboards, alerting, capacity. RED (Rate, Errors, Duration) for request-driven services; USE (Utilisation, Saturation, Errors) for resources. Stored in Prometheus / Mimir. +- Logs — 'what exactly happened in this component at this instant?' Reach for it for detail the trace doesn't carry. Structured (JSON), keyed by trace_id. Stored in Loki / ELK. +- Baggage — 'carry a value (tenant_id, user_tier) along the whole request so downstream services and their telemetry can use it.' Set once at the edge, readable everywhere downstream. W3C Baggage spec. +- Profiles — 'which function is burning CPU / allocating memory / holding locks, continuously, in production?' Reach for it for the latency the trace shows but doesn't explain. Pyroscope / Parca. + - The discipline: emit traces + metrics + logs always; add baggage when downstream needs request-scoped context; add profiling when you have CPU/memory mysteries a trace can't resolve. + +p. 50 + +> Walk each signal and the question it answers — the framing 'what question does this answer' is more useful to an engineer than 'what is this signal.' Traces are per-request; metrics are aggregate; logs are detail. The two newer ones deserve emphasis. Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant. Profiling is the frontier: continuous profilers (Pyroscope, Parca, Grafana Phlare) run in production at low overhead and answer the 'the trace says this span took 200ms but why' question by showing the actual call stack burning the CPU. The sub-bullet is the practical guidance on which to adopt when. + +## [063] OBSERVABILITY · Auto-injection vs. instrumentation — three distinct things (sldId 318) + +- Manual instrumentation — you write the spans. tracer.start_span(...), span.set_attribute(...), span.add_event(...). Needed for business operations the libraries can't know about ('reserve_stock', 'score_fraud'). The most signal per line, the most effort. +- Auto-instrumentation — a library / agent wraps known frameworks for you. HTTP servers and clients, gRPC, JDBC / database drivers, Kafka, Redis — each becomes spans with zero code change. Covers the plumbing; misses your domain. +- Auto-injection — the platform puts the auto-instrumentation agent into the pod for you. The OpenTelemetry Operator watches for an annotation (instrumentation.opentelemetry.io/inject) and mutates the pod at admission — no agent in your image, no Dockerfile change, no redeploy of the app to add tracing. +- How they layer: auto-injection delivers the agent → the agent provides auto-instrumentation of frameworks → you add manual spans for business logic on top. All three coexist in the same trace. + - Trade-off: auto-instrumentation is convenient but can be noisy (every framework call becomes a span) and slightly slower. Manual is precise but is work you own. Most teams: auto for the plumbing, manual for the domain, injection to roll it out fleet-wide. + +p. 51 + +> Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate. Manual: you call the tracer API yourself; necessary for domain operations. Auto-instrumentation: the OTel instrumentation libraries (or a language agent) wrap known frameworks: your HTTP handler, your DB driver, your Kafka client all emit spans without you touching them. Auto-injection: a Kubernetes-level mechanism; the OpenTelemetry Operator sees a pod annotation and injects the language agent + config as an init container / env vars at admission time, so you don't even put the agent in your image. Per-language specifics: Python: 'opentelemetry-instrument' wrapper or the operator injects it; instrumentation packages per library. Java/Quarkus: the OTel Java agent (-javaagent) auto-instruments everything; Quarkus also has build-time OTel extension. Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource. The bold line is the mental model: injection delivers the agent, the agent instruments frameworks, you add manual spans on top — they layer into one trace. + +## [064] OBSERVABILITY · Auto-instrument FastAPI with OpenTelemetry (sldId 319) + +[chip] python · OpenTelemetry + +```python +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.aiokafka import AIOKafkaInstrumentor +from opentelemetry import trace +FastAPIInstrumentor.instrument_app(app) # every request becomes a span +AIOKafkaInstrumentor().instrument() # context propagates onto the event +tracer = trace.get_tracer("order-service") +@app.post("/orders") +async def place_order(body: OrderIn): + with tracer.start_as_current_span("reserve_stock"): # custom span + await inventory.ReserveStock(body.sku, body.quantity) + # trace_id flows: REST -> gRPC -> Kafka -> consumer, automatically +``` + +The OTEL Collector exports traces to Tempo, metrics to Mimir/Prometheus, logs to Loki — all keyed by trace_id. + +p. 52 + +> The payoff line is how little code this takes. instrument_app(app) makes every FastAPI request a span automatically; the aiokafka instrumentor propagates trace context onto the event so the trace continues into consumers. You only hand-write spans for custom business operations worth measuring, like reserve_stock. The crucial behaviour is the last comment: the trace_id flows REST → gRPC → Kafka → consumer with no manual plumbing, because the instrumentors inject and extract context at each hop. The Collector ships everything to the backends keyed by that trace_id. Instrument once, correlate everything. + +## [065] OBSERVABILITY · How correlation works — context propagation (sldId 320) + +The trace context rides in-band with the request (W3C traceparent). The same trace_id at every hop is what stitches the story. + +p. 53 + +> Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling with the request, in-band, at every hop. The W3C traceparent header is the standard: version-traceid-parentspanid-flags. The trace_id (16 bytes) is constant across every hop of the request — that constancy is literally what stitches the distributed story into one trace. The span_id is new per operation, and each span records its parent span_id, which is how the backend rebuilds the tree. The flags byte carries the sampling decision (01 = sampled) so every hop honours the same decision. Alongside traceparent rides tracestate (vendor-specific) and baggage (your key-values). The transport differs per hop but the mechanism is identical: HTTP uses the traceparent header, gRPC uses metadata, Kafka uses message headers. The SDK injects the context on the way out and extracts it on the way in — automatically, which is why auto-instrumentation 'just works' across services. This propagated context is exactly why the same trace_id shows up on the trace waterfall, on every structured log line, and as an exemplar on your metrics — they all read it from the same in-band context. + +## [066] OBSERVABILITY · Baggage in practice — carry request context downstream (sldId 321) + +[chip] python · OpenTelemetry baggage API + +```python +from opentelemetry import baggage, context +from opentelemetry.trace import get_current_span +# Set baggage at the edge — it rides the whole trace, every downstream hop +@app.middleware("http") +async def add_baggage(request: Request, call_next): + ctx = baggage.set_baggage("tenant_id", request.headers.get("X-Tenant", "unknown")) + ctx = baggage.set_baggage("user_tier", lookup_tier(request), context=ctx) + token = context.attach(ctx) + try: + return await call_next(request) + finally: + context.detach(token) +# Read baggage anywhere downstream — even in a Kafka consumer three hops away +def handle_event(msg): + tenant = baggage.get_baggage("tenant_id") # propagated automatically + # also pin it on the current span so you can filter traces by tenant + get_current_span().set_attribute("tenant_id", tenant or "unknown") +``` + +Set once at the edge; read anywhere downstream. Pin to the span to filter traces by tenant_id. + +p. 54 + +> Baggage is the W3C-standard way to carry request-scoped key-values along the whole trace. Here we set tenant_id and user_tier in an HTTP middleware at the edge; the OTel SDK serialises them into the W3C baggage header and propagates them on every downstream call (HTTP, gRPC, Kafka) automatically. Three hops later, a Kafka consumer reads baggage.get_baggage('tenant_id') without anyone having passed it explicitly. The common idiom is to also set it as a span attribute (set_attribute) so the value is queryable in the trace UI: 'show me all slow traces for tenant acme.' Caution: baggage travels on the wire to every downstream service, including third parties if you call them, so never put secrets or PII in baggage. + +## [067] OBSERVABILITY · A single trace stitches the whole request (sldId 322) + +One trace_id propagates from the edge REST call through gRPC, Kafka and the consumer — five services, one story. + +p. 55 + +> This is what the instrumentation buys you, visually. One trace_id propagates from the edge REST POST through the gRPC ReserveStock call, onto the published order.placed event, and into the consumer that sends the notification. Five services, one readable waterfall. Point at the time axis: you can see exactly which span dominated latency. That same trace_id appears as an exemplar on your metrics and on every log line, so a latency alert is one click from the offending trace and its logs. This is what makes the choreographed, event-driven design from earlier sections debuggable. + +## [068] OBSERVABILITY · Sampling — head vs. tail (sldId 323) + +You can't store every trace at scale. Head decides at the start (cheap, blind); tail decides at the end (smart, costly). + +p. 56 + +> The cost-control reality: at scale you cannot store every trace, so you sample. The key question is when the keep/drop decision is made. Head sampling decides at the start, in the SDK, before the trace even completes — typically 'keep 1 in N' (probabilistic) or 'keep N per second' (rate-limiting). It's cheap and low-overhead, but it is blind at decision time: it can't keep a trace because it errored or was slow, because it decides before knowing. Tail sampling decides at the end, in the Collector, after the whole trace has been seen — the Collector buffers spans, waits for the trace to complete, then applies rules: keep all errors, keep all traces over 500ms, keep rare routes, sample the boring successful ones at 1%. Tail is smart but costs memory (buffering in-flight traces) and CPU (the Collector does real work). The strategies, named: probabilistic (keep X%), rate-limiting (cap per second), parent-based (follow the upstream's decision so a trace isn't half-sampled), and tail rules (error / latency / attribute based). The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate. Two rules to drill home: always sample by trace, not by span (a half-sampled trace is worse than no trace), and propagate the sampling flag (the 01 in traceparent) so every service agrees. Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy. + +## [069] OBSERVABILITY · Choosing a sampling strategy (sldId 324) + +- Probabilistic (head) — keep a fixed percentage (e.g. 10%). Simplest; predictable volume; blind to errors. Good baseline for high-traffic, low-stakes services. +- Rate-limiting (head) — keep at most N traces/sec per service. Protects your backend from traffic spikes; good when load is bursty. +- Parent-based (head) — honour the upstream service's sampling decision (carried in the traceparent flags). Essential in distributed systems: it stops a trace being sampled at service A but dropped at service B, which would give you broken half-traces. +- Tail-based (Collector) — buffer the whole trace, then keep by rule: all errors, all traces over a latency threshold, rare routes, plus a small % of the rest. The gold standard for keeping what matters, at the cost of Collector memory + CPU. +- Recommended production setup: parent-based head sampling at a modest baseline (1-10%) for representativeness, plus tail sampling at the Collector to guarantee every error and slow trace is kept. + - Don't sample metrics the way you sample traces (metrics aggregate everything). Keep logs in full with retention limits, or sample them on their own policy keyed by trace_id so a kept trace keeps its logs. + +p. 57 + +> The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-limiting is better when traffic is bursty because it caps absolute volume. Parent-based is non-negotiable in a distributed system: without it, service A samples a trace but service B independently drops it, and you get a broken trace with holes — worse than no trace. Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident. The cost is the Collector must buffer in-flight traces (memory) and evaluate rules (CPU), and it must see all spans of a trace (which constrains how you load-balance the Collector — use a trace-ID-aware load balancer). The recommended setup combines head (for a cheap representative baseline) and tail (for guaranteed capture of the interesting traces). The sub-bullet is the reminder: traces, metrics and logs are sampled differently — you never sample a metric counter, and logs typically follow their own retention or are sampled to match their parent trace. + +## [070] OBSERVABILITY · Prove it with simple tools before you ship (sldId 325) + +- curl / Postman — exercise REST contracts and inspect status codes, headers and pagination. +- hey — quick HTTP load to watch latency, error rate and the KEDA http-add-on scale the service. +- ghz — load-test gRPC endpoints and confirm timeouts and retries behave under pressure. +- GraphQL Playground — explore the composed schema and watch the gateway fan out in Tempo. +- Kiali — read the live service-mesh topology, mTLS status and request flow during a load test. + +p. 58 + +> Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure. GraphQL Playground explores the composed schema and, with tracing on, shows the gateway fanning out in Tempo. Kiali visualizes the live mesh: topology, mTLS status and real-time request flow during a load test. The message: prove the behaviour with simple tools before you ship, not after an incident. + +## [071] Security (sldId 326) + +12 + +Defence in depth, in layers. + +> Section 12 — security. The Anti-Patterns section that used to be 12 has shifted to 13. We organise this section around two ideas: the 4 C's of cloud-native security (concentric layers, each protecting what is inside it), and the seven patterns that you'll meet over and over in practice. The bias of this section: security is not a single product or a single team. It's a property of how the system is designed and run, owned at every layer. Each pattern here exists because no single layer is enough on its own. + +## [072] SECURITY · The 4 C's of cloud-native security (sldId 327) + +Cloud → Cluster → Container → Code. Concentric layers, each layer protects what is inside. + +p. 59 + +> The mental model from the CNCF security paper. Four concentric layers of responsibility, each enforcing controls appropriate to its scope. Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin. Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE. Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform. Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams. The point of the concentric metaphor: a weakness in any layer leaks into the inner ones. Strong code on a weak container is still vulnerable. Strong container on a weak cluster is still vulnerable. We design controls at all four. The 4 C's table lists each layer's controls and its owner. + +## [073] SECURITY · The 4 C's — what each layer enforces, and who owns it (sldId 328) + +- Cloud — IAM (principles of least privilege), VPC isolation, KMS for keys, audit logging, region & data-residency controls. Owner: platform / cloud admin. +- Cluster — Kubernetes RBAC, NetworkPolicy (default-deny + targeted allows), Pod Security Standards (restricted profile), admission control (OPA Gatekeeper / Kyverno), secret encryption at rest, etcd encryption. Owner: platform / SRE. +- Container — minimal base images (distroless, UBI-minimal), non-root users, read-only root filesystems, image signing (Sigstore / Cosign), vulnerability scanning (Trivy, Grype) on every build, secret mounting via projected volumes (never bake secrets into images). Owner: app team + platform. +- Code — secure defaults (parameterised queries, output encoding), input validation at the trust boundary, dependency scanning (Dependabot, Snyk, OSV-Scanner), secrets handling (no plaintext in source, no logging secrets), CSRF / SSRF / XXE awareness, threat modeling. Owner: app team. +- Cross-cutting: observability (audit who did what when), incident response runbooks, threat modeling per service, regular dependency updates. + - The pattern: a control at the wrong layer is the most common source of false confidence. Network ACLs cannot save you from an SQL injection. Code-level input validation cannot save you from a compromised image. Defence in depth is real. + +p. 60 + +> Walk each layer. The Cloud layer is where you control the perimeter and the identities and the keys; failures here often look like 'someone left a storage bucket open to the internet' or 'an over-permissive IAM role enabled lateral movement to another account.' The Cluster layer is the Kubernetes operating system for your workloads; failures look like 'the developer Pod could reach the database Pod' (no NetworkPolicy) or 'a privileged container was admitted' (no Pod Security Standards). The Container layer is about the image and runtime; failures look like 'a CVE in the base image' or 'the container ran as root and got out via a kernel exploit' or 'a secret was in the image.' Code is where most developer focus already lives. The sub-bullet is the take-home: a control at the wrong layer is false confidence. The pattern catalog holds seven patterns that show up across these four layers. + +## [074] SECURITY · Seven patterns to know (sldId 329) + +- Sidecar — move security concerns out of the app into a companion process. mTLS, JWT, audit, rate-limiting belong there. +- Valet Key — hand the client a short-lived signed token to access a resource directly. Less app proxying, less attack surface. +- Strangler Fig (for security modernisation) — encapsulate legacy auth behind an API gateway, replace pieces over time. +- Zero-Trust / Identity-Aware Proxy — every request authenticated and authorised. Perimeter trust is dead. +- Policy-as-Code — security gates declared in Git, evaluated at admission and at build. No human runs a checklist. +- Bulkhead — isolate resources so one failure (or compromise) cannot starve or cascade into others. +- Claim-Check — send a reference, not the payload. Keeps PII out of message bus history and limits the blast radius if a topic leaks. + - The seven appear in different layers and combine. Each slide that follows shows the pattern, then implementation — platform config where it's mostly YAML, code where it's mostly code. + +p. 61 + +> The roadmap for the next fourteen slides. These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation). You'll see each pattern with a diagram explaining how it works, then either a YAML configuration block (for the platform-heavy patterns) or language-specific code (for the code-heavy patterns). Use the sub-bullet to set audience expectation that the section alternates between pattern-and-implementation pairs. + +## [075] SECURITY · SIDECAR · Sidecar pattern — push security out of the app (sldId 330) + +mTLS, JWT validation, rate limiting, audit logging — a companion process in the same Pod handles them all. + +p. 62 + +> The sidecar pattern — a separate container running in the same Pod as the application, sharing localhost. The sidecar handles cross-cutting security concerns: terminates mTLS, validates JWTs, rate-limits, audits, enforces policy. The app receives only authenticated, authorised requests with trusted headers attached (X-Forwarded-Client-Cert, the validated JWT claims, the request ID). Why this matters: one security policy applied uniformly across every workload regardless of language. Same Envoy sidecar protects your Java app, your Python app, your Go app. App teams write business code, not crypto code. The sidecar is enforced by the platform (Istio injects them automatically) so it can't be disabled by accident. Examples in the field: Istio Envoy sidecars (the dominant choice), Linkerd2-proxy, OPA Sidecar for policy decisions, Cilium L7 with per-endpoint policy. + +## [076] SECURITY · SIDECAR · Reading the sidecar's trusted headers (sldId 331) + +- The sidecar handles mTLS, JWT validation, and policy decisions — the app trusts the headers the sidecar attaches and reads them as plain values. +- X-Forwarded-Client-Cert — the validated client identity from mTLS. Includes the SPIFFE ID (spiffe://cluster.local/ns/orders/sa/order-service) or the X.509 Subject. Set only by the sidecar; the app must reject requests that don't have it. +- JWT claims — the sidecar validates the signature, then passes the claims forward as a header or sets request.auth.claims via an envoy filter. The app reads sub, aud, scope, custom claims; it never re-validates the signature. +- Request ID — the sidecar attaches X-Request-Id for traceability. Log it; propagate it to downstream calls; correlate it with audit logs. +- Mode: only trust these headers when the sidecar is in front. In direct-to-Pod traffic (port-forward, debugging, mistakes), they could be spoofed. Use a NetworkPolicy to ensure traffic only enters via the sidecar's mTLS port. + - Examples: Istio Envoy sidecars (auto-injected), Linkerd2-proxy, OPA Sidecar (policy decisions), Cilium L7 policy. License: all Apache 2.0. + +p. 63 + +> The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches. The most common failure mode is an app that ignores X-Forwarded-Client-Cert and falls back to 'allow if you got here.' Make the headers required, not optional. The second-most-common failure mode is forgetting that traffic can sometimes bypass the sidecar (port-forward for debugging, host networking, direct pod-to-pod calls within the same node). NetworkPolicy is what closes that gap — only allow inbound traffic on the sidecar's mTLS port, never the app's container port directly. With both the sidecar and the NetworkPolicy, the app can fully trust the headers and become much simpler — no crypto code, no JWT parsing library, just header reads. + +## [077] SECURITY · VALET KEY · Valet key pattern — short-lived signed token, direct access (sldId 332) + +App mints a scoped, time-bound token; client uses it directly against the resource. App never proxies the bytes. + +p. 64 + +> The valet key pattern: instead of streaming a large file through your application, the application mints a scoped, time-bound, operation-restricted token (a 'valet key') and the client uses it to interact directly with the storage system. Pros: dramatically less data through the application (memory, bandwidth, latency); no long-lived credentials at the client; no app-side credential to leak; the token can expire and be revoked. Cons: requires the storage side to validate tokens; the token's permissions and TTL must be carefully scoped — a leaked token still has its window. Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape. The code shows the pattern in the service's language. + +## [078] SECURITY · VALET KEY · Valet key — scoped, time-bound, op-restricted (sldId 333) + +[chip] python · FastAPI + hmac (stdlib) + +```python +# main.py (excerpt), examples/12-security/python: an HMAC-SHA256 valet key +VALET_SECRET = os.environ.get("VALET_SECRET", "demo-secret-do-not-use-in-prod") +def _mint_valet(resource: str, operation: str, ttl: int = 300) -> dict: + expires = int(time.time()) + ttl # time-bound + payload = f"{resource}:{operation}:{expires}" # one object, one operation + sig = hmac.new(VALET_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() + return {"resource": resource, "operation": operation, "expires": expires, "token": sig} +def _verify_valet(resource: str, operation: str, expires: int, token: str) -> bool: + if time.time() > expires: + return False # expired: worthless + payload = f"{resource}:{operation}:{expires}" + expected = hmac.new( + VALET_SECRET.encode(), payload.encode(), hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(token, expected) # constant-time compare +@app.post("/valet-key") +async def mint_key(resource: str, operation: str = "GET"): + return _mint_valet(resource, operation) +# GET /verify-valet answers 403 for an expired or tampered key +``` + +p. 65 + +> The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (hmac.new with hashlib.sha256). POST /valet-key returns the resource, the operation, the expiry and the token. +> +> The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with hmac.compare_digest, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403. +> +> The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth. + +## [079] SECURITY · STRANGLER FIG · Strangler Fig for security modernisation (sldId 334) + +- Use case: encapsulate legacy authentication (LDAP-only, HTTP Basic, hardcoded session cookies) behind a modern API gateway, then replace the legacy auth path one service at a time. +- Step 0: introduce the gateway in front of the monolith. Clients now hit the gateway, which forwards to the legacy auth. Day-zero behaviour unchanged. +- Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Dex), and translates to whatever the legacy expects; it speaks both languages. +- Step 2: per route, switch the gateway from 'forward legacy headers' to 'forward only the JWT claims to the new service'. Legacy auth is no longer in the path for that route. +- Step 3: when no routes still need legacy auth, remove it. +- Bounded blast radius: each route's auth migration is a config change in the gateway, reversible without a release. + - See Appendix K for the general strangler-fig family (identify → move → redirect, with proxy, with shared database, with content-based routing). Same mechanics, applied to auth instead of business logic. + +p. 66 + +> The strangler fig was covered as a general pattern in Appendix K — here it shows up specifically for security modernisation, which is a common need: most enterprises have legacy auth (LDAP-only, custom SSO, NTLM, hardcoded API keys) buried in critical systems, and a 'big bang' replacement is too risky. The gateway becomes the bilingual translator: it validates modern JWTs from your IdP at the edge and translates to whatever the legacy backend expects (X-Remote-User header, basic auth, NTLM, custom session cookie). Per route, you migrate when the new path is verified — if anything breaks, flip the gateway config back to forwarding legacy headers. The sub-bullet points to Appendix K for the full mechanics; this slide is the application of those mechanics to a specific security need. + +## [080] SECURITY · ZERO-TRUST · Zero-trust / Identity-Aware Proxy (sldId 335) + +Every request authenticated and authorised, on every hop. Perimeter trust is dead. SPIFFE inside, IAP at the edge. + +p. 67 + +> The model: there is no trusted network. Every request, from any source, to any service, on every hop — authenticated and authorised. The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance. The new model (bottom): the user authenticates to an IAP (Identity-Aware Proxy) which evaluates identity, device posture, location, MFA. The IAP then forwards to a policy engine (OPA, Cedar) which returns an allow/deny per-request. The downstream service still authenticates the caller (mTLS + SPIFFE ID) and re-authorises against policy. Inside the cluster: mTLS via SPIFFE identity (every workload has an X.509 cert identifying its service account), AuthorizationPolicy per workload restricting which other workloads can call which endpoints. A breach in one service gives you nothing because the next call still has to prove identity and pass policy. + +## [081] SECURITY · ZERO-TRUST · Implementing zero-trust (sldId 336) + +- Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — they terminate TLS, validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT the downstream service sees in X-Forwarded-* headers. +- Inside the cluster (mTLS): Istio + SPIFFE assigns every workload an X.509 cert identifying its service account (spiffe://cluster.local/ns/orders/sa/order-service). Every service-to-service call is mutually authenticated automatically. +- Authorization: Istio AuthorizationPolicy declares 'order-service can call payment-service /charge but nothing else, only when carrying a valid customer JWT.' Per-workload, fine-grained, declarative. +- Policy decisions: OPA / Cedar evaluate context (user, time, device, target, action) and return allow/deny. Out-of-process so policy can change without redeploying every service. +- App-side discipline: read the validated peer identity from X-Forwarded-Client-Cert; never accept calls that arrive without it; log every authorization decision. + - License notes: Istio, SPIFFE/SPIRE, OPA, Cedar — all open source (Apache 2.0 or similar). Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too. + +p. 68 + +> Concrete implementations to ground the model. The edge IAP is what your users hit — they authenticate against your IdP (Keycloak, or Dex federating an existing directory), the IAP issues a session, every request from then on carries identity. Inside the cluster, SPIFFE is the service-identity mechanism that makes mTLS work without manual cert management — it's what gives every Pod a verifiable identity. Istio's AuthorizationPolicy is the declarative layer on top: 'who can call what.' OPA / Cedar is the more flexible policy engine when your decision logic is complex (per-tenant, per-time-of-day, per-data-classification). The bold line is the app-side discipline: don't make these external policies redundant by accepting unauthenticated calls in your code; use the peer identity from the headers as a required input. + +## [082] SECURITY · POLICY-AS-CODE · Policy-as-Code — security gates in Git (sldId 337) + +OPA Gatekeeper / Kyverno enforce declarative policies at admission. CI runs the same policies pre-merge. + +p. 69 + +> Security policies as code: declarative, version-controlled, tested, applied automatically. The flow on the diagram: a developer commits manifests; CI runs conftest or Kyverno against the manifests pre-merge (catches violations before they reach the cluster); on `kubectl apply` (or ArgoCD / Flux), the Kubernetes API server calls the admission controller (OPA Gatekeeper / Kyverno) which evaluates the ConstraintTemplate / ClusterPolicy against the incoming object. Non-conformant resources are rejected — they never become running Pods. The policy file lives in Git, reviewed like code, applied by the cluster. No human runs a checklist; consistency is enforced uniformly. Example policies: images must come from approved registries, no privileged containers, no host network, no host path mounts, all containers have resource limits, all images are signed. + +## [083] SECURITY · POLICY-AS-CODE · OPA Gatekeeper ConstraintTemplate (Rego) (sldId 338) + +[chip] yaml · OPA Gatekeeper (language-neutral) + +```yaml +# ConstraintTemplate — reusable policy logic in Rego +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8srequiresignedimages +spec: + # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array) + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiresignedimages + violation[{"msg": msg}] { + container := input.review.object.spec.containers[_] + not startswith(container.image, input.parameters.allowedRegistries[_]) + msg := sprintf("image %v not from an allowed registry", [container.image]) + } +--- +# Constraint — applies the policy to namespaces +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sRequireSignedImages +metadata: { name: must-be-signed } +spec: + match: + kinds: [{apiGroups: [""], kinds: ["Pod"]}] + excludedNamespaces: ["kube-system", "gatekeeper-system"] + parameters: + allowedRegistries: ["registry.acme.com/", "quay.io/redhat/"] +``` + +p. 70 + +> The two-piece pattern: ConstraintTemplate defines the logic (in Rego, OPA's policy language), Constraint is the instance that applies the logic to specific resources with specific parameters. The Rego in violation[{msg}] reads as 'for every container in the Pod, if its image doesn't start with any of the allowed registries, emit a violation with this message.' Gatekeeper translates that into an admission webhook that rejects non-conformant Pods before they're scheduled. The Constraint binds the template to the kinds (Pods) and namespaces (everything except system namespaces), with the allowedRegistries list as the parameter. Lives in Git, applied via `kubectl apply` (or ArgoCD), versioned and reviewed like code. An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic. + +## [084] SECURITY · BULKHEAD · Bulkhead — isolate resources, bound the blast radius (sldId 339) + +Per-tenant / per-call / per-priority pools. One tenant's storm cannot starve others. One compromise cannot cascade. + +p. 71 + +> Bulkhead: borrowed from naval architecture, where watertight compartments stop one breach from sinking the ship. In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. The left side of the diagram (red) shows the default: one shared pool means a single tenant's traffic storm starves everyone else's. The right side: separate pools, each with its own bounded size. A storm from tenant A consumes tenant A's pool and rejects further A requests — tenants B and background workloads keep running. Why this matters for security as well as resilience: a compromised tenant or a compromised endpoint can be used to mount a denial-of-service against the shared system; bulkheads cap that to its own pool. Implementations: Resilience4j Bulkhead (JVM), Polly (.NET), asyncio.Semaphore (Python), Istio per-destination connection pools at the mesh layer. + +## [085] SECURITY · BULKHEAD · Per-tenant bulkheads in asyncio (sldId 340) + +[chip] python · asyncio + aiohttp + +```python +# Per-tenant bulkheads using asyncio.Semaphore +# One tenant cannot starve others; one compromised tenant cannot cascade +from asyncio import Semaphore, TimeoutError, wait_for +from collections import defaultdict +# 5 concurrent requests per tenant; new ones wait or get rejected +BULKHEADS: dict[str, Semaphore] = defaultdict(lambda: Semaphore(5)) +@app.post("/orders") +async def place_order(req: OrderRequest, tenant: str = Depends(current_tenant)): + bulkhead = BULKHEADS[tenant] + try: + # acquire-with-timeout: fail fast if the bulkhead is saturated + await wait_for(bulkhead.acquire(), timeout=0.5) + except TimeoutError: + raise HTTPException(503, f"tenant {tenant} bulkhead saturated") + try: + return await downstream.place(req) + finally: + bulkhead.release() +# Per-tenant HTTP connection pools — socket-level isolation +TENANT_SESSIONS: dict[str, aiohttp.ClientSession] = {} +def session_for(tenant: str) -> aiohttp.ClientSession: + if tenant not in TENANT_SESSIONS: + TENANT_SESSIONS[tenant] = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=20)) # 20 sockets per tenant + return TENANT_SESSIONS[tenant] +``` + +p. 72 + +> Two complementary bulkheads. First, the in-process Semaphore caps concurrent in-flight work per tenant — once 5 are running, a new request waits up to 500ms then fails fast with 503. The fail-fast is critical: if the downstream is slow, we don't queue requests until they pile up, we tell the caller now and let them back off. Second, per-tenant aiohttp.TCPConnector instances cap sockets per tenant — a tenant cannot exhaust the TCP socket pool with one storm. The two together provide both work-isolation (semaphore) and socket-isolation (connector). For critical isolation, you can also run per-tenant Pod replicas of the service: Kubernetes-level bulkheading. + +## [086] SECURITY · CLAIM-CHECK · Claim-check — send a reference, not the payload (sldId 341) + +Large or sensitive payload goes to a secure store; the message bus carries only an opaque token to fetch it. + +p. 73 + +> The claim-check pattern: a producer puts the (large or sensitive) payload into a secure object store and publishes only a claim ticket (an opaque token plus a fetch URI) to the message bus. The consumer receives the ticket, fetches the payload from the store with the token. Two big security wins: PII never sits in Kafka's topic history (which is replicated, retained, and possibly visible to operators and other tools); and the token can expire and be revoked, while a Kafka message cannot. The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security. Compare to the write-around caching pattern: same shape, different motivation. Where: any flow where sensitive payloads (health records, financial documents, customer PII) cross messaging infrastructure that shouldn't see them; any flow where payloads are large enough that the bus would be the bottleneck. + +## [087] SECURITY · CLAIM-CHECK · Producer mints claim, consumer fetches payload (sldId 342) + +[chip] python · aiokafka + valet key + +```python +# claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +async def publish_with_claim(producer: AIOKafkaProducer, order_id: str, body: bytes): + key = f"payloads/{order_id}" + await store.put(key, body, encrypt=True) # encrypted at rest, ACL'd + claim = { + "order_id": order_id, + "uri": f"https://objects.internal/{key}", + **_mint_valet(key, "GET", ttl=900), # resource, op, expires, token + } + await producer.send_and_wait( # only the claim goes on the topic + "order.documents", json.dumps(claim).encode(), key=order_id.encode()) +# consumer: read the claim, fetch the body out of band +async for msg in consumer: + claim = json.loads(msg.value) + body = await store.get(claim["uri"], token=claim["token"]) # the store checks the ticket + await handle(body) # PII never touched the topic + await consumer.commit() # after the side effect +``` + +p. 74 + +> Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's _mint_valet (900 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with aiokafka's send_and_wait. The PII is never on Kafka's wire. +> +> The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give. +> +> Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing. + +## [088] SECURITY · Choosing the right pattern (sldId 343) + +- Sidecar — when you want one security policy uniformly across every workload, regardless of language. Cost: an extra container per Pod (memory + startup time). +- Valet Key — when clients need direct access to a large or specific resource (file upload, image download). Cost: storage system must validate tokens (most do). +- Strangler Fig (auth) — when you have legacy authentication you can't change all at once. Cost: a gateway running translation logic during the transition. +- Zero-Trust / IAP — the default model for any system with users outside your immediate control. Cost: mTLS everywhere has real CPU and operational overhead. +- Policy-as-Code — always. Always. The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno. +- Bulkhead — any multi-tenant system, any system with mixed-priority workloads. Cost: pools are extra moving parts to size and tune. +- Claim-Check — when payloads carry PII or are large enough to bloat the message bus. Cost: an extra fetch hop and the store-and-bus must agree on token semantics. +- Most production systems combine all of these, not as a checklist but as a decision per concern. + +p. 75 + +> Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a few MB of memory, a couple hundred ms of startup); for most systems the security uniformity makes it worth that. Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry. Strangler fig (auth) is a deliberate transition strategy; the gateway running translation is the cost. Zero-trust / mTLS everywhere has real CPU overhead (TLS handshakes are not free) — mesh implementations like Istio amortise it well but it's not zero. Policy-as-code's cost is the upfront investment in writing policies; the ongoing cost is near zero. Bulkheads are extra moving parts to tune. Claim-check adds a hop. The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist. + +## [089] SECURITY · Take-aways and references (sldId 344) + +- Defence in depth across the 4 C's: cloud, cluster, container, code. A control at the wrong layer is false confidence. +- Identity is the new perimeter. mTLS + SPIFFE inside; IAP / OIDC at the edge. Every request authenticated and authorised, on every hop. +- Policy in Git, enforced by the cluster. OPA Gatekeeper or Kyverno at admission; conftest in CI for early feedback. +- PII never crosses your message bus in cleartext. Claim-check or end-to-end encrypt. +- Isolate by tenant, by call, by priority. Bulkheads bound the blast radius of both incidents and outages. + - References: NIST SP 800-204A (microservices security); CNCF Cloud Native Security Whitepaper; OWASP API Security Top 10; the OpenSSF SLSA framework; Istio AuthorizationPolicy docs; OPA Gatekeeper library; OWASP Microservices Security Cheat Sheet. + - Open-source pillars: Istio (Apache 2.0), SPIFFE/SPIRE (Apache 2.0), OPA / Gatekeeper (Apache 2.0), Kyverno (Apache 2.0), Sigstore (Apache 2.0), Falco (Apache 2.0), Trivy (Apache 2.0). + +p. 76 + +> Close with the take-aways the audience should remember. Defence in depth across the 4 C's is the philosophical anchor. Identity-as-perimeter is the operational anchor. Policy-in-Git is the automation anchor. The PII-never-in-cleartext and bulkhead lines are the specific applications. The reference list is what you'd recommend for further reading — NIST 800-204A is the canonical microservices security paper; CNCF's Cloud Native Security Whitepaper organises the same material; OWASP API Top 10 is the application-layer reference; SLSA covers supply-chain security (signing, provenance, build integrity). The open-source list emphasises that the entire security stack is buildable with Apache 2.0 components. + +## [090] Anti-Patterns (sldId 345) + +13 + +Most outages re-couple what you separated. + +> The final main section, and the most memorable framing of the whole talk: nearly every cloud-native failure is coupling sneaking back in. You did the work to separate things; entropy and deadlines pull them back together. This section names the specific ways that happens and gives heuristics to catch it early. + +## [091] ANTI-PATTERNS · How cloud-native API systems go wrong (sldId 346) + +Each anti-pattern reintroduces the coupling the architecture worked to remove. + +p. 77 + +> Walk the grid briefly — most of these are callbacks to earlier sections. Distributed monolith (services that must deploy together), shared database (two services on one table), chatty calls (deep synchronous chains), no contract/leaky internal models (DB rows as the public API), missing observability (debugging by guesswork), retry storms (no backoff). The unifying diagnosis is on screen: each one reintroduces the coupling the architecture worked to remove. If you only remember one slide for code review, it's this one. The anti-pattern heuristics turn these into positive rules. + +## [092] ANTI-PATTERNS · The heuristics that keep you out of them (sldId 347) + +- If two services must deploy together, they are one service — merge them or fix the contract. +- One writer per dataset; everyone else reads through the API or subscribes to its events. +- Push aggregation to a gateway/BFF; forbid service-to-service call chains deeper than one hop. +- No contract in the registry, no merge — and every retry has a timeout and a bound. +- If you can't trace it, you can't run it: observability is a launch requirement, not a follow-up. + +p. 78 + +> These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or fix the contract. (2) One writer per dataset; everyone else reads through the API or subscribes to events — no shared tables. (3) Push aggregation to a gateway or BFF and forbid synchronous call chains deeper than one hop. (4) No contract in the registry, no merge — and every retry has a timeout and a bound. (5) If you can't trace it, you can't run it — observability is a launch requirement, not a follow-up. Offer these as a checklist the team can literally paste into a PR template. + +## [093] IN CLOSING · The whole talk, in five lines (sldId 348) + +- Cloud-native is a set of properties; the platform exists to make them the default. +- Match each interaction to its style (REST, gRPC, GraphQL or async) and compose at the edge. +- Own your data; share it as contracts and facts, never as shared tables. +- Make the contract a governed artifact: registry to enforce it, metadata to find it, observability to run it. +- Every anti-pattern is coupling sneaking back in. Design to keep things apart. + +p. 79 + +> The recap — read these five as the entire talk compressed. If the audience remembers nothing else: cloud-native is properties not tools; match the protocol to the interaction and compose at the edge; own your data and share it as contracts and facts; make the contract a governed artifact (registry, metadata, observability); and watch for coupling sneaking back in. Pause here before References and Q&A. If it's the long-form session, this is the natural point to say 'and now the appendices go deeper on four things people always ask about' and continue. + +## [094] REFERENCES · The shoulders this stands on (sldId 349) + +- Cloud-Native Patterns and Architecture +- Building Microservices, 2nd ed. — Sam Newman (O'Reilly) +- Designing Data-Intensive Applications, 2nd ed. — Martin Kleppmann & Chris Riccomini (O'Reilly) +- Building Event-Driven Microservices — Adam Bellemare (O'Reilly) +- Cloud Native Patterns — Cornelia Davis (Manning) +- Patterns of Distributed Systems — Unmesh Joshi (Addison-Wesley) +- Architecture Modernization — Nick Tune & Jean-Georges Perrin (Manning) +- Cloud Native Anti-Patterns — Gerald Bachlmayr +- Principles of Web API Design — James Higginbotham (Addison-Wesley) +- Patterns for API Design — Olaf Zimmermann et al. (Addison-Wesley) +- API Design Patterns — JJ Geewax (Manning) +- Learning Domain-Driven Design — Vlad Khononov (O'Reilly) +- Balancing Coupling in Software Design — Vlad Khononov (Addison-Wesley) + +p. 80 + +> The reading list this talk stands on. Call out a couple of pairings: Newman's Building Microservices for the boundaries and integration story; Kleppmann's Designing Data-Intensive Applications for the data and consistency foundations behind the outbox and saga; Bellemare for the event-driven backbone; and the API-design trio (Higginbotham, Zimmermann et al., Geewax) for contract and versioning depth. The slides and these references are enough to build the system, not only to discuss it. + +## [095] Thank you (sldId 350) + +Questions? + +> End of the main talk. Thank the audience and open for questions. The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session. They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation. + +## [096] REFERENCE · Glossary of acronyms (1 of 2) — protocols, data, events (sldId 351) + +- Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface). +- Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue, where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe). +- Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log: the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used: a cache eviction policy); SETEX (the Redis "SET with EXpiry" command: sets a key and its TTL atomically); ORM (Object-Relational Mapping); asyncpg (Python's async Postgres driver); Pydantic (Python validation / serialisation library, not an acronym). +- Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service: a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD, which translates between models; ACL also means Access Control List in security); Saga (not an acronym: a sequence of local transactions with compensating actions). + +p. 81 + +> Glossary part 1, the protocol/data/event vocabulary. Call out the ones most likely to trip people up: gRPC is recursively 'gRPC Remote Procedure Call' but practically it's Google's high-performance RPC over HTTP/2 with Protocol Buffers. mTLS (mutual TLS) is the one people often gloss — ordinary TLS authenticates the server to the client; mutual TLS also authenticates the client to the server, which is the basis of zero-trust service identity. The data row has the SETEX and TTL the audience asked about explicitly: TTL is the expiry duration on a cached key; SETEX is the Redis command that sets a value and its TTL in one atomic operation (SETEX key seconds value). LRU is the eviction policy that drops the least-recently-touched key when the cache is full. CDC and WAL go together — CDC tails the WAL. Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List. + +## [097] REFERENCE · Glossary of acronyms (2 of 2) — platform, security, telemetry (sldId 352) + +- Kubernetes & cloud — K8s (Kubernetes); RBAC (Role-Based Access Control); HPA (Horizontal Pod Autoscaler); KEDA (Kubernetes Event-Driven Autoscaling); PSS (Pod Security Standards); CRD (Custom Resource Definition); IAM (Identity & Access Management); KMS (Key Management Service); VPC (Virtual Private Cloud); CNI (Container Network Interface). +- Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials). +- Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency). +- Runtime & build — WSGI / ASGI (Web / Asynchronous Server Gateway Interface: Python's sync / async web server contracts); GIL (Global Interpreter Lock); venv (virtual environment); DI (Dependency Injection); DTO (Data Transfer Object); GC (Garbage Collection). + +p. 82 + +> Glossary part 2, the platform/security/observability vocabulary. The Kubernetes row: HPA scales on CPU/memory, KEDA scales on event sources (queue depth, Kafka lag, HTTP rate) — the distinction matters in the autoscaling discussion. PSS (Pod Security Standards) replaced the deprecated PodSecurityPolicy. The security row has the terms from section 12: SPIFFE/SPIRE give workloads cryptographic identities for mTLS; OPA is the policy engine behind policy-as-code; IAP is the zero-trust edge proxy; JWT/OIDC/OAuth are the token/identity standards; SLSA/CVE/SBOM are supply-chain security. The observability row has RED and USE, the two metrics methods worth memorising: RED (Rate, Errors, Duration) for services, USE (Utilisation, Saturation, Errors) for resources. p99 means the 99th-percentile latency — 99% of requests are faster than this; it's what you alert on, not the average. The runtime row is tailored to this deck's language. + +## [098] Appendix A · Protocols in Depth (sldId 353) + +A + +REST, gRPC and GraphQL: how they differ. + +> Appendix A. This is the deep dive behind the 'pick the protocol' slide from section 2. We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire. By the end the audience should be able to defend a protocol choice on technical grounds, not fashion. + +## [099] APPENDIX A · PROTOCOLS · Three paradigms and their mental models (sldId 354) + +REST models resources, gRPC models procedures, GraphQL models a typed graph — the same intent, three shapes. + +p. 83 + +> Start with the mental model, because it drives everything else. REST is resource-oriented: nouns and HTTP verbs, you transfer representations of state. gRPC is procedure-oriented: you call typed methods, it's behaviour not state, and the .proto is the contract. GraphQL is graph-oriented: one typed schema the client traverses, picking exactly the fields it wants. The same place_order intent takes three different shapes. Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction. + +## [100] APPENDIX A · PROTOCOLS · Over-fetching, under-fetching and round trips (sldId 355) + +REST trades round trips or bespoke endpoints for simplicity; GraphQL shapes one response; gRPC is a typed call. + +p. 84 + +> This is the practical difference clients feel. With REST you often need several round trips (order, then its items, then stock, then shipping) or you build a bespoke aggregate endpoint — and you still get fixed payload shapes that over- or under-fetch. GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three. The trade: GraphQL gives clients flexibility at the cost of gateway complexity; gRPC gives speed at the cost of rigidity; REST gives reach and simplicity at the cost of round trips. + +## [101] APPENDIX A · PROTOCOLS · The wire: transport, encoding and streaming (sldId 356) + +Reach and caching, or raw speed, or client-shaped responses — choose on the wire properties you need. + +p. 85 + +> What travels on the wire, and why it constrains your choice. REST: HTTP/1.1 or /2, JSON text, request/response, cacheable via standard HTTP, browser-native — maximum reach. gRPC: HTTP/2 only, protobuf binary, full uni- and bidirectional streaming, no HTTP caching, and it needs grpc-web to work from a browser. GraphQL: HTTP POST to a single URL, JSON, request/response plus subscriptions, caching is application-level (not HTTP), browser-native. The decision rule: if you need broad reach and HTTP caching, REST; if you need raw speed and streaming between services you control, gRPC; if you need client-shaped responses over many sources, GraphQL. + +## [102] APPENDIX A · PROTOCOLS · Choosing between them in practice (sldId 357) + +- Public or partner-facing, browser or third-party clients, caching matters → REST. +- Internal service-to-service, low latency, high call volume, streaming → gRPC. +- Aggregating many back-ends for varied clients that each want different fields → GraphQL at the gateway. +- A fact that many consumers react to, decoupled in time → none of these; emit an event. +- Real systems use all four; standardizing on one wire for everything is itself an anti-pattern. + +p. 86 + +> The decision guide. Public/partner/browser with caching → REST. Internal, hot, streaming → gRPC. Client-driven aggregation across back-ends → GraphQL at the gateway. A fact many consumers react to → don't make it a call at all, emit an event. The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done. + +## [103] APPENDIX A · PROTOCOLS · Common mistakes with each (sldId 358) + +- REST: verbs in URLs (/createOrder), 200-for-everything, offset pagination, and ignoring cache headers. +- gRPC: exposing it directly to browsers without grpc-web; treating it as REST-with-protobuf and ignoring streaming. +- GraphQL: the N+1 resolver explosion; no query depth/complexity limits; using it for simple single-resource reads. +- All three: leaking internal database models straight onto the wire, so you can never evolve them. +- Async: using a queue when you needed a log (no replay), or a request when you needed a fact. + +p. 87 + +> The failure modes, drawn from real review comments. REST: verbs in URLs, returning 200 with an error body, offset pagination that breaks under load, and throwing away free HTTP caching. gRPC: trying to call it straight from a browser without grpc-web, or using it as 'REST with protobuf' and never touching its streaming superpower. GraphQL: the classic N+1 resolver explosion (fix with DataLoader batching), no depth or complexity limits so a malicious query melts the gateway, and reaching for it when a plain GET would do. The universal one is the last bold-adjacent: leaking your database schema onto the wire kills your ability to evolve — the same leaky-internal-models anti-pattern from section 12. + +## [104] Appendix B · Versioning & Evolution (sldId 359) + +B + +REST vs RESTful, proto breaking changes, GraphQL evolution. + +> Appendix B answers the question every team fights about: how do we version this without breaking everyone? The answer differs sharply by protocol. REST has several strategies with real trade-offs; gRPC has precise wire-compatibility rules; GraphQL rejects versioning entirely in favour of evolution. We'll see each, with code. + +## [105] APPENDIX B · VERSIONING · Four ways to version a REST API (sldId 360) + +URI versioning is common and visible; media-type versioning keeps URLs stable — the more RESTful choice. + +p. 88 + +> REST has four strategies, ordered from coarse to fine. URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation. Query param: simple to default but muddies caches. Custom header: keeps URLs clean but is invisible and often forgotten. Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach. In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct. Know which you're choosing and why. The media-type versioning code shows how. + +## [106] APPENDIX B · VERSIONING · Media-type versioning with FastAPI (sldId 361) + +[chip] python · FastAPI + +```python +from fastapi import FastAPI, Header, Response +app = FastAPI() +V1 = "application/vnd.acme.order+json" # default / legacy +V2 = "application/vnd.acme.order.v2+json" # adds 'currency' +@app.get("/orders/{oid}") # one stable URL — the resource +async def get_order(oid: str, accept: str = Header(default=V1)): + order = await orders.get(oid) + if accept == V2: # negotiate the representation + body = {**order.model_dump(), "currency": order.currency} + return Response(content=dumps(body), media_type=V2) + body = order.model_dump() # v1 shape, unchanged + return Response(content=dumps(body), media_type=V1) +# the URL never changes; clients opt into a representation via Accept +``` + +The resource URL is stable; the representation is negotiated. New clients ask for v2; old clients keep getting v1. + +p. 89 + +> This makes 'RESTful versioning' concrete. There's one URL for the order resource — it never changes. The client signals which representation it wants via the Accept header; the server negotiates and responds with the matching Content-Type. New clients ask for the v2 media type and get the new currency field; old clients send nothing, default to v1, and are completely unaffected. Contrast with /v2/orders, where you've now got two URLs for the same underlying thing. Mention that you'd normally structure this with response models per version rather than hand-building dicts, but the negotiation mechanism is the point. + +## [107] APPENDIX B · VERSIONING · gRPC / protobuf: what is a breaking change (sldId 362) + +Field numbers are the contract — names and order are not. Never reuse or renumber a field. + +p. 90 + +> gRPC's rules are precise because the wire format is positional. Safe, wire-compatible changes: add a field with a new number, add an rpc method, add an enum value, rename a field (the number is what matters, not the name), mark fields deprecated, and reserve removed tags. Breaking changes: change a field's number or type, reuse a removed number, remove a field without reserving its tag, rename or move a service or package, or change the request/response message type. The one people miss is on screen at the bottom: changing a method between unary and any streaming mode changes the HTTP/2 framing and is always breaking. The discipline: field numbers are forever; never reuse or renumber. Code shows safe evolution next. + +## [108] APPENDIX B · VERSIONING · Evolving a .proto safely (sldId 363) + +[chip] protobuf + +```protobuf +syntax = "proto3"; +message Order { + string id = 1; + string sku = 2; + int32 qty = 3; + reserved 4; // tag 4 once held 'notes' — never reuse it + reserved "notes"; + string currency = 5; // safe: brand-new field, new tag number + PaymentState state = 6; // safe: new field; old clients ignore it +} +enum PaymentState { PENDING = 0; PAID = 1; REFUNDED = 2; } // 0 must exist +// breaking (don't): qty -> int64, or moving 'sku' to tag 7, or reusing tag 4 +``` + +Add with new tags, reserve what you remove, and never change an existing field's number or type. + +p. 91 + +> Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire. We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work. Note the enum must keep a zero value as its default. The comment at the bottom lists the things that would break the wire: widening qty's type, moving an existing field to a new tag, or reusing a reserved tag. Apicurio's compatibility check (section 9) enforces exactly these rules in CI. + +## [109] APPENDIX B · VERSIONING · GraphQL versions by evolution, not by number (sldId 364) + +There is no /v2 in GraphQL: add fields, mark old ones @deprecated, watch usage, then retire. + +p. 92 + +> GraphQL takes a completely different stance: there are no versions. There's one schema that evolves continuously. The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero. The insight: GraphQL pushes you toward data-driven deprecation instead of big-bang version cutovers. Code shows the @deprecated directive next. + +## [110] APPENDIX B · VERSIONING · Deprecating a field in Strawberry (sldId 365) + +[chip] python · Strawberry + +```python +import strawberry +@strawberry.type +class Order: + id: str + total: float = strawberry.field( + deprecation_reason="Use totalMinor (integer cents) instead.", + ) # still served; shows in introspection + total_minor: int # the replacement field, added additively +# Clients see the deprecation in the schema and in tooling (Playground). +# Server-side field usage metrics tell you when total has zero callers — +# only then do you remove it. No /v2 endpoint ever exists. +``` + +Additive change plus @deprecated plus usage metrics replaces versioning entirely. + +p. 93 + +> The mechanics in Strawberry. We add total_minor additively and mark the old total field with a deprecation_reason. The field keeps working and nothing breaks, but the deprecation shows up in introspection and in tools like Playground, nudging client developers. The crucial operational piece is the comment: you instrument field-level usage on the server, and you only remove total when its usage drops to zero. That's the whole GraphQL philosophy — evolution governed by data, with the breaking removal deferred until it's provably safe. Compare and contrast with REST's media types and gRPC's tag rules: each protocol answers the same problem differently. + +## [111] APPENDIX B · VERSIONING · Principles that cut across all three (sldId 366) + +- Additive change is always safe; removal and mutation are where you break people. +- Make compatibility mechanical — the registry's BACKWARD/FORWARD/FULL rules, checked in CI, not by reviewers' memory. +- Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. +- Run old and new in parallel and migrate consumers before retiring — retirement is the only step that can break. +- Version data the same way you version APIs: event schemas in the registry follow the identical rules. + +p. 94 + +> Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break people — so design to add. Make compatibility mechanical: the registry's rules checked in CI, never a reviewer remembering. Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing. Run old and new in parallel and migrate before you retire, because retirement is the only inherently breaking move. And the bold line that connects this back to the event-driven section: your event schemas version by exactly the same rules — the registry treats an Avro schema and an OpenAPI spec identically. Versioning data and versioning APIs are the same discipline. + +## [112] Appendix C · WebSockets at Scale (sldId 367) + +C + +FastAPI, pod pinning, the backplane, binary framing, reconnection. + +> Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes. WebSockets are long-lived, stateful and pinned to a pod — the opposite of the stateless request model the platform assumes. We'll build one in FastAPI, see why it fights scaling, fix it with a backplane, make it fast and versioned with protobuf, and make it survivable with reconnection. + +## [113] APPENDIX C · WEBSOCKETS · A WebSocket endpoint in FastAPI (sldId 368) + +[chip] python · FastAPI + +```python +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +app = FastAPI() +clients: dict[str, WebSocket] = {} # per-pod state — not shared! +@app.websocket("/ws/{user_id}") +async def ws(sock: WebSocket, user_id: str): + await sock.accept() + clients[user_id] = sock # this pod now owns this connection + try: + while True: + msg = await sock.receive_json() # full-duplex, long-lived + await handle(user_id, msg) + except WebSocketDisconnect: + clients.pop(user_id, None) # clean up on disconnect +``` + +Short to write, but clients{} is per-pod. That single line is the entire scaling problem. + +p. 95 + +> FastAPI makes the endpoint trivial: accept, then loop receiving and handling messages full-duplex until disconnect. But point hard at the clients dict — it lives in this pod's memory. That one design fact drives everything in this appendix: the connection and the knowledge of who's connected live on exactly one pod. Everything stateless about HTTP (any replica can serve any request) is now false. The scaling problem follows from that. + +## [114] APPENDIX C · WEBSOCKETS · Why WebSockets fight Kubernetes scaling (sldId 369) + +A long-lived connection pins to one pod; new pods get no existing connections and scale-down drops them. + +p. 96 + +> Here's the collision with Kubernetes. A WebSocket connection, once established through the load balancer, is pinned to one pod for its entire lifetime. Two problems follow. Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have. Scale-down and rollouts: terminating a pod severs every connection it was holding, so a routine deploy disconnects thousands of users at once. Stateless HTTP shrugs both off; WebSockets don't. The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling. + +## [115] APPENDIX C · WEBSOCKETS · The scaling problem, stated precisely (sldId 370) + +- Pinning: a connection is bound to one pod; the LB can't move it without dropping it. +- Uneven load: new replicas only receive new connections, so existing hotspots persist. +- Rollouts hurt: every deploy or scale-down severs the connections on the affected pods. +- Cross-pod delivery: to push to user X you must reach whichever pod holds X — which you don't track. +- Connection limits & memory: each socket is held-open state; pods cap out on file descriptors, not CPU. + +p. 97 + +> Name the five distinct problems so the audience can recognize them. Pinning: the LB can't migrate a live socket. Uneven load: scaling adds capacity for new connections but doesn't rebalance existing ones. Rollouts hurt: every deploy is a mass disconnect event — you need graceful drain and client reconnection. Cross-pod delivery: to push a message to user X you must route to the specific pod holding X's socket, and nothing tracks that by default. And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too. The pub/sub backplane solves or eases every one of these. + +## [116] APPENDIX C · WEBSOCKETS · Scaling out: a pub/sub backplane (sldId 371) + +Each pod owns only its own sockets; a Redis or Kafka backplane fans a message out to whichever pod holds the client. + +p. 98 + +> The pattern that makes WebSockets horizontally scalable. Each pod still owns only its own sockets, which is fine, but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay. When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that holds X's socket delivers it. Now scale-up and zero-downtime rollouts work again, because no pod is the single home of a conversation — connections can land anywhere and still receive everything. Redis is the common default; Kafka if you already run it and want the durability. + +## [117] APPENDIX C · WEBSOCKETS · Binary framing with protobuf — fast and versioned (sldId 372) + +[chip] protobuf + python + +```protobuf+python +// envelope.proto — one versioned wrapper for every WebSocket message +message Envelope { + uint32 version = 1; // bump for an incompatible payload change + uint64 seq = 2; // per-connection monotonic counter — drives resume + string type = 3; // payload discriminator, e.g. "OrderPlaced" + bytes payload = 4; // a nested protobuf message, already serialised +} +# server: one binary frame per event instead of JSON text +async def push(sock: WebSocket, seq: int, event: OrderPlaced): + env = Envelope( + version=2, seq=seq, type="OrderPlaced", + payload=event.SerializeToString()) # nested protobuf + await sock.send_bytes(env.SerializeToString()) # one binary frame +# client checks env.version; unknown major version -> reconnect & renegotiate +``` + +Protobuf frames are smaller and faster than JSON; the versioned envelope and seq number enable evolution and resume. + +p. 99 + +> Two wins in one slide. Performance: protobuf binary frames over send_bytes are markedly smaller and cheaper to parse than JSON text — meaningful when you're pushing high-frequency updates to many sockets. Versioning: because WebSocket messages have no URL to hang a version on, you version the envelope. A version field lets the client detect an incompatible payload format and renegotiate or reconnect; the type field dispatches to the right protobuf message; and a monotonic seq number per connection is what makes reconnection and replay possible, the basis of resume-don't-restart failure handling. So the envelope solves both 'make it fast' and 'how do I version a socket message' at once. + +## [118] APPENDIX C · WEBSOCKETS · Failure handling: resume, don't restart (sldId 373) + +Heartbeats detect dead links; the client reconnects with backoff and resumes from the last acknowledged sequence. + +p. 100 + +> The cloud-native posture: treat every connection as disposable and design for its loss. Heartbeats (ping/pong) detect a dead link quickly — TCP alone can hang for minutes. When the connection drops, the client reconnects with exponential backoff plus jitter (so ten thousand clients don't reconnect in lockstep and hammer your pods after a rollout). On reconnect, the client sends its last-seen sequence number (last_seq = 1042); the server replays only the events the client missed (1043 onward), and because each frame carries a seq, the client can dedupe — so the whole thing is at-least-once and idempotent. The principle on screen: resume, don't restart. A reconnect should be invisible to the user, not a full state reload. + +## [119] APPENDIX C · WEBSOCKETS · A cloud-native WebSocket checklist (sldId 374) + +- Externalize fan-out to a backplane (Redis or Kafka); never assume one pod holds the client. +- Scale and trigger on connection count and memory, not CPU; set realistic per-pod connection caps. +- Drain gracefully on shutdown: stop accepting, signal clients to reconnect, let backoff spread the herd. +- Heartbeat to detect dead links; reconnect with exponential backoff + jitter; resume via a per-connection seq. +- Use binary frames with a versioned envelope; authenticate on connect and re-check on resume. + +p. 101 + +> The operational checklist to leave on screen. Externalize fan-out to a backplane so no pod is special. Scale and set your KEDA/HPA trigger on connection count and memory, not CPU, and cap connections per pod realistically. Drain gracefully on shutdown — stop accepting, tell clients to reconnect, and let backoff-with-jitter spread the reconnect storm across your rollout. Heartbeat to detect dead links fast; reconnect with backoff and jitter; resume from a per-connection sequence number so the user never notices. And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere. Do these and WebSockets are just another well-behaved cloud-native workload. + +## [120] Appendix D · Saga State & Compensation (sldId 375) + +D + +Persisting state, compensating cleanly, and the step-B-needs-A's-data problem. + +> Appendix D is the deep mechanics behind the saga slide in the Data section. Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them. + +## [121] APPENDIX D · SAGAS · Where a Python saga keeps its state (sldId 376) + +Memory won't survive a pod restart — persist every step transition in a DB, an event log, or a workflow engine. + +p. 102 + +> First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-flight saga with no idea what to compensate. Three real options. A DB-backed state machine: one row per saga instance holding its status and a context blob; you own the transitions — simplest, and great for most cases. Event-sourced: append each step as an event and rebuild state by replay — full audit history, fits naturally with the Kafka log. A workflow engine like Temporal or Camunda: durable execution with retries and timers built in — most power, least code, but a new dependency to run. The cross-cutting rule on screen: the state transition and the outbound command must commit together (the outbox pattern again), or you get lost or duplicated steps. + +## [122] APPENDIX D · SAGAS · A persisted saga state machine (sldId 377) + +[chip] python · asyncpg + +```python +STEPS = ["charge_payment", "reserve_stock", "book_shipping"] +async def advance(saga_id: str): + async with db.transaction(): # state + command commit together + s = await db.get_saga(saga_id, for_update=True) # row lock + if s.status != "RUNNING": + return # idempotent: already done/failed + step = STEPS[s.step_index] + try: + result = await invoke(step, s.context) # call the service + s.context[step] = result # accumulate output + s.step_index += 1 + if s.step_index == len(STEPS): s.status = "COMPLETED" + except StepError: + s.status = "COMPENSATING" # hand off to the unwinder + await db.save_saga(s) # durable after every step +``` + +One locked row per saga, advanced one durable step at a time. A crash just means 'load the row and call advance again.' + +p. 103 + +> This is the DB-backed option made concrete, and the default choice for a bounded saga. Key ideas: one row per saga, selected FOR UPDATE so two workers can't advance the same saga concurrently. The status guard makes advance() idempotent — calling it on a finished saga is a no-op, which matters because retries and redeliveries will call it again. Each successful step accumulates its result into the context blob, which is what lets a later step read data an earlier step produced, and bumps the step index; the row is saved durably after every step inside the same transaction as the work record. Recovery needs no special path: after a crash, a sweeper loads RUNNING sagas and calls advance() again. No special crash logic. + +## [123] APPENDIX D · SAGAS · Compensating transactions unwind a failed saga (sldId 378) + +Each committed step has an inverse; on failure they run in reverse order — there is no cross-service rollback. + +p. 104 + +> Compensation is how a saga 'rolls back' when there's no actual rollback available across services. Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release. When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first. Two things that bite people, both on screen: compensations must themselves be idempotent and durable, because a crash mid-unwind has to resume cleanly; and a compensation can itself fail, so it needs its own retries. The saga ends in a persisted COMPENSATED state with a full audit trail of what was done and undone. + +## [124] APPENDIX D · SAGAS · Running compensations in reverse (sldId 379) + +[chip] python · asyncpg + +```python +COMPENSATIONS = { # inverse for each forward step + "charge_payment": "refund_payment", + "reserve_stock": "release_stock", + "book_shipping": "cancel_shipping", +} +async def compensate(saga_id: str): + async with db.transaction(): + s = await db.get_saga(saga_id, for_update=True) + # undo only the steps that actually completed, newest first + for step in reversed(STEPS[: s.step_index]): + await invoke(COMPENSATIONS[step], s.context, + idempotency_key=f"{saga_id}:{step}") # safe to retry + s.status = "COMPENSATED" + await db.save_saga(s) +``` + +Compensate only completed steps, in reverse, with an idempotency key so a retry never double-refunds. + +p. 105 + +> The compensation logic. A simple map gives each forward step its inverse. We iterate reversed(STEPS[:step_index]), only the steps that completed, newest first, and invoke each compensation. The idempotency_key built from saga_id plus step is the line that prevents the nightmare scenario: a retry double-refunding a customer. The compensating service dedupes on that key. In a production version you'd track per-step compensation status too, so if compensation itself crashes halfway you resume from the right place rather than re-running already-done compensations — but the idempotency key makes even naive re-runs safe. This is why compensations are designed up front, as part of each step, not bolted on after. + +## [125] APPENDIX D · SAGAS · When step B needs data only step A produced (sldId 380) + +Don't make B call A. Persist A's output into the saga context and pass it forward; on resume, read it back. + +p. 106 + +> This case is a common source of bad designs. The orchestrator calls service A, then service B — but B needs a value that only A produced (say A returns a reservation_id and a warehouse region that B requires to proceed). The tempting wrong answer is to have B call A directly — now you've created a hidden synchronous dependency between B and A, exactly the chatty coupling the whole architecture fights. The right answer is on screen: the orchestrator owns a saga context (a 'blackboard'), persists A's output into it after step A, and passes the needed values into B's call. B never knows A exists. And note this is already in our state-machine code — that 's.context[step] = result' line is exactly this. + +## [126] APPENDIX D · SAGAS · Carrying context forward — and resuming after a crash (sldId 381) + +[chip] python · asyncpg + +```python +# After A runs, its output is in s.context['reserve_stock'], e.g.: +# {'reservation_id': 'r-77', 'region': 'eu-1'} +async def invoke(step, context): + if step == "book_shipping": + res = context["reserve_stock"] # B reads A's output from context + return await shipping.book( + reservation_id=res["reservation_id"], # data that only A produced + region=res["region"], + ) + ... +# Crash between A and B? The sweeper reloads the saga row, sees step_index +# points at book_shipping and context already holds A's result, and calls +# advance() — B proceeds with A's data. A is never re-called; nothing is lost. +``` + +B reads A's result from the persisted context, never from A. Resume = reload the row and continue at the next step. + +p. 107 + +> This closes the loop and answers the resume question directly. Because step A persisted its output into the saga context (committed durably with the state transition), step B reads reservation_id and region out of context: it calls shipping.book with data that originated in A, but it never calls A. Now the crash scenario you raised: the orchestrator dies between A completing and B starting. On restart, the sweeper reloads the saga row; step_index already points at book_shipping and the context already contains A's result, both committed before the crash. It calls advance(), B runs with A's data, and A is never re-invoked — no lost data, no duplicate reservation. The persisted context is simultaneously the answer to 'how does B get A's data' and 'how do I resume cleanly' — one mechanism, both problems. That's the payoff of persisting state every step. + +## [127] Appendix E · API Error Handling (sldId 382) + +E + +One discipline, four wire formats. + +> Appendix E. Error handling is where cloud-native systems fall apart, more often than the happy path. The thesis: agree one error contract (a stable machine code, a safe human message, a correlation id, a retryable flag, field details) and then map that same model onto REST, gRPC, Kafka and GraphQL. We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it. + +## [128] APPENDIX E · ERRORS · One error model, four wire formats (sldId 383) + +The same five facts in every error; only the encoding changes per protocol. + +p. 108 + +> Start with what every error must carry, regardless of protocol: a stable machine-readable code clients can branch on (not a localized string), a human-readable message safe to show, a correlation/trace id that ties the error to the distributed trace from section 11, a retryable flag (with retry-after when known), and structured field-level details. The four protocols just encode those same five facts differently — REST as problem+json, gRPC as status+details, Kafka via headers and a DLQ record, GraphQL as an errors[] entry with extensions. Agree this once as a shared contract; never leak stack traces or internal type names to callers. + +## [129] APPENDIX E · ERRORS · REST: status, then Problem Details (sldId 384) + +The status code is the category; the problem+json body carries the machine code and specifics. + +p. 109 + +> REST has two layers. First the HTTP status class, which is the category and drives client behaviour: 4xx means the client must fix the request and should not blindly retry; 429 and 503 mean 'busy' and must be paired with a Retry-After header so clients back off; 5xx means a server fault that idempotent calls may retry with backoff. Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId. Status for the category, body for the specifics. + +## [130] APPENDIX E · ERRORS · REST errors with FastAPI (sldId 385) + +[chip] python · FastAPI + +```python +from fastapi import Request +from fastapi.responses import JSONResponse +class StockError(Exception): + def __init__(self, detail): self.detail = detail +@app.exception_handler(StockError) # one place: domain -> wire +async def on_stock_error(request: Request, exc: StockError): + return JSONResponse( + status_code=409, + media_type="application/problem+json", # RFC 9457 + content={ + "type": "https://errors.acme.io/stock", + "title": "Insufficient stock", + "status": 409, + "detail": exc.detail, + "code": "STOCK_UNAVAILABLE", # stable machine code + "traceId": current_trace_id(), # correlate w/ the trace + }) +``` + +Map every domain error to problem+json in one handler — stable code, safe message, trace id; never a raw stack trace. + +p. 110 + +> A single exception handler is the right pattern: domain code raises a typed exception, and one place maps it to the wire format, so you never scatter ad-hoc error JSON across endpoints. Note the media type is application/problem+json (RFC 9457), the status matches the category (409 here — a conflict the client can act on), and the body carries the stable code and the traceId for correlation. What's absent: any stack trace, exception class name, or SQL; those leak internals and are an attack surface. In FastAPI you'd register one handler per error family, or a base-class handler that switches on a code. + +## [131] APPENDIX E · ERRORS · gRPC: status codes + rich details (sldId 386) + +A small fixed set of canonical codes, plus structured google.rpc.Status details in the trailers. + +p. 111 + +> gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it. INVALID_ARGUMENT and NOT_FOUND are client bugs: don't retry. FAILED_PRECONDITION and ALREADY_EXISTS are state issues: don't retry. UNAVAILABLE and ABORTED are transient: retry with backoff. RESOURCE_EXHAUSTED means you're being rate-limited — honour the RetryInfo delay. DEADLINE_EXCEEDED means tune the timeout. Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else. + +## [132] APPENDIX E · ERRORS · gRPC errors with rich status details (sldId 387) + +[chip] python · grpc + grpcio-status + +```python +from grpc_status import rpc_status +from google.rpc import status_pb2, code_pb2, error_details_pb2 +from google.protobuf import any_pb2, duration_pb2 +async def ReserveStock(self, request, context): + if inventory_down: + info = error_details_pb2.ErrorInfo( + reason="STOCK_DOWN", domain="inventory") # machine detail + retry = error_details_pb2.RetryInfo( + retry_delay=duration_pb2.Duration(seconds=2)) # back off 2s + status = status_pb2.Status( + code=code_pb2.UNAVAILABLE, # canonical code + message="inventory temporarily down", + details=[_pack(info), _pack(retry)]) + await context.abort_with_status(rpc_status.to_status(status)) +``` + +Pick the canonical code (UNAVAILABLE = retryable) and attach ErrorInfo + RetryInfo so clients back off correctly. + +p. 112 + +> This is the rich-error model in Python via the grpcio-status package. The canonical code UNAVAILABLE tells the client and the mesh this is transient and retryable; the google.rpc.Status carries structured details — ErrorInfo with a machine reason and domain, and RetryInfo telling the client exactly how long to wait before retrying. abort_with_status puts all of it in the trailers. The key discipline: don't overload a generic INTERNAL code for everything — the code is how automated clients decide whether and when to retry, so getting it right is what prevents both lost work and retry storms. + +## [133] APPENDIX E · ERRORS · Kafka: retry topics and a dead-letter queue (sldId 388) + +Commit the offset, route the failed record aside, and keep consuming — a poison record never blocks the partition. + +p. 113 + +> Kafka error handling has a failure mode unique to logs: a single poison record can block the whole partition forever if you keep retrying it in place, because the offset never advances — head-of-line blocking that stalls every record behind it. The fix is to move failures aside: try a bounded number of in-place retries, then route the record to a delayed-retry topic (reprocessed after a delay without blocking the live stream), and after N attempts send it to a dead-letter queue for humans or tooling. You commit the offset either way, so the main consumer keeps making progress. The DLQ record carries the original payload plus error and trace headers. + +## [134] APPENDIX E · ERRORS · Kafka retry + DLQ with aiokafka (sldId 389) + +[chip] python · aiokafka + +```python +# bounded in-place retries, then a delayed-retry topic, then a DLQ +async for msg in consumer: + try: + await handle(msg) + await consumer.commit() # success + except PoisonError: + attempts = int(header(msg, "retries", 0)) + if attempts < MAX_RETRIES: + await producer.send("orders.retry.30s", # delayed retry + value=msg.value, + headers=[("retries", str(attempts + 1).encode())]) + else: + await producer.send("orders.DLQ", value=msg.value, + headers=[("error", b"poison"), ("traceId", tid())]) + await consumer.commit() # commit so head-of-line is free +``` + +Retry a bounded number of times, then DLQ — and always commit, so one poison record can't stall the partition. + +p. 114 + +> The whole point is the final commit in the except branch: whether the record succeeded, got routed to a retry topic, or landed in the DLQ, we commit the offset so the partition keeps moving. The retries header tracks attempts and is incremented on each delayed-retry hop; after MAX_RETRIES the record goes to the DLQ with its payload and a traceId header for investigation. This is the manual version of what a framework gives you declaratively — and it's the pattern that stops one bad message from taking down a consumer group. Make handle() idempotent, because at-least-once delivery means retried records may have partially applied. + +## [135] APPENDIX E · ERRORS · GraphQL: partial success with errors[] (sldId 390) + +One HTTP 200; the failed field is null and explained in errors[] with a machine code in extensions. + +p. 115 + +> GraphQL breaks the one-request-one-status assumption: a single query can touch many resolvers, so the response is almost always HTTP 200 and carries both a data object and an errors array. When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId. The rest of the data still returns. The anti-pattern to call out: blanket-failing the whole query (or returning a 500) when only one field broke — that throws away useful data the client already asked for. Partial success is a feature; let the client decide whether to retry just the failed field. + +## [136] APPENDIX E · ERRORS · GraphQL field errors with Strawberry (sldId 391) + +[chip] python · Strawberry + +```python +import strawberry +from graphql import GraphQLError +@strawberry.type +class Order: + id: str + total: float + @strawberry.field + async def stock(self) -> int | None: # one field's resolver + try: + return await inventory.remaining(self.sku) + except Unavailable: + raise GraphQLError( # nulls this field only + "inventory unavailable", + extensions={"code": "UNAVAILABLE", # machine code + "retryable": True, + "traceId": current_trace_id()}) +``` + +Raise from the field resolver: Strawberry nulls just that field and adds it to errors[] with your extensions — the rest of data still returns. + +p. 116 + +> Raising a GraphQLError from a single field's resolver is exactly right: Strawberry catches it, sets that field to null, and appends an entry to the errors[] array with the message, the path, and your extensions — while every other field in the query resolves and returns normally. The extensions object is where the shared error contract lives in GraphQL: code, retryable, traceId. Resist the urge to let the exception bubble to the top and fail the whole operation; that's the blanket-failure anti-pattern. The client sees the order and total, sees stock is null, reads extensions.code, and decides whether to retry just stock. + +## [137] APPENDIX E · ERRORS · How a retry storm forms — and how to stop it (sldId 392) + +Naive retries multiply load on a struggling service; bounded, spaced, circuit-broken retries break the feedback loop. + +p. 117 + +> This is the failure mode that turns a brief blip into a full outage. The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries. It's a positive feedback loop, and naive retry logic is the amplifier. The controls that break it, bottom of the slide: bound every call with a timeout; cap retries to a small number and only for idempotent calls; space them with exponential backoff plus jitter (so clients don't retry in lockstep); add a circuit breaker to fail fast while the dependency is down; enforce a retry budget (cap retries as a percentage of traffic); and shed load early with 429/UNAVAILABLE rather than queueing work you can't do. The Istio config from section 2 provides several of these at the mesh layer without code changes. + +## [138] APPENDIX E · ERRORS · Error-handling heuristics across all four (sldId 393) + +- One contract, four encodings: a stable code, a safe message, a trace id, a retryable flag, field details — everywhere. +- The category lives in the protocol: HTTP status, gRPC code, GraphQL errors[] entry, Kafka DLQ vs retry topic. +- Make retryability explicit and honour it: Retry-After, RetryInfo, extensions.retryable — don't make clients guess. +- Never leak internals: no stack traces, SQL, or class names on the wire; map them to a code and log the detail server-side. +- Bound every retry (timeout, small max, backoff + jitter, circuit breaker, retry budget) and make handlers idempotent. + +p. 118 + +> The cross-cutting checklist to leave on screen. One contract expressed four ways — the same five facts whether it's problem+json, google.rpc.Status, a GraphQL extensions object, or a Kafka DLQ header. Let each protocol carry the category natively rather than inventing your own. Make retryability explicit and machine-readable so clients and the mesh back off correctly instead of guessing. Never leak internals — that's both a security issue and a coupling issue. And the bold line is the one that prevents outages: every retry is bounded and spaced, protected by a circuit breaker and a retry budget, and every handler is idempotent so a redelivered or retried request is safe. Get these right and your failure paths become as boring as your happy paths. + +## [139] Appendix F · DDD & Hexagonal Architecture (sldId 394) + +F + +Where the boundaries and the contracts come from. + +> Appendix F. The whole talk assumes you've drawn good service boundaries and know what each API should say — this is where those come from. Domain-Driven Design gives you the strategic tools to place boundaries (subdomains, bounded contexts) and the tactical tools to model what's inside (aggregates, domain events); hexagonal architecture keeps the four protocols we've discussed as mere adapters around a clean domain core. Together they answer: where does an API boundary go, what should its contract say, and how do I keep REST/gRPC/GraphQL/Kafka out of my business logic. The reference is Vlad Khononov's Learning Domain-Driven Design. + +## [140] APPENDIX F · DDD · Why DDD belongs in an API talk (sldId 395) + +- An API is a contract between two bounded contexts — so “where's the boundary?” and “what does the contract say?” are DDD questions before they're API questions. +- Strategic DDD places the boundaries: subdomains (core, supporting, generic) and bounded contexts, each with its own model and ubiquitous language. +- Tactical DDD models what's inside: aggregates (consistency boundaries), entities, value objects and domain events — which become your resources and your Kafka events. +- Hexagonal architecture keeps it clean: the domain core is isolated, and REST, gRPC, GraphQL and Kafka are just adapters plugged into ports. +- Get the boundaries right and the APIs almost design themselves; get them wrong and no amount of REST polish will save you. + +p. 119 + +> Set up why a DDD appendix belongs in an API talk. The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries. Distinguish strategic DDD (where the lines go) from tactical DDD (how you model inside a line), and preview that hexagonal architecture is what stops the four protocols from contaminating the domain. The closing line is the one to land: boundaries dominate outcomes far more than protocol polish. + +## [141] APPENDIX F · DDD · Strategic DDD: subdomains & bounded contexts (sldId 396) + +Place boundaries by business value and rate of change; each context owns one model and exposes it through an API. + +p. 120 + +> Strategic DDD first. Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications). Each subdomain becomes one or more bounded contexts — a boundary inside which one model and one ubiquitous language are consistent. The punchline here is the bottom box: that boundary is exactly where the API sits, and the contract translates between two contexts' models rather than sharing internals. Map it to our system: order and pricing are core, inventory and shipping supporting, auth and notification generic. + +## [142] APPENDIX F · DDD · A bounded context is a service-and-API boundary (sldId 397) + +- A bounded context is the scope within which one model and one ubiquitous language stay consistent — the natural unit for a service and its API. +- The same word means different things across contexts: an “order” in fulfillment is not an “order” in billing. Don't force one shared model across the system. +- The context's published API is its only public surface; the schema, storage and internal types stay private — this is the “no leaky internal models” rule from anti-patterns, restated. +- Put boundaries where the business naturally splits and where a team can own them end-to-end (Conway's law) — that's where the integration cost is worth paying. + +p. 121 + +> Make the bounded context concrete as the service/API boundary. The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead. Tie back explicitly to the anti-patterns section: 'no leaky internal models' is just 'keep the bounded context's internals private.' And invoke Conway's law: boundaries that don't match team ownership generate constant cross-team coordination. + +## [143] APPENDIX F · DDD · Context mapping: how contexts integrate (sldId 398) + +The relationship patterns between contexts, and which ones your API implements. + +p. 122 + +> Context mapping catalogues the ways two contexts relate. Walk the starred ones because they're what APIs implement: an Open-Host Service is a context that, because many others integrate with it, publishes a stable public API; the Published Language is the shared, versioned schema that API speaks; and an Anticorruption Layer is the translation you build when consuming someone else's context so their model doesn't leak into yours. The others are trade-offs: Conformist means you just accept the upstream model; Shared Kernel means you share a model and therefore change in lockstep — high coupling, use sparingly; Partnership means two contexts co-evolve; Separate Ways means don't integrate. The starred three are the ones to design explicitly. + +## [144] APPENDIX F · DDD · Open-Host Service & Published Language (sldId 399) + +- Open-Host Service: a context integrated by many publishes a stable, documented API as its public protocol — that's your REST / gRPC / GraphQL surface. +- Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry. +- Anticorruption Layer: when you consume another context, translate their model into yours at the edge so their concepts never leak into your domain. +- Conformist is the cheap opposite — accept their model wholesale and inherit their changes; choose an ACL instead when the upstream model is messy or volatile. +- So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer. + +p. 123 + +> This slide does the real work of connecting DDD vocabulary to the platform we built. Open-Host Service = the public API; Published Language = the schema in the registry; Anticorruption Layer = the translation at your edge. The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory. Emphasize the ACL-vs-Conformist choice as a real decision: conform when the upstream is clean and stable, build an ACL when it's messy or changes a lot, because the ACL localizes the blast radius of their changes to one translation layer. + +## [145] APPENDIX F · HEXAGONAL · Hexagonal architecture: ports & adapters (sldId 400) + +The domain core is isolated; REST, gRPC, GraphQL and Kafka are just adapters that plug into ports. + +p. 124 + +> This is the architectural payoff of the whole protocols discussion. The domain core in the middle holds entities, aggregates and domain logic and depends on nothing — no framework, no I/O. It declares ports: inbound ports (what it can be asked to do) and outbound ports (what it needs from the world). Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port. Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports. The arrows all point inward: adapters depend on the domain, never the reverse. The consequence we care about: the four protocols we spent the talk on are interchangeable adapters; you can add gRPC alongside REST, or a Kafka consumer, without touching a line of domain logic. + +## [146] APPENDIX F · HEXAGONAL · Ports & adapters in code (sldId 401) + +[chip] python · ports & adapters + +```python +# domain/ports.py — the domain declares the interfaces it needs (ports) +class OrderRepository(Protocol): # outbound port + async def save(self, order: Order) -> None: ... +class EventPublisher(Protocol): # outbound port + async def publish(self, e: DomainEvent) -> None: ... +# domain/service.py — pure application logic: no framework, no I/O +class PlaceOrder: + def __init__(self, repo: OrderRepository, events: EventPublisher): + self.repo, self.events = repo, events + async def __call__(self, cmd: PlaceOrderCmd) -> Order: + order = Order.create(cmd) # aggregate enforces invariants + await self.repo.save(order) + await self.events.publish(OrderPlaced(order.id)) + return order +# adapters/rest.py — a driving adapter; gRPC/GraphQL look the same +@app.post("/orders") +async def place_order(body: OrderIn, uc: PlaceOrder = Depends(build_uc)): + return await uc(body.to_cmd()) # protocol -> domain, nothing more +``` + +The domain depends only on ports it declares; REST/gRPC/Kafka are adapters wired in at the edge. Swap or add one without touching PlaceOrder. + +p. 125 + +> Walk the three blocks. Ports: the domain declares plain interfaces (Protocols) for what it needs, a repository and an event publisher, and depends on nothing else. The application service PlaceOrder is pure: it creates the aggregate, saves via the port, publishes a domain event via the port, and never imports FastAPI, asyncpg or Kafka. The REST adapter is trivial — it turns an HTTP body into a command and calls the use case; a gRPC or GraphQL adapter would be the same three lines. The outbound ports are implemented by adapters (a Postgres repository, a Kafka publisher) injected at startup. This is dependency inversion in service of the boundary: the protocols are replaceable, the domain is stable. + +## [147] APPENDIX F · DDD · Aggregates and domain events (sldId 402) + +- An aggregate is a cluster of objects treated as one consistency unit — the boundary inside which invariants hold and a single transaction commits. It's usually what sits behind one API resource. +- That makes the aggregate the natural transaction boundary, and therefore the outbox boundary: the state change and the domain event commit together (section 4). +- Domain events (“OrderPlaced”) are first-class in DDD; they become the Kafka events other contexts react to (section 5) — choreography between bounded contexts. +- Keep aggregates small: one aggregate per transaction, reference others by id, and let events connect them — which is exactly the saga story in Appendix D. + +p. 126 + +> Tactical DDD, and it ties three earlier sections together. The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource. Because it's the transaction boundary, it's also the outbox boundary from section 4: state plus event commit atomically. Domain events are the DDD concept that becomes your Kafka topic payloads, enabling choreography across contexts (section 5). And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D). This slide is where DDD stops being abstract and visibly underpins the data, event and saga designs. + +## [148] APPENDIX F · DDD · How DDD maps to our API stack (sldId 403) + +- Bounded context → a service and its API boundary. +- Open-Host Service / Published Language → the REST/gRPC/GraphQL contract + the Apicurio registry. +- Anticorruption Layer → model translation at the gateway / consumer edge. +- Aggregate → an API resource and the transaction/outbox boundary behind it. +- Domain event → a Kafka event other contexts consume. + - Reference: Vlad Khononov — Learning Domain-Driven Design (O'Reilly, 2021). + +p. 127 + +> The summary map — leave it up while you recap. Every DDD concept on the left has shown up concretely on the right somewhere in the talk: contexts are services, the OHS/Published Language is the registry-backed contract, the ACL is edge translation, aggregates are resources and outbox boundaries, domain events are Kafka events. The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on. Point people to Khononov's Learning Domain-Driven Design for the full treatment. + +## [149] Appendix G · Balancing Coupling (sldId 404) + +G + +Coupling isn't the enemy — imbalance is. + +> Appendix G. We've thrown around “coupled” and “decoupled” throughout as if loose is always better. This sharpens it, using Vlad Khononov's Balancing Coupling model. The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it. Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together. This gives precise language for why a shared database is bad but a registry-backed API contract is fine, and it ties every anti-pattern from section 12 back to one model. + +## [150] APPENDIX G · COUPLING · Coupling is necessary — balance it, don't eliminate it (sldId 405) + +- Components must share some knowledge to collaborate; a system with zero coupling also has zero behaviour. “Loosely coupled” is too blunt a goal. +- Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility. +- The headline: a coupling is only painful when strength, distance and volatility are all high at once — reduce any one and the pain drops. +- This is why “just split it into microservices” can backfire: spreading strong, volatile coupling across a network maximises all three dimensions — a distributed monolith. + +p. 128 + +> Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real skill is balancing it. Credit Khononov's Balancing Coupling model and stress that it measures the connection between components, not the components, along three dimensions. The headline sentence is the whole model in one line — pain needs all three of strength, distance and volatility to be high. The bold line is the cautionary tale that resonates with anyone burned by microservices: splitting strongly- and volatile-coupled code across a network doesn't remove the coupling, it just adds distance to it — the distributed monolith. + +## [151] APPENDIX G · COUPLING · Coupling has three dimensions (sldId 406) + +Integration strength × distance × volatility — a coupling only hurts when all three are high. + +p. 129 + +> The model in one picture. Integration strength is how much knowledge two parts share — the more they share, the more likely a change in one forces a change in the other. Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary. Volatility is how often the coupled thing changes. The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile. A strong, distant coupling on something that never changes costs you nothing. That's why all three dimensions have to be considered together rather than chasing “lose coupling” in the abstract. + +## [152] APPENDIX G · COUPLING · The three dimensions, in practice (sldId 407) + +- Integration strength: how much knowledge two parts share — from intrusive (private internals) down to contract (an explicit interface). Less shared knowledge = looser coupling. +- Distance: how far apart they sit — method, class, module, service, system. Greater distance raises the cost of any change that crosses the boundary. +- Volatility: how often the coupled thing changes. Khononov derives it from DDD subdomain type — core is volatile, generic is stable. +- Modularity = strength XOR distance: keep strong coupling local, and make anything distant a weak (contract) coupling. Being both strong and distant is the trap. + +p. 130 + +> Unpack each dimension with a concrete handle. Strength is a ladder (intrusive to contract). Distance is intuitive — the further apart, the more it costs to change across the seam. Volatility is the clever part of the model: you can't know the future, but you can predict change-rate from DDD subdomain type (core changes a lot, generic barely changes), which connects this appendix straight back to Appendix F. The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith. + +## [153] APPENDIX G · COUPLING · Integration strength: a ladder, worst to best (sldId 408) + +From intrusive (shared internals) to contract (a versioned schema) — across distance, aim for the bottom rung. + +p. 131 + +> Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the classic case is two services on one database, where either can break the other invisibly. Functional coupling: they share business logic or rules and must change together for functional reasons. Model coupling: they share a data model or DTOs — a shared library of types. Contract coupling: they integrate only through an explicit, stable, versioned contract — and that's our REST, gRPC, GraphQL and Kafka schemas in the registry. Lower on the ladder means less shared knowledge and looser coupling. The rule that matters: the greater the distance between two components, the further down this ladder you need to be. + +## [154] APPENDIX G · COUPLING · Strength in our system (sldId 409) + +- Intrusive (worst): two services sharing one database, or reaching into another's internals — any change can break the other invisibly. This is the shared-database anti-pattern. +- Functional / Model: sharing business logic or a model — e.g. a shared library of types. Fine within a context; risky across teams because it forces lockstep change. +- Contract (best across distance): integrate only through an explicit, versioned schema — our REST/gRPC/GraphQL APIs and Kafka event schemas, governed by the registry. +- The rule: the greater the distance, the weaker the coupling must be. Across services that means contract coupling — never shared state. + +p. 132 + +> Map the ladder onto decisions in our system. The shared database is intrusive coupling and it's the worst precisely because the integration strength is maximal and invisible — neither side knows what the other depends on. Shared libraries are model/functional coupling: perfectly fine inside one bounded context or team, dangerous when they force two independently-deployed teams to release together. Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance. The bold rule is the actionable takeaway and it's exactly why every cross-service integration in the talk went through a registered contract rather than a shared table. + +## [155] APPENDIX G · COUPLING · Balancing coupling: strength vs. distance (sldId 410) + +Strong or distant, rarely both — and put any unavoidable strong-and-distant coupling where volatility is low. + +p. 133 + +> This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module — perfectly healthy, keep it local. Low strength and distant is healthy microservices — services integrating through contracts. Low strength and close is needless indirection — you've split things that could just be merged. And the danger quadrant, top-right, is high strength at high distance: the distributed monolith, global complexity, where one change ripples across services and teams. The right-hand panel adds the third dimension: even a top-right coupling is tolerable if it never changes, but on volatile core-domain code it's where slow delivery and outages come from. So the formula: balance equals strength-XOR-distance, OR low volatility. + +## [156] APPENDIX G · COUPLING · Distance and volatility (sldId 411) + +- Distance is the cost multiplier: a change contained in one module is cheap; the same change spanning two services and two teams is slow and expensive. +- Volatility is the trigger: a strong, distant coupling on code that never changes is harmless; on volatile core-domain code it's where outages and slow delivery originate. +- Predict volatility from the domain: core subdomains change often — keep their couplings weak and local; generic subdomains are stable, so you can afford more coupling. +- Balance = (strength XOR distance) OR low volatility — put any unavoidable strong-and-distant coupling where the code rarely changes. + +p. 134 + +> Deepen distance and volatility. Distance is why the same logical change can be a one-line edit or a multi-team, multi-deploy project — it multiplies cost. Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt. The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas. The bold formula is Khononov's balance condition — worth writing on a whiteboard in a design review. + +## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldId 412) + +- Every protocol choice we've made is a coupling choice: REST, gRPC, GraphQL and events are all contract coupling — the loosest kind that still lets services collaborate. +- The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration strength on its consumers. +- Connascence (Page-Jones) is a finer lens on the same idea — API versioning is managing connascence of name and position in your schemas (why protobuf field numbers matter). +- Every anti-pattern from section 12 is an imbalance: shared DB = intrusive at distance; chatty sync chains = strong + distant + volatile; leaky models = model coupling across a boundary. + +p. 135 + +> Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing contract coupling because it's the loosest that still works across distance. The registry's compatibility gate is, in this language, a guard against a producer silently increasing integration strength on consumers — that's what a breaking change is. Mention connascence as the finer-grained classical model (Page-Jones) that versioning manages — protobuf field numbers are connascence of position made explicit and stable. The bold line re-diagnoses the anti-patterns section through the coupling model, which is a satisfying way to show these aren't separate lists of rules but one underlying principle. + +## [158] APPENDIX G · COUPLING · Heuristics for balanced coupling (sldId 413) + +- Don't chase zero coupling — chase balance. Strong coupling is fine when it's local and stable. +- As distance grows, drive strength down: across services, integrate only through versioned contracts, never shared state. +- Keep strong, volatile couplings inside a single bounded context or aggregate, where distance is small. +- Use DDD subdomain types to predict volatility, and put your weakest couplings around the parts that change most. + - References: Vlad Khononov — Learning Domain-Driven Design (O'Reilly) and Balancing Coupling in Software Design (Addison-Wesley). + +p. 136 + +> The closing heuristics — the slide to leave up. Don't eliminate coupling, balance it; lower strength as distance rises; keep strong-and-volatile couplings local (inside an aggregate or context); and use subdomain types to predict volatility so your loosest seams are around your most-changing code. These five lines are a usable design-review checklist. Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices. + +## [159] Appendix H · Graceful Shutdown (sldId 414) + +H + +Disposability, kept as a promise. + +> Appendix H. This is the operational flip side of factor 9 (disposability) and the cloud-native pillars: pods are killed and replaced constantly, so a service has to be able to go away cleanly. Ungraceful exits are the source of the 5xx blips users see during otherwise-healthy rolling deploys. The thesis: graceful shutdown isn't one setting, it's a short protocol between your app and Kubernetes — stop new traffic, finish in-flight work, drain consumers and sockets, then exit before the grace period runs out. + +## [160] APPENDIX H · SHUTDOWN · Disposability is a promise you have to keep (sldId 415) + +- Pods are cattle: every deploy, scale-down, autoscale event or reschedule kills and replaces them — often many times a day. +- An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout. +- Graceful shutdown is the flip side of factor 9, disposability: stop taking new work, finish what you accepted, release resources, then exit. +- It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in. + +p. 137 + +> Set up the appendix by connecting it to two earlier ideas: factor 9 (disposability) from the twelve-factor arc, and the resilience pillar. The motivating story is the one every team has lived: a rolling deploy that looks healthy in tests but throws a scatter of 5xx errors in production because pods were torn down mid-request. Stress that this is not about a magic flag — it's a sequence the app and Kubernetes perform together, on a clock. This appendix walks that sequence, then shows the code on both sides of it. + +## [161] APPENDIX H · SHUTDOWN · What happens when Kubernetes stops a pod (sldId 416) + +SIGTERM starts a race; readiness-down plus a short preStop keep new traffic off until you've drained. + +p. 138 + +> Walk the two lanes. Top, Kubernetes: it sends SIGTERM to PID 1, starts the terminationGracePeriodSeconds clock, and will SIGKILL you if you overrun. The catch is the red box — removing the pod from the Service endpoints is asynchronous (kube-proxy, kubelet, the mesh all have to catch up), so for a moment after SIGTERM traffic can still arrive. Bottom, your pod: the response is to flip readiness DOWN immediately (which starts the deregistration) and run a short preStop sleep to cover the lag, then stop accepting, drain in-flight work, stop consumers and close resources, and exit — ideally well before the grace period ends. The red arrows are the whole point: readiness + preStop are what keep new traffic off your dying pod during the race. + +## [162] APPENDIX H · SHUTDOWN · The contract with Kubernetes (sldId 417) + +- SIGTERM is the signal: Kubernetes sends it to PID 1, then waits terminationGracePeriodSeconds (default 30s) before SIGKILL. +- Readiness DOWN is how you stop new traffic — the pod leaves the Service endpoints but keeps running to finish in-flight work. +- There's a race: endpoint removal is asynchronous, so traffic can still arrive briefly after SIGTERM — a short preStop sleep covers the lag. +- Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down. +- Set the grace period larger than your longest acceptable in-flight operation — too short cuts work off, too long stalls deploys. + +p. 139 + +> These five points are the rules of the protocol. The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining). The asynchronous-deregistration race is the subtle one — it's why a preStop sleep exists at all; it's not about doing work, it's about waiting for the rest of the cluster to stop routing to you. And the sizing rule on the grace period is the practical lever: it must exceed your real worst-case in-flight operation, with headroom, or you'll be SIGKILLed mid-request. + +## [163] APPENDIX H · SHUTDOWN · Graceful means something different per workload (sldId 418) + +Only HTTP drains automatically; Kafka consumers, WebSockets and jobs each need their own drain. + +p. 140 + +> The trap is assuming 'graceful shutdown' is one thing. Only HTTP request draining tends to be automatic (the server stops accepting and finishes in-flight). Everything else you wire up yourself in your shutdown hook. Kafka consumers: stop polling, finish the records already in hand, commit offsets, and leave the group so the rebalance is clean. WebSockets (from Appendix C): you can't finish a long-lived connection, so send a close/go-away and rely on client reconnection plus the per-connection seq and backplane. Jobs and schedulers: stop starting new work and either let running jobs finish or checkpoint so they resume after restart. The green footer is the safety net under all of it — because a hard SIGKILL is always possible, idempotent handlers make a redelivered request or message harmless. + +## [164] APPENDIX H · SHUTDOWN · Graceful shutdown in FastAPI / uvicorn (sldId 419) + +[chip] python · FastAPI + uvicorn + +```python +# main.py (excerpt), examples/21-graceful-shutdown/python +import asyncio, signal # (contextlib and fastapi imports elided) +DRAIN_DELAY_SECONDS = 5 # lets endpoint removal propagate (the preStop sleep) +shutting_down, uvicorn_sigterm, loop = False, None, None +def handle_sigterm(signum, frame): + global shutting_down + if shutting_down: return # one SIGTERM drives the whole drain + shutting_down = True # fail readiness now, keep serving for the delay + loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS, + uvicorn_sigterm, signum, frame) # then uvicorn drains HTTP +@asynccontextmanager +async def lifespan(app): + global uvicorn_sigterm, loop # uvicorn's handler is already installed: chain to it + loop = asyncio.get_running_loop() + uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm) + yield # --- runs after uvicorn has drained HTTP --- + await db_pool.close() # close pools, flush, release resources +app = FastAPI(lifespan=lifespan) +@app.get("/readyz") +async def readyz(): return JSONResponse({}, 503 if shutting_down else 200) +# run: uvicorn main:app --timeout-graceful-shutdown 20 # bounds the HTTP drain +``` + +One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS the handler hands over to uvicorn, which drains in-flight HTTP within 20 s. + +p. 141 + +> Three moving parts, all started by one SIGTERM, the only signal Kubernetes sends before SIGKILL. First, the handler flips a readiness flag so /readyz returns 503 at once; this is the 'stop new traffic' step, and the process keeps serving while the load balancer stops routing to it. Second, after DRAIN_DELAY_SECONDS (5 s in Example 21; it plays the part of a preStop sleep) the handler chains to uvicorn's own: uvicorn installs its signal handlers before it imports the app and runs the lifespan, so a handler that replaces them without chaining means uvicorn never starts its drain and the process waits for SIGKILL. uvicorn then stops accepting and finishes in-flight HTTP; --timeout-graceful-shutdown 20 bounds that wait, so delay plus drain stays inside the 30-second grace period. Third, FastAPI's lifespan shutdown block (after the yield) runs once HTTP has drained: that is where you stop consumers (an aiokafka consumer commits offsets and leaves the group) and close pools, and the process exits 0. The order matters: readiness down, drain HTTP, then drain everything else. Pair this with the preStop hook and the termination grace period in the pod spec. + +## [165] APPENDIX H · SHUTDOWN · The Kubernetes side: probes, preStop, grace period (sldId 420) + +[chip] yaml · Kubernetes + +```yaml +# deployment.yaml — give the pod time to deregister and drain +spec: + terminationGracePeriodSeconds: 30 # >= preStop + drain + buffer + containers: + - name: order-service + readinessProbe: # flips the pod out of the Service + httpGet: { path: /readyz, port: 8080 } + periodSeconds: 5 + failureThreshold: 2 # ~10s to stop routing after DOWN + livenessProbe: # keeps answering during the drain + httpGet: { path: /healthz, port: 8080 } + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] # cover async endpoint removal +``` + +Readiness removes you from the Service; preStop covers the dereg lag; terminationGracePeriodSeconds must exceed your full drain or you get SIGKILLed mid-flight. + +p. 142 + +> The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover. +> +> Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages. +> +> The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number. + +## [166] APPENDIX H · SHUTDOWN · Beyond HTTP: messages, sockets, jobs (sldId 421) + +- Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook. +- Kafka consumers: stop polling, finish the records in hand, commit offsets, and leave the group so the rebalance is clean (ties to Event-Driven). +- WebSockets: send a close / go-away, let clients reconnect to another pod, and lean on the per-connection seq + backplane from Appendix C. +- Jobs and schedulers: don't start new work; let running jobs finish or checkpoint so they resume after restart. +- A hard SIGKILL is always possible — idempotent handlers make a redelivered request or message safe, which is your real safety net. + +p. 143 + +> Reinforce the per-workload point from the diagram with the concrete drain steps. The Kafka sequence is the one to emphasize because it connects to the at-least-once delivery discussion from the Event-Driven section: committing offsets on the way out minimizes redelivery, and leaving the group makes the rebalance fast. WebSockets can't be 'finished,' so graceful there means handing the client off, which is exactly what the Appendix C reconnection design enables. The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare. + +## [167] APPENDIX H · SHUTDOWN · Graceful shutdown checklist (sldId 422) + +- Handle SIGTERM; flip readiness DOWN first so the Service stops routing before you stop accepting. +- Add a short preStop sleep to cover asynchronous endpoint removal; keep liveness UP throughout the drain. +- Drain in-flight HTTP, commit consumer offsets, close sockets and pools — all bounded by terminationGracePeriodSeconds. +- Make the work idempotent so the inevitable occasional SIGKILL is survivable. +- Test it under a real SIGTERM during a rolling deploy — not just a happy-path integration test. + - References: Quarkus lifecycle guide & M. Eisele, “Quarkus Graceful Shutdown” (2026); uvicorn — --timeout-graceful-shutdown. + +p. 144 + +> The leave-up summary. Read it as an ordered runbook: readiness first, preStop to cover the race, then the actual drains, all inside the grace budget. The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event. And the bold final point is the one teams skip: graceful shutdown that's never tested under a real SIGTERM during a rollout is just hopeful configuration. Encourage a small script (or a chaos test) that kills a pod mid-request and asserts the client still got an answer — exactly how the referenced Quarkus write-up proves its recipe. + +## [168] Appendix I · L7 Routing & Traffic Management (sldId 423) + +I + +Software-defined, north/south and east/west. + +> Appendix I. This one is large enough to be its own talk — the user explicitly suggested it could be cut later for that. The thesis: L7 routing isn't one box in a rack any more; it's a four-layer stack of software (edge, gateway, mesh, in-app) that makes decisions on every HTTP and gRPC envelope crossing the network, north/south and east/west. We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with FastAPI + durable-rules, then circle back to east/west, where most routing decisions happen. + +## [169] APPENDIX I · L7 ROUTING · L7 routing: software, not silicon (sldId 424) + +- The hardware load balancer used to be where routing decisions lived. In a cloud-native system that work has moved into software — programmable, observable, and runnable anywhere. +- L7 means application layer: the router sees the HTTP or gRPC envelope (host, path, method, headers, cookies, gRPC service.method) and can route on any of it. +- It lives in four layers — edge / ingress, API gateway, service mesh, and (sometimes) inside the app itself. Each makes a different type of L7 decision. +- It runs both directions: north/south (clients into your system) and east/west (service to service). Most L7 decisions happen on internal traffic. +- The non-hardware stack is open source and composable: HAProxy, Nginx, Envoy, Istio, Linkerd, Kong, Traefik, FastAPI, durable-rules, Open Policy Agent — plus a YAML or two. + +p. 145 + +> Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else. The four-layer mental model (edge, gateway, mesh, in-app) is the backbone of the whole appendix — we'll touch each layer in turn. Stress north/south plus east/west early; people default to thinking about ingress, but inside a microservices system the volume of east/west calls dwarfs north/south, and that's where mTLS, retries and locality routing earn their keep. Name the open-source pieces explicitly so the audience knows the talk is about real, free, runnable software. + +## [170] APPENDIX I · L7 ROUTING · L4 vs L7: what each layer can decide on (sldId 425) + +L4 sees connections; L7 sees the conversation — paths, headers, methods, cookies, gRPC service.method. + +p. 146 + +> Anchor the discussion in the OSI model just enough to be useful. L4 is the layer of IPs, ports and TCP — a classic load balancer can pick a backend by 5-tuple and hash-balance traffic, but it cannot read URLs, headers, cookies, methods or gRPC metadata. L7 sees the full HTTP/gRPC envelope: host, path, headers, cookies, JWT claims, query parameters, methods, and the gRPC service.method pair. That's the entire menu of inputs the routing patterns in this appendix use to make routing decisions. The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower). + +## [171] APPENDIX I · L7 ROUTING · The L7 capability menu (sldId 426) + +- Routing rules: path / host / header / method / cookie / gRPC method → backend. +- TLS termination: decrypt at the edge, re-encrypt (or use mTLS) inward. +- Traffic management: canary, blue/green, A/B, header-based, geo, shadow. +- Payload modification: inject / strip headers, rewrite URLs, transform bodies. +- Resilience: timeouts, retries with backoff + jitter, circuit breakers, outlier ejection — the same toolkit from Appendix E, applied at the network. +- Cross-cutting: authn/authz, rate limiting, WAF, request mirroring, full traces and metrics on every hop. + +p. 147 + +> Read this as the menu of L7 features you can mix and match across layers. The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section. Most of these features used to be hardware appliances; now they're an annotation or a YAML block away. The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification. + +## [172] APPENDIX I · L7 ROUTING · Where L7 lives: the layered stack (sldId 427) + +Four layers, north/south at the top, east/west at the bottom, each making a different type of L7 decision. + +p. 148 + +> This is the backbone diagram for the whole appendix. North/south traffic (clients into the system) enters through the edge/ingress layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API gateway (Kong, Istio gateway, Spring Cloud Gateway), which handles product-level concerns like auth, rate limiting and transformation. Once inside the cluster, east/west traffic is governed by the service mesh (Istio + Envoy sidecars, or Linkerd) — every internal call gets mTLS, retries, outlier detection and locality-aware load balancing. Finally, when L7 decisions need to consider business rules the platform doesn't know about, you push routing into the app itself (FastAPI + durable-rules + aiokafka). Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them. + +## [173] APPENDIX I · L7 ROUTING · A request's L7 path (sldId 428) + +One HTTP request is a chain of L7 decisions made by four independent components. + +p. 149 + +> Walk a single request left to right. It starts at the client — say a browser or another service. The edge load balancer terminates TLS, does host/path routing and maybe WAF; the API gateway authenticates the caller, applies rate limits and may transform; the mesh sidecar adds mTLS for the cluster's internal hop, applies retries and locality routing; and finally the service can do its own in-app routing if the business rules demand it. Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end. This is the request lifecycle from section 11, viewed through the L7 lens. + +## [174] APPENDIX I · L7 ROUTING · Open-source L7 building blocks (sldId 429) + +- HAProxy: fast, battle-tested L7 LB; ACL-driven config; classic ingress. +- Nginx: ubiquitous web server / LB; location-block routing; widely used as ingress controller. +- Envoy: the modern L7 proxy; HTTP/2, gRPC, dynamic config; the sidecar of choice. +- Istio: a control plane built on Envoy; CRDs for routing, security, observability across the mesh. +- Kong, Traefik, Spring Cloud Gateway: opinionated API gateways with plugin ecosystems. +- FastAPI / Starlette + durable-rules: when the routing logic is a business rule, push it into the app — a Rete-based rule engine in pure Python. + +p. 150 + +> Differentiate the pieces so the audience knows when to reach for which. HAProxy and Nginx are the workhorses of the edge — mature, fast, predictable. Envoy is the modern choice for sidecars because it speaks HTTP/2 and gRPC natively and supports dynamic configuration via xDS. Istio (and Linkerd) wrap Envoy in a control plane and give you the mesh CRDs (VirtualService, DestinationRule). Kong, Traefik and Spring Cloud Gateway are API gateways with plugin ecosystems and a more product-aware feature set. The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster. + +## [175] APPENDIX I · L7 ROUTING · Content-based routing with HAProxy (sldId 430) + +[chip] haproxy.cfg + +```haproxy.cfg +frontend http_in + bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 # TLS here; h2 for gRPC + acl is_api path_beg /api/ + acl is_grpc req.hdr(content-type) -m beg application/grpc + acl is_internal req.hdr(x-internal) -m str 1 + acl is_canary hdr(cookie) -m sub canary=on + use_backend grpc_pool if is_grpc # by content-type + use_backend order_v2_pool if is_canary # by cookie + use_backend order_int_pool if is_internal # by header + use_backend order_pool if is_api # by path + default_backend web_pool +backend order_v2_pool + balance roundrobin + server v2a 10.1.0.10:8080 check + server v2b 10.1.0.11:8080 check +``` + +ACLs read the L7 envelope (path, header, cookie, content-type) and use_backend dispatches — classic, fast, predictable. + +p. 151 + +> HAProxy is the lingua franca of L7 LBs. The pattern is always the same: declare ACLs that read parts of the L7 envelope (path_beg for URL prefixes, hdr/req.hdr for headers, content-type for the body type), then use_backend dispatches based on those ACLs in order. This single frontend shows four different content-based routing decisions — by content-type (gRPC traffic goes to a gRPC pool), by cookie (canary users), by header (internal users), by path. Notice TLS terminates at this frontend; everything inside is plaintext within the cluster (or re-encrypted via mTLS at the mesh layer). HAProxy's reload semantics are graceful by default — it can drain old workers while new ones take traffic, which matters for Appendix H's shutdown discipline. + +## [176] APPENDIX I · L7 ROUTING · Content-based routing with Nginx (sldId 431) + +[chip] nginx.conf + +```nginx.conf +# map directive — declarative routing table on a header value +map $http_x_app_version $upstream_pool { + default order_v1; + "v2" order_v2; + "v2beta" order_v2_beta; +} +server { + listen 443 ssl; http2 on; + ssl_certificate /etc/nginx/certs/tls.crt; + ssl_certificate_key /etc/nginx/certs/tls.key; + location /api/orders/ { # path-based routing + proxy_pass http://$upstream_pool; # picked by the map + proxy_set_header X-Request-Id $request_id; + } + location ~ ^/api/grpc/ { # gRPC traffic + grpc_pass grpc://orders_grpc_pool; + } +} +``` + +map turns a header value into an upstream pool name; location blocks dispatch by path and protocol — HTTP and gRPC side by side. + +p. 152 + +> Nginx's content-based routing is built around location blocks and the map directive. The map is the elegant bit: it converts a header value into a variable, which then drives proxy_pass — you express the routing table declaratively, then point a single location at the variable. Location blocks split HTTP from gRPC (grpc_pass) so you can run both protocols through one server. The X-Request-Id injection is a tiny example of payload modification — the L7 router can inject correlation headers so downstream traces can be stitched together. Nginx as an ingress controller in Kubernetes (the ingress-nginx project) is one of the most-deployed L7 routers in the world. + +## [177] APPENDIX I · L7 ROUTING · Content-based routing in the mesh: Istio VirtualService (sldId 432) + +[chip] yaml · Istio VirtualService + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: # gRPC by service.method + - uri: { prefix: /acme.orders.v1.OrderService/ } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by header + - headers: { x-internal: { exact: "1" } } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by cookie + - headers: { cookie: { regex: ".*canary=on.*" } } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] # default +``` + +Match by URI prefix (gRPC service.method), header, or cookie — same L7 inputs, expressed as Kubernetes CRDs. + +p. 153 + +> The same content-based routing primitives, but expressed as Kubernetes CRDs that the mesh control plane (Istio) compiles down to Envoy sidecar configuration. Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie. The default route at the bottom is critical; without it, unmatched requests have nowhere to go. The CRD lives in Git, gets reviewed like any other change, and Istio reconfigures every sidecar in the cluster within seconds. This is the modern face of L7 routing — declarative, GitOps-managed, distributed across thousands of pods. + +## [178] APPENDIX I · L7 ROUTING · SSL / TLS termination at L7 (sldId 433) + +- Edge terminates TLS so the L7 router can read and route on the request — you cannot inspect headers or paths through an encrypted connection. +- Re-encrypt or mTLS inward: terminate at the edge, then either plaintext within the cluster (rare today) or mTLS via the mesh (the modern default). +- Certificate management: cert-manager + Let's Encrypt for public certs; SPIFFE / SDS for service identity inside the mesh; Istio rotates pod certs automatically. +- TLS passthrough (no termination) when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only. +- Cost: TLS handshakes are real CPU. Modern stacks use session resumption, TLS 1.3 and HTTP/2 to amortise the cost across many requests. + +p. 154 + +> Cover the three modes of TLS at the edge: terminate-and-decrypt (the common case, lets you do L7 things), terminate-and-re-encrypt (most secure default in a mesh, since mTLS inside is automatic), and passthrough (when you must not see the payload, e.g. client mTLS for regulated traffic — route by SNI only). cert-manager is the standard cluster-side answer for public certificates; SPIFFE-style identity and Envoy's SDS handle service-to-service certs automatically inside the mesh. Mention the CPU cost briefly so the L7 latency budget makes sense — a TLS handshake is milliseconds, and you want to amortise it via session resumption, TLS 1.3 and HTTP/2 multiplexing. + +## [179] APPENDIX I · L7 ROUTING · Sticky sessions: cookie vs IP-hash vs none (sldId 434) + +Convenient for legacy state, hostile to elastic scaling — prefer stateless behind a JWT. + +p. 155 + +> Three modes worth knowing about. No affinity is the default and what you want — any pod gets any request, load is even, and client state lives somewhere else (Redis, the JWT, the database). Cookie-based stickiness is what you fall back to when something stateful (a shopping cart in memory, a legacy session) has to stay with one pod; the LB sets a cookie on the first response and uses it to route subsequent requests to the same pod. It works behind any L7 LB but ties the user's session to one pod's lifetime — restart that pod, lose the session. IP-hash hashes the source IP to a pod; it needs no cookies but is fragile to NAT and mobile networks where many users share an IP, leading to skewed load. The red footer is the real cost: stickiness fights elastic scaling, hurts pod restarts and breaks scale-to-zero. Use it only for a known constraint, never as a default. + +## [180] APPENDIX I · L7 ROUTING · Sticky sessions in practice (sldId 435) + +- When you need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod. +- Cookie-based is almost always the right choice over IP-hash: it survives NAT and lets clients explicitly drop the cookie. +- What stickiness breaks: even load balancing, fast pod restarts, scale-to-zero, blue/green flips, and a clean Appendix H graceful shutdown for any stateful pod. +- The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable. + +p. 156 + +> Make stickiness a last resort, not a first reach. Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it. The list of things stickiness breaks is the real argument against it: it actively fights the entire cloud-native delivery model — elastic scaling, fast restarts, graceful shutdown. The bold final point is the architectural fix: externalise the state. Redis for session data, JWT claims for user identity, the WebSocket backplane from Appendix C for fan-out. With that done, your LB is back to no-affinity and the platform works the way it wants to. + +## [181] APPENDIX I · L7 ROUTING · Intelligent traffic steering (sldId 436) + +Six familiar shapes — each is a header or weight rule away. + +p. 157 + +> Six patterns that all share the same L7 primitives. Canary: 90/10 weighted routing to a new version, watching metrics and ramping. Blue/green: two identical environments, traffic flipped atomically (and rolled back atomically). A/B test: a stable cohort maps to a variant via a user identifier so the same user always sees the same variant — critical for valid measurement. Header-based: dark launches and internal-only previews by routing on x-internal or similar. Geo / latency: send EU users to EU pods for RTT and data residency. Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load. The point is they're not different things mechanically; they're the same routing rule expressed with different inputs and weights. The difficult part is the deploy discipline around them: alerts that trigger an automatic rollback, gradual ramps, and the metrics that tell you the canary is bad before users do. + +## [182] APPENDIX I · L7 ROUTING · Canary release: weighted Istio routing (sldId 437) + +[chip] yaml · Istio canary + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - route: + - destination: { host: orders, subset: v1 } + weight: 90 # 90% on the stable version + - destination: { host: orders, subset: v2 } + weight: 10 # 10% on the canary +--- +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: orders } +spec: + host: orders + subsets: + - name: v1 + labels: { version: v1 } + - name: v2 + labels: { version: v2 } +``` + +VirtualService splits traffic by weight; DestinationRule defines the subsets by pod labels — the canonical canary release pattern. + +p. 158 + +> The canonical Istio canary. Two CRDs working together: DestinationRule defines subsets by pod label (version=v1 and version=v2), and VirtualService routes by weight to those subsets. Changing the weights in Git and reconciling the cluster ramps the canary — typically through 10%, 25%, 50%, 100% over hours or days, with metric gates between each step. If error rate or latency on v2 crosses a threshold, an automated controller (Flagger, Argo Rollouts) flips the weights back to 100/0 in seconds. The same weighted-routing primitive does shadow traffic, geo balance, and blue/green flips — the only thing that changes is which subsets you target and what the weights are. + +## [183] APPENDIX I · L7 ROUTING · Header-based steering: dark launches (sldId 438) + +[chip] yaml · Istio header-based + +```yaml +# route internal beta users to v2; everyone else stays on v1 +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: + - headers: + x-acme-beta: { exact: "true" } # set by your auth proxy + route: [{ destination: { host: orders, subset: v2 } }] + - match: + - headers: + user-agent: { regex: ".*AcmeInternal.*" } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] +``` + +First match wins; an internal-only header (set upstream by auth) hands real production traffic to v2 — with zero blast radius. + +p. 159 + +> Header-based steering is how you do dark launches and betas safely. An upstream identity proxy sets an x-acme-beta header for opted-in users, and the VirtualService routes those users to v2 — in real production traffic, on real data, but with zero exposure to anyone outside the beta. First match wins, so order the rules from most-specific to least, ending with the catch-all default. This is also how you do internal-only previews (route by an internal user-agent string) and per-tenant feature gates (route by an x-tenant-id). Combined with weighted routing, you can do 'roll the canary forward only for the beta cohort,' which is the most surgical release pattern in the toolbox. + +## [184] APPENDIX I · L7 ROUTING · Payload modifications (sldId 439) + +- Header injection: X-Request-Id, traceparent, X-Forwarded-For, identity claims — propagate context downstream so the trace stitches together. +- Header stripping: drop Authorization or PII headers before forwarding to a less-trusted backend; strip Server / X-Powered-By outbound. +- URL rewrites: /api/v1/orders → /orders internally; clean public URLs over messy service paths. +- Body transformation: rare but possible — protocol translation, redaction, format conversion (Envoy filters, Starlette middleware). +- Cost: body inspection means reading the full request into memory — the slowest L7 thing you can do. Use it sparingly, never on every request. + +p. 160 + +> Distinguish the cheap modifications from the expensive ones. Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers. Body transformation is the expensive one because it forces the router to buffer the full payload, which kills streaming, blows memory on large requests and can become a bottleneck under load. Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate). The bold point is the practical advice: if you can do it in the headers, do it in the headers. + +## [185] APPENDIX I · L7 ROUTING · Header injection and URL rewrites (sldId 440) + +[chip] yaml · Envoy / Istio + +```yaml +# Envoy / Istio: inject identity headers, strip secrets, rewrite paths +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders.acme.io] + http: + - match: [{ uri: { prefix: /api/v1/orders } }] + rewrite: + uri: /orders # external -> internal path + route: [{ destination: { host: orders } }] + headers: + request: + set: + x-client-ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" # caller address + x-peer-identity: "%DOWNSTREAM_PEER_URI_SAN%" + remove: [ authorization, cookie ] # don't leak inward + response: + remove: [ server, x-powered-by ] # don't leak outward +``` + +Rewrite paths, inject the caller address and identity, strip secrets — all declaratively in the VirtualService. + +p. 161 + +> Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders. +> +> The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none. +> +> The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved. + +## [186] APPENDIX I · L7 ROUTING · Beyond the edge: routing inside the app (sldId 441) + +- Sometimes the routing decision is a business rule the platform doesn't know about: route 'high-value-customer claims' here, 'European HL7 messages of type A03' there. +- Pushing those rules into HAProxy ACLs or VirtualService matches does not scale: domain experts own them, they change often, and they depend on the payload, not just the envelope. +- The pattern: an in-app router (FastAPI middleware, a custom dispatcher, or a faust-streaming agent for streams) calls a rule engine (durable-rules, Open Policy Agent) and routes on the result. +- It runs inside one service, behind the mesh — not on the network. The mesh still does mTLS, retries and locality; the rule engine just decides where to send the message next. + +p. 162 + +> Pivot from network-layer routing to in-app routing. The threshold is roughly: if the decision can be expressed in a few YAML lines and depends only on the envelope, do it at the mesh. If it depends on the payload, requires a decision table that domain experts edit, or involves complex conditions, push it into the app. The classic example (and the one the code models) is HL7 healthcare messages: route by patient class, encounter type, message subtype — dozens of intersecting conditions a non-developer needs to maintain. That belongs in a rule engine, not a VirtualService. Stress that this isn't an alternative to the mesh; it sits behind the mesh, complementing it. + +## [187] APPENDIX I · L7 ROUTING · FastAPI + durable-rules: routing by business rules (sldId 442) + +FastAPI is the HTTP front; durable-rules is the engine; aiokafka does the dispatch. + +p. 163 + +> This is the in-app routing pattern in Python, and it mirrors what enterprise integration frameworks do on other platforms — just composed from smaller pieces. Inputs arrive over REST (or WebSocket, or Kafka). They hit a FastAPI (or Starlette) endpoint, which gathers any extra context with an enrich step, hands the merged fact to durable-rules to evaluate the ruleset, and dispatches the result to one or more destinations via an aiokafka producer (or an HTTP client). The durable-rules box on the right is the rule engine: a Rete-based forward-chaining engine written in C with a Python ruleset DSL, and the part domain experts edit. The 'data, not code' principle still applies: the routing logic lives in the ruleset, separate from the dispatcher, and can change without redeploying the FastAPI service. + +## [188] APPENDIX I · L7 ROUTING · The rules, externalised: durable-rules (sldId 443) + +[chip] python · durable-rules + +```python +# orders_rules.py — edited by domain experts, not developers +from durable.lang import ruleset, when_all, m +with ruleset("orders"): + @when_all((m.amount_cents > 50_000) & (m.tier == "VIP")) + def vip(c): # high-value VIP orders + c.m.where_to = ["kafka:orders.priority", "http:notify-vip"] + @when_all(m.region == "EU") + def eu(c): # European orders → EU region + c.m.where_to = ["kafka:orders.eu"] + @when_all(+m.order_id) # catch-all (lowest priority) + def default(c): + if not getattr(c.m, "where_to", None): + c.m.where_to = ["kafka:orders.default"] +``` + +Each @when_all is a small business statement: when X is true, set where_to to Y. Owned and reviewed by domain experts. + +p. 164 + +> Read these as 'when ... then ...' statements. The 'when' is a pattern match on facts in the rule engine's working memory (durable-rules implements Rete forward chaining); the 'then' is the action — here, setting a where_to list of destination URIs that the dispatcher will consume. Two real rules and a default. Domain experts own this file (the same people who define what 'VIP' or 'high-value' mean), and it can be redeployed without a developer touching the FastAPI dispatcher code. For more complex decision tables, swap durable-rules for Open Policy Agent (Rego) or business-rules; the shape stays the same. + +## [189] APPENDIX I · L7 ROUTING · The dispatcher, in FastAPI (sldId 444) + +[chip] python · FastAPI + aiokafka + +```python +from durable.lang import post +from fastapi import FastAPI, Depends +from aiokafka import AIOKafkaProducer +import httpx, json +import orders_rules # registers the ruleset +app = FastAPI() +@app.post("/orders/route") +async def route(order: dict, + producer: AIOKafkaProducer = Depends(kafka_producer), + http: httpx.AsyncClient = Depends(http_client)): + ctx = await enrich(order) # gather context (DB / HTTP) + fact = post("orders", order | ctx) # evaluate the ruleset + for dest in fact["where_to"]: # dispatch per destination + proto, target = dest.split(":", 1) + if proto == "kafka": + await producer.send(target, json.dumps(order).encode()) + elif proto == "http": + await http.post(target, json=order) + return {"routed_to": fact["where_to"]} +``` + +Enrich → evaluate → dispatch — the same content-based-router pattern the EIP literature describes, in plain async Python. + +p. 165 + +> The dispatcher is small by design. Enrich gathers any extra context the rules need (a DB lookup, an HTTP call to a customer service); the merged fact goes to post() which fires the durable-rules ruleset; each destination in the resulting where_to list is dispatched via the right transport. Note the protocol prefix in destinations ("kafka:" or "http:") — it's a tiny convention that lets one ruleset drive multiple transports. For event-shaped flows, swap the FastAPI endpoint for a faust-streaming agent or an aiokafka consumer loop; the ruleset and dispatch logic stay the same. This is the in-app L7 router in maybe 20 lines of code. + +## [190] APPENDIX I · L7 ROUTING · Every L7 hop costs time (sldId 445) + +Typical p50/p99 added per stage — the budget you spend before your service even runs. + +p. 166 + +> Set expectations with the numbers. Each L7 capability has a typical cost: TLS termination is sub-2ms, path/header routing is sub-1ms, auth/JWT validation is a few ms, WAF a few more, mesh sidecar another millisecond per hop, rate-limit Redis lookup is a couple of ms. Body inspection is the outlier — it can be 5 to 50 ms depending on size, which is why we say to use it sparingly. The numbers are typical, not authoritative; profile your own stack. The right-side card has the working heuristic: stay under ~10ms total L7 on hot paths. With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency. The retry-storm discipline from Appendix E applies here too — timeouts must account for the total L7 budget plus the service's own work, or retries will pile up. + +## [191] APPENDIX I · L7 ROUTING · L7 pitfalls and how to avoid them (sldId 446) + +- Too many hops: edge → gateway → ingress → mesh sidecar in → mesh sidecar out adds up fast. Collapse layers that aren't earning their place. +- Regex-heavy ACLs / matches: each regex evaluation is real CPU. Prefer exact / prefix matches; use regex only when needed. +- Sticky sessions break elasticity without any error: the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone. +- Body inspection on every request: blows memory, kills streaming. If you must transform bodies, do it on a small percentage or just at the edge. +- Sync auth lookups on the hot path: cache validated tokens, validate JWT signatures locally, never do a per-request DB lookup for identity. +- Misaligned timeouts and retries between layers — a 30s gateway timeout + 3 mesh retries with 10s each = a retry storm at the bottom (see Appendix E). + +p. 167 + +> Run quickly through the failure modes. The hop-count one is the most common: every layer feels free until you measure p99 — then you realise the request crossed seven proxies. The regex point is a perf footgun many people miss. The sticky-session one ties back to the sticky-sessions comparison. Body inspection is the latency-budget killer. The auth-lookup one is the most common cause of cascading slowdowns under load. And the timeout-misalignment is the gateway-to-mesh-to-service version of the retry storm we covered in Appendix E — the lesson is identical: bound retries, align timeouts top-down, give the deepest layer the strictest budget. + +## [192] APPENDIX I · L7 ROUTING · East / West: L7 between services, inside the mesh (sldId 447) + +Every service-to-service call is L7-routable through the sidecars. + +p. 168 + +> Most L7 decisions in a microservices system don't happen at the edge — they happen east/west, between services, every time service A calls service B. The mesh's sidecar pair handles that traffic: mTLS between services (encryption and identity), HTTP/2 multiplexing, native gRPC support, retries with backoff and jitter, outlier detection (eject misbehaving pods automatically), locality-aware load balancing (prefer same-zone pods to keep RTT low), header-based routing and canary weights for east/west versions, plus rich per-call traces and metrics. The headline is the second box: all of that, per call, with no app code. Application services keep talking plain HTTP or gRPC; the sidecars make it secure, reliable and observable in flight. This is the modern answer to 'how do you build a microservices system that works in production.' + +## [193] APPENDIX I · L7 ROUTING · Why east/west L7 matters (sldId 448) + +- Internal traffic dominates: in a microservices system the volume of east/west calls dwarfs north/south by 10x or more. +- Security: mTLS everywhere by default — every internal call has an identity, encryption and an audit trail without per-service code. +- Resilience: timeouts, retries, circuit breakers and outlier ejection live in the sidecar, applied uniformly to every internal hop. +- Observability: every internal call gets a span, latency histogram and per-route success rate — the request lifecycle from section 11, populated automatically. +- Locality: prefer same-zone or same-region pods so internal RTT stays in single-digit milliseconds, and graceful failover when a zone goes. +- Routing decisions: header-based canaries and weighted releases work east/west too — you can canary a backend service that has no external traffic at all. + +p. 169 + +> Hammer the volume point first: most production L7 routing happens between services, not at the edge. The security argument is the one that justifies the mesh on its own — mTLS everywhere is otherwise hard to retrofit. The resilience and observability arguments are the operational ones; you stop writing the same boilerplate in every service. Locality is the perf argument: with the right Istio config, two pods in the same zone talk over a few milliseconds; cross-region traffic gets degraded gracefully. And the last point is non-obvious but important: canary releases of a deeply internal service (say, a payments calculation engine that no client touches directly) are possible because the mesh can split east/west traffic by weight — you cannot canary internal services through an edge LB. + +## [194] APPENDIX I · L7 ROUTING · Istio for east/west: DestinationRule + VirtualService (sldId 449) + +[chip] yaml · Istio east/west + +```yaml +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: payments } +spec: + host: payments.acme.svc.cluster.local + trafficPolicy: + # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10 + outlierDetection: # eject bad pods + consecutive5xxErrors: 5 + interval: 30s + baseEjectionTime: 30s + # ... loadBalancer: localityLbSetting enabled (prefer same zone) + subsets: + - { name: v1, labels: { version: v1 } } + - { name: v2, labels: { version: v2 } } +--- +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: payments } +spec: + hosts: [payments.acme.svc.cluster.local] # internal DNS — east/west + http: + - route: + - { destination: { host: payments, subset: v1 }, weight: 95 } + - { destination: { host: payments, subset: v2 }, weight: 5 } +``` + +Same CRDs as north/south, but pointed at an internal host — outlier detection, locality LB, canary weights, all between services with no app code. + +p. 170 + +> Same Istio primitives as the weighted canary, but applied to an internal service (note the cluster.local host). DestinationRule sets up the connection pool (HTTP/2 multiplexing for gRPC), enables outlier detection (5 consecutive 5xxs in 30s ejects the pod), enables locality-aware load balancing (same-zone preferred), and defines version subsets. VirtualService weights east/west traffic 95/5 between v1 and v2 — a canary release of a backend service that no external client knows exists. This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars. + +## [195] APPENDIX I · L7 ROUTING · gRPC east/west specifics (sldId 450) + +- gRPC is HTTP/2 under the hood, so it works natively in any L7 router that speaks HTTP/2 (Envoy, Istio, modern HAProxy/Nginx). +- Route by gRPC service.method: the URI path is /package.Service/Method — use a uri prefix match in Istio, grpc_pass in Nginx, or path_beg in HAProxy. +- Header-based balancing matters more for gRPC: connections are long-lived and multiplexed, so least-request or per-request load balancing beats round-robin. +- Streaming RPCs (server-streaming, client-streaming, bidi): retries are dangerous if the stream is non-idempotent — cap retries to UNARY calls in the mesh policy. +- Trailers carry status: a 200 + a non-OK trailer is the gRPC equivalent of a 5xx — outlier detection has to be configured to read trailers, not just the status line. + +p. 171 + +> gRPC has a few sharp edges of its own. Route on the gRPC URI prefix to do per-method canaries. Use least-request (or random-3-choices) load balancing instead of round-robin because gRPC connections are long-lived — a few long-running streams can starve other backends if you round-robin connections instead of requests. Be careful with retries on streaming RPCs: the mesh can retry a UNARY call safely if it's idempotent, but retrying a half-completed streaming RPC will replay half the work — Istio lets you scope retries to specific gRPC status codes and request kinds. And the trailer point is subtle: gRPC returns its real status in HTTP trailers (a 200 with a non-OK gRPC status is a failure), so outlier detection has to know to read those, or it will think a failing service is healthy. + +## [196] APPENDIX I · L7 ROUTING · When to use which L7 layer (sldId 451) + +- Edge / ingress: TLS termination, host/path routing, WAF, public-facing rate limits. Anything north/south that affects all clients. +- API gateway: cross-cutting product policy — auth, per-consumer rate limits, request transformation, monetisation, key management. +- Service mesh: east/west routing, mTLS, retries, outlier detection, canaries between internal services. The default home for service-to-service L7. +- In-app router (FastAPI + durable-rules): when routing depends on business rules, payload content, or decisions that domain experts must own. +- Rule of thumb: prefer the lowest layer that can fully express the decision, and never push business rules into mesh CRDs. + +p. 172 + +> This is the decision guide to leave on screen for a beat. The mental model: each layer has a job it's good at, and pushing work to the wrong layer is where complexity comes from. Edge/ingress for the things every client crosses. API gateway for product-shaped policy. Mesh for east/west and everything internal. In-app for business rules. The bold rule of thumb prevents the common antipattern of growing increasingly baroque Istio VirtualService configs to encode rules that domain experts need to maintain — if it's a business rule, it belongs in a rule engine inside a service, not in a network CRD. + +## [197] APPENDIX I · L7 ROUTING · L7 routing checklist (sldId 452) + +- Pick one ingress, one gateway, one mesh — don't run three competing L7 layers because each team had a favourite. +- TLS terminates at the edge; mTLS between services in the mesh; cert rotation is automatic (cert-manager + SDS), not a quarterly outage. +- Routing rules live in Git, reviewed like code; GitOps reconciles them; rollback is a git revert. +- Every L7 layer emits traces and metrics — sample the spans, alert on per-route SLOs, watch for retry budget exhaustion. +- Stickiness is an exception, not a default; business rules belong in a rule engine inside a service, not in network YAML. +- Test under load and under failure: bring a pod down, eject a zone, ramp a canary, verify the mesh does what the YAML says. + - References: HAProxy / Nginx / Envoy docs; Istio traffic management guide; durable-rules (PyPI); Open Policy Agent (OPA) and Rego docs; FastAPI / Starlette middleware docs. + +p. 173 + +> Leave the checklist up at the end. The first point is the most common organisational failure — a cluster ends up running three ingress controllers because three teams each picked one. Pick one and standardise. The cert-rotation point is the one teams underestimate — expired certs are still a top-3 outage cause in many shops. GitOps for routing is the operational discipline that makes the whole thing reviewable. Observability and the load/failure-test point are non-negotiable. The references sub-bullet is the reading list: the durable-rules project on PyPI, Open Policy Agent (OPA) docs for declarative policy, and the FastAPI and Starlette middleware guides. + +## [198] Appendix K · Monolith to Microservices (sldId 453) + +K + +Patterns that survive the migration with the system still running. + +> Appendix K covers the migration patterns. The thesis: you don't decompose a monolith by stopping it and rewriting; you decompose it incrementally, one bounded asset at a time, with the system serving users the whole way through. We'll walk through the monolith as the starting point, modular monoliths as a frequently-correct destination, the strangler-fig family of patterns for progressive extraction, content-based routing for fine-grained cutover, and the decorating collaborator pattern for wrapping legacy code you can't touch. Code in this appendix is FastAPI + httpx, but the patterns are language-neutral. + +## [199] APPENDIX K · MONOLITH · A monolith is a unit of deployment (sldId 454) + +One process, one binary, one heartbeat. All modules live or die together. Often the right starting point — don't be ashamed of it. + +p. 174 + +> Define the term before fixing it. A monolith is not a code-organisation problem — it's a deployment-organisation property. Even a well-organised codebase with clear modules, layers, and tests is a monolith if it ships as one binary on one heartbeat. The defining property is on the slide: change anywhere, redeploy everything; bug in Reporting can take down Payments; one scaling unit for workloads that scale differently. But the second italic line is what to leave audience with: monoliths are not always wrong. For new products, small teams, or anything that fits in one team's head, they're often the simplest and fastest path to value. Premature decomposition is its own anti-pattern. The conversation is: when does this monolith start hurting more than it helps? — and that's when these patterns earn their keep. + +## [200] APPENDIX K · MODULAR MONOLITH · The modular monolith — often the right destination (sldId 455) + +Still one deployable, but with strict in-process module boundaries enforced by build tooling — not by gentlemen's agreement. + +p. 175 + +> The intermediate pattern that's often the right destination, not a stepping stone. Modules within one process have clear public APIs (PlaceOrder, ReserveStock, ChargeCard) and private internals that other modules cannot reach. Enforcement is the key bit: in Python, this is import-linter or modulegraph rules in CI that fail the build if Orders.internals is imported outside the orders package; in larger codebases you might split into separate sub-packages or even separate distributions with pyproject.toml dependencies. Modules can share a database, but each owns a schema and no cross-schema joins are allowed (also enforced in CI by query analysis or just convention). Why this is often the destination: you get most of the benefits of microservices (clear boundaries, replaceable modules, independent reasoning) at a fraction of the operational cost (one deployment, one log stream, one debugger session). If you need to split later for different scaling profiles, teams or release cadences, you have a small refactor, not a rewrite. The cost of being wrong about decomposition is also much lower: rename the public API, move the file, ship. + +## [201] APPENDIX K · DECOMPOSED DBS · Modular monolith with decomposed databases (sldId 456) + +Data ownership becomes per-module — the hardest part of the split, done while still one process. The next step is mechanical. + +p. 176 + +> Splitting the database is the hard part of decomposition: schema dependencies, cross-table joins, and reporting queries all create coupling that stays invisible until you try to move data. The insight of this pattern: do that work while you're still one process. Each module gets its own Postgres / Mongo / whatever, its own connection pool, its own migration history. Cross-module data is now async or event-driven — you replace a JOIN with a service call or an event subscription. Same discipline microservices require, but with one operational target instead of N. Once this is done, promoting a module to a separate service is a refactor of the deployment topology, not a data-migration project. (Many teams discover at this point that they don't need separate services: the modular monolith with decomposed data is enough.) Practical tip: start with the highest-coupling module first; the second hardest is the database that the reporting layer joins across, so do that one early. + +## [202] APPENDIX K · STRANGLER FIG · Identify → move → redirect (sldId 457) + +Named after the strangler fig vine: grow the new system around the old until the old can be removed. + +p. 177 + +> The Martin Fowler strangler-fig pattern, named for the vine that grows around its host tree until the host is gone and the vine stands on its own. The three steps are the unit of work you repeat. Identify: pick a bounded asset — not 'the orders subsystem,' but 'order creation' or 'order status query' specifically. The smaller and more bounded, the safer the move. Move: build the new service alongside the monolith, replicate the behaviour, test it in parallel without production traffic. REDIRECT: switch callers to the new service. Each cycle is independently reversible — if the new service is wrong, redirect back and try again. There are three ways to do the redirect: via a proxy, via a proxy plus partial routing, or via a shared database. The proxy is the foundation for the rest; almost everything starts with that step. + +## [203] APPENDIX K · STRANGLER FIG · With a proxy — step zero of every migration (sldId 458) + +Introduce the routing layer first, before extracting anything. Then redirection becomes a config change, not a release. + +p. 178 + +> The pre-condition for incremental migration: clients hit the proxy, the proxy forwards to the monolith. Day zero, behaviour is unchanged. But you now have the lever — the proxy is where you'll later route 1% of /orders/* traffic to the extracted service, then 5%, then 100%. Without this step, every cutover requires coordinated client releases. With it, every cutover is a proxy config change. As a bonus, the proxy is also the right place for TLS termination, auth, rate-limiting, and observability — things you wanted regardless. For Python shops this is typically nginx or Envoy as a Kubernetes Service ahead of the app Pods; you could also use Traefik or HAProxy. The strangler-fig literature calls this an 'event interception' — you intercept the request before it gets to the legacy system, giving yourself a place to redirect later. + +## [204] APPENDIX K · STRANGLER FIG · With proxy + redirection (sldId 459) + +The proxy splits traffic between the new service and the shrinking monolith. Roll forward by widening the route; roll back by narrowing. + +p. 179 + +> Now the proxy does the routing work: by path (/orders/* → new service), by header (X-Tenant: acme → new service), by percentage (1% of all traffic → new service), or by feature flag (users in the beta group → new service). The new service handles its slice; the monolith handles everything else. Clients still see one URL, one auth boundary, one TLS cert. Roll forward by widening the new-service route in the proxy config; roll back by narrowing it. The new service can fail completely and the monolith picks up — the bounded blast radius is the entire point of this pattern. Production observability matters here: instrument both paths so you can verify the new service is producing the same outcomes as the monolith before you widen the route. For Python deployments this is typically encoded in nginx server blocks, Envoy route configs, or Istio VirtualService rules — same patterns from Appendix I, used for migration instead of canary release. + +## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) + +Process boundary first, data boundary later. Useful intermediate state, not a destination. Schema coupling will bite eventually. + +p. 180 + +> Sometimes the data split is too risky to do at the same time as the process split. In that case you let the new service and the monolith share the database for a while. Both apps read and write the same tables; the new service owns the API but not the data. This pattern is useful for two cases: extracting reads first (the new service is a read replica with a nicer API), and as a stepping stone when the data migration is a separate project. But this is not a destination: every schema change now needs both apps to agree, and schema migrations become a coordination tax that grows with every team that owns part of the same database. The bold red caption is the rule: this is a transition state. The next step is to give the new service its own store, which means CDC out of the monolith's DB into the new service's DB, or dual-writing during a brief sync window, or event-sourcing the data into the new service. + +## [206] APPENDIX K · ROUTING · Content-based routing during migration (sldId 461) + +[chip] python · FastAPI + httpx + +```python +# FastAPI middleware that routes by request content during cutover. +from fastapi import FastAPI, Request +import httpx +app = FastAPI() +MONOLITH = "http://monolith.svc.cluster.local:8080" +NEW_SVC = "http://orders.svc.cluster.local:8080" +EU_SVC = "http://orders-eu.svc.cluster.local:8080" +client = httpx.AsyncClient(timeout=5.0) +@app.middleware("http") +async def content_router(request: Request, call_next): + if request.url.path.startswith("/orders"): + body = await request.body() + payload = json_or_empty(body) + # decide by payload content, not URL + if payload.get("tenant") == "acme": + upstream = NEW_SVC # cut Acme over to new service + elif payload.get("region") == "EU": + upstream = EU_SVC # EU traffic to regional service + else: + upstream = MONOLITH # default: legacy + return await forward(upstream, request, body) + return await call_next(request) +# ... forward(): replays the request to upstream with httpx and streams the reply +``` + +Reads the body, decides by content (tenant / region / feature flag), forwards to monolith or new service. + +p. 181 + +> The middleware shape. Reading the body costs CPU on every routed request (and you must be careful: once consumed, you must hand the bytes through to the upstream). The decision is by payload content: tenant, region, feature flag, user segment — not by URL pattern (the URL is the same). This makes the pattern more flexible than path-based proxying: you can roll out the new service to one tenant at a time, see how it behaves under real traffic, then widen. The httpx.AsyncClient is reused across requests so the connection pool is warm. For Python applications running behind a real proxy (nginx, Envoy, Istio), you'd express the same logic in Lua / WASM filters or an EnvoyFilter; the FastAPI middleware version shows the conceptual shape. The CPU cost is the only real downside — if your migration window is months, that body parse adds up; if it's days, the cost is irrelevant compared to the migration risk it controls. + +## [207] APPENDIX K · DECORATOR · The decorating collaborator (sldId 462) + +The new service wraps the legacy one, adds capability, then becomes the API. Useful when you can't modify the legacy code. + +p. 182 + +> Different shape from the proxy patterns: the decorator owns behaviour, not just routing. The new service sits in front of the legacy API and adds cross-cutting capability: caching, audit, validation, enrichment, fan-out to Kafka, rate-limiting, metrics. The legacy service is unaware it has been wrapped. Clients now talk to the decorator's API, which delegates to the legacy service for the underlying work but contributes everything else. Over time, the decorator can take ownership of more behaviour — once the decorator owns enough, the legacy service can be replaced without clients noticing. This is particularly useful when the legacy system is a 3rd-party product, a vendor system, or a piece of code so fragile that touching it is forbidden. The decorator is a real microservice (with its own data, its own deploy, its own scaling) — not just a router. Common Python implementations: a FastAPI app that proxies to a SOAP or legacy REST service while emitting Kafka events and caching responses. + +## [208] APPENDIX K · DECORATOR · A decorating collaborator in Python (sldId 463) + +[chip] python · FastAPI + httpx + redis + aiokafka + +```python +# FastAPI app that wraps a legacy /orders API and adds: +# (1) Redis caching of GET responses +# (2) Kafka event emission on writes +# (3) request/response auditing +# ... imports: FastAPI, redis.asyncio.Redis, aiokafka.AIOKafkaProducer, httpx, json +app = FastAPI() +legacy = httpx.AsyncClient(base_url="http://legacy.svc:8080", timeout=5.0) +cache = Redis.from_url("redis://redis:6379") +audit = AIOKafkaProducer(bootstrap_servers="kafka:9092") +@app.get("/orders/{oid}") +async def get_order(oid: str): + if cached := await cache.get(f"order:{oid}"): # 1. cache hit + return json.loads(cached) + r = await legacy.get(f"/orders/{oid}") # 2. legacy call + order = r.json() + await cache.setex(f"order:{oid}", 60, json.dumps(order)) + return order +@app.post("/orders") +async def place_order(body: dict): + r = await legacy.post("/orders", json=body) # 1. legacy first + order = r.json() + await audit.send_and_wait("order.placed", json.dumps(order).encode()) # 2. emit event + await cache.delete(f"order:{order['id']}") # 3. invalidate cache + return order +``` + +The decorator adds caching, event emission and (if you wire it up) auditing — capabilities the legacy service can't add on its own. + +p. 183 + +> Tight, complete. The decorator is a real FastAPI app. The GET path adds a Redis cache with a 60-second TTL — a capability the legacy service didn't have. The POST path delegates the actual write to the legacy service (it's still the source of truth), then emits a Kafka event so downstream systems can subscribe to the new event stream, then invalidates the cache. The legacy service has no idea it's been wrapped; clients have no idea they're talking to a decorator. Over time you can move more behaviour into the decorator: validation rules, idempotency keys, dedup, multi-source reads. When you're ready to retire the legacy service, you implement the order storage inside the decorator (or in a clean new service it calls) and stop forwarding — the API stays the same for clients. This is also the right shape for adding capabilities your vendor can't provide, such as PII redaction, retention policies or multi-region failover, to a system you can't modify. The cost is one extra hop in the request path; the benefit is owning behaviour you couldn't own before. + +## [209] APPENDIX K · DECISIONS · Choosing the right migration pattern (sldId 464) + +- Modular monolith first, microservices later — the cheapest decomposition is often inside one process. Many systems never need to leave this state. +- Decompose the database before decomposing the deployment. The data split is the hard part; doing it in one process is much cheaper than coordinating across services. +- Always introduce the proxy first — the routing layer is what makes incremental cutover possible. No proxy = no incremental migration. +- Strangler with shared database is a transition state. Plan its end before you start it. Schema coupling is debt that accrues. +- Content-based routing when you need to cut over by tenant / region / segment instead of by URL. Costs CPU at the proxy; buys precise risk control. +- Decorating collaborator when you need to add behaviour to a system you can't modify. Most useful for vendor / 3rd-party / legacy-mainframe wrappers. +- Reversibility is the design property to optimise for. Every migration step should be undo-able by a config change, not a release. + +p. 184 + +> Close the appendix with the decision rules. The first point is the most important and most often skipped: a modular monolith is frequently a fine destination. Microservices have real operational cost, and not every system warrants paying it. The second point is the most underrated: the database split is where projects get stuck for years — do it early, while still one process, when it's cheaper to redo if you got the boundaries wrong. The third point is the platform discipline: no proxy, no incremental migration; every cutover is a coordinated client release; every rollback is the same. The fourth point is the schedule discipline: shared-database strangler is OK as a means but never as an end — if you don't plan its dissolution, it becomes the new monolith. The fifth and sixth are about choosing the right pattern: content-based routing for fine-grained progress, decorator for capability addition. The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release. Migrations fail when they reach an irreversible step too early. + +## [210] Appendix L · Caching patterns (sldId 465) + +L + +Six patterns for keeping a cache useful and your data correct. + +> Appendix L is the caching reference. Five reasons to cache, then six patterns to do it well. The patterns differ in who reads/writes the cache, when, and who owns the consistency story. We use Redis as the canonical cache because it's the default open-source choice on Kubernetes — same patterns apply to in-memory caches, Memcached, or any KV store. CDN and HTTP-layer caching is mentioned in passing but isn't this appendix's focus. + +## [211] APPENDIX L · WHY CACHE · Five things a cache earns you (sldId 466) + +- Application performance — a cache hit is microseconds; a database round trip is milliseconds. The hot path becomes ~100x faster on hits. +- Backend load — caching shields the database from repeated reads of unchanged data. Read-heavy workloads can offload 80-95% of queries with a sensible TTL. +- Throughput — the same Pod fleet serves dramatically more requests because it spends less time waiting on the DB. Vertical scaling without buying anything. +- Predictable performance — p99 latency stops being a function of DB load. Tail latencies flatten because the hot path doesn't contend on the same lock-bound resource as everyone else. +- Database cost — fewer queries means smaller (or fewer) DB instances, smaller IOPS budgets, less replication bandwidth. Cache is almost always cheaper than the DB tier it offloads. + - Trade-off: every cache adds a consistency story, an invalidation story, and a failure-mode story. Pick the pattern that matches the consistency you need from the six patterns in this appendix. + +p. 185 + +> Five concrete reasons that justify the caching investment. Start with application performance — the user-facing speedup is the visible win. Backend load matters more in steady state: shielding the database is what lets your DB tier stay smaller and cheaper than your app tier. Throughput follows from both — the same Pods serve more requests when they're not blocked on I/O. Predictable performance is the operational win that is hard to measure but real: a cache layer flattens p99 because most requests don't contend for DB locks. Database cost is the financial win that often pays for the entire caching infrastructure. The sub-bullet at the end is what trips teams up: every cache adds complexity. This appendix covers the six standard patterns; pick the one that matches the consistency story you need, not the one that sounds best. + +## [212] APPENDIX L · CACHE-ASIDE · Cache-aside (look-aside) — the default (sldId 467) + +The application reads cache, falls back to DB, populates cache. Explicit and simple; the most common pattern. + +p. 186 + +> The pattern everyone starts with. The application is in charge: try the cache first, on a miss read the database, populate the cache on the way back, return the value. Every other pattern in this appendix differs from this one in who does the work — cache-aside is the baseline where the app does it all. Pros: explicit, debuggable, no magic. The cache is just a key-value store; the app decides what to cache and when. Cons: every read has the if/else; cache miss costs two round trips (cache check + DB read); cache logic is repeated across services if you don't extract it. The TTL is the consistency knob — longer = fewer DB hits, more stale-read risk. Use this when you want explicit control and your reads dominate writes. The cache-aside code is two functions; the discipline is to remember to invalidate on writes (covered in write-through and write-around). + +## [213] APPENDIX L · CACHE-ASIDE · Cache-aside in Python with Redis (sldId 468) + +[chip] python · FastAPI + redis.asyncio + Postgres + +```python +from redis.asyncio import Redis +from fastapi import FastAPI, HTTPException +import json +app = FastAPI() +cache = Redis.from_url("redis://redis:6379") # connection pool +TTL = 60 # seconds +@app.get("/products/{pid}") +async def get_product(pid: str): + key = f"product:{pid}" + if cached := await cache.get(key): # 1. cache lookup + return json.loads(cached) # — hit + product = await db.fetch_one( # 2. DB on miss + "SELECT id, name, price_cents FROM products WHERE id = $1", pid) + if not product: + raise HTTPException(404) + await cache.setex(key, TTL, json.dumps(dict(product))) # 3. populate + return dict(product) +@app.put("/products/{pid}") +async def update_product(pid: str, body: dict): + await db.execute("UPDATE products SET name = $1, price_cents = $2 WHERE id = $3", + body["name"], body["price_cents"], pid) + await cache.delete(f"product:{pid}") # 4. invalidate on write + return {"ok": True} +``` + +Try cache; on miss, hit DB and populate. Invalidate on write. Three operations — the rest is conventions about TTL and naming. + +p. 187 + +> The minimal cache-aside implementation. Three things to point out. First, the connection pool: redis.asyncio.Redis from_url returns a pool by default, so concurrent requests share connections — don't create a Redis client per request. Second, the key shape: 'product:{pid}' is a namespace convention; common practice is 'entity:type:id' so you can SCAN and delete by pattern when you need to mass-invalidate. Third, the invalidate-on-write step (step 4): if you don't invalidate, the cache will serve stale data for the full TTL. Choose: invalidate (this code) or set the new value directly (write-through). For cache failures, wrap the cache.get() in a try/except so a Redis outage doesn't take down your reads — fall back to the DB and log. For high-cardinality keys, set a maxmemory policy on Redis (allkeys-lru typical) so the cache doesn't grow unbounded. + +## [214] APPENDIX L · READ-THROUGH · Read-through — the cache fetches on miss (sldId 469) + +Application sees one interface; the cache library handles DB fallback transparently. + +p. 188 + +> The variation where the cache library, not the application, calls the database on a miss. The app calls cache.get() and gets the value back — doesn't know or care whether the cache had it or had to fetch. Implementation requires a cache library that supports loaders (typically a callback you register at cache creation time). Pros: cleaner application code, single read interface. Cons: less control over what triggers a DB hit; the cache library must be told how to read the underlying source. For Python with Redis specifically, you don't get this out of the box from the redis client — you wrap it. Higher-level libraries like aiocache or cachetools support read-through patterns natively, or you write a thin loader wrapper. Use this pattern when read paths are repetitive across the codebase and you want to eliminate the if/else from every caller. + +## [215] APPENDIX L · READ-THROUGH · Read-through in Python: a tiny loader wrapper (sldId 470) + +[chip] python · read-through wrapper on redis.asyncio + +```python +# ... imports: redis.asyncio.Redis, typing, json +class ReadThrough: + def __init__(self, redis: Redis, loader: Callable[[str], Awaitable[dict]], + ttl: int = 60, prefix: str = ""): + # ... keeps redis, loader (called on cache miss), ttl and prefix + async def get(self, id: str) -> Optional[dict]: + key = f"{self.prefix}{id}" + if cached := await self.redis.get(key): + return json.loads(cached) # hit + value = await self.loader(id) # miss → loader fetches from DB + if value is not None: + await self.redis.setex(key, self.ttl, json.dumps(value)) + return value +# Wire it up once at startup — callers don't see Redis at all +products = ReadThrough( + redis = Redis.from_url("redis://redis:6379"), + loader = lambda pid: db.fetch_one("SELECT * FROM products WHERE id=$1", pid), + ttl = 60, prefix = "product:", +) +@app.get("/products/{pid}") +async def get_product(pid: str): + return await products.get(pid) # one interface; no if/else +``` + +The loader is registered once; every caller sees one .get() method. The cache miss path is invisible. + +p. 189 + +> The wrapper is small but the discipline is significant. Callers can't accidentally bypass the cache or forget to populate it — they call products.get(pid) and get the right behaviour. Different entity types get different ReadThrough instances with different loaders and TTLs. For libraries that give you this out of the box: aiocache supports cached() decorators that work this way, and cachetools.func has TTL-based wrappers (though without async support natively). The trade vs cache-aside: you trade a few lines of if/else per caller for a wrapper layer that callers must use. In a large codebase that's worth it; in a small one it's overengineering. Note: a loader-style cache makes thundering-herd protection easier — add a Redis lock in the loader so only one caller does the DB fetch when many callers miss the same key at once (the 'cache stampede' problem). + +## [216] APPENDIX L · WRITE-THROUGH · Write-through — cache and DB written synchronously (sldId 471) + +Every write hits both the cache and the DB. Cache is never stale; writes pay both latencies. + +p. 190 + +> When stale reads are not acceptable, write-through forces every write to update both stores atomically (or as close to atomically as you can manage). The application calls cache.set(...) and db.update(...) and both must succeed before the response returns. Pros: a read immediately after a write sees the new value; the cache is never stale for the writer's view. Cons: write latency is the sum of both stores; you have to handle the case where the cache write succeeds but the DB write fails (or vice versa) — there's no two-phase commit in this world. The usual discipline: write the DB first, write the cache second, accept that a failure between them means the cache may briefly hold the new value while the DB doesn't (or vice versa, depending on your order). Pair with a short TTL so any inconsistency self-heals. + +## [217] APPENDIX L · WRITE-THROUGH · Write-through in Python (sldId 472) + +[chip] python · write-through with redis.asyncio + Postgres + +```python +@app.put("/products/{pid}") +async def update_product(pid: str, body: dict): + key = f"product:{pid}" + # 1. write the DB first — it's the source of truth + await db.execute("UPDATE products SET name=$1, price_cents=$2 WHERE id=$3", + body["name"], body["price_cents"], pid) + # 2. write the cache second — set, don't invalidate + updated = await db.fetch_one("SELECT * FROM products WHERE id=$1", pid) + await cache.setex(key, TTL, json.dumps(dict(updated))) + return {"ok": True} +# Alternative: write cache from the request body if you trust it to be complete +@app.put("/products/{pid}") +async def update_product_fast(pid: str, body: dict): + key = f"product:{pid}" + await db.execute("UPDATE products SET name=$1, price_cents=$2 WHERE id=$3", + body["name"], body["price_cents"], pid) + # cache the body directly — faster, but the cached value may diverge + # from the DB if there are server-side defaults / triggers / computed fields + await cache.setex(key, TTL, json.dumps({**body, "id": pid})) + return {"ok": True} +``` + +DB first (source of truth), cache second (read-back the canonical row, or trust the body for speed). + +p. 191 + +> Two flavours of write-through. The first reads back the row from the DB after update and caches that row — the canonical value, including any server-side defaults, triggers, or computed columns. The second writes the request body directly to the cache, which is faster (one less DB round trip) but the cached value may diverge from the DB if the DB applies server-side modifications you didn't replicate. Pick by your DB schema's complexity. Either way, the DB write goes first because the DB is the source of truth — if the cache write fails, you've lost cache freshness but not data. If the DB write fails, you return an error and don't touch the cache. The TTL is your safety net: even if cache and DB diverge briefly, they'll re-converge when the cache entry expires and the next read re-populates from the DB. + +## [218] APPENDIX L · WRITE-AROUND · Write-around — writes skip the cache (sldId 473) + +Writes go to the DB only. The next read populates the cache lazily. Good for write-heavy, low-re-read workloads. + +p. 192 + +> When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful: most cached entries expire before being read again. Write-around solves this: write the DB, leave the cache alone. The next reader pays the cache-miss cost and populates the cache. Pros: no cache pollution by write-only data; the cache holds only what is being read. Cons: the first read after every write is slow (cache miss); reads of recently-written data are slower than under cache-aside. Pair with cache-aside or read-through on the read side. Common use: any 'append-only' or 'mostly-written' workload — events, log lines, audit entries, IoT telemetry where you read summaries, not individual entries. + +## [219] APPENDIX L · WRITE-AROUND · Write-around in Python (sldId 474) + +[chip] python · write-around with selective read-side caching + +```python +# Common with append-heavy data: events, logs, telemetry. +# Writes go straight to the DB. The cache is irrelevant on writes. +# Reads use cache-aside (next-read populates). +@app.post("/events") +async def record_event(body: dict): + await db.execute( + "INSERT INTO events (id, type, payload, ts) VALUES ($1, $2, $3, NOW())", + body["id"], body["type"], json.dumps(body["payload"])) + # no cache write here — most events are never read again + return {"ok": True} +# ... optional variant: the same INSERT, then cache.delete(f"event:{id}") +@app.get("/events/{eid}") +async def get_event(eid: str): + # standard cache-aside on the read side + key = f"event:{eid}" + if cached := await cache.get(key): + return json.loads(cached) + row = await db.fetch_one("SELECT * FROM events WHERE id=$1", eid) + if row is None: + raise HTTPException(404) + await cache.setex(key, 60, json.dumps(dict(row))) + return dict(row) +``` + +Writes skip the cache entirely; the first read populates it. Avoids cache thrash from one-time writes. + +p. 193 + +> Two write variants and the standard cache-aside read for context. The pure write-around just inserts to the DB — the cache isn't touched at all. The defensive variant deletes any sibling entry in case some concurrent process populated the cache between the start of the write and its completion — cheap insurance for high-contention paths. The read side is plain cache-aside. The pattern shines for workloads where the write-to-re-read ratio is high: a security event log might see 10,000 writes a minute and 100 reads a day for active alerts — caching every write would just churn entries. Compare to a product catalog where every read is a probable cache hit — there, write-through makes more sense because you do want the writer's update to appear immediately in subsequent reads. + +## [220] APPENDIX L · WRITE-BACK · Write-back (write-behind) — fast writes, eventual durability (sldId 475) + +Writes return immediately after touching the cache; a background flusher persists to the DB asynchronously. + +p. 194 + +> The fastest-write pattern, with the most caveats. The client writes to the cache and gets an immediate ack — the cache is the source of truth for that window. A background process drains the cache to the DB, batching writes for efficiency. Pros: write latency equals cache latency — microseconds, not milliseconds; massive throughput on write-heavy workloads. Cons: a cache crash before the flusher catches up loses unflushed writes; the cache must be configured for persistence (Redis AOF or RDB snapshots), and even then you might lose seconds of data. Reads from outside the cache (analytics, BI) lag by the flush interval. Use this pattern only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've worked out what 'lost the last 5 seconds of writes' means for your business. Common: real-time gaming state, IoT telemetry with a downstream aggregator, write-heavy buffering before a slower backing store. + +## [221] APPENDIX L · WRITE-BACK · Write-back with a background flusher in Python (sldId 476) + +[chip] python · write-back with asyncio background task + +```python +MAX_FLUSH_ATTEMPTS, MAX_FLUSH_BACKOFF = 5, 8 # excerpt, examples/25-caching/python +async def flusher(): # 2. drain the dirty set to the DB + failures = 0 + while True: # back off after a failed flush + await asyncio.sleep(min(FLUSH_PERIOD * 2**failures, MAX_FLUSH_BACKOFF)) + ids = await cache.spop("metric:dirty", FLUSH_BATCH) # cache: decode_responses=True + if not ids: continue + rows = [(m, json.dumps(d)) for m in ids if (d := await cache.hgetall(f"metric:{m}"))] + try: # upsert: an id flushed twice is one row + async with pool.acquire() as conn: + await conn.executemany("INSERT INTO metrics (id, payload) VALUES ($1, $2) " + "ON CONFLICT (id) DO UPDATE SET payload = $2, ts = NOW()", rows) + failures = 0 + except Exception: # 3. re-queue, bounded by attempts + log.exception("flusher error: flush failed"); failures += 1 + for mid in ids: + n = await cache.hincrby("metric:attempts", mid, 1) + key = "metric:failed" if n >= MAX_FLUSH_ATTEMPTS else "metric:dirty" + await cache.sadd(key, mid) # parked after 5 tries, never dropped +@app.put("/write-back/metrics/{mid}") +async def write_back_write(mid: str, body: MetricIn): + mapping = {"value": str(body.value), "tags": json.dumps(body.tags)} + await cache.hset(f"metric:{mid}", mapping=mapping) # 1. cache only + await cache.sadd("metric:dirty", mid) # mark for flush + return JSONResponse(status_code=202, content={"ok": True, "action": "cached-for-flush"}) +``` + +A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed. + +p. 195 + +> Write-back has more moving parts than the other patterns; the slide shows the shape of Example 25. (1) PUT /write-back/metrics/{mid} writes the metric to a Redis hash, adds the id to a 'dirty set', which tells the flusher which ids still have to reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) The flusher runs as a background asyncio task: it SPOPs up to FLUSH_BATCH dirty ids and writes them in one executemany upsert (ON CONFLICT (id) DO UPDATE), so an id flushed twice stays one row. SPOP is atomic, so two flusher instances each get a distinct subset of ids and the flusher scales out. (3) When the flush fails, each id's count in the metric:attempts hash goes up and the id goes back on the dirty set; after five attempts it moves to metric:failed with its value still in the cache and the failure in the log, so an outage never loses a write without a trace. The sleep between cycles doubles after each failed flush, from one second to at most eight. Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale. + +## [222] APPENDIX L · REFRESH-AHEAD · Refresh-ahead — keep hot keys warm (sldId 477) + +A background task refreshes hot keys before their TTL expires. Users never see a cold miss on popular items. + +p. 196 + +> The pattern for eliminating the 'cache miss latency cliff' on popular keys. Plain cache-aside has a sharp behaviour: while a key is cached, reads are fast; the moment its TTL expires, the next reader pays the slow DB-fetch path. For homepage data, top-products, frequently-queried entities, that latency spike is user-visible. Refresh-ahead pre-empts this: a background task watches the cache (or maintains a set of 'hot' keys) and refreshes their values from the DB before the TTL fires. Users always read warm cache. Pros: no user-visible misses on the keys you mark as hot. Cons: extra DB load for keys that may not be requested again before expiry; you need a way to identify which keys are hot. Use this when miss latency matters more than DB throughput on the affected keys. + +## [223] APPENDIX L · REFRESH-AHEAD · Refresh-ahead in Python (sldId 478) + +[chip] python · refresh-ahead with Redis sorted set + +```python +# refresh-ahead (excerpt): cache, db, time and asyncio as in cache-aside +TTL_S = 60 +REFRESH_BEFORE_S = 10 # refresh when TTL drops below this +async def refresher(): + while True: + await asyncio.sleep(5) + cutoff = time.time() - 300 # keys touched in last 5 min + await cache.zremrangebyscore("product:hot", 0, cutoff) + for pid_b in await cache.zrange("product:hot", 0, -1): + pid = pid_b.decode() + ttl = await cache.ttl(f"product:{pid}") + if 0 < ttl < REFRESH_BEFORE_S: # refresh window + row = await db.fetch_one("SELECT * FROM products WHERE id=$1", pid) + if row: + await cache.setex(f"product:{pid}", TTL_S, json.dumps(dict(row))) +@asynccontextmanager +async def lifespan(app): + task = asyncio.create_task(refresher()) + yield + task.cancel() +app = FastAPI(lifespan=lifespan) # created once, with the refresher +@app.get("/products/{pid}") +async def get_product(pid: str): + await cache.zadd("product:hot", {pid: time.time()}) # touch hot-list + ... # then cache-aside on the key +``` + +Track 'hot' keys in a Redis sorted set scored by last-access time. Refresh those keys when their TTL falls under a threshold. + +p. 197 + +> The implementation uses a Redis sorted set (ZADD) to track which keys are 'hot' — scored by the last-access timestamp. The background refresher periodically checks each hot key's TTL; when it falls under REFRESH_BEFORE_S, the refresher re-reads the DB and re-populates the cache before the user-facing path would see a miss. Keys not accessed in the last 5 minutes are removed from the hot set (so we don't refresh products no one is asking for). Two tuning knobs: REFRESH_BEFORE_S (how early to refresh; longer = more safety, more DB load) and the hot-key window (how recent counts as hot). Variants: instead of tracking hot keys explicitly, you can use Redis's keyspace notifications to subscribe to TTL-expiry events and refresh reactively — cleaner but more setup. This pattern shines for read-heavy workloads where a small set of keys serves most traffic (the 80/20 product page, the top tweets, the trending articles) and miss latency is user-visible. For low-skew read patterns where every key is equally likely, refresh-ahead is wasted effort. + +## [224] APPENDIX L · ANTI-PATTERNS · When not to cache, and what to watch for (sldId 479) + +- Don't cache by default. Caching adds a consistency story, an invalidation story, and a failure-mode story. If reads are already fast, just don't add a cache — the cheapest cache is no cache. +- Don't cache rarely-read data. Caching write-once-read-never data thrashes your cache and gains nothing. Profile your read patterns first. +- Don't cache without a TTL. Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable. Always set an explicit TTL or maxmemory-policy on Redis (allkeys-lru typical). +- Avoid cache stampedes (thundering herd): when a hot key expires and many concurrent readers all miss at once, they all hit the DB simultaneously. Mitigate with per-key locks in the loader, or with refresh-ahead, or by jittering TTLs slightly per-entry. +- Watch for cache aside + write-around without invalidation — the write goes to the DB, but a stale cached value can persist for the full TTL. Invalidate on every write, or use a shorter TTL. +- Treat the cache as a performance optimisation, never as the source of truth. A cache outage must mean slower reads, not data loss — always have a DB fallback path. Test this by stopping Redis in staging. + +p. 198 + +> The six things that go wrong with caching. The first point is the most important and most often missed: caching is a complexity tax, only worth paying when reads hurt. The second is the write-around lesson stated as a rule: profile first. The third is operational: an unbounded Redis with no eviction policy will OOM-kill itself; allkeys-lru is the typical default. The fourth is the cache stampede problem: hot key expires → 100 concurrent readers all miss → 100 simultaneous DB reads → DB tips over. Mitigations: distributed lock per key (only one fetches, the rest wait); refresh-ahead; TTL jitter (add random ms to TTL so similar keys don't expire together). The fifth is the most common stale-data bug in production. The bold final line is the architecturally important one: the cache is never the source of truth. If Redis dies, you must serve slower reads, not return errors. Build the DB fallback path; test it by killing Redis in staging. + +## [225] APPENDIX L · DECISIONS · Choosing the right caching pattern (sldId 480) + +- Cache-aside — the default. Use when you want explicit control and reads dominate writes. The pattern everyone should know first. +- Read-through — use when the read path is repetitive across the codebase and you want to factor out the if/else. Same semantics as cache-aside; cleaner code. +- Write-through — use when zero stale-read risk after a write matters more than write latency. Read-heavy with infrequent writes is the sweet spot. +- Write-around — use when writes far outnumber re-reads. Avoids cache pollution by write-once data. +- Write-back — use only when extreme write throughput is the constraint, eventual durability is acceptable and your cache is configured for persistence. +- Refresh-ahead — use when a small set of hot keys serves most traffic and miss latency is unacceptable. Adds DB load for the hot set; eliminates user-visible misses on it. +- Most production systems use cache-aside for 95% of cases, with refresh-ahead on a small set of identified hot keys and write-through where stale-read is forbidden. Mix patterns; don't pick one as the rule. + +p. 199 + +> The decision rules to leave audience with. Cache-aside is the default and the right pattern for most reads. Read-through is the same semantics with cleaner code if you have a wrapper library. Write-through trades write latency for read consistency, which pays off when stale reads after writes are harmful (financial, healthcare, regulatory). Write-around is the right pattern for telemetry / events / logs / any append-heavy workload. Write-back is the niche extreme-throughput pattern, with serious caveats. Refresh-ahead is the hot-key pattern. The bold final line is the take-home: don't pick one pattern as 'the' cache strategy. Use cache-aside as the default, layer refresh-ahead on the hot keys you identify, use write-through where consistency demands it, write-around for telemetry. Different read/write profiles want different patterns; matching them is the engineering work. + +## [226] Appendix M · Failure modes (sldId 481) + +M + +Where it all breaks — and the patterns that hold. + +> Appendix M is the synthesis appendix — it brings together error handling, graceful shutdown, observability and the resilience patterns from across the deck, and points them at the specific ways distributed systems fail. If you can only take one slide from this appendix, take the matrix at the end: it maps each failure mode to the patterns that address it. This appendix references section 11 (Observability), section 12 (Security — bulkheads), and Appendix H (Graceful shutdown); it is the place where those threads are tied together. If this runs long in your session, it stands alone and can be presented on its own. + +## [227] APPENDIX M · Distributed systems fail differently (sldId 482) + +- In a single process, an operation succeeds or it throws. Across a network, a call can succeed, fail, hang forever, succeed-but-the-response-is-lost, or succeed-slowly — and you often cannot tell which. +- Partial failure is the defining trait: some dependencies are up, some down, some degraded, all at the same time. There is no global 'the system is up'. +- Failures are not independent: one slow component can exhaust a caller's resources and take down healthy services around it. Failure spreads. +- Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and what users experience diverge. +- The goal is not zero failures; that is impossible. The goal is to bound the blast radius and recover automatically. All management is risk management. + - This appendix: six failure modes, the defensive patterns that address them (with code), Istio's mesh-level role, graceful shutdown, how to observe failures, and a mapping matrix. + +p. 200 + +> Set the frame. The single most important conceptual shift for developers moving from monoliths is partial failure: in a monolith a function call either returns or throws, synchronously, in-process. Across a network there is a whole spectrum of failure, including the worst kind — the call that hangs, or the one that succeeded on the server but whose response was lost, leaving the client unsure whether to retry. Emphasise that failures are correlated (one failure causes others) and that some failures are actively deceptive (gray failures). Land the bold point hard: we are not trying to prevent failure, we are trying to bound it and recover. This reframes everything that follows from 'how do we stop failures' to 'how do we contain and survive them.' + +## [228] APPENDIX M · A taxonomy of failure modes (sldId 483) + +Network failures, component failures, load failures — and the defensive toolkit that addresses them. + +p. 201 + +> The map for the appendix. Three families. NETWORK failures: partition (nodes alive but unreachable) and split-brain (both sides act as leader). COMPONENT failures: partial (some deps fail) and gray (some degrade and are perceived differently by different observers). LOAD failures: amplification (a failure feeds on itself) and cascading (failure spreads to neighbours). Underneath sits the defensive toolkit — timeouts and deadlines, retry with backoff and jitter, circuit breakers, bulkheads (section 12), load shedding, fallback/degradation, quorum/consensus, Istio mesh policies, graceful shutdown (Appendix H), and observability (section 11). Every pattern addresses specific failure modes; the closing matrix maps which to which. Reconnect to the section-1 maxim: all management is risk management — you manage the distribution of failures, not their existence. + +## [229] APPENDIX M · NETWORK · Network partition (sldId 484) + +Nodes are alive and healthy; they cannot reach each other. CAP forces a choice: consistency or availability. + +p. 202 + +> See the diagram notes. The key teaching point: a partition is not a crash. Every node is healthy and running; the network between them is the thing that failed. This is what makes partitions so confusing to debug — every individual component looks fine. CAP theorem becomes concrete here: while partitioned, you choose to stay consistent (the minority side refuses writes, sacrificing availability) or available (both sides accept writes, sacrificing consistency, reconciling later). Most data systems let you tune this. Kubernetes itself uses etcd, which chooses consistency (the minority side of a control-plane partition stops accepting writes). Address with quorum/consensus, idempotent writes (so a retried write after heal is safe), and partition-tolerant design. + +## [230] APPENDIX M · NETWORK · Split-brain (sldId 485) + +A partition where both sides think they are the leader and both accept writes. State diverges; the worst partition outcome. + +p. 203 + +> See the diagram notes. Split-brain is the specific catastrophic outcome of a partition in a system that elects a leader or primary. Both sides lose contact, each assumes the other died, each promotes itself, and now you have two primaries accepting conflicting writes. On heal you have divergent state and no clean way to merge. The prevention is quorum: an action requires a majority (N/2 + 1) of nodes to agree, so a minority partition mathematically cannot act — it knows it is the minority and steps down. This is why cluster sizes are odd (3, 5, 7): to always have a clear majority. Fencing tokens (monotonically increasing IDs that let resources reject a stale leader's writes) and leases are complementary. etcd/Raft give the Kubernetes control plane this property out of the box. + +## [231] APPENDIX M · COMPONENT · Partial failure (sldId 486) + +The defining trait of distributed systems: some dependencies fail while others keep working. Handle each leg. + +p. 204 + +> See the diagram notes. This is the slide for developers moving from monoliths. In a monolith, the order service and the inventory service are function calls in the same process — if one is 'down' the whole process is down. Distributed, the order service calls inventory (healthy), payment (down), and shipping (slow) and must make a per-dependency decision for each: use the healthy result, fall back or fail for the down one, time out and fall back for the slow one. The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller. Address with per-call timeouts, per-dependency circuit breakers, fallbacks, and bulkheads. The next several slides are exactly these patterns, with code. + +## [232] APPENDIX M · COMPONENT · Gray failure (sldId 487) + +The insidious one: a component is degraded and is perceived differently by different observers. Liveness is green; users suffer. + +p. 205 + +> See the diagram notes. Gray failure is the failure mode that defeats naive health checking. The classic shape: a node is slow or dropping a fraction of requests, but its liveness probe (GET /healthz returning 200 from a trivial handler) still passes, so Kubernetes keeps it in rotation and the load balancer keeps sending it traffic. The system believes it is healthy; users experience errors and latency. This is 'differential observability' — the observer that matters (the client) sees something different from the observer that decides (the probe). The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims. This ties directly to section 11. + +## [233] APPENDIX M · LOAD · Amplification loops & retry storms (sldId 488) + +A failure triggers retries, which add load, which cause more failures. Backoff, jitter and retry budgets break the loop. + +p. 206 + +> See the diagram notes. Amplification is a positive feedback loop: the system's own defensive behaviour (retrying) becomes the attack. Two compounding effects. First, retry multiplication across layers: if the gateway retries 3x, and it calls a service that retries 3x, which calls a service that retries 3x, one user request becomes 27 requests to the bottom of the stack. Second, synchronisation: every client that failed at the same moment retries at the same moment, creating a thundering herd. The result can be metastable — the system stays collapsed even after the original trigger is gone, because the retry load alone sustains the failure. Breaking it: exponential backoff with jitter (de-synchronise), retry budgets (cap retries to a small percentage of live traffic so they can never multiply unboundedly), retry at exactly one layer, and circuit breakers to stop the doomed calls entirely. This is the failure mode most often caused by well-intentioned resilience code. + +## [234] APPENDIX M · LOAD · Cascading failure (sldId 489) + +One component's failure overloads its neighbours, which fail in turn — the domino effect up the call graph. + +p. 207 + +> See the diagram notes. Cascading failure is how a single slow dependency takes down an entire system. The mechanism: a slow database causes the calling service's request threads (or async tasks, or connection pool) to fill up waiting; with its resources exhausted, that service now fails or slows for all callers, not just the ones touching the database; its callers' resources then fill up waiting on it; and so on up the graph until the user-facing edge is down. The amplifiers are synchronous call chains and unbounded resources (unbounded queues, unbounded thread pools, no timeouts). Each defensive pattern breaks one link: timeouts stop threads blocking forever; bulkheads isolate the pool so the database calls can't consume all of it; circuit breakers stop calling the slow dependency entirely; load shedding rejects excess work at the edge before it propagates. This is the failure mode bulkheads (section 12) were invented for. + +## [235] APPENDIX M · The defensive toolkit — patterns and what they buy (sldId 490) + +- Timeout + deadline propagation — never wait forever; pass the remaining budget downstream so nobody does doomed work. Defends: partial, cascading. +- Retry with backoff + jitter — ride out transient failures without building a storm; only for idempotent calls; use a retry budget. Defends: partial, amplification (if done right). +- Circuit breaker — stop calling a failing dependency; fail fast; test for recovery. Defends: cascading, amplification, gray. +- Bulkhead (§12) — isolate resources by tenant/call/priority so one failure can't starve the rest. Defends: partial, cascading. +- Load shedding — when overloaded, reject excess early (503) rather than collapsing. Defends: amplification, cascading. +- Quorum / consensus — majority-required action; the only real answer to split-brain. Defends: partition, split-brain. +- Fallback / graceful degradation — return a useful degraded response (cache, default, queue-for-later) instead of an error. Defends: partial, gray. +- Istio mesh policies + graceful shutdown + observability: the platform layer underneath all of these patterns. Each one gets code, and a matrix maps patterns to modes. + +p. 208 + +> The toolkit overview before the deep dives. Each line names a pattern and the failure modes it defends against — this is the verbal version of the closing matrix. The thing to stress: no pattern is sufficient alone, and several patterns interact (retry without a circuit breaker amplifies; a circuit breaker without a fallback just fails faster; bulkheads without timeouts still let threads block within a pool). Production resilience is the composition of several of these, tuned together. Each gets its own diagram and per-language code so developers can implement them; the bold line flags that the platform-level pieces (Istio, shutdown, observability) sit underneath the application-level patterns. + +## [236] APPENDIX M · TIMEOUTS · Timeouts + deadline propagation (sldId 491) + +Every remote call needs a timeout; the remaining budget should travel downstream so nobody does doomed work. + +p. 209 + +> See the diagram notes. Two ideas. First, every remote call must have a timeout — the default in many HTTP clients is infinite, which is how a single slow dependency hangs a whole service. Second, deadline propagation: the original deadline (say 1000ms) travels with the request, and each hop subtracts the time it has used and passes the remainder downstream. When the remaining budget is too small to plausibly finish, the downstream fails immediately rather than starting work that will be thrown away. gRPC does this natively with deadlines; for HTTP you pass a deadline header and honour it. The waste this prevents is real: without it, your database spends 500ms on a query for a request the user abandoned 450ms ago, and that wasted work is exactly what tips an overloaded system over. The code example shows the language-specific mechanics. + +## [237] APPENDIX M · TIMEOUTS · Timeouts + deadlines (sldId 492) + +[chip] python · httpx + asyncio + +```python +import httpx, asyncio, time +# Every client has an explicit timeout — the httpx default is None (infinite) +client = httpx.AsyncClient(timeout=httpx.Timeout(2.0, connect=0.5)) +async def charge(order: dict, deadline_ms: int) -> dict: + remaining = deadline_ms - elapsed_ms() # budget left for this hop + if remaining <= 50: # too little to finish + raise DeadlineExceeded("no budget left") # fail fast, don't start + try: + r = await asyncio.wait_for( + client.post("/charge", json=order, + headers={"X-Deadline-Ms": str(remaining)}), # propagate + timeout=remaining / 1000) + r.raise_for_status() + return r.json() + except (asyncio.TimeoutError, httpx.TimeoutException): + raise HTTPException(504, "downstream timed out") +``` + +p. 210 + +> Two things: the client is constructed with an explicit timeout (httpx defaults to no timeout, which is a classic foot-gun), and the handler honours an inbound deadline, refusing to start work it cannot finish and propagating the remaining budget downstream in a header. asyncio.wait_for is the belt-and-braces total-time bound. gRPC propagates deadlines through the deadline parameter without extra code; for HTTP you carry it in a header convention like X-Deadline-Ms and honour it at each hop. + +## [238] APPENDIX M · RETRY · Retry with exponential backoff + jitter (sldId 493) + +Retry transient failures — but spread retries with exponential backoff + jitter, cap them, and only for idempotent calls. + +p. 211 + +> See the diagram notes. Retry is the most abused resilience pattern because the naive version (retry immediately, a fixed number of times, at every layer) is precisely how you build the amplification storm from the amplification-loop discussion. The correct version has four properties: exponential backoff (each retry waits longer: 100ms, 200ms, 400ms), jitter (add randomness so clients don't synchronise), a cap on both attempts and total time, and a retry budget (retries may never exceed, say, 10% of live traffic). And it only applies to idempotent operations — retrying a non-idempotent POST without an idempotency key can double-charge a customer. Always pair retry with a circuit breaker so that once a dependency is clearly down, you stop retrying entirely. The code example shows the idiomatic library for each language. + +## [239] APPENDIX M · RETRY · Retry with backoff + jitter (sldId 494) + +[chip] python · tenacity + +```python +from tenacity import (retry, stop_after_attempt, wait_exponential_jitter, + retry_if_exception_type, before_sleep_log) +# Retry only idempotent calls. Exponential backoff + jitter. Cap attempts. +@retry( + retry=retry_if_exception_type(httpx.TransportError), # transient only + wait=wait_exponential_jitter(initial=0.1, max=2.0), # backoff + jitter + stop=stop_after_attempt(3), # hard cap + reraise=True) +async def get_quote(symbol: str) -> dict: + r = await client.get(f"/quote/{symbol}") # GET is idempotent — safe + r.raise_for_status() + return r.json() +# Do not decorate a non-idempotent POST unless it carries an idempotency key. +# Pair with a circuit breaker so a down dependency stops retries entirely. +``` + +p. 212 + +> tenacity is the idiomatic Python retry library. The decorator encodes all four correct properties: retry only on transient transport errors (not on 4xx, not on business errors), exponential backoff with jitter, a hard cap on attempts, and reraise so the final failure surfaces. The two comments carry the discipline: only idempotent operations (a GET, or a POST with an idempotency key), and always pair with a circuit breaker so you stop retrying a dependency that is clearly down rather than hammering it. + +## [240] APPENDIX M · CIRCUIT BREAKER · Circuit breaker — the state machine (sldId 495) + +Closed → Open (fail fast) → Half-Open (test) → Closed. Stops doomed calls; gives the dependency room to recover. + +p. 213 + +> See the diagram notes. The circuit breaker is the pattern that most directly stops cascading and amplification. Three states. CLOSED: calls pass through, failures are counted over a rolling window. When the failure ratio crosses the threshold, the breaker trips to OPEN: calls fail immediately without even attempting the downstream, which both gives a fast answer to the caller and gives the struggling dependency room to recover instead of being hammered. After a reset timeout the breaker goes HALF-OPEN: it allows a few trial calls; if they succeed it closes (recovered), if they fail it re-opens. Crucial details: one breaker per dependency (never a single global breaker), and pair it with a fallback so that an open breaker degrades gracefully rather than just erroring faster. Libraries differ per language; the code example shows each. + +## [241] APPENDIX M · CIRCUIT BREAKER · Circuit breaker + fallback (sldId 496) + +[chip] python · purgatory + +```python +from purgatory import AsyncCircuitBreakerFactory +from purgatory.domain.model import OpenedState # raised while the breaker is open +# Open after 5 failures; stay open 30s; then half-open to test recovery +breakers = AsyncCircuitBreakerFactory(default_threshold=5, default_ttl=30) +async def charge(order: dict) -> dict: + try: + async with await breakers.get_breaker("payment"): + # While OPEN this raises immediately — no doomed downstream call + r = await client.post("/charge", json=order, timeout=2.0) + r.raise_for_status() + return r.json() + except OpenedState: # breaker is open → degrade + await outbox.enqueue(order) # graceful fallback + return {"status": "pending", "id": order["id"]} +# One breaker per dependency ("payment"), never one global breaker. +``` + +p. 214 + +> purgatory is a small async circuit-breaker library. The breaker is keyed per dependency ('payment') — the comment stresses one-per-dependency, never global. While the breaker is OPEN, entering the context raises OpenedState immediately without attempting the call, which is the fail-fast behaviour that stops cascading. The except branch is the fallback: instead of erroring, we enqueue the order to an outbox and return a pending status — graceful degradation. aiobreaker and a hand-rolled breaker are alternatives; the shape is the same. + +## [242] APPENDIX M · BULKHEAD · Bulkhead — isolate so one failure can't sink the rest (sldId 497) + +- Covered in depth in section 12 (Security) as a blast-radius pattern; it is equally a resilience pattern, so it appears here in the failure-mode context. +- Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. A storm in one pool exhausts only that pool. +- Against cascading failure: if database calls have their own bounded pool, a slow database exhausts that pool and returns errors for DB-backed calls — but the service's other endpoints keep serving from their own pools. +- Against partial failure: a single misbehaving tenant or dependency is contained to its own compartment instead of consuming the shared resource. +- Implementation (from §12): asyncio.Semaphore plus per-tenant aiohttp/httpx connection pools — lightweight in-process bulkheads paired with per-call timeouts. At the mesh: Istio DestinationRule connectionPool gives the same isolation at the network layer. + - Pair with timeouts: a bulkhead bounds how many calls run concurrently, but without a timeout those calls can still block forever and keep the bulkhead full. The two patterns need each other. + +p. 215 + +> Bulkhead got full treatment in section 12, so here we just re-frame it in the failure-mode context and cross-reference. The key insight to land: a bulkhead bounds concurrency, but concurrency-bounding alone doesn't help if the bounded calls block forever — you must combine bulkhead (limit how many) with timeout (limit how long). Point back to section 12 for the per-language code (we don't repeat it here to save slides), and forward to the Istio slide for mesh-level bulkheading via connectionPool limits. + +## [243] APPENDIX M · SHEDDING & FALLBACK · Load shedding and graceful degradation (sldId 498) + +- Load shedding — when you are over capacity, reject excess work immediately (HTTP 503 + Retry-After) at the edge, before it consumes resources. A system that sheds 20% of load stays up for the other 80%; a system that accepts everything collapses for 100%. +- Shed by priority: drop health-check-style or low-value traffic first, protect revenue-critical paths. Adaptive concurrency limits (e.g. Netflix's, or Envoy's) shed automatically based on observed latency. +- Graceful degradation — when a dependency is unavailable, return a useful reduced response instead of an error: serve stale cache, omit the personalised section, queue the write for later, show a default recommendation. +- Degradation turns a hard failure into a soft one the user may barely notice. A product page that can't reach the reviews service should still show the product — just without reviews. +- Both are the application-level complement to the circuit breaker: the breaker decides to stop calling; the fallback decides what to return instead; load shedding decides what not to accept in the first place. + - Reference §11: you cannot shed or degrade intelligently without observability — you shed based on measured saturation, and you alert when degradation rates climb. + +p. 216 + +> Two related patterns that often get skipped. Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses. The maths is simple: if you are at 120% of capacity and accept everything, everything is slow and timing out (0% goodput); if you shed the excess 20%, the 80% you accept is served well. Adaptive limiters do this automatically based on latency. Graceful degradation is the product-level decision about what to return when you can't return everything — it requires the product/design team's input on what's essential vs nice-to-have. The bold line ties shedding, breakers and fallback together as a system: stop accepting (shed), stop calling (break), return something useful (fall back). + +## [244] APPENDIX M · ISTIO · Istio — resilience at the mesh layer (sldId 499) + +Timeouts, retries, outlier detection (gray-failure ejection) and circuit breaking — uniform, config-driven, no app code. + +p. 217 + +> See the diagram notes. The service mesh gives you a large slice of the resilience toolkit without writing code in every service, in every language. VirtualService configures per-route timeouts and retries (with Envoy adding backoff and jitter automatically, and retryOn conditions controlling which failures are retried). DestinationRule configures connectionPool limits (mesh-level bulkheading) and outlierDetection — which is the mesh's answer to gray failure: it watches each host, and when one returns consecutive errors it ejects that host from the load-balancing pool for a cooldown period, regardless of what the host's own health check claims. That is exactly the gray-failure defence the health-check can't provide. The important discipline: don't double up. If Istio is retrying, don't also retry in the app (that's amplification). Decide where each concern lives — typically: timeouts and outlier detection at the mesh, business-aware retries and fallbacks in the app. + +## [245] APPENDIX M · SHUTDOWN · Graceful shutdown (see App. H) (sldId 500) + +[chip] python · FastAPI lifespan + +```python +# main.py (excerpt), examples/21-graceful-shutdown/python: one SIGTERM, four steps +def handle_sigterm(signum, frame): # installed in the lifespan, chained to uvicorn's + global shutting_down + if shutting_down: return # already draining + shutting_down = True # 1. /readyz answers 503: the LB stops routing + loop.call_soon_threadsafe(loop.call_later, DRAIN_DELAY_SECONDS, # 2. keep serving 5 s + stop_server, signum, frame) +def stop_server(signum, frame): # 3. uvicorn stops accepting, finishes in-flight + uvicorn_sigterm(signum, frame) # (--timeout-graceful-shutdown 20) +@asynccontextmanager +async def lifespan(app: FastAPI): + global uvicorn_sigterm, loop + loop = asyncio.get_running_loop() + uvicorn_sigterm = signal.signal(signal.SIGTERM, handle_sigterm) + yield # ---------- app serves ---------- + await db_pool.close() # 4. close resources, exit 0 +@app.get("/readyz") +async def readyz(): + if shutting_down: # liveness (/healthz) stays 200 + return JSONResponse({"ready": False, "reason": "shutting down"}, 503) + return {"ready": True} # (the example also pings Postgres here) +# K8s: terminationGracePeriodSeconds: 30 >= 5 s delay + 20 s drain + buffer (App. H) +``` + +p. 218 + +> The shutdown sequence that prevents dropped requests during a rolling deploy, from Example 21. One SIGTERM starts it: (1) the handler flips a flag so /readyz returns 503 and Kubernetes stops sending new traffic, while /healthz stays 200; (2) the service keeps serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) the handler then hands over to uvicorn, which stops accepting and finishes in-flight requests within --timeout-graceful-shutdown; (4) the lifespan exit closes the pools and the process exits 0. The ordering matters: stop taking new work before draining existing work. This is the application half; Appendix H covers the Kubernetes half (preStop hook, terminationGracePeriodSeconds). Together they give zero-downtime rollouts. + +## [246] APPENDIX M · OBSERVE · Observing failure modes (see §11) (sldId 501) + +- Network partition — watch peer-connectivity and cross-zone error metrics; alert on quorum-loss events from etcd / your datastore. +- Split-brain — alert on more-than-one-leader conditions, divergent-replica metrics, and fencing-token rejections in logs. +- Partial failure — RED metrics per dependency (rate, errors, duration), not just per service. A spike in errors to one downstream is the early signal. +- Gray failure — the hard one: compare health-check status against real-traffic SLOs. Latency percentiles (p99) diverging from the average, and Istio outlier-ejection counts, are your tells. +- Amplification — watch request-rate against user-action-rate (if requests >> actions, retries are multiplying), retry-count metrics, and goodput falling while load rises. +- Cascading — a distributed trace shows the error spreading service-to-service; saturation metrics (thread pools, queue depth, connection pools) climb together across services. + - The unifying tool is the trace_id from §11: when an alert fires, pivot from the metric to an exemplar trace to its logs to find which leg failed first and how it spread. + +p. 219 + +> Tie the appendix back to section 11. Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop. The most important specific guidance: measure RED per dependency (so partial failures are visible), compare health-check status to real-traffic SLOs (so gray failures are visible), and watch request-rate vs user-action-rate (so amplification is visible). The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response. + +## [247] APPENDIX M · Which pattern addresses which failure mode (sldId 502) + +Read a row to defend a failure mode; read a column to see what a pattern buys you. Production layers several. + +p. 220 + +> The synthesis slide — if the audience keeps one thing, keep this. Reading across a row tells you the portfolio of patterns that defend a given failure mode (e.g. cascading failure is addressed by timeouts, circuit breakers, bulkheads, load shedding, and graceful shutdown together). Reading down a column tells you what a given pattern buys you across failure modes. Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule. Second, almost every other cell is about assembling a portfolio — there is no single pattern that handles partial, gray, amplification and cascading failures; you layer timeouts, retries-with-budget, breakers, bulkheads, shedding and fallbacks, and you observe the result. Defence in depth, exactly as in the security section, applied to reliability. + +## [248] APPENDIX M · Take-aways and references (sldId 503) + +- Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often infinite. +- Retry only idempotent calls, with backoff + jitter + a budget, at one layer, always paired with a circuit breaker. Naive retries cause the outages they were meant to prevent. +- Bound the blast radius: bulkheads isolate, circuit breakers stop the bleeding, load shedding protects capacity, fallbacks keep the user served. +- Quorum is the only cure for split-brain. Let etcd / Raft / your datastore handle consensus; don't invent your own. +- Push uniform concerns (timeouts, outlier detection, mesh bulkheads) to Istio; keep business-aware concerns (fallbacks, idempotency) in the app. Don't double-retry. +- Observe from the client's vantage point (RED per dependency, SLOs, outlier ejections) — gray failures are invisible to naive health checks. Drain on shutdown (App. H). + - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkhead origin); the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling); Istio traffic-management docs; the metastable-failures research (Bronson et al., 2021). + +p. 221 + +> Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: design for partial failure, timeout everything, retry carefully, bound the blast radius, use quorum for consensus, split concerns between mesh and app, and observe from the client's perspective. The references are the canonical sources: the Google SRE book's cascading-failures and overload chapters are the single best free resource; Michael Nygard's 'Release It!' is where the circuit breaker and bulkhead patterns were named for software; the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol; and the metastable failures paper (Bronson et al.) is the modern formalisation of amplification loops. Encourage the audience to read the SRE cascading-failures chapter as the next step. + +## [249] Feature Flags (sldId 504) + +N + +Progressive delivery, decoupled from deploy — OpenFeature + flagd + +> This appendix adds the runtime switch that separates shipping code from releasing a feature. It is a deep-dive you keep open when you are about to roll something out gradually. The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, like every other example in the book. The arc: why deploy is not release, the four kinds of flag, how evaluation works, the code to evaluate and target and roll out, flags as a control plane over patterns we already covered, and the lifecycle discipline that keeps flags from becoming debt. + +## [250] APPENDIX N · FEATURE FLAGS · Deploy is not release (sldId 505) + +p. 222 + +the artifact ships dark; releasing is a runtime flag flip — and so is rollback + +> The reframe that makes everything else work: deploying an artifact and releasing a feature are different events, and a flag is what splits them. The artifact ships to production with the new path turned off - a non-event, visible only in the deploy log. Releasing is then a runtime flag flip: on for one percent, then a wider cohort, then everyone, each step reversible by narrowing the flag rather than rolling back a deployment. This is what lets a team merge to main many times a day yet release on its own cadence, and it is the mechanism behind every canary and every safe cutover in the book. Rollback stops being a redeploy and becomes a config change measured in seconds. + +## [251] APPENDIX N · FEATURE FLAGS · Four kinds of flag (sldId 506) + +p. 223 + +by lifetime and owner — each wants different defaults and lifecycle + +> Feature flag covers four different tools, and conflating them is the root of most flag messes. They differ along two axes - how long the flag lives, and who decides to flip it. Release flags are temporary and developer-owned: they ship new code dark, ramp it, and are then removed. Experiment or A/B flags are temporary and data-owned: they split traffic across variants so a metric can be measured, and they require sticky assignment. Ops or kill-switch flags are long-lived and SRE-owned: a permanent control to disable a feature or dependency fast under load or incident. Permission or entitlement flags are long-lived and product-owned: they gate functionality by plan, tier, or region. The defaults differ too - a release flag fails off, so half-shipped code stays dark, while a kill-switch usually fails on, so the feature survives a flag-system outage. + +## [252] APPENDIX N · FEATURE FLAGS · Evaluating a flag: OpenFeature + flagd (sldId 507) + +p. 224 + +local evaluation, vendor-neutral SDK, fail-safe to the default + +> Evaluation should not couple your code to a vendor. OpenFeature is the CNCF vendor-neutral API standard - one SDK surface across languages - and flagd is its lightweight open-source backend that evaluates flags from a file, ConfigMap, or synced source, in-cluster. Together they keep the whole feature-flag story open-source and running on plain Kubernetes, with no managed SaaS, and swapping flagd for another provider later is a configuration change, not a code change. Two properties matter most. First, evaluation returns the default if the provider is unreachable - a flag-system outage must never take the application down. Second, the SDK is vendor-neutral: the app calls getBooleanValue with a flag key, a default, and an evaluation context, and never names the backend. The context carries the targeting inputs - a stable key plus attributes like plan and region - that the rules act on. + +## [253] APPENDIX N · FEATURE FLAGS · Evaluating a flag (sldId 508) + +p. 225 + +[chip] python · FastAPI + +```python +# configure the OpenFeature provider once, at startup +from openfeature import api +from openfeature.contrib.provider.flagd import FlagdProvider +from openfeature.evaluation_context import EvaluationContext + +api.set_provider(FlagdProvider(host='flagd', port=8013)) +flags = api.get_client() + +@app.post('/checkout') +async def checkout(cart: Cart, x_user: str = Header()): + ctx = EvaluationContext( + targeting_key=x_user, # sticky key + attributes={'plan': cart.plan, 'region': cart.region}) + # one line - same call whether on/off, entitlement, or rollout + use_new = flags.get_boolean_value('new-checkout', False, ctx) + return await (new_checkout(cart) if use_new else legacy_checkout(cart)) +``` + +The same one line of code serves an on/off, a per-plan entitlement, or a 25% rollout. + +> The provider is registered once at startup; from then on the application asks the client for a value. The crucial thing to notice is that the application code is the same one line regardless of whether the flag is a simple on/off, a per-plan entitlement, or a 5 percent rollout. All of that lives in the flag's configuration, not the code. The default is what gets returned if flagd is unreachable, so the new path stays dark on a flag-system outage rather than erroring. The targeting key is the user id, which is what makes a percentage rollout sticky. Across every language the shape is identical: register a flagd provider, get a client, build an evaluation context with a targeting key and attributes, and call getBooleanValue with a safe default. + +## [254] APPENDIX N · FEATURE FLAGS · Targeting and rollout live in config (sldId 509) + +p. 226 + +[chip] json · flagd + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "==": [ {"var": "plan"}, "enterprise" ] }, "on", + { "fractional": [ {"var": "targetingKey"}, + ["on", 25], ["off", 75] ] } + ] + } + } + } +} +``` + +Enterprise plans always on (entitlement); everyone else split 25/75 (sticky rollout) — no code change. + +> Because the rules live in flagd, changing who sees a feature never touches the application. A flag definition is a set of variants, a default, and a targeting expression. Two operators do most of the work: a conditional for segmentation - here, the enterprise plan always gets the new checkout, an entitlement - and the fractional operator for percentage rollouts, here splitting everyone else 25/75. The fractional operator buckets on the targeting key, which is what gives sticky assignment: the same user hashes to the same bucket every time. Ramping the rollout is editing the 25 upward; rolling back is editing it down - both take effect in the running fleet within flagd's sync interval, with no redeploy. A kill switch is the same file: set a flag's state to DISABLED and it stops evaluating entirely. + +## [255] APPENDIX N · FEATURE FLAGS · Percentage rollout via sticky bucketing (sldId 510) + +p. 227 + +consistent per-user assignment; instant ramp and rollback + +> Sticky bucketing matters for two reasons. For a rollout, it prevents flicker - a user who got the new checkout on their first visit keeps getting it, rather than bouncing between variants on refresh. For an experiment, it is a correctness requirement: a user must stay in one arm of the test for the measurement to mean anything. The mechanism is simple - hash a stable key, the user id, into a bucket from 0 to 99, and compare it to the rollout threshold. Because the hash is stable, the same user always lands in the same bucket. Ramping the rollout is moving the threshold up; rolling back is moving it down, and both take effect in the running fleet within the sync interval with no deploy on the path of either. + +## [256] APPENDIX N · FEATURE FLAGS · A kill switch (sldId 511) + +p. 228 + +[chip] python · FastAPI + +```python +# ops kill-switch - default True (fail open): on until an operator kills it +async def recommend(user_id: str) -> Recommendations: + if not flags.get_boolean_value('recommendations-enabled', True, ctx(user_id)): + return Recommendations.empty() # degrade - still serve the page + return await recommendation_client.fetch(user_id) # pairs w/ a breaker +``` + +Default true = fail open: only an operator flipping the flag disables it. The default flips with the flag type. + +> A kill switch is the operational flag you reach for during an incident: wrap a risky or expensive dependency in a flag so it can be disabled instantly without a deploy. It is the human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; an operator flips the kill switch by hand. The default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only an explicit flip removes it. When it is killed, the page still renders - just without the recommendations - which is graceful degradation, the same idea as the load-shedding discussion in the Failure Modes appendix. + +## [257] APPENDIX N · FEATURE FLAGS · Flags over the patterns you already have (sldId 512) + +p. 229 + +a flag is a runtime control surface + +> Feature flags are not a new subsystem so much as a runtime control plane over patterns already in this book. A canary is a percentage flag deciding which build a request exercises, often paired with the L7 routing from the routing appendix. A strangler-fig cutover is a flag, or the proxy's percentage, steering a widening slice from the monolith to the extracted service. A saga step can be guarded by a flag to switch a new compensation path on cautiously. And an experiment is a sticky-bucketed variant flag with a metric attached. Recognising flags as the control surface for all of these keeps you from building a bespoke mechanism for each - the same decoupling, expressed four ways. + +## [258] APPENDIX N · FEATURE FLAGS · The flag lifecycle (sldId 513) + +p. 230 + +a release flag is born with a removal ticket + +> The failure mode of feature flags is not technical, it is organisational: flags that are never removed. Every flag is a branch in your code and a dimension in your test matrix, and a stale one is dead code, a hidden conditional, and a live risk surface that nobody remembers the meaning of. The discipline that keeps it healthy: give every release flag an owner and an expiry from the moment it is created, and treat removing it as part of finishing the feature, not optional cleanup. Long-lived ops and entitlement flags are exempt - they are permanent by design - which is exactly why distinguishing the four types matters. A short-lived flag that has been at 100 percent for two weeks should be deleted along with the branch it guarded. + +## [259] APPENDIX N · FEATURE FLAGS · Testing, observing, and anti-patterns (sldId 514) + +p. 231 + + - Test both sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag. + - Put the resolved variant on your telemetry - a span attribute and a metric label - so a latency or error change attributes to a specific flag and cohort. +- Anti-patterns: flags as a config store; release flags that never die; stacks of flags whose combinations no one reasons about; experiments without sticky assignment; business logic buried in flag rules. + +> A flag multiplies the states your system can be in, so test both sides of every flag you depend on; a test suite that only exercises the default path will not catch a bug behind the flag. Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them explicitly. Observability ties flags back to the Observability section: record the resolved variant as a span attribute and a metric label, so a latency or error change can be attributed to a specific flag and cohort. The recurring anti-patterns: using flags as a general configuration store - they are for release control, not for tuning timeouts; letting release flags become permanent; stacking many flags whose combinations no one reasons about; running experiments without sticky assignment, which makes the data meaningless; and putting real business logic inside flag rules instead of in code. + +## [260] APPENDIX N · FEATURE FLAGS · Take-aways (sldId 515) + +p. 232 + +- Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. +- Distinguish the four flag types - they want different owners, defaults, and lifecycles. +- Evaluate through a vendor-neutral SDK (OpenFeature) against an OSS backend (flagd), always with a safe default. +- Keep targeting and rollout in configuration, not code, and make percentage rollouts sticky. +- Treat release flags as debt with a removal ticket; put the resolved variant on your telemetry. + +References: OpenFeature spec & flagd docs (CNCF) · Hodgson, “Feature Toggles” · Accelerate / DORA · Unleash (OSS alternative). + +> Close on the imperatives. Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. Distinguish the four flag types, because they want different owners, defaults, and lifecycles. Evaluate through a vendor-neutral SDK against an open-source backend, always with a safe default so a flag outage cannot take the app down. Keep targeting and rollout rules in configuration, not code, and make percentage rollouts sticky. Treat release flags as debt with a removal ticket attached, and put the resolved variant on your telemetry so you can attribute a regression to a cohort. The canonical references are the OpenFeature specification and the flagd documentation, Pete Hodgson's Feature Toggles article for the taxonomy of flag types, and the Accelerate and DORA research for the deploy-versus-release distinction; Unleash is a capable open-source alternative backend behind the same OpenFeature SDK. + +## [261] API Testing with Newman (sldId 516) + +O + +Postman collections as an automated gate on Docker Engine + +> This appendix turns the Postman collections the team already pokes at by hand into an automated test gate, using Newman, Postman's command-line runner. It is a deep-dive you keep open when you are wiring API tests into CI. The key idea that makes it cheap: Newman tests the API as a black box over HTTP, so one collection is the conformance suite for every implementation in the book, and the only language-specific step is bringing the service under test up. The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid. + +## [262] APPENDIX O · API TESTING · Why Newman (sldId 517) + +p. 233 + +the headless runner turns a Postman collection into CI-readable results + +> A Postman collection is an executable contract, not just a saved list of requests. Newman runs it without the GUI and emits machine-readable results: a cli reporter for a human watching, a junit reporter that CI turns into a test report, and an html reporter to share. There is no new test framework to learn - the tests are the JavaScript assertions already supported inside Postman, and Newman is the bridge from works-when-I-click-it to verified-on-every-pull-request. The same file serves three audiences: a developer in Postman, a reviewer reading the tests, and a pipeline failing the build when an assertion breaks. + +## [263] APPENDIX O · API TESTING · Anatomy of a collection (sldId 518) + +p. 234 + +folders of requests, each with assertions; the environment supplies the variables + +> A collection nests folders and requests; each request can carry a pre-request script, the HTTP call itself, and a block of tests that run against the response. The tests are small JavaScript blocks using Postman's pm API - asserting the status, the response time, the shape of the body. Environment variables supply the parts that change between runs, like the base URL and an auth token, resolved at run time. Storing a value from one response - a new order id - in a variable lets a later request chain off it, which is how a collection models a whole flow rather than isolated calls. + +## [264] APPENDIX O · API TESTING · Tests live in the request (sldId 519) + +p. 235 + +[chip] json · Postman + +{% raw %} +```json +{ + "name": "Create order", + "request": { + "method": "POST", + "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }], + "url": "{{base_url}}/orders", + "body": { "mode": "raw", "raw": "{\"sku\":\"A-1\",\"qty\":2}" } + }, + "event": [{ + "listen": "test", + "script": { "exec": [ + "pm.test('201 Created', () => pm.response.to.have.status(201));", + "pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));", + "const body = pm.response.json();", + "pm.test('returns an id', () => pm.expect(body).to.have.property('id'));", + "pm.collectionVariables.set('order_id', body.id);" + ] } + }] +} +``` +{% endraw %} + +Assertions run after the response; the saved order_id chains into later requests. + +> Here is a request that creates an order and asserts on the result. The test block asserts a 201 Created, parses the body, asserts it carries an id, and stores that id in a collection variable so a later request - fetch, cancel, or pay for the order - can use it. Beyond status codes, a test can validate the response shape against a JSON Schema, which is how you turn the metadata and error-format work from earlier appendices into an enforced contract: assert that an error body matches the Problem Details schema rather than checking exact strings, so the test stays stable as data changes but still catches a removed field or a wrong type. + +## [265] APPENDIX O · API TESTING · One collection, six backends (sldId 520) + +p. 236 + +Newman tests over HTTP, so one collection validates every implementation + +> Because Newman exercises the API as a black box over HTTP, the collection does not know or care which language implements the service. The same collection is the conformance suite for every implementation in this book - point it at whichever service is running and the assertions are identical. The collection is the API specification made executable. Bringing the service under test up is the only language-specific step; the Newman command that follows is the same everywhere. + +## [266] APPENDIX O · API TESTING · Launch the service under test (sldId 521) + +p. 237 + +[chip] bash · Python + +```bash +# Python / FastAPI — serve, then the same collection +uvicorn app.main:app --port 8080 & # http://localhost:8080 +newman run orders.postman_collection.json -e local.postman_environment.json +``` + +Only this step is language-specific; the Newman command is identical for all six. + +> The only per-language step is starting the service. Each implementation exposes the same HTTP API on the same port, so the base URL in the environment file is identical and the Newman command that follows does not change. That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it. In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop. + +## [267] APPENDIX O · API TESTING · Running Newman & shaping output (sldId 522) + +p. 238 + +[chip] bash + +{% raw %} +```bash +# install once (Node.js): +# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1 +newman run orders.postman_collection.json \ + --environment local.postman_environment.json \ + --reporters cli,junit,htmlextra \ + --reporter-junit-export results/newman.xml \ + --timeout-request 5000 \ + --bail +# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure + +# data-driven: one iteration per CSV row; the columns become {{variables}} +newman run orders.postman_collection.json -e local.postman_environment.json \ + --iteration-data cases.csv +``` +{% endraw %} + +Reporters target the audience; --bail gates fast; --iteration-data drives edge cases. + +> The runner takes the collection and an environment file, and you choose reporters for the audience: cli for a human, junit for the XML CI turns into a report, htmlextra for a shareable page. --bail stops at the first failed assertion, which is what you usually want in a gate; drop it to see every failure in one run. A single set of requests can be run repeatedly over a table of inputs with --iteration-data, which is how you cover boundary values, bad input, and edge cases without duplicating requests - and the expected status can travel in the data too, so one request becomes a table of positive and negative cases. + +## [268] APPENDIX O · API TESTING · Data-driven tests (sldId 523) + +p. 239 + +one request set, many inputs — boundary values and negative cases from a table + +> Data-driven testing runs the same requests once per row of a CSV. The columns become variables in the requests, and a column can carry the expected status so a single assertion checks status against the row's expectation. This is how a handful of requests becomes broad coverage: the happy path, a zero-quantity order that must be rejected with a 422, an over-stock order that must also be rejected - all without copy-pasting requests. Keeping the cases in data rather than in duplicated requests is what makes the suite maintainable as the API grows. + +## [269] APPENDIX O · API TESTING · Newman in CI (sldId 524) + +p. 240 + +[chip] yaml · GitHub Actions + +```yaml +# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js) +jobs: + api-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: docker compose up -d --build --wait # order, payment, inventory, ... + - run: ./scripts/wait-for-ready.sh # poll /readyz before testing + - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1 + - run: | + newman run orders.postman_collection.json \ + -e ci.postman_environment.json \ + --reporters cli,junit \ + --reporter-junit-export results/newman.xml + - uses: actions/upload-artifact@v7 # keep the report even on failure + if: always() + with: { name: newman-results, path: results/ } +``` + +Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build. + +> Newman exits non-zero when any assertion fails, which is all a pipeline needs to gate a merge. The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them. The readiness poll matters: start testing only once the services report ready, using the readiness endpoint from the Observability and Graceful Shutdown material, or the first requests race the boot and fail spuriously. Upload the junit report as an artifact even on failure so the failing assertion is visible in the run. + +## [270] APPENDIX O · API TESTING · The gate on Docker Engine (sldId 525) + +p. 241 + +the same stack you run locally, gating every pull request + +> The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks. The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock. + +## [271] APPENDIX O · API TESTING · Where Newman fits (sldId 526) + +p. 242 + +the API/contract tier: on top of unit tests, under a thin end-to-end layer + +> Newman does not replace unit tests; it is the layer on top of them. It owns the API/contract tier: real HTTP against a running service, fast enough to run on every pull request. Keep the base of fast unit tests broad, use Newman for the contract and integration behaviour that only shows up over real HTTP, and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system. Pushing everything into Newman makes a slow, brittle suite; ignoring it leaves the contract untested. Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material. + +## [272] APPENDIX O · API TESTING · Take-aways (sldId 527) + +p. 243 + +- Treat the Postman collection as the executable specification - Newman runs it headlessly to guard every change. +- One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box. +- Validate response shape against a schema, not just status codes; drive edge cases from data, not duplicated requests. +- Assert the failure paths - 401, 403, 409/422, 429, idempotency - as carefully as the happy path. +- Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier. + +References: Postman docs & Newman CLI docs (reporters, --iteration-data, --bail) · Postman Learning Center (pm API, JSON-schema validation). + +> Close on the imperatives. Treat the Postman collection as the executable specification of your API, and let Newman run it headlessly so the same assertions guard every change. Because Newman tests over HTTP, one collection is the conformance suite for all six implementations. Validate response shape against a schema, not just status codes; drive edge cases from data rather than duplicated requests; and assert the failure paths as carefully as the happy path. Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer. The references are the Postman documentation and the Newman command-line documentation, and the Postman Learning Center guides on writing tests with the pm API and on JSON-schema validation. + +## [273] Example 05 on minikube (sldId 528) + +R + +The same services and the same verify.sh on a single-node Kubernetes cluster + +> Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod. +> +> The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results. + +## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) + +p. 244 + +- Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress. +- Scope: Example 05 (event-driven fan-out), three services, all six languages, one node. +- One Kafka broker, one Postgres instance, no service mesh: a local topology, not production. +- The contract carries over: Service names, the Secret, environment variables, the health endpoint. +- The same verify.sh runs against compose and the cluster: ten checks per language. + +Site: Appendix R · Code: examples/05-event-driven/k8s/ + +> What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it. +> +> What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged. +> +> The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec. + +## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) + +p. 245 + +- Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5. +- 8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services. +- NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation. +- No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. +- Every command passes --context cndp-k8s; setup-profile.sh restores the prior context. + +minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 + +> minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version. +> +> Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1. +> +> The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag. + +## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) + +p. 246 + +The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec + +> The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret. +> +> From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs. + +## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) + +p. 247 + +- kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1). +- postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw. +- Credential → Secret cndp-postgres-app, read through secretKeyRef. +- build: → docker build, then minikube image load; imagePullPolicy: Never. +- healthcheck → startup and readiness probes on the order-service /healthz. +- ports: 8080:8080 → NodePort 30080. + +The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses. + +> Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts. +> +> The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing. + +## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) + +p. 248 + +[chip] yaml · kustomize + +```yaml +# k8s/overlays/python/kustomization.yaml (excerpt) +images: + - name: order-service + newName: localhost/cndp-05/python-order-service + newTag: v1 + # shipping-consumer and notification-consumer map the same way +# appended to each Deployment's environment by a JSON patch +- name: DATABASE_URL + value: "postgres://$(PG_USER):$(PG_PASSWORD)@cndp-postgres-rw:5432/appdb" +- name: KAFKA_BOOTSTRAP + value: "cndp-kafka-bootstrap:9092" +# memory: requests 128Mi, limits 256Mi +``` + +base/ is language-neutral; each overlay maps the images and appends the variables its language reads. + +> The Python overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service. +> +> Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base. + +## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) + +p. 249 + +docker build on the host, minikube image load into the node; pods never pull + +> The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load. +> +> The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest. + +## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) + +p. 250 + +[chip] bash · minikube + +```bash +# from examples/05-event-driven/, with no other profile or container running +k8s/setup-profile.sh # create cndp-k8s; check the published ports +k8s/build-images.sh python # docker build, then minikube image load +k8s/deploy.sh python # Strimzi, Kafka, Postgres, LGTM, then the overlay +RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh + +# all six languages, a fresh namespace for each +OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh + +# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile +k8s/teardown.sh --operator +minikube stop -p cndp-k8s +``` + +With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged. + +> The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Python images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay. +> +> verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0. +> +> What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images. + +## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) + +p. 251 + +- 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). +- Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s. +- Tempo received traces from order-service, shipping-consumer and notification-consumer in every language. +- shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset. +- Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them. + +Times cover the image build and load, the deploy, the checks and the namespace teardown. + +> The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language. +> +> The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster. diff --git a/lgtm-presentation/text/Designing-Cloud-Native-APIs-Quarkus.md b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Quarkus.md new file mode 100644 index 00000000..8c5de9ac --- /dev/null +++ b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Quarkus.md @@ -0,0 +1,4075 @@ +# Designing-Cloud-Native-APIs-Quarkus + +Source: Designing-Cloud-Native-APIs-Quarkus.pptx · 281 slides + +## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native APIs (sldId 256) + +From principles to a running system on Kubernetes — communication, composition, data, events, and the platform that governs them. + +Robert Sedor + +Chief Architect, Application Development + +> Welcome and framing. This is a working architect's tour of designing cloud-native APIs on Quarkus 3.40.1 (LTS, Java 25) — not a survey of buzzwords. Set expectations: we build one realistic system (five services and a gateway) and re-examine it from twelve angles, then the appendices go deep on protocols, versioning, WebSockets, sagas, errors, caching, failure modes and API testing, and run Example 05 on minikube. Tell the audience the whole deck is a reference they can keep. Mention the running stack up front: Java with Quarkus (Quarkus REST, gRPC, SmallRye GraphQL, Reactive Messaging, Kafka Streams, WebSockets Next, Quarkus Flow), on Kubernetes with Kafka, Istio and KEDA. The platform around the code (Strimzi, Apicurio, OpenMetadata, the LGTM stack) is the same for every language; Quarkus is how we write the services. + +## [002] AGENDA · Agenda (sldId 257) + +- Setting up · Patterns in the age of AI +- 01 · Cloud-Native Principles +- 02 · Communications +- 03 · Composition +- 04 · Data +- 05 · Event-Driven +- 06 · Stream Processing +- 07 · Workflows & Jobs +- 08 · API Management +- 09 · API Registry +- 10 · API Metadata +- 11 · Observability +- 12 · Security +- 13 · Anti-Patterns +- Appendices A–D · Protocols, Versioning, WebSockets, Sagas +- Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing +- Appendices K–L · Monolith → Microservices, Caching Patterns +- Appendices M–O · Failure modes, Feature Flags, API Testing +- Appendix R · Example 05 on minikube +- Glossary of acronyms + +p. 1 + +> The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns. +> +> The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck. +> +> Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order. + +## [003] OVERVIEW · What we'll build, and the order we'll build it (sldId 258) + +- Foundations — cloud-native principles and the canonical system we carry throughout +- Communications — REST, gRPC, GraphQL, and synchronous vs. asynchronous +- Composition & Data — gateways, federation, database-per-service, outbox, sagas +- Event-driven & Stream processing — the log, schemas, derived streams, KEDA scaling +- Workflows & Jobs — orchestration, choreography, batch and scheduled work +- The API platform — gateway, registry (Apicurio), metadata (OpenMetadata), observability +- Anti-patterns — and the heuristics that keep you out of them + +p. 2 + +> Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close. Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to. For a 90-minute talk, cover the main twelve; for the full three hours, work through the appendices too. + +## [004] SETTING UP · Why patterns matter more in the age of AI (sldId 259) + +- AI tooling (Claude, Claude Code, frontier models) turns intent into code at a rate that was not possible before. The bottleneck shifted: it's no longer typing, it's knowing what to ask for and what to accept. +- Patterns are your vocabulary at three layers: design patterns (factory, strategy, observer), architecture patterns (hexagonal, microservices, event-driven), system design patterns (sagas, caching, strangler fig — the scope of this deck). +- Without that vocabulary you can't specify precisely, can't evaluate what comes back, can't tell good code from plausible-looking code, or notice when an anti-pattern is dressed as a solution. The AI is fluent; you still need to be literate. +- All architecture is the least-worst decision — every choice trades something away. +- All management is risk management — you manage the distribution of failures, not their existence. +- There is no such thing as a free lunch — every pattern has a cost; ask whether you can afford it. +- There is no problem which cannot be made worse — the wrong fix amplifies the original pain. + +p. 3 + +> Open the deck by naming the elephant. AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows. They produce code fluently. From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run. The risk is also new: a developer who doesn't have the vocabulary to evaluate what comes back is rolling dice in production. The patterns in this deck are not nostalgic computer-science trivia; they are the literacy that lets you direct AI tooling instead of being directed by it. Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing). The four maxims are the philosophical spine. ‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit. ‘Risk management’: you manage the distribution of failures, not their existence. ‘No free lunch’: every pattern (cache, saga, eventing, decomposition) has a cost — a stale read window, a compensation path, a coordination tax, an operational complexity. The question is whether you are buying the right thing for what you are paying. ‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap. The closing line is the audience take-home: AI is the amplifier; the patterns are the literacy that decides which direction it amplifies. + +## [005] SETTING UP · Three layers of patterns literacy (sldId 260) + +p. 4 + +> The visual companion to the three layers of patterns literacy. Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch). The arrows up the middle show how each lower layer informs the next: you can't reason about a saga (system layer) without knowing what a state machine and a compensation are (architecture / design layer); you can't reason about content-based routing without understanding the strategy pattern. The blue caption in the middle is the through-line: knowing this vocabulary is what lets you ask AI tooling for the right thing, recognise what it produces, and judge why it is or isn't fit. The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles. Make the audience comfortable with the maxims, especially the last one (no problem cannot be made worse) — it's the warning at the heart of using AI tooling well. + +## [006] THE RUNNING EXAMPLE · One system, carried end to end (sldId 261) + +We will design every concern against these five services and a gateway — the same example, re-examined from each angle. + +p. 5 + +> This is the single most important slide to anchor on — every later example refers back to it. Walk each service: order-service owns the order lifecycle and exposes REST at the edge; inventory, payment and shipping are internal, reached by gRPC and contributing to GraphQL reads; notification-service is Kafka-only, consuming order.placed. The graphql-gateway composes reads across them. Emphasize that each service exposes only the protocols that fit its role — not a uniform surface. Promise the audience we'll return to this picture a dozen times. + +## [007] Cloud-Native Principles (sldId 262) + +01 + +Properties first, tools second. + +> Transition into section one. The core message: cloud-native is not a stack you buy, it is a set of runtime properties your service must exhibit. Tools (Kubernetes, Istio, KEDA) exist to make those properties cheap to obtain — but if your service isn't written to lean on them, no platform can rescue it. + +## [008] CLOUD-NATIVE PRINCIPLES · Cloud-native is a set of runtime properties (sldId 263) + +If your API isn't disposable, observable, resilient, elastic, automatable and loosely coupled, the platform can't help it. + +p. 6 + +> Six pillars. Spend a sentence on each: disposable (12-factor, fast start, clean stop, no local state), observable (traces/metrics/logs, health probes), resilient (timeouts, retries, circuit breakers, graceful degradation), elastic (scale to load and to zero), automatable (declarative config, GitOps, self-healing), loosely coupled (contracts not calls, async where it fits). The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties. + +## [009] CLOUD-NATIVE PRINCIPLES · The platform makes the properties the default (sldId 264) + +- Kubernetes gives you the disposable process model: liveness/readiness probes, rolling deploys, self-healing. +- KEDA (and the http-add-on) make elasticity real — scale on lag or RPS, all the way to zero. +- Istio supplies resilience and security at the wire: timeouts, retries, circuit breaking, mTLS. +- OpenTelemetry + Tempo / Mimir / Prometheus / Grafana / Kiali make every request observable. +- Your job is to write services that lean on these — not to reimplement them per service. + +p. 7 + +> The punchline is the bold line: your job is to lean on the platform, not reimplement it. Concretely — don't write your own retry library when Istio does it at the wire; don't build a bespoke autoscaler when KEDA scales on lag and RPS; don't hand-roll metrics plumbing when OpenTelemetry plus the LGTM stack gives it to you. Every line of resilience code you write per service is a line you maintain forever and that drifts between teams. The platform makes the good behaviour the default. + +## [010] CLOUD-NATIVE PRINCIPLES · Design-first: the contract precedes the code (sldId 265) + +- Agree the interface before implementing it — OpenAPI for REST, .proto for gRPC, AsyncAPI for events. +- The contract is a versioned artifact in the registry, gated in CI, owned by the producing domain. +- Consumers code against the contract and generated stubs, never against your database or internals. +- This is what makes services independently deployable — the promise the contract encodes is the boundary. + - Tooling: Maven with the quarkus-bom for reproducible builds; Quarkus REST emits OpenAPI (SmallRye); buf / protoc for gRPC; SmallRye GraphQL exports SDL. + +p. 8 + +> Design-first is the cultural prerequisite for everything that follows. The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI. Consumers code against the contract and generated stubs, never against your database. Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary. If you skip this, you get distributed coupling that's worse than a monolith because it fails over the network. In the Quarkus world the contract artifacts come naturally: SmallRye OpenAPI emits the REST spec, protoc/buf compile the .proto, and SmallRye GraphQL exports the SDL — all checked into the registry. + +## [011] CLOUD-NATIVE PRINCIPLES · The twelve-factor app, revisited (sldId 266) + +- The twelve-factor app (2011) is the original recipe for software that's disposable, scalable and portable — the discipline beneath the runtime properties we just saw. +- It predates Kubernetes but maps onto it almost one-to-one: most factors are now things the platform provides, not code you write. +- So read the factors by ownership — a few you still author (config from the environment, statelessness, clean start/stop, logs as streams) and the rest the platform hands you (scaling, build/release/run, attached backing services). +- In a microservices, event-driven system they get richer: “backing services” means Postgres, Kafka and the schema registry as swappable URLs; “logs” becomes correlated traces, metrics and logs. +- Treat it as a pre-flight check: “will this survive the platform?” The twelve factors are listed and mapped to where each lives in our architecture. + +p. 9 + +> Frame the twelve-factor app for the audience: it's the 2011 Heroku methodology that first codified what we now call cloud-native, and it's worth revisiting because it predates Kubernetes yet maps onto it almost perfectly. The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services). In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability. End by teeing up the list and the mapping diagrams. + +## [012] CLOUD-NATIVE PRINCIPLES · What the twelve factors are (sldId 267) + +The original 2011 checklist — most are now platform concerns; only a few are yours to write. + +p. 10 + +> A lot of people nod along to ‘twelve-factor’ without being able to name the twelve, so here they are at a glance, one line each. Don't read them aloud — let people scan. Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams). That sets up the map of each factor onto our architecture. + +## [013] CLOUD-NATIVE PRINCIPLES · Twelve factors, mapped to our system (sldId 268) + +Most factors are now platform defaults you adopt; a few are discipline you still write. + +p. 11 + +> This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building. Read it left to right: the twelve factors cluster into five things they ask of you, and each maps to a concrete piece of our stack and the section that covers it. Build & ship (codebase, dependencies, build/release/run, parity) becomes one repo, one image per release, the same image everywhere (§1). Configure becomes environment config (§1). Run on the platform (stateless processes, concurrency, disposability, port binding) becomes stateless pods, KEDA scaling, fast start/stop, and services behind Istio (§1, 2, 6). Attach resources becomes Postgres, Kafka and Apicurio as swappable URLs (§4–5). Operate (logs, admin processes) becomes OpenTelemetry into the LGTM stack and admin work as Jobs/CronJobs (§7, 11). The caption is the message: most of these are now defaults you adopt, not code you write — which is exactly what 'cloud-native' buys you. + +## [014] CLOUD-NATIVE PRINCIPLES · A 12-factor Quarkus service, ready for the platform (sldId 269) + +[chip] java · Quarkus REST + +```java +// HealthResource.java (excerpt), examples/01-cloud-native-principles/quarkus +@Path("/") @Produces(MediaType.APPLICATION_JSON) +public class HealthResource { + @ConfigProperty(name = "quarkus.datasource.jdbc.url") String jdbcUrl; // env, factor III + @GET @Path("healthz") // liveness: the process is up + public Map healthz() { return Map.of("status", "ok"); } + @GET @Path("readyz") // readiness: SELECT 1 (one retry elided) + public Response readyz() { + try (var conn = DriverManager.getConnection(jdbcUrl, dbUser, dbPass); + var rs = conn.createStatement().executeQuery("SELECT 1")) { + return Response.ok(Map.of("status", "ready", + "checks", Map.of("database", "ok"))).build(); + } catch (Exception e) { // down: 503, out of rotation + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("status", "down", "checks", Map.of("database", "unreachable"))) + .build(); + } + } +``` + +Probes, env-config and an explicit version are the price of admission for a Kubernetes Deployment. + +p. 12 + +> Walk the code: config comes from the environment via MicroProfile Config (factor III): QUARKUS_DATASOURCE_JDBC_URL, the username and password and SERVICE_VERSION override application.properties, never a baked-in file. The two probes are plain JAX-RS handlers in Example 01: /healthz is liveness (am I alive? if not, restart me) and never checks a dependency; /readyz is readiness (can I serve?): it runs SELECT 1 over JDBC, retrying once after a second, and when the database is unreachable it answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. SmallRye Health (@Liveness and @Readiness checks on /q/health/live and /q/health/ready) is the extension that serves the same pair without handler code; the example writes them by hand so every language serves the same paths and bodies. Getting liveness vs readiness wrong is a top cause of cascading restarts, so stress the distinction. Maven with the quarkus-bom (platform 3.40.1) pins every dependency reproducibly. + +## [015] Communications (sldId 270) + +02 + +Match the interaction style to the call. + +> Section two. The thesis: there is no single right protocol — there is a right protocol per interaction. Resist the urge to standardize on one wire for everything. The section gives a decision rule and then shows each protocol in code against the running system. + +## [016] COMMUNICATIONS · Pick the protocol for the interaction (sldId 271) + +REST for reach, gRPC for internal speed, GraphQL for client-driven reads, async for decoupled facts. + +p. 13 + +> Four interaction styles, four defaults. REST for the public/edge surface where reach and caching matter; gRPC for internal service-to-service where latency and streaming matter; GraphQL for client-driven reads that compose many sources in one round trip; async events for decoupled facts that fan out. Most real systems use all four — the skill is matching each call to the right one. Note the sync/async chips at the bottom. This slide is the map for the whole section. + +## [017] COMMUNICATIONS · REST at the edge with Quarkus REST (sldId 272) + +[chip] java · Quarkus REST + +```java +@Path("/orders") +@Produces(MediaType.APPLICATION_JSON) +public class OrderResource { // Quarkus REST + record OrderIn(@NotBlank String sku, @Positive int quantity) {} // validated + @POST + @Transactional + public RestResponse place(@Valid OrderIn body) { + Order o = orders.create(body); + events.publish("order.placed", o); // emit the fact (the outbox makes it safe) + return RestResponse.status(Response.Status.CREATED, o); // 201, not 200 + } + @GET // cursor pagination, not offset + public Page list(@RestQuery String after, + @RestQuery @DefaultValue("50") int limit) { + return orders.page(after, limit); + } +} +``` + +Resource-shaped URLs, Bean-Validation contracts, correct status codes, cursor pagination — the edge surface other teams depend on. + +p. 14 + +> This is the edge surface. Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs); (2) request contracts validated declaratively: here a Java record with Bean Validation annotations (@NotBlank, @Positive, @Valid), the Quarkus equivalent of Pydantic models, so bad input never reaches your logic; (3) correct status codes — 201 on create, not 200; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth. The method is blocking and Quarkus REST runs it on a worker thread; return Mutiny's Uni, Quarkus's reactive type, when the endpoint should be non-blocking. SmallRye OpenAPI generates the spec from the resource, and that spec is what we register and gate. Test this surface with curl and Postman. + +## [018] COMMUNICATIONS · gRPC for internal service-to-service calls (sldId 273) + +[chip] protobuf + java · Quarkus gRPC + +```protobuf+java +// inventory.proto — the contract, registered in Apicurio +syntax = "proto3"; +service Inventory { + rpc ReserveStock (ReserveRequest) returns (ReserveReply); +} +message ReserveRequest { string sku = 1; int32 quantity = 2; } +message ReserveReply { bool reserved = 1; int32 remaining = 2; } +// Quarkus gRPC generates the Mutiny service interface from the .proto at build time +@GrpcService +public class InventoryService implements Inventory { // generated interface + @Override + public Uni reserveStock(ReserveRequest req) { + return stock.reserve(req.getSku(), req.getQuantity()) + .map(r -> ReserveReply.newBuilder() + .setReserved(r.ok()).setRemaining(r.remaining()).build()); + } +} +``` + +Schema-first, binary, multiplexed over HTTP/2 — the right default for chatty internal paths. Test with ghz. + +p. 15 + +> gRPC for the internal paths. The .proto is the contract, and it lives in the registry like OpenAPI. Quarkus gRPC compiles the .proto with protoc at build time and you implement the generated Mutiny interface, annotated @GrpcService so Quarkus wires it up — no manual server bootstrap. Highlight that the field numbers (=1, =2) are the wire contract — we return to exactly why in Appendix B. It's binary and multiplexed over HTTP/2, so it's fast and supports streaming, and the reactive Uni keeps it non-blocking. The right default for chatty service-to-service calls where you control both ends. Load-test it with ghz. + +## [019] COMMUNICATIONS · Synchronous coupling vs. asynchronous facts (sldId 274) + +A synchronous call shares fate; an event shares a fact. Reach for async wherever immediacy isn't required. + +p. 16 + +> The central trade-off of the whole talk in one picture. Top row: a synchronous call couples caller to callee in time and availability — if payment-service is down, the order request fails too, and availability multiplies downward across a chain. Bottom row: emitting order.placed as a fact lets the order succeed immediately; consumers catch up from the log if they were down. The rule: reach for async wherever immediacy isn't strictly required. This sets up the entire event-driven section later. + +## [020] COMMUNICATIONS · The mesh handles the wire, not your code (sldId 275) + +Istio sidecars give you mTLS, retries, timeouts and telemetry — uniformly, without per-service code. + +p. 17 + +> Service mesh in one picture. Your app speaks plain HTTP or gRPC to localhost; the Istio sidecar (Envoy) intercepts and adds mTLS, retries, timeouts and telemetry on the wire — uniformly, for every service, with zero application code. istiod is the control plane distributing policy and identity (SPIFFE). The key insight ties back to principles: resilience and security become platform defaults, not per-service reimplementations. The configuration itself is an Istio VirtualService. + +## [021] COMMUNICATIONS · Resilience as configuration, not per-call code (sldId 276) + +[chip] yaml · istio + +```yaml +# Istio: bounded retries and timeout for inventory-service +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: inventory } +spec: + hosts: [inventory] + http: + - timeout: 2s + retries: + attempts: 2 # bounded — never an unbounded retry storm + perTryTimeout: 800ms + retryOn: connect-failure,unavailable + route: + - destination: { host: inventory } +``` + +Bounded retries with backoff live in the mesh. Unbounded client retries are how an incident becomes an outage. + +p. 18 + +> Resilience as configuration. This VirtualService gives inventory a 2-second timeout, two retry attempts with an 800ms per-try timeout, retrying only on connect-failure and unavailable. The critical word is bounded — attempts: 2, not infinite. Tell the war story: unbounded client-side retries are how a brief blip becomes a full outage, because every caller piles on the struggling service (a retry storm). Putting this in the mesh means it's consistent across every service and tunable without redeploying code. + +## [022] Composition (sldId 277) + +03 + +Aggregate at the edge, not inside services. + +> Section three. The question: when a client needs data from several services, where does the joining happen? The answer that keeps services decoupled: at the edge, in a gateway or BFF — never by services calling each other in deep chains. That chaining is how you accidentally rebuild a monolith over the network. + +## [023] COMPOSITION · One query, many services (sldId 278) + +The gateway presents one schema and fans out to REST and gRPC back-ends, then joins and shapes the result. + +p. 19 + +> GraphQL federation against the running system. The gateway presents one schema to clients and fans out to REST and gRPC back-ends, then joins and shapes the result. The client makes one round trip and asks for exactly the fields it needs. The services stay unaware of each other; the gateway owns the composition. Contrast this with services calling services, which we'll warn against on the patterns slide. The resolver code shows how the gateway composes the calls. + +## [024] COMPOSITION · A composing resolver in SmallRye GraphQL (sldId 279) + +[chip] java · SmallRye GraphQL + +```java +@GraphQLApi // the gateway's schema (SmallRye GraphQL) +public class OrderApi { + @Query // REST call to order-service + public Order order(String id) { + return orderClient.get(id); + } + // @Source adds a 'stock' field to Order, fetched only when queried + public Integer stock(@Source Order order) { // via gRPC + return inventory.reserveStockView(order.sku()).getRemaining(); + } +} +// SmallRye GraphQL exports the SDL to the registry automatically. +``` + +The gateway owns aggregation; each field resolves against the owning service. @Source attaches a field fetched from elsewhere. + +p. 20 + +> The resolver makes federation concrete. The top-level order query calls order-service over REST; the stock method, annotated with @Source Order, adds a stock field to the Order type that resolves on demand by calling inventory-service over gRPC. Each field resolves against its owning service — the gateway is just the composition layer. Mention the N+1 risk: naive per-field resolution can explode into many back-end calls, so production gateways batch (SmallRye GraphQL supports a BatchSource for exactly this). SmallRye GraphQL is the JVM-standard, MicroProfile GraphQL implementation Quarkus ships; it exports the SDL to the registry automatically. + +## [025] COMPOSITION · Three ways to compose — and where each belongs (sldId 280) + +Push aggregation to a gateway or BFF. Chaining calls inside services rebuilds the monolith over the network. + +p. 21 + +> Three composition patterns: an API gateway (one composed schema for everyone), a backend-for-frontend (a tailored aggregate per client type — web vs mobile), and GraphQL federation (a distributed graph). All three push aggregation outward to the edge. The anti-pattern to call out explicitly: service-to-service call chains, where order calls inventory calls pricing calls... — synchronous, deeply coupled, and impossible to reason about. That rebuilds the monolith with network latency between the pieces. + +## [026] Data (sldId 281) + +04 + +Own your data; share it through contracts. + +> Section four, and the hardest discipline to hold. The rule: each service owns its data and shares it only through APIs and events — never through a shared database or by reaching into another service's tables. This is what makes independent deployment and independent scaling real. The slides cover ownership, the dual-write trap, and the saga. + +## [027] DATA · A database per service (sldId 282) + +Each service owns its schema and storage engine; no other service touches its tables directly. + +p. 22 + +> Database-per-service. Each service owns its schema and even its storage engine — order and payment on Postgres, something else on a document store if that fits. No other service touches those tables directly; the only doors in are the API and the events. The hard part is social, not technical: someone will always want to 'just join across two services' databases' for a report. That shortcut is the single most common way microservice independence dies. Reporting needs go through the metadata/lineage layer or a dedicated read model, not direct table access. + +## [028] DATA · CQRS — Command Query Responsibility Segregation (sldId 283) + +Separate the write model from the read model. They have different shapes, scales and even different stores. + +p. 23 + +> CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises for invariants and consistency: a normalised schema in a transactional store (Postgres, MySQL). The read side optimises for query shape: denormalised wide tables, an Elasticsearch index, a Mongo document, or whatever fits the question being asked. The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern. The cost: eventual consistency, a small lag between the write committing and the read model catching up. The benefit: each side can scale, schema-evolve and even be stored independently. Use this pattern when reads and writes have different shapes and access patterns. Avoid it when a single store handles both; CQRS adds operational complexity that isn't free. The classic motivating case: a service that takes thousands of writes per second but is read by analytics, dashboards and search — the write model serves the writes, the read model serves the rest. CQRS pairs naturally with event sourcing on the write side and event streaming for projections; the Event-Driven section draws both distinctions. + +## [029] DATA · CQRS in practice (sldId 284) + +[chip] java · Quarkus REST + Panache + SmallRye Reactive Messaging + +```java +@Path("/orders") // write side: command handler +public class OrderWriteResource { + @Inject WriteOrderRepository writes; + @Inject @Channel("order-placed") Emitter events; + @POST @Transactional // blocking: worker thread + public Order create(OrderIn body) { + Order order = new Order(body); + writes.persist(order); // write model + events.send(new OrderCreatedEvent(order)); // → projection + return order; + } +} +@Path("/orders") // read side: query handler, denormalised store +public class OrderReadResource { + @Inject ReadOrderSummaryRepository reads; // hits read DB + @GET @Path("/{id}/summary") + public OrderSummary summary(@PathParam("id") String id) { + return reads.findByIdOptional(id) // never the write store + .orElseThrow(() -> new WebApplicationException(404)); + } +} +@ApplicationScoped // projection: events upsert the read model +public class OrderSummaryProjection { + @Inject ReadOrderSummaryRepository reads; + @Incoming("order-placed") @Transactional // topic order.placed + public void on(OrderCreatedEvent ev) { reads.upsert(OrderSummary.from(ev)); } +} +``` + +p. 24 + +> The Quarkus shape. Two separate JAX-RS resources: OrderWriteResource handles commands, OrderReadResource handles queries. They could even be in different services/processes. The write side uses an Emitter bound to an outgoing Reactive Messaging channel — the SmallRye runtime publishes to Kafka. The projection is a separate @ApplicationScoped bean with an @Incoming("order-placed") method (topic order.placed) that consumes the stream and updates the read store via a separate ReadOrderSummaryRepository (different Panache datasource). The read model can be a wide denormalised table, an Elasticsearch index, or a Mongo document — chosen by query shape. Different scaling for the read side and write side; different team ownership possible. + +## [030] DATA · Log tailing — the database commit log is the event stream (sldId 285) + +Debezium tails the WAL and publishes every committed row as a Kafka event. No dual-write. + +p. 25 + +> The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC. The application writes ordinary SQL. The database commits and the change lands in the write-ahead log (WAL on Postgres, redo log on Oracle, binlog on MySQL, transaction log on SQL Server). Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved. The application doesn't know Debezium exists. There is no dual-write — the commit is the single source of truth, and the event is a strictly downstream consequence. This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use. Debezium supports Postgres, MySQL, MariaDB, SQL Server, Oracle, MongoDB, Db2, Cassandra, and Vitess out of the box. Run mode: deploy as Kafka Connect workers, or use Debezium Server (standalone) if you don't want a Kafka Connect cluster. WAL = Write-Ahead Log: the database's commit-ordered durable log that already exists for replication and crash recovery; Debezium just subscribes to it. + +## [031] DATA · Dual-write is unsafe — use the outbox (sldId 286) + +Write state and the outbox row in one transaction; CDC relays the committed fact to Kafka. + +p. 26 + +> This is the slide that prevents a class of production bugs. The dual-write problem: if you write to your database and then publish to Kafka as two separate operations, a crash between them leaves you inconsistent — the order exists but the event was never sent, or vice versa. The fix is the transactional outbox: write the state change and an outbox row in one local transaction, then a change-data-capture tool (Debezium) tails the database log and relays the committed row to Kafka. The database commit is the single source of truth. The outbox write itself is one database transaction, shown in code. + +## [032] DATA · The outbox write — one transaction, no lost events (sldId 287) + +[chip] java · Agroal JDBC + Debezium + +```java +// OrderResource.java (excerpt), examples/04-data/quarkus: two rows, one transaction +@POST @Transactional // one JTA transaction around both inserts +public Response createOrder(CreateOrderRequest request) throws Exception { + String orderId = UUID.randomUUID().toString(); + try (Connection conn = dataSource.getConnection(); // Agroal enlists it in the txn + PreparedStatement stmt = conn.prepareStatement( + "INSERT INTO orders (id, sku, quantity, status) VALUES (?, ?, ?, ?) " + + "RETURNING created_at")) { + // ... bind orderId, sku, quantity, "confirmed"; executeQuery (the state change) + try (PreparedStatement outboxStmt = conn.prepareStatement( + "INSERT INTO outbox (aggregate_id, event_type, payload) " + + "VALUES (?, ?, ?::jsonb)")) { + // ... setString: orderId, "order.placed", payloadJson {order_id, sku, quantity} + outboxStmt.executeUpdate(); // same transaction: the outbox row + } + return Response.status(Response.Status.CREATED).entity(order).build(); + } +} +// No Kafka call on the request path: Debezium tails the WAL and publishes order.placed. +``` + +The database commit is the source of truth. The outbox + CDC turns it into a guaranteed, ordered event. + +p. 27 + +> The whole trick is the @Transactional method: the insert of the order row and the insert of the outbox row run on one Agroal connection inside one JTA transaction, so they commit or roll back together. There is no Kafka call inside the request path: if the broker is down, the order still commits and the event ships later when Debezium tails the write-ahead log and relays the outbox row in commit order. This decouples request latency and availability from Kafka's. +> +> Example 04 needs no outbox extension: the outbox is a small table (aggregate id, event type, JSON payload, created_at), the payload is serialized with Jackson, and a standard Debezium Postgres connector handles the CDC. The Debezium Outbox Event Router single-message transform unwraps the row into the event envelope on the connector side. The same commit-together discipline reappears in the saga appendix. + +## [033] DATA · Consistency without distributed transactions: the saga (sldId 288) + +A chain of local transactions; failures trigger compensating actions, never a cross-service rollback. + +p. 28 + +> The saga is how you get consistency across services without a distributed transaction (there is no two-phase commit here, and you don't want one). It's a sequence of local transactions, each emitting the event that triggers the next: order created, payment taken, stock reserved, shipment booked. When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order). Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics. + +## [034] Event-Driven (sldId 289) + +05 + +Emit facts; let consumers decide. + +> Section five — the asynchronous backbone we kept pointing at. The mental shift: a producer emits a fact about something that happened (order.placed) and does not know or care who consumes it. Consumers decide what to do. This inverts the dependency direction, and it is what decouples services in time and ownership. + +## [035] EVENT-DRIVEN · The event backbone — one fact, many reactions (sldId 290) + +order.placed fans out to consumers the producer never names. Add a consumer without touching the producer. + +p. 29 + +> order.placed fans out to payment, shipping and notification — and the producer names none of them. The power move: you can add a new consumer (say, a fraud-check service) without touching or redeploying the producer. Contrast with the synchronous world, where adding a dependency means changing the caller. This is the open/closed principle at the architecture level. Kafka (run by Strimzi on Kubernetes) is the backbone; Pulsar is an alternative with similar semantics. + +## [036] EVENT-DRIVEN · Producing and consuming with Reactive Messaging (sldId 291) + +[chip] java · SmallRye Reactive Messaging + +```java +// producer — order-service emits the fact; channel order-placed → topic order.placed +@ApplicationScoped +public class OrderEvents { + @Inject @Channel("order-placed") Emitter emitter; // Reactive Messaging + public void publish(Order o) { + emitter.send(KafkaRecord.of(o.id(), o)); // keyed; schema-aware serde + } +} +// consumer — notification-service, Kafka-only by design +@ApplicationScoped +public class Notifier { + @Incoming("order-placed") // offset tracked per group + public Uni onOrder(Message msg) { + return sendEmail(msg.getPayload()) + .chain(msg::ack); // ack after the side-effect — at-least-once + } +} +``` + +Ack after the side-effect, and make handlers idempotent — at-least-once delivery is the contract. + +p. 30 + +> SmallRye Reactive Messaging is the Quarkus equivalent of aiokafka, but declarative. The producer injects an Emitter on a named @Channel mapped to a Kafka topic in application.properties; sending a KafkaRecord keys the message by order id. The consumer is a method annotated @Incoming. Two points: the channel is configured for manual ack, and we ack only once the side-effect (sendEmail) has succeeded. Ack first and then crash, and the work is lost. Because we ack after, a crash mid-handler means redelivery, so delivery is at-least-once and your handlers must be idempotent (dedupe by message key or an idempotency key). Exactly-once is mostly at-least-once plus idempotency. The serde is wired to Apicurio, the schema registry. + +## [037] EVENT-DRIVEN · Schemas are the contract — the registry enforces them (sldId 292) + +Apicurio holds versioned schemas; compatibility is checked at publish time so breaking changes are rejected early. + +p. 31 + +> Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate. The schema (Avro, Protobuf or JSON Schema) lives in Apicurio. Producers and consumers fetch it; compatibility is checked at publish time so a breaking change is rejected before it ever reaches the topic. This is what makes adding a consumer safe. We come back to event versioning rules in Appendix B. + +## [038] EVENT-DRIVEN · Event sourcing vs. event streaming (sldId 293) + +Two patterns, often confused. Sourcing: the events are the state. Streaming: events are the transport. + +p. 32 + +> The vocabulary clarification most teams need before they go further with events. Event sourcing means events are the database — there is no 'current state' row anywhere; current state is computed by folding the entire event history. Adding a credit to an account isn't UPDATE balance SET balance = balance + 100; it's INSERT INTO events VALUES ('credit', 100), and reading the balance means summing every event for that account (with snapshots for performance). Strong fit for audit-heavy systems (banking, healthcare records, regulatory), temporal queries ("what was the balance on March 3rd?"), and any domain where the history is itself a primary asset. Tools: Marten (Postgres-backed, .NET), EventStoreDB, Axon (JVM). Event streaming means events are how facts move between services — each service still keeps its current state in a normal database, but emits events ("order.placed", "payment.processed") that other services subscribe to. The event is a transport mechanism, not a storage mechanism. Strong fit for integration, fan-out, decoupling. Tools: Kafka, Pulsar, NATS JetStream. These are not mutually exclusive. A common combination is full CQRS: the write side is event-sourced (so you have an audit trail of state changes), and the read-side projections are fed by streaming those events to consumers that build denormalised views. The acid test: is the event itself the source of truth (sourcing), or is it a propagated fact about a change that happened in a store (streaming)? In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core. + +## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) + +Different jobs, often together: substrate (Kafka, Pulsar), compute (Flink), movement (NiFi, Debezium). + +p. 33 + +> Each of the four tools does a different job. Kafka is the substrate: a partitioned, ordered, durable log. Consumers track their own offsets and can replay. It's the de facto event spine for cloud-native architectures; Strimzi runs it natively on Kubernetes. Pulsar is also substrate, in the same broad category as Kafka but with native pub/sub plus queue semantics in the same product, tiered storage that pushes cold data to cheap object stores automatically, and multi-tenancy built into the broker. Pulsar has a Kafka-protocol shim so consumers can speak Kafka while writing to Pulsar. Flink is compute: not a transport layer, but a stateful stream-processing engine. Flink sits on top of Kafka or Pulsar, consumes streams, applies operators (windowed aggregations, joins, filters, stateful transformations) with exactly-once semantics via checkpointing, and produces new streams. Alternatives: Kafka Streams (lighter, JVM-only, embedded library), Spark Structured Streaming (heavier, batch-mindset). NiFi is movement: a visual dataflow / ETL tool with 200+ source-and-sink connectors and back-pressure built in. Think of it as the answer to 'I need to get data from SFTP to Kafka to an object store with these transforms in the middle' — you draw the flow in the UI rather than writing a pipeline. Debezium (covered in the Data section) is the CDC variant of movement. Together they compose: typical pipeline is source DB → Debezium or NiFi → Kafka or Pulsar → Flink for windowed aggregations → sink. The mistake is treating these as competitors. They complement each other; pick by the question your team is asking: durably hold events (substrate), compute over them (Flink), move them around (NiFi/Debezium). + +## [040] EVENT-DRIVEN · The log is the source of truth (sldId 295) + +Consumers track their own offset; replay is just rewinding. Kafka (Strimzi) and Pulsar are logs, not queues. + +p. 34 + +> The mental model correction most people need: Kafka is a log, not a queue. Messages are retained after they are read, and each consumer group tracks its own offset. That means replay is just rewinding an offset: a new consumer can process all of history, and a buggy consumer can be fixed and replayed. This is the foundation for event sourcing and for rebuilding stream-processing state, the subject of stream processing. Strimzi runs Kafka on Kubernetes; Pulsar offers similar log semantics with tiered storage. + +## [041] Stream Processing (sldId 296) + +06 + +Streams in, derived streams out. + +> Section six builds directly on the log. Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams. This is where real-time analytics, materialized views and complex event reactions live. + +## [042] STREAM PROCESSING · Derive new streams from streams (sldId 297) + +A streaming app is a topology of sources, stateful processors and sinks — joins, windows and aggregates. + +p. 35 + +> A stream-processing app is a topology: sources (input topics), stateful processors (filter, join, window, aggregate) and sinks (output topics or stores). The output of one topology is just another stream that other services consume. Emphasize stateful — unlike a simple consumer, these processors keep local state (running totals, window buffers) that must survive restarts. That state-management problem is what the next code slide and KEDA slide address. + +## [043] STREAM PROCESSING · A windowed aggregation with Kafka Streams (sldId 298) + +[chip] java · Quarkus Kafka Streams + +```java +// stream-processor RevenueTopology.java (excerpt): order.placed -> revenue.by-merchant +@ApplicationScoped +public class RevenueTopology { + @Produces // quarkus-kafka-streams runs it + public Topology topology() { + StreamsBuilder builder = new StreamsBuilder(); + ObjectMapperSerde accSerde = new ObjectMapperSerde<>(Acc.class); + builder.stream(ORDERS, Consumed.with(Serdes.String(), Serdes.String())) + .map((key, json) -> parse(json)) // re-key by merchant_id + .filter((merchant, amount) -> merchant != null) + .groupByKey(Grouped.with(Serdes.String(), Serdes.String())) + .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(windowSeconds))) + .aggregate(() -> new Acc(0, BigDecimal.ZERO), // local, changelog-backed + (merchant, amount, acc) -> add(merchant, acc, amount), + Materialized.with(Serdes.String(), accSerde)) + .toStream() + .map((window, acc) -> KeyValue.pair(window.key(), toJson(window, acc))) + .to(REVENUE, Produced.with(Serdes.String(), Serdes.String())); + return builder.build(); + } +} +``` + +Local state + a changelog topic gives fault-tolerant aggregation. The output is itself a stream other services can consume. + +p. 36 + +> Kafka Streams is the JVM-native stream processor and the natural Quarkus counterpart to faust-streaming in the Python deck. The Topology is just a CDI-produced bean — Quarkus manages its lifecycle. The key idea is the Materialized state store: the per-merchant running total is local state, but it's backed by a Kafka changelog topic, so if the pod dies the state rebuilds by replaying the changelog — fault-tolerant local state with no external database in the hot path. The tumbling window (window.seconds, 10 seconds by default) buckets the aggregation by time; the accumulator, a count and a BigDecimal total, is serialized with ObjectMapperSerde, and each update is written to the output topic as a JSON row (window, merchant_id, order_count, revenue). The output revenue.by-merchant topic is itself a stream other services consume. If you'd rather express this as routing/EIP than a topology, Quarkus + Camel is the alternative; Flink is the heavyweight option. + +## [044] STREAM PROCESSING · KEDA scales workers on the signal that matters (sldId 299) + +Scale Kafka workers on consumer lag and HTTP services on request rate — both down to zero when idle. + +p. 37 + +> KEDA closes the elasticity loop. The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU. KEDA reads that lag and scales workers up, and down to zero when there's no lag. For synchronous HTTP services, the KEDA http-add-on scales on request concurrency/rate. One mechanism, two signals. Scale-to-zero is a real cost lever for spiky workloads. The KEDA configuration covers both: lag for events and RPS for HTTP. + +## [045] STREAM PROCESSING · Scale on lag (events) and on RPS (HTTP) (sldId 300) + +[chip] yaml · KEDA + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: { name: payment-consumer } +spec: + scaleTargetRef: { name: payment-consumer } + minReplicaCount: 0 + maxReplicaCount: 20 + triggers: + - type: kafka + metadata: { bootstrapServers: "cndp-kafka-bootstrap:9092", topic: order.placed, + consumerGroup: payment, lagThreshold: "100" } +--- +# HTTP: scale the Quarkus service on concurrent requests (KEDA http-add-on) +apiVersion: http.keda.sh/v1alpha1 +kind: HTTPScaledObject +metadata: { name: order-service } +spec: + hosts: [orders.example.com] + scaleTargetRef: { name: order-service, service: order-service, port: 8080 } + replicas: { min: 0, max: 30 } +``` + +One mechanism, two signals: queue depth for async workers, concurrency for synchronous APIs. + +p. 38 + +> Two manifests, one idea. The ScaledObject scales payment-consumer on Kafka lag for the order.placed topic, with minReplicaCount: 0 — true scale to zero when the topic is quiet, springing back when lag builds. The HTTPScaledObject does the same for the synchronous order-service based on request load via the http-add-on. The lagThreshold of 100 is the tuning knob: how many messages of backlog before adding a worker. Point out that scale-to-zero plus a cold-start budget is the trade-off to discuss with the audience for latency-sensitive paths. + +## [046] Workflows and Jobs (sldId 301) + +07 + +Not every unit of work is a request. + +> Section seven. A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work. Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes. For orchestration in this stack, Quarkus Flow (a CNCF Serverless Workflow engine that runs inside your Quarkus app) is the natural choice; Appendix D uses it for the saga orchestrator. + +## [047] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 302) + +A coordinator that commands keeps the flow in one place; events that trigger each other decouple but hide the flow. + +p. 39 + +> The two ways to coordinate a multi-step process. Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service. Choreography: each service reacts to the previous one's event with no central brain — maximally decoupled, but the end-to-end flow is emergent and hard to see. Neither is universally right; choose by the selection criteria in the comparison. This connects straight to the saga discussion and Appendix D. + +## [048] WORKFLOWS AND JOBS · Choosing between the two (sldId 303) + +Orchestration + +- One coordinator owns the saga and its state +- Flow is explicit and centrally observable +- One place to reason about, change and audit +- Risk: the orchestrator becomes a god-service +- Good for complex, long-lived business processes + +Choreography + +- Each service reacts to the previous event +- Maximally decoupled; no central bottleneck +- Flow is emergent — harder to see end-to-end +- Risk: nobody owns the overall process +- Good for simple, additive event reactions + +p. 40 + +> The decision heuristic: use orchestration for complex, long-lived business processes where you need auditability and a clear owner (order fulfillment, loan approval). Use choreography for simple, additive reactions where decoupling matters more than visibility (send a welcome email when a user signs up). The failure modes are mirror images: orchestration risks a god-service; choreography risks nobody owning the process and no one able to answer 'where is order 42 stuck?'. Observability (section 11) is what makes choreography survivable. + +## [049] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 304) + +Long, batch or periodic work belongs in Jobs/CronJobs or a scaled queue worker — never on the request path. + +p. 41 + +> Where non-request work lives on Kubernetes. A Job runs a task to completion (a data migration, a nightly export). A CronJob schedules Jobs (the reconciliation that runs at 2am). A queue worker continuously drains a topic and is scaled by KEDA on lag. The anti-pattern: doing long or heavy work inside an HTTP handler, blocking the request and timing out the client. The rule of thumb on screen — long, batch or periodic work never belongs on the request path. Hand it to a Job or a worker and return immediately, ideally with a status URL the client can poll. In-app, the Quarkus Scheduler (@Scheduled) and quarkus-flow-scheduler cover recurring work without leaving the JVM, while Kubernetes Jobs/CronJobs own the heavier or isolated batch runs. + +## [050] API Management (sldId 305) + +08 + +Treat the API surface as a platform. + +> Section eight zooms out from individual services to the API surface as a whole. Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability). The next three sections each take one of these in depth. + +## [051] API MANAGEMENT · Four cooperating planes (sldId 306) + +Gateway and mesh at runtime; registry for contracts; metadata for discovery; observability for signals. + +p. 42 + +> The four planes, and they map exactly to the rest of the platform sections. Runtime plane: the gateway and Istio mesh handle traffic, auth, rate-limiting and resilience. Contract plane: the registry (section 9) holds every API and event schema, versioned and gated. Discovery plane: metadata/catalog (section 10) lets people find and trust APIs. Signal plane: observability (section 11) tells you what is happening. The point: these are distinct responsibilities with distinct owners, not one monolithic 'API gateway product' that tries to do everything. + +## [052] API MANAGEMENT · Versioning and evolution are part of management (sldId 307) + +- Evolve additively — new optional fields and endpoints; never repurpose or remove without a new version. +- Carry a major version in the path (/v2) or media type; deprecate with headers and a sunset date. +- The registry's compatibility rules (BACKWARD / FORWARD / FULL) enforce this mechanically at publish time. +- Run old and new side by side; migrate consumers off the old contract before you retire it. +- Lifecycle: design → register → publish → consume → deprecate → retire — each step has an owner and a gate. + +p. 43 + +> Versioning is a management concern, not an afterthought. The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period. When you must break, carry a major version (in the path or media type) and run old and new side by side with a published sunset date, migrating consumers off before retiring. The registry's compatibility rules enforce the additive discipline mechanically at publish time. The bold lifecycle line is the takeaway: every contract moves design → register → publish → consume → deprecate → retire, and each transition has an owner and a gate. Appendix B drills into how REST, gRPC and GraphQL each handle this differently. + +## [053] API Registry (sldId 308) + +09 + +One versioned home for every contract. + +> Section nine. If contracts are the boundary, they need one authoritative home. The registry is where every OpenAPI spec, .proto, AsyncAPI doc and event schema lives — versioned, compatibility-checked, and gated in CI. This is the difference between 'we have contracts somewhere in git' and 'contracts are enforced before code ships'. + +## [054] API REGISTRY · Apicurio: contracts, versioned and gated (sldId 309) + +OpenAPI, AsyncAPI, proto and Avro/JSON schemas live in one registry with compatibility rules and CI gating. + +p. 44 + +> Apicurio is the concrete registry in this stack. Every contract type lands here: REST OpenAPI, GraphQL SDL, gRPC .proto, and event schemas (Avro/JSON). Each artifact has versions and a compatibility rule (BACKWARD, FORWARD, FULL). At publish time the registry checks the new version against the rule and rejects incompatible changes. Producers register; consumers fetch and generate stubs. The serde libraries for Kafka pull schemas from here by ID at runtime. A CI gate turns this into an automatic guardrail. + +## [055] API REGISTRY · Gate breaking changes in CI with Maven (sldId 310) + +[chip] xml · Maven + Apicurio + +```xml + + + io.apicurio + apicurio-registry-maven-plugin + 3.3.3 + + register + + http://apicurio.registry.svc/apis/registry/v3 + true + + orders + order-placed + AVRO + CREATE_VERSION + src/main/avro/order-placed.avsc + + + + +``` + +The dry-run register fails the build on an incompatible change — the contract is enforced before code ships, not after. + +p. 45 + +> This is the guardrail that makes the whole contract story real, done the Maven way. The apicurio-registry-maven-plugin's register goal with dryRun set checks the new schema against the artifact's compatibility rule in the registry without storing it, and fails the build (and the merge) if it is incompatible. The older test-update goal still exists in 3.3.3 but is deprecated in favour of dryRun. The point to make: the contract is enforced before code ships, not discovered broken in production by a downstream team. The rule (e.g. BACKWARD — new consumers can read old data) is set once per artifact and enforced automatically forever; a separate register goal publishes on the main branch after merge. Tie it back: this is design-first from section 1, now mechanized in the build. + +## [056] API Metadata (sldId 311) + +10 + +If they can't find it, it doesn't exist. + +> Section ten. A contract that's enforced but undiscoverable is half useless. Metadata is the discovery and trust layer: a searchable catalog of every API, topic and table, with ownership, lineage and quality signals. If a consumer can't find your API and trust it, they'll build their own or screen-scrape — and your boundary erodes. + +## [057] API METADATA · Discovery, lineage and ownership (sldId 312) + +OpenMetadata ingests from services, the registry and Kafka to build a searchable catalog with lineage. + +p. 46 + +> OpenMetadata is the catalog in this stack. It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog. The differentiator over a wiki is automated lineage: it knows that this topic feeds that table feeds this API, so it can answer 'if I change this field, who breaks?' before you change it. Ingestion is a scheduled connector, so the catalog stays current without manual upkeep. What consumers get from the catalog is the point of running it. + +## [058] API METADATA · What the catalog gives every consumer (sldId 313) + +- Discovery — search across APIs, topics and tables; find the owning team and how to get access. +- Trust — published SLOs, data-quality test results, freshness, and a tier/criticality rating. +- Lineage — column- and topic-level lineage answers 'if I change this, who breaks?' before you change it. +- Ownership & governance — tags, classifications (PII), and accountable owners attached to every asset. + - Ingestion runs as a scheduled connector against OpenAPI specs, the registry, Kafka and Postgres. + +p. 47 + +> Four concrete payoffs for consumers. Discovery: search across APIs, topics and tables, and find the owning team and how to get access — no more asking around in Slack. Trust: published SLOs, data-quality results, freshness and a criticality tier tell you whether to depend on it. Lineage: column- and topic-level impact analysis answers the change-safety question proactively. Ownership and governance: tags, PII classifications and accountable owners on every asset — which is also what your compliance team needs. The throughline of sections 9–10: a contract is only as good as your ability to find it, trust it, and know who owns it. + +## [059] Observability (sldId 314) + +11 + +Instrument once; correlate everything. + +> Section eleven, and the one that makes everything else operable. In a distributed system you cannot debug by reading one log file — a single user action touches five services. Observability is how you reconstruct that story. The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them. + +## [060] OBSERVABILITY · Three signals, one correlated story (sldId 315) + +OpenTelemetry emits traces, metrics and logs; the LGTM-style stack stores them and Grafana joins them. + +p. 48 + +> Three signals, each answering a different question. Traces: where did the time go and which service failed in this one request? Metrics: what are the aggregate rates, errors and latencies over time? Logs: what exactly happened in this component? OpenTelemetry is the vendor-neutral way to emit all three from your code. The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them. The magic is the shared trace_id linking a metric spike to the exact traces and logs behind it. The instrumentation comes first, then the trace. + +## [061] OBSERVABILITY · The signals in depth — traces, metrics, logs, baggage, profiles (sldId 316) + +Three classic pillars plus two that matter more every year. OpenTelemetry emits all five; one trace_id correlates them. + +p. 49 + +> Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer where the time went and which hop failed in one request. Metrics answer the trend question — aggregate rates, errors, latencies over time (the RED method: Rate, Errors, Duration; the USE method: Utilisation, Saturation, Errors). Logs answer what exactly happened in one component. Baggage is the signal most people miss — it's key-value context (tenant_id, user_tier, feature_flag) that propagates along the whole trace, so a value set at the edge is readable in a Kafka consumer three hops downstream. Profiles are the newest signal — continuous CPU / memory / lock sampling per service, answering which function is burning the CPU (Pyroscope, Parca, Grafana Phlare). The correlation bar at the bottom is the whole point: traces, metrics, logs, baggage and profiles all share trace_id + span_id + resource attributes (service.name, k8s.pod.name, deployment.version), so you can pivot — a metric spike leads to an exemplar trace, which leads to its logs, which leads to its CPU profile. OpenTelemetry is the vendor-neutral SDK and wire format for all five; traces/metrics/logs are GA, baggage is stable, profiling is the newest (the OTel profiling signal reached the ecosystem in 2024). + +## [062] OBSERVABILITY · What each signal answers — and when to reach for it (sldId 317) + +- Traces — 'where did the time go in this request, which hop failed?' Reach for it when debugging latency or a specific failed request. Stored in Tempo / Jaeger. +- Metrics — 'what is the aggregate rate / error / latency trend, is it getting worse?' Reach for it for dashboards, alerting, capacity. RED (Rate, Errors, Duration) for request-driven services; USE (Utilisation, Saturation, Errors) for resources. Stored in Prometheus / Mimir. +- Logs — 'what exactly happened in this component at this instant?' Reach for it for detail the trace doesn't carry. Structured (JSON), keyed by trace_id. Stored in Loki / ELK. +- Baggage — 'carry a value (tenant_id, user_tier) along the whole request so downstream services and their telemetry can use it.' Set once at the edge, readable everywhere downstream. W3C Baggage spec. +- Profiles — 'which function is burning CPU / allocating memory / holding locks, continuously, in production?' Reach for it for the latency the trace shows but doesn't explain. Pyroscope / Parca. + - The discipline: emit traces + metrics + logs always; add baggage when downstream needs request-scoped context; add profiling when you have CPU/memory mysteries a trace can't resolve. + +p. 50 + +> Walk each signal and the question it answers — the framing 'what question does this answer' is more useful to an engineer than 'what is this signal.' Traces are per-request; metrics are aggregate; logs are detail. The two newer ones deserve emphasis. Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant. Profiling is the frontier: continuous profilers (Pyroscope, Parca, Grafana Phlare) run in production at low overhead and answer the 'the trace says this span took 200ms but why' question by showing the actual call stack burning the CPU. The sub-bullet is the practical guidance on which to adopt when. + +## [063] OBSERVABILITY · Auto-injection vs. instrumentation — three distinct things (sldId 318) + +- Manual instrumentation — you write the spans. tracer.spanBuilder(...).startSpan(), span.setAttribute(...), span.addEvent(...). Needed for business operations the libraries can't know about ('reserve_stock', 'score_fraud'). The most signal per line, the most effort. +- Auto-instrumentation — a library / agent wraps known frameworks for you. HTTP servers and clients, gRPC, JDBC / database drivers, Kafka, Redis — each becomes spans with zero code change. Covers the plumbing; misses your domain. +- Auto-injection — the platform puts the auto-instrumentation agent into the pod for you. The OpenTelemetry Operator watches for an annotation (instrumentation.opentelemetry.io/inject) and mutates the pod at admission — no agent in your image, no Dockerfile change, no redeploy of the app to add tracing. +- How they layer: auto-injection delivers the agent → the agent provides auto-instrumentation of frameworks → you add manual spans for business logic on top. All three coexist in the same trace. + - Trade-off: auto-instrumentation is convenient but can be noisy (every framework call becomes a span) and slightly slower. Manual is precise but is work you own. Most teams: auto for the plumbing, manual for the domain, injection to roll it out fleet-wide. + +p. 51 + +> Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate. Manual: you call the tracer API yourself; necessary for domain operations. Auto-instrumentation: the OTel instrumentation libraries (or a language agent) wrap known frameworks: your HTTP handler, your DB driver, your Kafka client all emit spans without you touching them. Auto-injection: a Kubernetes-level mechanism; the OpenTelemetry Operator sees a pod annotation and injects the language agent + config as an init container / env vars at admission time, so you don't even put the agent in your image. Per-language specifics: Python: 'opentelemetry-instrument' wrapper or the operator injects it; instrumentation packages per library. Java/Quarkus: the OTel Java agent (-javaagent) auto-instruments everything; Quarkus also has build-time OTel extension. Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource. The bold line is the mental model: injection delivers the agent, the agent instruments frameworks, you add manual spans on top — they layer into one trace. + +## [064] OBSERVABILITY · Auto-instrument with Quarkus OpenTelemetry (sldId 319) + +[chip] java · Quarkus OpenTelemetry + +```java +# application.properties — add quarkus-opentelemetry; REST, gRPC and +# Reactive Messaging are instrumented automatically. Just point it at the Collector: +quarkus.otel.exporter.otlp.endpoint=http://otel-collector:4317 +// Only custom business spans need code — @WithSpan does it declaratively +@ApplicationScoped +public class OrderService { + @Inject @GrpcClient("inventory") Inventory inventory; + @WithSpan("reserve_stock") // custom span on this method + public Uni reserve(String sku, int qty) { + return inventory.reserveStock(req(sku, qty)); + } + // trace_id flows REST -> gRPC -> Kafka -> consumer, automatically +} +``` + +The OTEL exporter sends traces to Tempo, metrics to Mimir/Prometheus, logs to Loki — all keyed by trace_id. + +p. 52 + +> The payoff line is how little code this takes. Adding the quarkus-opentelemetry extension auto-instruments every REST endpoint, gRPC call and Reactive Messaging hop — spans are created and context is propagated onto events with zero code. You only hand-write spans for custom business operations worth measuring, and even that is declarative: @WithSpan on a method. The crucial behaviour is the last comment: the trace_id flows REST → gRPC → Kafka → consumer with no manual plumbing, because the Quarkus instrumentation injects and extracts context at each hop. The OTLP exporter ships everything to the Collector keyed by that trace_id. Instrument once, correlate everything. + +## [065] OBSERVABILITY · How correlation works — context propagation (sldId 320) + +The trace context rides in-band with the request (W3C traceparent). The same trace_id at every hop is what stitches the story. + +p. 53 + +> Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling with the request, in-band, at every hop. The W3C traceparent header is the standard: version-traceid-parentspanid-flags. The trace_id (16 bytes) is constant across every hop of the request — that constancy is literally what stitches the distributed story into one trace. The span_id is new per operation, and each span records its parent span_id, which is how the backend rebuilds the tree. The flags byte carries the sampling decision (01 = sampled) so every hop honours the same decision. Alongside traceparent rides tracestate (vendor-specific) and baggage (your key-values). The transport differs per hop but the mechanism is identical: HTTP uses the traceparent header, gRPC uses metadata, Kafka uses message headers. The SDK injects the context on the way out and extracts it on the way in — automatically, which is why auto-instrumentation 'just works' across services. This propagated context is exactly why the same trace_id shows up on the trace waterfall, on every structured log line, and as an exemplar on your metrics — they all read it from the same in-band context. + +## [066] OBSERVABILITY · Baggage in practice — carry request context downstream (sldId 321) + +[chip] java · Quarkus + OpenTelemetry baggage API + +```java +import io.opentelemetry.api.baggage.Baggage; +import io.opentelemetry.api.trace.Span; +// Set baggage at the edge (a JAX-RS request filter) +@Provider +public class BaggageFilter implements ContainerRequestFilter { + public void filter(ContainerRequestContext ctx) { + Baggage.current().toBuilder() + .put("tenant_id", ctx.getHeaderString("X-Tenant")) + .put("user_tier", lookupTier(ctx)) + .build() + .makeCurrent(); // rides the whole trace + } +} +// Read baggage anywhere downstream — even in a Reactive Messaging consumer +@Incoming("order-placed") // topic order.placed +public void handle(OrderEvent ev) { + String tenant = Baggage.current().getEntryValue("tenant_id"); + Span.current().setAttribute("tenant_id", tenant); // filterable in trace UI +} +``` + +Set once at the edge; read anywhere downstream. Pin to the span to filter traces by tenant_id. + +p. 54 + +> Quarkus uses the OpenTelemetry Java API directly for baggage. A JAX-RS ContainerRequestFilter sets tenant_id and user_tier at the edge; .makeCurrent() puts them in the active context, and the Quarkus OTel extension propagates them on every downstream call (REST Client, gRPC, Reactive Messaging to Kafka) via the W3C baggage header. A downstream @Incoming consumer reads Baggage.current().getEntryValue('tenant_id') with no explicit plumbing. Pin it to the span with Span.current().setAttribute(...) so it's queryable in Jaeger/Tempo. Caution: baggage is on the wire to every downstream hop — never put secrets or PII in it. + +## [067] OBSERVABILITY · A single trace stitches the whole request (sldId 322) + +One trace_id propagates from the edge REST call through gRPC, Kafka and the consumer — five services, one story. + +p. 55 + +> This is what the instrumentation buys you, visually. One trace_id propagates from the edge REST POST through the gRPC ReserveStock call, onto the published order.placed event, and into the consumer that sends the notification. Five services, one readable waterfall. Point at the time axis: you can see exactly which span dominated latency. That same trace_id appears as an exemplar on your metrics and on every log line, so a latency alert is one click from the offending trace and its logs. This is what makes the choreographed, event-driven design from earlier sections debuggable. + +## [068] OBSERVABILITY · Sampling — head vs. tail (sldId 323) + +You can't store every trace at scale. Head decides at the start (cheap, blind); tail decides at the end (smart, costly). + +p. 56 + +> The cost-control reality: at scale you cannot store every trace, so you sample. The key question is when the keep/drop decision is made. Head sampling decides at the start, in the SDK, before the trace even completes — typically 'keep 1 in N' (probabilistic) or 'keep N per second' (rate-limiting). It's cheap and low-overhead, but it is blind at decision time: it can't keep a trace because it errored or was slow, because it decides before knowing. Tail sampling decides at the end, in the Collector, after the whole trace has been seen — the Collector buffers spans, waits for the trace to complete, then applies rules: keep all errors, keep all traces over 500ms, keep rare routes, sample the boring successful ones at 1%. Tail is smart but costs memory (buffering in-flight traces) and CPU (the Collector does real work). The strategies, named: probabilistic (keep X%), rate-limiting (cap per second), parent-based (follow the upstream's decision so a trace isn't half-sampled), and tail rules (error / latency / attribute based). The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate. Two rules to drill home: always sample by trace, not by span (a half-sampled trace is worse than no trace), and propagate the sampling flag (the 01 in traceparent) so every service agrees. Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy. + +## [069] OBSERVABILITY · Choosing a sampling strategy (sldId 324) + +- Probabilistic (head) — keep a fixed percentage (e.g. 10%). Simplest; predictable volume; blind to errors. Good baseline for high-traffic, low-stakes services. +- Rate-limiting (head) — keep at most N traces/sec per service. Protects your backend from traffic spikes; good when load is bursty. +- Parent-based (head) — honour the upstream service's sampling decision (carried in the traceparent flags). Essential in distributed systems: it stops a trace being sampled at service A but dropped at service B, which would give you broken half-traces. +- Tail-based (Collector) — buffer the whole trace, then keep by rule: all errors, all traces over a latency threshold, rare routes, plus a small % of the rest. The gold standard for keeping what matters, at the cost of Collector memory + CPU. +- Recommended production setup: parent-based head sampling at a modest baseline (1-10%) for representativeness, plus tail sampling at the Collector to guarantee every error and slow trace is kept. + - Don't sample metrics the way you sample traces (metrics aggregate everything). Keep logs in full with retention limits, or sample them on their own policy keyed by trace_id so a kept trace keeps its logs. + +p. 57 + +> The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-limiting is better when traffic is bursty because it caps absolute volume. Parent-based is non-negotiable in a distributed system: without it, service A samples a trace but service B independently drops it, and you get a broken trace with holes — worse than no trace. Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident. The cost is the Collector must buffer in-flight traces (memory) and evaluate rules (CPU), and it must see all spans of a trace (which constrains how you load-balance the Collector — use a trace-ID-aware load balancer). The recommended setup combines head (for a cheap representative baseline) and tail (for guaranteed capture of the interesting traces). The sub-bullet is the reminder: traces, metrics and logs are sampled differently — you never sample a metric counter, and logs typically follow their own retention or are sampled to match their parent trace. + +## [070] OBSERVABILITY · Prove it with simple tools before you ship (sldId 325) + +- curl / Postman — exercise REST contracts and inspect status codes, headers and pagination. +- hey — quick HTTP load to watch latency, error rate and the KEDA http-add-on scale the service. +- ghz — load-test gRPC endpoints and confirm timeouts and retries behave under pressure. +- GraphQL UI (GraphiQL) — explore the composed schema and watch the gateway fan out in Tempo. +- Kiali — read the live service-mesh topology, mTLS status and request flow during a load test. + +p. 58 + +> Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure. The GraphQL UI (GraphiQL, which Quarkus serves in dev) explores the composed schema and, with tracing on, shows the gateway fanning out in Tempo. Kiali visualizes the live mesh: topology, mTLS status and real-time request flow during a load test. The message: prove the behaviour with simple tools before you ship, not after an incident. + +## [071] Security (sldId 326) + +12 + +Defence in depth, in layers. + +> Section 12 — security. The Anti-Patterns section that used to be 12 has shifted to 13. We organise this section around two ideas: the 4 C's of cloud-native security (concentric layers, each protecting what is inside it), and the seven patterns that you'll meet over and over in practice. The bias of this section: security is not a single product or a single team. It's a property of how the system is designed and run, owned at every layer. Each pattern here exists because no single layer is enough on its own. + +## [072] SECURITY · The 4 C's of cloud-native security (sldId 327) + +Cloud → Cluster → Container → Code. Concentric layers, each layer protects what is inside. + +p. 59 + +> The mental model from the CNCF security paper. Four concentric layers of responsibility, each enforcing controls appropriate to its scope. Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin. Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE. Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform. Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams. The point of the concentric metaphor: a weakness in any layer leaks into the inner ones. Strong code on a weak container is still vulnerable. Strong container on a weak cluster is still vulnerable. We design controls at all four. The 4 C's table lists each layer's controls and its owner. + +## [073] SECURITY · The 4 C's — what each layer enforces, and who owns it (sldId 328) + +- Cloud — IAM (principles of least privilege), VPC isolation, KMS for keys, audit logging, region & data-residency controls. Owner: platform / cloud admin. +- Cluster — Kubernetes RBAC, NetworkPolicy (default-deny + targeted allows), Pod Security Standards (restricted profile), admission control (OPA Gatekeeper / Kyverno), secret encryption at rest, etcd encryption. Owner: platform / SRE. +- Container — minimal base images (distroless, UBI-minimal), non-root users, read-only root filesystems, image signing (Sigstore / Cosign), vulnerability scanning (Trivy, Grype) on every build, secret mounting via projected volumes (never bake secrets into images). Owner: app team + platform. +- Code — secure defaults (parameterised queries, output encoding), input validation at the trust boundary, dependency scanning (Dependabot, Snyk, OSV-Scanner), secrets handling (no plaintext in source, no logging secrets), CSRF / SSRF / XXE awareness, threat modeling. Owner: app team. +- Cross-cutting: observability (audit who did what when), incident response runbooks, threat modeling per service, regular dependency updates. + - The pattern: a control at the wrong layer is the most common source of false confidence. Network ACLs cannot save you from an SQL injection. Code-level input validation cannot save you from a compromised image. Defence in depth is real. + +p. 60 + +> Walk each layer. The Cloud layer is where you control the perimeter and the identities and the keys; failures here often look like 'someone left a storage bucket open to the internet' or 'an over-permissive IAM role enabled lateral movement to another account.' The Cluster layer is the Kubernetes operating system for your workloads; failures look like 'the developer Pod could reach the database Pod' (no NetworkPolicy) or 'a privileged container was admitted' (no Pod Security Standards). The Container layer is about the image and runtime; failures look like 'a CVE in the base image' or 'the container ran as root and got out via a kernel exploit' or 'a secret was in the image.' Code is where most developer focus already lives. The sub-bullet is the take-home: a control at the wrong layer is false confidence. The pattern catalog holds seven patterns that show up across these four layers. + +## [074] SECURITY · Seven patterns to know (sldId 329) + +- Sidecar — move security concerns out of the app into a companion process. mTLS, JWT, audit, rate-limiting belong there. +- Valet Key — hand the client a short-lived signed token to access a resource directly. Less app proxying, less attack surface. +- Strangler Fig (for security modernisation) — encapsulate legacy auth behind an API gateway, replace pieces over time. +- Zero-Trust / Identity-Aware Proxy — every request authenticated and authorised. Perimeter trust is dead. +- Policy-as-Code — security gates declared in Git, evaluated at admission and at build. No human runs a checklist. +- Bulkhead — isolate resources so one failure (or compromise) cannot starve or cascade into others. +- Claim-Check — send a reference, not the payload. Keeps PII out of message bus history and limits the blast radius if a topic leaks. + - The seven appear in different layers and combine. Each slide that follows shows the pattern, then implementation — platform config where it's mostly YAML, code where it's mostly code. + +p. 61 + +> The roadmap for the next fourteen slides. These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation). You'll see each pattern with a diagram explaining how it works, then either a YAML configuration block (for the platform-heavy patterns) or language-specific code (for the code-heavy patterns). Use the sub-bullet to set audience expectation that the section alternates between pattern-and-implementation pairs. + +## [075] SECURITY · SIDECAR · Sidecar pattern — push security out of the app (sldId 330) + +mTLS, JWT validation, rate limiting, audit logging — a companion process in the same Pod handles them all. + +p. 62 + +> The sidecar pattern — a separate container running in the same Pod as the application, sharing localhost. The sidecar handles cross-cutting security concerns: terminates mTLS, validates JWTs, rate-limits, audits, enforces policy. The app receives only authenticated, authorised requests with trusted headers attached (X-Forwarded-Client-Cert, the validated JWT claims, the request ID). Why this matters: one security policy applied uniformly across every workload regardless of language. Same Envoy sidecar protects your Java app, your Python app, your Go app. App teams write business code, not crypto code. The sidecar is enforced by the platform (Istio injects them automatically) so it can't be disabled by accident. Examples in the field: Istio Envoy sidecars (the dominant choice), Linkerd2-proxy, OPA Sidecar for policy decisions, Cilium L7 with per-endpoint policy. + +## [076] SECURITY · SIDECAR · Reading the sidecar's trusted headers (sldId 331) + +- The sidecar handles mTLS, JWT validation, and policy decisions — the app trusts the headers the sidecar attaches and reads them as plain values. +- X-Forwarded-Client-Cert — the validated client identity from mTLS. Includes the SPIFFE ID (spiffe://cluster.local/ns/orders/sa/order-service) or the X.509 Subject. Set only by the sidecar; the app must reject requests that don't have it. +- JWT claims — the sidecar validates the signature, then passes the claims forward as a header or sets request.auth.claims via an envoy filter. The app reads sub, aud, scope, custom claims; it never re-validates the signature. +- Request ID — the sidecar attaches X-Request-Id for traceability. Log it; propagate it to downstream calls; correlate it with audit logs. +- Mode: only trust these headers when the sidecar is in front. In direct-to-Pod traffic (port-forward, debugging, mistakes), they could be spoofed. Use a NetworkPolicy to ensure traffic only enters via the sidecar's mTLS port. + - Examples: Istio Envoy sidecars (auto-injected), Linkerd2-proxy, OPA Sidecar (policy decisions), Cilium L7 policy. License: all Apache 2.0. + +p. 63 + +> The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches. The most common failure mode is an app that ignores X-Forwarded-Client-Cert and falls back to 'allow if you got here.' Make the headers required, not optional. The second-most-common failure mode is forgetting that traffic can sometimes bypass the sidecar (port-forward for debugging, host networking, direct pod-to-pod calls within the same node). NetworkPolicy is what closes that gap — only allow inbound traffic on the sidecar's mTLS port, never the app's container port directly. With both the sidecar and the NetworkPolicy, the app can fully trust the headers and become much simpler — no crypto code, no JWT parsing library, just header reads. + +## [077] SECURITY · VALET KEY · Valet key pattern — short-lived signed token, direct access (sldId 332) + +App mints a scoped, time-bound token; client uses it directly against the resource. App never proxies the bytes. + +p. 64 + +> The valet key pattern: instead of streaming a large file through your application, the application mints a scoped, time-bound, operation-restricted token (a 'valet key') and the client uses it to interact directly with the storage system. Pros: dramatically less data through the application (memory, bandwidth, latency); no long-lived credentials at the client; no app-side credential to leak; the token can expire and be revoked. Cons: requires the storage side to validate tokens; the token's permissions and TTL must be carefully scoped — a leaked token still has its window. Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape. The code shows the pattern in the service's language. + +## [078] SECURITY · VALET KEY · Valet key — scoped, time-bound, op-restricted (sldId 333) + +[chip] java · Quarkus REST + javax.crypto (HmacSHA256) + +```java +// ValetKeyService.java (excerpt), examples/12-security/quarkus: HMAC-SHA256 valet key +@ApplicationScoped +public class ValetKeyService { + private static final int TTL_SECONDS = 300; // time-bound + private final byte[] secret; // VALET_SECRET, injected through @ConfigProperty + public Map mint(String resource, String operation) { + long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS; + String payload = resource + ":" + operation + ":" + expires; // one object, one op + Map result = new LinkedHashMap<>(); + result.put("resource", resource); + result.put("operation", operation); + result.put("expires", expires); + result.put("token", hmac(payload)); // HmacSHA256, hex-encoded + return result; + } + public boolean verify(String resource, String operation, long expires, String token) { + if (System.currentTimeMillis() / 1000 > expires) return false; // expired + String expected = hmac(resource + ":" + operation + ":" + expires); + return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time + } +} +// OrderResource maps POST /valet-key to mint() and GET /verify-valet to verify() +``` + +p. 65 + +> The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (javax.crypto.Mac with HmacSHA256). POST /valet-key returns the resource, the operation, the expiry and the token. +> +> The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with MessageDigest.isEqual, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403. +> +> The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth. + +## [079] SECURITY · STRANGLER FIG · Strangler Fig for security modernisation (sldId 334) + +- Use case: encapsulate legacy authentication (LDAP-only, HTTP Basic, hardcoded session cookies) behind a modern API gateway, then replace the legacy auth path one service at a time. +- Step 0: introduce the gateway in front of the monolith. Clients now hit the gateway, which forwards to the legacy auth. Day-zero behaviour unchanged. +- Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Dex), and translates to whatever the legacy expects; it speaks both languages. +- Step 2: per route, switch the gateway from 'forward legacy headers' to 'forward only the JWT claims to the new service'. Legacy auth is no longer in the path for that route. +- Step 3: when no routes still need legacy auth, remove it. +- Bounded blast radius: each route's auth migration is a config change in the gateway, reversible without a release. + - See Appendix K for the general strangler-fig family (identify → move → redirect, with proxy, with shared database, with content-based routing). Same mechanics, applied to auth instead of business logic. + +p. 66 + +> The strangler fig was covered as a general pattern in Appendix K — here it shows up specifically for security modernisation, which is a common need: most enterprises have legacy auth (LDAP-only, custom SSO, NTLM, hardcoded API keys) buried in critical systems, and a 'big bang' replacement is too risky. The gateway becomes the bilingual translator: it validates modern JWTs from your IdP at the edge and translates to whatever the legacy backend expects (X-Remote-User header, basic auth, NTLM, custom session cookie). Per route, you migrate when the new path is verified — if anything breaks, flip the gateway config back to forwarding legacy headers. The sub-bullet points to Appendix K for the full mechanics; this slide is the application of those mechanics to a specific security need. + +## [080] SECURITY · ZERO-TRUST · Zero-trust / Identity-Aware Proxy (sldId 335) + +Every request authenticated and authorised, on every hop. Perimeter trust is dead. SPIFFE inside, IAP at the edge. + +p. 67 + +> The model: there is no trusted network. Every request, from any source, to any service, on every hop — authenticated and authorised. The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance. The new model (bottom): the user authenticates to an IAP (Identity-Aware Proxy) which evaluates identity, device posture, location, MFA. The IAP then forwards to a policy engine (OPA, Cedar) which returns an allow/deny per-request. The downstream service still authenticates the caller (mTLS + SPIFFE ID) and re-authorises against policy. Inside the cluster: mTLS via SPIFFE identity (every workload has an X.509 cert identifying its service account), AuthorizationPolicy per workload restricting which other workloads can call which endpoints. A breach in one service gives you nothing because the next call still has to prove identity and pass policy. + +## [081] SECURITY · ZERO-TRUST · Implementing zero-trust (sldId 336) + +- Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — they terminate TLS, validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT the downstream service sees in X-Forwarded-* headers. +- Inside the cluster (mTLS): Istio + SPIFFE assigns every workload an X.509 cert identifying its service account (spiffe://cluster.local/ns/orders/sa/order-service). Every service-to-service call is mutually authenticated automatically. +- Authorization: Istio AuthorizationPolicy declares 'order-service can call payment-service /charge but nothing else, only when carrying a valid customer JWT.' Per-workload, fine-grained, declarative. +- Policy decisions: OPA / Cedar evaluate context (user, time, device, target, action) and return allow/deny. Out-of-process so policy can change without redeploying every service. +- App-side discipline: read the validated peer identity from X-Forwarded-Client-Cert; never accept calls that arrive without it; log every authorization decision. + - License notes: Istio, SPIFFE/SPIRE, OPA, Cedar — all open source (Apache 2.0 or similar). Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too. + +p. 68 + +> Concrete implementations to ground the model. The edge IAP is what your users hit — they authenticate against your IdP (Keycloak, or Dex federating an existing directory), the IAP issues a session, every request from then on carries identity. Inside the cluster, SPIFFE is the service-identity mechanism that makes mTLS work without manual cert management — it's what gives every Pod a verifiable identity. Istio's AuthorizationPolicy is the declarative layer on top: 'who can call what.' OPA / Cedar is the more flexible policy engine when your decision logic is complex (per-tenant, per-time-of-day, per-data-classification). The bold line is the app-side discipline: don't make these external policies redundant by accepting unauthenticated calls in your code; use the peer identity from the headers as a required input. + +## [082] SECURITY · POLICY-AS-CODE · Policy-as-Code — security gates in Git (sldId 337) + +OPA Gatekeeper / Kyverno enforce declarative policies at admission. CI runs the same policies pre-merge. + +p. 69 + +> Security policies as code: declarative, version-controlled, tested, applied automatically. The flow on the diagram: a developer commits manifests; CI runs conftest or Kyverno against the manifests pre-merge (catches violations before they reach the cluster); on `kubectl apply` (or ArgoCD / Flux), the Kubernetes API server calls the admission controller (OPA Gatekeeper / Kyverno) which evaluates the ConstraintTemplate / ClusterPolicy against the incoming object. Non-conformant resources are rejected — they never become running Pods. The policy file lives in Git, reviewed like code, applied by the cluster. No human runs a checklist; consistency is enforced uniformly. Example policies: images must come from approved registries, no privileged containers, no host network, no host path mounts, all containers have resource limits, all images are signed. + +## [083] SECURITY · POLICY-AS-CODE · OPA Gatekeeper ConstraintTemplate (Rego) (sldId 338) + +[chip] yaml · OPA Gatekeeper (language-neutral) + +```yaml +# ConstraintTemplate — reusable policy logic in Rego +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8srequiresignedimages +spec: + # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array) + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiresignedimages + violation[{"msg": msg}] { + container := input.review.object.spec.containers[_] + not startswith(container.image, input.parameters.allowedRegistries[_]) + msg := sprintf("image %v not from an allowed registry", [container.image]) + } +--- +# Constraint — applies the policy to namespaces +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sRequireSignedImages +metadata: { name: must-be-signed } +spec: + match: + kinds: [{apiGroups: [""], kinds: ["Pod"]}] + excludedNamespaces: ["kube-system", "gatekeeper-system"] + parameters: + allowedRegistries: ["registry.acme.com/", "quay.io/redhat/"] +``` + +p. 70 + +> The two-piece pattern: ConstraintTemplate defines the logic (in Rego, OPA's policy language), Constraint is the instance that applies the logic to specific resources with specific parameters. The Rego in violation[{msg}] reads as 'for every container in the Pod, if its image doesn't start with any of the allowed registries, emit a violation with this message.' Gatekeeper translates that into an admission webhook that rejects non-conformant Pods before they're scheduled. The Constraint binds the template to the kinds (Pods) and namespaces (everything except system namespaces), with the allowedRegistries list as the parameter. Lives in Git, applied via `kubectl apply` (or ArgoCD), versioned and reviewed like code. An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic. + +## [084] SECURITY · BULKHEAD · Bulkhead — isolate resources, bound the blast radius (sldId 339) + +Per-tenant / per-call / per-priority pools. One tenant's storm cannot starve others. One compromise cannot cascade. + +p. 71 + +> Bulkhead: borrowed from naval architecture, where watertight compartments stop one breach from sinking the ship. In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. The left side of the diagram (red) shows the default: one shared pool means a single tenant's traffic storm starves everyone else's. The right side: separate pools, each with its own bounded size. A storm from tenant A consumes tenant A's pool and rejects further A requests — tenants B and background workloads keep running. Why this matters for security as well as resilience: a compromised tenant or a compromised endpoint can be used to mount a denial-of-service against the shared system; bulkheads cap that to its own pool. Implementations: Resilience4j Bulkhead (JVM), Polly (.NET), asyncio.Semaphore (Python), Istio per-destination connection pools at the mesh layer. + +## [085] SECURITY · BULKHEAD · SmallRye Fault Tolerance bulkheads (sldId 340) + +[chip] java · Quarkus + SmallRye Fault Tolerance (MicroProfile) + +```java +// OrderService.java — declarative bulkhead (SmallRye Fault Tolerance) +@ApplicationScoped +public class OrderService { + @Inject @RestClient PaymentClient payments; + @Bulkhead(value = 50, waitingTaskQueue = 100) // 50 concurrent, 100 waiting + @Timeout(3000) // fail fast after 3 s + @AsynchronousNonBlocking // Uni return, no worker thread + public Uni place(OrderRequest req) { + return payments.charge(req).map(p -> Order.from(req, p)); + } +} +// TenantBulkhead.java — one semaphore per tenant (as examples/12-security) +@ApplicationScoped +public class TenantBulkhead { + private final ConcurrentMap sems = new ConcurrentHashMap<>(); + public T handle(String tenant, Callable work) throws Exception { + Semaphore sem = sems.computeIfAbsent(tenant, t -> new Semaphore(5)); + if (!sem.tryAcquire()) throw new BulkheadException(tenant); // this tenant only + try { return work.call(); } finally { sem.release(); } + } +} +``` + +p. 72 + +> Quarkus ships SmallRye Fault Tolerance, the MicroProfile Fault Tolerance implementation. @Bulkhead(value = 50, waitingTaskQueue = 100) admits at most 50 concurrent invocations, queues up to 100 and rejects the rest; @Timeout fails the call after three seconds. @AsynchronousNonBlocking guards a method that returns a Mutiny Uni without moving it to a worker thread, which is what lets the waiting queue apply. +> +> Per-tenant bulkheads need one limit per tenant, so the example keeps a semaphore per tenant: a tenant that fills its 5 slots gets a BulkheadException while every other tenant keeps its capacity. Fault-tolerance metrics are exported through Micrometer, so dashboards can show saturation. At the network layer, Istio's DestinationRule connectionPool adds mesh-level bulkheading on top of these in-process limits. + +## [086] SECURITY · CLAIM-CHECK · Claim-check — send a reference, not the payload (sldId 341) + +Large or sensitive payload goes to a secure store; the message bus carries only an opaque token to fetch it. + +p. 73 + +> The claim-check pattern: a producer puts the (large or sensitive) payload into a secure object store and publishes only a claim ticket (an opaque token plus a fetch URI) to the message bus. The consumer receives the ticket, fetches the payload from the store with the token. Two big security wins: PII never sits in Kafka's topic history (which is replicated, retained, and possibly visible to operators and other tools); and the token can expire and be revoked, while a Kafka message cannot. The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security. Compare to the write-around caching pattern: same shape, different motivation. Where: any flow where sensitive payloads (health records, financial documents, customer PII) cross messaging infrastructure that shouldn't see them; any flow where payloads are large enough that the bus would be the bottleneck. + +## [087] SECURITY · CLAIM-CHECK · Producer mints claim, consumer fetches payload (sldId 342) + +[chip] java · Quarkus + SmallRye Reactive Messaging + +```java +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +@ApplicationScoped +public class ClaimPublisher { + @Inject ObjectStore store; // any encrypted object store + @Inject ValetKeyService valet; // examples/12-security + @Inject @Channel("documents-out") Emitter claims; // topic order.documents + public void publish(String orderId, byte[] body) { + String key = "payloads/" + orderId; + store.put(key, body, true); // encrypted at rest, ACL'd + JsonObject claim = new JsonObject(valet.mint(key, "GET")) + .put("order_id", orderId) + .put("uri", "https://objects.internal/" + key); + claims.send(claim); // only the claim on the topic + } +} +@ApplicationScoped +public class ClaimConsumer { + @Inject ObjectStore store; + @Incoming("documents-in") // topic order.documents + public void onClaim(JsonObject claim) { + byte[] body = store.get(claim.getString("uri"), claim.getString("token")); + handle(body); // PII never touched the topic + } +} +``` + +p. 74 + +> Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's ValetKeyService.mint (300 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with a Reactive Messaging Emitter. The PII is never on Kafka's wire. +> +> The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give. +> +> Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing. + +## [088] SECURITY · Choosing the right pattern (sldId 343) + +- Sidecar — when you want one security policy uniformly across every workload, regardless of language. Cost: an extra container per Pod (memory + startup time). +- Valet Key — when clients need direct access to a large or specific resource (file upload, image download). Cost: storage system must validate tokens (most do). +- Strangler Fig (auth) — when you have legacy authentication you can't change all at once. Cost: a gateway running translation logic during the transition. +- Zero-Trust / IAP — the default model for any system with users outside your immediate control. Cost: mTLS everywhere has real CPU and operational overhead. +- Policy-as-Code — always. Always. The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno. +- Bulkhead — any multi-tenant system, any system with mixed-priority workloads. Cost: pools are extra moving parts to size and tune. +- Claim-Check — when payloads carry PII or are large enough to bloat the message bus. Cost: an extra fetch hop and the store-and-bus must agree on token semantics. +- Most production systems combine all of these, not as a checklist but as a decision per concern. + +p. 75 + +> Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a few MB of memory, a couple hundred ms of startup); for most systems the security uniformity makes it worth that. Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry. Strangler fig (auth) is a deliberate transition strategy; the gateway running translation is the cost. Zero-trust / mTLS everywhere has real CPU overhead (TLS handshakes are not free) — mesh implementations like Istio amortise it well but it's not zero. Policy-as-code's cost is the upfront investment in writing policies; the ongoing cost is near zero. Bulkheads are extra moving parts to tune. Claim-check adds a hop. The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist. + +## [089] SECURITY · Take-aways and references (sldId 344) + +- Defence in depth across the 4 C's: cloud, cluster, container, code. A control at the wrong layer is false confidence. +- Identity is the new perimeter. mTLS + SPIFFE inside; IAP / OIDC at the edge. Every request authenticated and authorised, on every hop. +- Policy in Git, enforced by the cluster. OPA Gatekeeper or Kyverno at admission; conftest in CI for early feedback. +- PII never crosses your message bus in cleartext. Claim-check or end-to-end encrypt. +- Isolate by tenant, by call, by priority. Bulkheads bound the blast radius of both incidents and outages. + - References: NIST SP 800-204A (microservices security); CNCF Cloud Native Security Whitepaper; OWASP API Security Top 10; the OpenSSF SLSA framework; Istio AuthorizationPolicy docs; OPA Gatekeeper library; OWASP Microservices Security Cheat Sheet. + - Open-source pillars: Istio (Apache 2.0), SPIFFE/SPIRE (Apache 2.0), OPA / Gatekeeper (Apache 2.0), Kyverno (Apache 2.0), Sigstore (Apache 2.0), Falco (Apache 2.0), Trivy (Apache 2.0). + +p. 76 + +> Close with the take-aways the audience should remember. Defence in depth across the 4 C's is the philosophical anchor. Identity-as-perimeter is the operational anchor. Policy-in-Git is the automation anchor. The PII-never-in-cleartext and bulkhead lines are the specific applications. The reference list is what you'd recommend for further reading — NIST 800-204A is the canonical microservices security paper; CNCF's Cloud Native Security Whitepaper organises the same material; OWASP API Top 10 is the application-layer reference; SLSA covers supply-chain security (signing, provenance, build integrity). The open-source list emphasises that the entire security stack is buildable with Apache 2.0 components. + +## [090] Anti-Patterns (sldId 345) + +13 + +Most outages re-couple what you separated. + +> The final main section, and the most memorable framing of the whole talk: nearly every cloud-native failure is coupling sneaking back in. You did the work to separate things; entropy and deadlines pull them back together. This section names the specific ways that happens and gives heuristics to catch it early. + +## [091] ANTI-PATTERNS · How cloud-native API systems go wrong (sldId 346) + +Each anti-pattern reintroduces the coupling the architecture worked to remove. + +p. 77 + +> Walk the grid briefly — most of these are callbacks to earlier sections. Distributed monolith (services that must deploy together), shared database (two services on one table), chatty calls (deep synchronous chains), no contract/leaky internal models (DB rows as the public API), missing observability (debugging by guesswork), retry storms (no backoff). The unifying diagnosis is on screen: each one reintroduces the coupling the architecture worked to remove. If you only remember one slide for code review, it's this one. The anti-pattern heuristics turn these into positive rules. + +## [092] ANTI-PATTERNS · The heuristics that keep you out of them (sldId 347) + +- If two services must deploy together, they are one service — merge them or fix the contract. +- One writer per dataset; everyone else reads through the API or subscribes to its events. +- Push aggregation to a gateway/BFF; forbid service-to-service call chains deeper than one hop. +- No contract in the registry, no merge — and every retry has a timeout and a bound. +- If you can't trace it, you can't run it: observability is a launch requirement, not a follow-up. + +p. 78 + +> These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or fix the contract. (2) One writer per dataset; everyone else reads through the API or subscribes to events — no shared tables. (3) Push aggregation to a gateway or BFF and forbid synchronous call chains deeper than one hop. (4) No contract in the registry, no merge — and every retry has a timeout and a bound. (5) If you can't trace it, you can't run it — observability is a launch requirement, not a follow-up. Offer these as a checklist the team can literally paste into a PR template. + +## [093] IN CLOSING · The whole talk, in five lines (sldId 348) + +- Cloud-native is a set of properties; the platform exists to make them the default. +- Match each interaction to its style (REST, gRPC, GraphQL or async) and compose at the edge. +- Own your data; share it as contracts and facts, never as shared tables. +- Make the contract a governed artifact: registry to enforce it, metadata to find it, observability to run it. +- Every anti-pattern is coupling sneaking back in. Design to keep things apart. + +p. 79 + +> The recap — read these five as the entire talk compressed. If the audience remembers nothing else: cloud-native is properties not tools; match the protocol to the interaction and compose at the edge; own your data and share it as contracts and facts; make the contract a governed artifact (registry, metadata, observability); and watch for coupling sneaking back in. Pause here before References and Q&A. If it's the long-form session, this is the natural point to say 'and now the appendices go deeper on four things people always ask about' and continue. + +## [094] REFERENCES · The shoulders this stands on (sldId 349) + +- Cloud-Native Patterns and Architecture +- Building Microservices, 2nd ed. — Sam Newman (O'Reilly) +- Designing Data-Intensive Applications, 2nd ed. — Martin Kleppmann & Chris Riccomini (O'Reilly) +- Building Event-Driven Microservices — Adam Bellemare (O'Reilly) +- Cloud Native Patterns — Cornelia Davis (Manning) +- Patterns of Distributed Systems — Unmesh Joshi (Addison-Wesley) +- Architecture Modernization — Nick Tune & Jean-Georges Perrin (Manning) +- Cloud Native Anti-Patterns — Gerald Bachlmayr +- Principles of Web API Design — James Higginbotham (Addison-Wesley) +- Patterns for API Design — Olaf Zimmermann et al. (Addison-Wesley) +- API Design Patterns — JJ Geewax (Manning) +- Learning Domain-Driven Design — Vlad Khononov (O'Reilly) +- Balancing Coupling in Software Design — Vlad Khononov (Addison-Wesley) +- Quarkus guides (quarkus.io/guides), Quarkus Flow & Debezium docs — for the runtime specifics + +p. 80 + +> The reading list this talk stands on. Call out a couple of pairings: Newman's Building Microservices for the boundaries and integration story; Kleppmann's Designing Data-Intensive Applications for the data and consistency foundations behind the outbox and saga; Bellemare for the event-driven backbone; and the API-design trio (Higginbotham, Zimmermann et al., Geewax) for contract and versioning depth. The architecture books are language-agnostic — they apply whether you build on Quarkus, Spring or anything else. The Quarkus guides plus the Quarkus Flow and Debezium docs are the practical references for the runtime specifics shown in the code. The slides and these references are enough to build the system, not only to discuss it. + +## [095] Thank you (sldId 350) + +Questions? + +> End of the main talk. Thank the audience and open for questions. The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session. They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation. + +## [096] REFERENCE · Glossary of acronyms (1 of 2) — protocols, data, events (sldId 351) + +- Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface). +- Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue, where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe). +- Data & persistence — CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log: the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used: a cache eviction policy); SETEX (the Redis "SET with EXpiry" command: sets a key and its TTL atomically); ORM (Object-Relational Mapping); JPA (Jakarta Persistence API); Panache (Quarkus's ORM-simplification layer over Hibernate, not an acronym). +- Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service: a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD, which translates between models; ACL also means Access Control List in security); Saga (not an acronym: a sequence of local transactions with compensating actions). + +p. 81 + +> Glossary part 1, the protocol/data/event vocabulary. Call out the ones most likely to trip people up: gRPC is recursively 'gRPC Remote Procedure Call' but practically it's Google's high-performance RPC over HTTP/2 with Protocol Buffers. mTLS (mutual TLS) is the one people often gloss — ordinary TLS authenticates the server to the client; mutual TLS also authenticates the client to the server, which is the basis of zero-trust service identity. The data row has the SETEX and TTL the audience asked about explicitly: TTL is the expiry duration on a cached key; SETEX is the Redis command that sets a value and its TTL in one atomic operation (SETEX key seconds value). LRU is the eviction policy that drops the least-recently-touched key when the cache is full. CDC and WAL go together — CDC tails the WAL. Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List. + +## [097] REFERENCE · Glossary of acronyms (2 of 2) — platform, security, telemetry (sldId 352) + +- Kubernetes & cloud — K8s (Kubernetes); RBAC (Role-Based Access Control); HPA (Horizontal Pod Autoscaler); KEDA (Kubernetes Event-Driven Autoscaling); PSS (Pod Security Standards); CRD (Custom Resource Definition); IAM (Identity & Access Management); KMS (Key Management Service); VPC (Virtual Private Cloud); CNI (Container Network Interface). +- Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials). +- Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency). +- Runtime & build — JVM (Java Virtual Machine); JIT (Just-In-Time compilation); AOT (Ahead-Of-Time compilation, as in a GraalVM or Mandrel native image); GC (Garbage Collection); CDI (Contexts & Dependency Injection); DI (Dependency Injection); DTO (Data Transfer Object); POJO (Plain Old Java Object). + +p. 82 + +> Glossary part 2, the platform/security/observability vocabulary. The Kubernetes row: HPA scales on CPU/memory, KEDA scales on event sources (queue depth, Kafka lag, HTTP rate) — the distinction matters in the autoscaling discussion. PSS (Pod Security Standards) replaced the deprecated PodSecurityPolicy. The security row has the terms from section 12: SPIFFE/SPIRE give workloads cryptographic identities for mTLS; OPA is the policy engine behind policy-as-code; IAP is the zero-trust edge proxy; JWT/OIDC/OAuth are the token/identity standards; SLSA/CVE/SBOM are supply-chain security. The observability row has RED and USE, the two metrics methods worth memorising: RED (Rate, Errors, Duration) for services, USE (Utilisation, Saturation, Errors) for resources. p99 means the 99th-percentile latency — 99% of requests are faster than this; it's what you alert on, not the average. The runtime row is tailored to this deck's language. + +## [098] Appendix A · Protocols in Depth (sldId 353) + +A + +REST, gRPC and GraphQL: how they differ. + +> Appendix A. This is the deep dive behind the 'pick the protocol' slide from section 2. We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire. By the end the audience should be able to defend a protocol choice on technical grounds, not fashion. + +## [099] APPENDIX A · PROTOCOLS · Three paradigms and their mental models (sldId 354) + +REST models resources, gRPC models procedures, GraphQL models a typed graph — the same intent, three shapes. + +p. 83 + +> Start with the mental model, because it drives everything else. REST is resource-oriented: nouns and HTTP verbs, you transfer representations of state. gRPC is procedure-oriented: you call typed methods, it's behaviour not state, and the .proto is the contract. GraphQL is graph-oriented: one typed schema the client traverses, picking exactly the fields it wants. The same place_order intent takes three different shapes. Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction. + +## [100] APPENDIX A · PROTOCOLS · Over-fetching, under-fetching and round trips (sldId 355) + +REST trades round trips or bespoke endpoints for simplicity; GraphQL shapes one response; gRPC is a typed call. + +p. 84 + +> This is the practical difference clients feel. With REST you often need several round trips (order, then its items, then stock, then shipping) or you build a bespoke aggregate endpoint — and you still get fixed payload shapes that over- or under-fetch. GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three. The trade: GraphQL gives clients flexibility at the cost of gateway complexity; gRPC gives speed at the cost of rigidity; REST gives reach and simplicity at the cost of round trips. + +## [101] APPENDIX A · PROTOCOLS · The wire: transport, encoding and streaming (sldId 356) + +Reach and caching, or raw speed, or client-shaped responses — choose on the wire properties you need. + +p. 85 + +> What travels on the wire, and why it constrains your choice. REST: HTTP/1.1 or /2, JSON text, request/response, cacheable via standard HTTP, browser-native — maximum reach. gRPC: HTTP/2 only, protobuf binary, full uni- and bidirectional streaming, no HTTP caching, and it needs grpc-web to work from a browser. GraphQL: HTTP POST to a single URL, JSON, request/response plus subscriptions, caching is application-level (not HTTP), browser-native. The decision rule: if you need broad reach and HTTP caching, REST; if you need raw speed and streaming between services you control, gRPC; if you need client-shaped responses over many sources, GraphQL. + +## [102] APPENDIX A · PROTOCOLS · Choosing between them in practice (sldId 357) + +- Public or partner-facing, browser or third-party clients, caching matters → REST. +- Internal service-to-service, low latency, high call volume, streaming → gRPC. +- Aggregating many back-ends for varied clients that each want different fields → GraphQL at the gateway. +- A fact that many consumers react to, decoupled in time → none of these; emit an event. +- Real systems use all four; standardizing on one wire for everything is itself an anti-pattern. + +p. 86 + +> The decision guide. Public/partner/browser with caching → REST. Internal, hot, streaming → gRPC. Client-driven aggregation across back-ends → GraphQL at the gateway. A fact many consumers react to → don't make it a call at all, emit an event. The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done. + +## [103] APPENDIX A · PROTOCOLS · Common mistakes with each (sldId 358) + +- REST: verbs in URLs (/createOrder), 200-for-everything, offset pagination, and ignoring cache headers. +- gRPC: exposing it directly to browsers without grpc-web; treating it as REST-with-protobuf and ignoring streaming. +- GraphQL: the N+1 resolver explosion; no query depth/complexity limits; using it for simple single-resource reads. +- All three: leaking internal database models straight onto the wire, so you can never evolve them. +- Async: using a queue when you needed a log (no replay), or a request when you needed a fact. + +p. 87 + +> The failure modes, drawn from real review comments. REST: verbs in URLs, returning 200 with an error body, offset pagination that breaks under load, and throwing away free HTTP caching. gRPC: trying to call it straight from a browser without grpc-web, or using it as 'REST with protobuf' and never touching its streaming superpower. GraphQL: the classic N+1 resolver explosion (fix with DataLoader batching), no depth or complexity limits so a malicious query melts the gateway, and reaching for it when a plain GET would do. The universal one is the last bold-adjacent: leaking your database schema onto the wire kills your ability to evolve — the same leaky-internal-models anti-pattern from section 12. + +## [104] Appendix B · Versioning & Evolution (sldId 359) + +B + +REST vs RESTful, proto breaking changes, GraphQL evolution. + +> Appendix B answers the question every team fights about: how do we version this without breaking everyone? The answer differs sharply by protocol. REST has several strategies with real trade-offs; gRPC has precise wire-compatibility rules; GraphQL rejects versioning entirely in favour of evolution. We'll see each, with code. + +## [105] APPENDIX B · VERSIONING · Four ways to version a REST API (sldId 360) + +URI versioning is common and visible; media-type versioning keeps URLs stable — the more RESTful choice. + +p. 88 + +> REST has four strategies, ordered from coarse to fine. URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation. Query param: simple to default but muddies caches. Custom header: keeps URLs clean but is invisible and often forgotten. Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach. In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct. Know which you're choosing and why. The media-type versioning code shows how. + +## [106] APPENDIX B · VERSIONING · Media-type versioning with Quarkus REST (sldId 361) + +[chip] java · Quarkus REST + +```java +@Path("/orders") +public class OrderResource { + static final String V1 = "application/vnd.acme.order+json"; // legacy + static final String V2 = "application/vnd.acme.order.v2+json"; // adds currency + @GET @Path("/{id}") @Produces(V2) // one stable URL — the resource + public Uni getV2(String id) { // chosen by Accept: …v2+json + return orders.get(id).map(OrderV2::from); + } + @GET @Path("/{id}") @Produces(V1) // same path, different representation + public Uni getV1(String id) { // default for old clients + return orders.get(id).map(OrderV1::from); + } +} +// JAX-RS content negotiation routes on Accept; the URL never changes. +``` + +The resource URL is stable; the representation is negotiated. New clients ask for v2; old clients keep getting v1. + +p. 89 + +> This makes 'RESTful versioning' concrete in Quarkus. There's one URL for the order resource — it never changes. Two methods share the path but declare different @Produces media types; JAX-RS content negotiation picks the method matching the client's Accept header and sets the Content-Type. New clients ask for the v2 media type and get the currency field; old clients send the default and get v1, completely unaffected. Contrast with /v2/orders, where you've now got two URLs for the same underlying thing. The OrderV1/OrderV2 records are explicit representation DTOs — exactly the pattern that keeps your wire contract decoupled from your entity. + +## [107] APPENDIX B · VERSIONING · gRPC / protobuf: what is a breaking change (sldId 362) + +Field numbers are the contract — names and order are not. Never reuse or renumber a field. + +p. 90 + +> gRPC's rules are precise because the wire format is positional. Safe, wire-compatible changes: add a field with a new number, add an rpc method, add an enum value, rename a field (the number is what matters, not the name), mark fields deprecated, and reserve removed tags. Breaking changes: change a field's number or type, reuse a removed number, remove a field without reserving its tag, rename or move a service or package, or change the request/response message type. The one people miss is on screen at the bottom: changing a method between unary and any streaming mode changes the HTTP/2 framing and is always breaking. The discipline: field numbers are forever; never reuse or renumber. Code shows safe evolution next. + +## [108] APPENDIX B · VERSIONING · Evolving a .proto safely (sldId 363) + +[chip] protobuf + +```protobuf +syntax = "proto3"; +message Order { + string id = 1; + string sku = 2; + int32 qty = 3; + reserved 4; // tag 4 once held 'notes' — never reuse it + reserved "notes"; + string currency = 5; // safe: brand-new field, new tag number + PaymentState state = 6; // safe: new field; old clients ignore it +} +enum PaymentState { PENDING = 0; PAID = 1; REFUNDED = 2; } // 0 must exist +// breaking (don't): qty -> int64, or moving 'sku' to tag 7, or reusing tag 4 +``` + +Add with new tags, reserve what you remove, and never change an existing field's number or type. + +p. 91 + +> Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire. We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work. Note the enum must keep a zero value as its default. The comment at the bottom lists the things that would break the wire: widening qty's type, moving an existing field to a new tag, or reusing a reserved tag. Apicurio's compatibility check (section 9) enforces exactly these rules in CI. + +## [109] APPENDIX B · VERSIONING · GraphQL versions by evolution, not by number (sldId 364) + +There is no /v2 in GraphQL: add fields, mark old ones @deprecated, watch usage, then retire. + +p. 92 + +> GraphQL takes a completely different stance: there are no versions. There's one schema that evolves continuously. The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero. The insight: GraphQL pushes you toward data-driven deprecation instead of big-bang version cutovers. Code shows the @deprecated directive next. + +## [110] APPENDIX B · VERSIONING · Deprecating a field in SmallRye GraphQL (sldId 365) + +[chip] java · SmallRye GraphQL + +```java +public record Order( + String id, + @Deprecated(since = "2026-05") // shows in the schema + UI + @Description("Use totalMinor (integer cents) instead.") + double total, // still served + int totalMinor // replacement, added additively +) {} +// @Deprecated surfaces in introspection and GraphiQL with the reason. +// Field-level usage metrics tell you when 'total' has zero callers — +// only then do you remove it. No /v2 endpoint ever exists. +``` + +Additive change plus @Deprecated plus usage metrics replaces versioning entirely. + +p. 93 + +> The mechanics in SmallRye GraphQL. We add totalMinor additively and mark the old total field with Java's @Deprecated plus a @Description carrying the reason; SmallRye maps these onto GraphQL's @deprecated schema directive. The field keeps working and nothing breaks, but the deprecation shows up in introspection and in GraphiQL, nudging client developers. The crucial operational piece is the comment: you instrument field-level usage, and you only remove total when its usage drops to zero. That's the whole GraphQL philosophy — evolution governed by data, with the breaking removal deferred until provably safe. Each protocol answers the same problem differently. + +## [111] APPENDIX B · VERSIONING · Principles that cut across all three (sldId 366) + +- Additive change is always safe; removal and mutation are where you break people. +- Make compatibility mechanical — the registry's BACKWARD/FORWARD/FULL rules, checked in CI, not by reviewers' memory. +- Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. +- Run old and new in parallel and migrate consumers before retiring — retirement is the only step that can break. +- Version data the same way you version APIs: event schemas in the registry follow the identical rules. + +p. 94 + +> Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break people — so design to add. Make compatibility mechanical: the registry's rules checked in CI, never a reviewer remembering. Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing. Run old and new in parallel and migrate before you retire, because retirement is the only inherently breaking move. And the bold line that connects this back to the event-driven section: your event schemas version by exactly the same rules — the registry treats an Avro schema and an OpenAPI spec identically. Versioning data and versioning APIs are the same discipline. + +## [112] Appendix C · WebSockets at Scale (sldId 367) + +C + +WebSockets Next, pod pinning, the backplane, binary framing, reconnection. + +> Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes. WebSockets are long-lived, stateful and pinned to a pod — the opposite of the stateless request model the platform assumes. We'll build one with Quarkus WebSockets Next, see why it fights scaling, fix it with a backplane, make it fast and versioned with protobuf, and make it survivable with reconnection. + +## [113] APPENDIX C · WEBSOCKETS · A WebSocket endpoint with WebSockets Next (sldId 368) + +[chip] java · Quarkus WebSockets Next + +```java +@WebSocket(path = "/ws/{userId}") // quarkus-websockets-next +public class OrderSocket { + @Inject WebSocketConnection connection; // this pod owns this connection + @Inject OpenConnections connections; // per-pod registry — not shared! + @OnOpen + public void onOpen(@PathParam String userId) { + connection.userData().put(KEY_USER, userId); + } + @OnTextMessage // full-duplex, long-lived + public Uni onMessage(ClientMsg msg) { + return handle(msg); + } + // @OnClose cleanup is automatic; OpenConnections tracks live sockets +} +``` + +Declarative, but OpenConnections is per-pod. That single fact is the entire scaling problem. + +p. 95 + +> WebSockets Next makes the endpoint declarative: @WebSocket with a path, @OnOpen, @OnTextMessage (with automatic JSON binding to a record), and automatic @OnClose cleanup — far cleaner than the old Jakarta WebSocket API. But point hard at OpenConnections — the registry of who's connected lives in this pod's memory. That one design fact drives everything in this appendix: the connection and the knowledge of who's connected live on exactly one pod. Everything stateless about HTTP (any replica can serve any request) is now false. The scaling problem follows from that. + +## [114] APPENDIX C · WEBSOCKETS · Why WebSockets fight Kubernetes scaling (sldId 369) + +A long-lived connection pins to one pod; new pods get no existing connections and scale-down drops them. + +p. 96 + +> Here's the collision with Kubernetes. A WebSocket connection, once established through the load balancer, is pinned to one pod for its entire lifetime. Two problems follow. Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have. Scale-down and rollouts: terminating a pod severs every connection it was holding, so a routine deploy disconnects thousands of users at once. Stateless HTTP shrugs both off; WebSockets don't. The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling. + +## [115] APPENDIX C · WEBSOCKETS · The scaling problem, stated precisely (sldId 370) + +- Pinning: a connection is bound to one pod; the LB can't move it without dropping it. +- Uneven load: new replicas only receive new connections, so existing hotspots persist. +- Rollouts hurt: every deploy or scale-down severs the connections on the affected pods. +- Cross-pod delivery: to push to user X you must reach whichever pod holds X — which you don't track. +- Connection limits & memory: each socket is held-open state; pods cap out on file descriptors, not CPU. + +p. 97 + +> Name the five distinct problems so the audience can recognize them. Pinning: the LB can't migrate a live socket. Uneven load: scaling adds capacity for new connections but doesn't rebalance existing ones. Rollouts hurt: every deploy is a mass disconnect event — you need graceful drain and client reconnection. Cross-pod delivery: to push a message to user X you must route to the specific pod holding X's socket, and nothing tracks that by default. And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too. The pub/sub backplane solves or eases every one of these. + +## [116] APPENDIX C · WEBSOCKETS · Scaling out: a pub/sub backplane (sldId 371) + +Each pod owns only its own sockets; a Redis or Kafka backplane fans a message out to whichever pod holds the client. + +p. 98 + +> The pattern that makes WebSockets horizontally scalable. Each pod still owns only its own sockets, which is fine, but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay. When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that holds X's socket delivers it. Now scale-up and zero-downtime rollouts work again, because no pod is the single home of a conversation — connections can land anywhere and still receive everything. Redis is the common default; Kafka if you already run it and want the durability. + +## [117] APPENDIX C · WEBSOCKETS · Binary framing with protobuf — fast and versioned (sldId 372) + +[chip] protobuf + java + +```protobuf+java +// envelope.proto — version the message, not the URL +message Envelope { + uint32 version = 1; // bump for incompatible payload changes + string type = 2; // "order.updated", "ping", ... + bytes payload = 3; // the protobuf-encoded body for `type` + uint64 seq = 4; // monotonic per-connection — drives resume +} +// server: send compact binary frames instead of JSON text +@OnBinaryMessage // Envelope bound by a BinaryMessageCodec +public Uni onFrame(Envelope env) { return dispatch(env); } +Envelope frame = Envelope.newBuilder().setVersion(2) + .setType("order.updated").setSeq(n) + .setPayload(order.toByteString()).build(); +connection.sendBinaryAndAwait(Buffer.buffer(frame.toByteArray())); +// client checks env.version; unknown major version -> reconnect & renegotiate +``` + +Protobuf frames are smaller and faster than JSON; the versioned envelope and seq number enable evolution and resume. + +p. 99 + +> Two wins in one slide. Performance: protobuf binary frames via @OnBinaryMessage and connection.sendBinary are markedly smaller and cheaper to parse than JSON text — meaningful when pushing high-frequency updates to many sockets. With a BinaryMessageCodec registered for Envelope, WebSockets Next binds the incoming bytes to the generated type. Versioning: because WebSocket messages have no URL to hang a version on, you version the envelope. A version field lets the client detect an incompatible payload format and renegotiate; the type field dispatches to the right protobuf message; and a monotonic seq number per connection is what makes reconnection and replay possible, the subject of the failure-handling slide. So the envelope solves both 'make it fast' and 'how do I version a socket message' at once. + +## [118] APPENDIX C · WEBSOCKETS · Failure handling: resume, don't restart (sldId 373) + +Heartbeats detect dead links; the client reconnects with backoff and resumes from the last acknowledged sequence. + +p. 100 + +> The cloud-native posture: treat every connection as disposable and design for its loss. Heartbeats (ping/pong) detect a dead link quickly — TCP alone can hang for minutes. When the connection drops, the client reconnects with exponential backoff plus jitter (so ten thousand clients don't reconnect in lockstep and hammer your pods after a rollout). On reconnect, the client sends its last-seen sequence number (last_seq = 1042); the server replays only the events the client missed (1043 onward), and because each frame carries a seq, the client can dedupe — so the whole thing is at-least-once and idempotent. The principle on screen: resume, don't restart. A reconnect should be invisible to the user, not a full state reload. + +## [119] APPENDIX C · WEBSOCKETS · A cloud-native WebSocket checklist (sldId 374) + +- Externalize fan-out to a backplane (Redis or Kafka); never assume one pod holds the client. +- Scale and trigger on connection count and memory, not CPU; set realistic per-pod connection caps. +- Drain gracefully on shutdown: stop accepting, signal clients to reconnect, let backoff spread the herd. +- Heartbeat to detect dead links (WebSockets Next auto-ping-interval); reconnect with exponential backoff + jitter; resume via a per-connection seq. +- Use binary frames with a versioned envelope; authenticate on connect and re-check on resume. + +p. 101 + +> The operational checklist to leave on screen. Externalize fan-out to a backplane so no pod is special. Scale and set your KEDA/HPA trigger on connection count and memory, not CPU, and cap connections per pod realistically. Drain gracefully on shutdown — stop accepting, tell clients to reconnect, and let backoff-with-jitter spread the reconnect storm across your rollout. Heartbeat to detect dead links fast; reconnect with backoff and jitter; resume from a per-connection sequence number so the user never notices. And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere. Do these and WebSockets are just another well-behaved cloud-native workload. + +## [120] Appendix D · Saga State & Compensation (sldId 375) + +D + +Persisting state, compensating cleanly, and the step-B-needs-A's-data problem. + +> Appendix D is the deep mechanics behind the saga slide in the Data section. Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them. + +## [121] APPENDIX D · SAGAS · Where a Quarkus saga keeps its state (sldId 376) + +Memory won't survive a pod restart — persist every step transition in a DB, an event log, or a workflow engine. + +p. 102 + +> First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-flight saga with no idea what to compensate. Three real options. A DB-backed state machine: one row per saga instance holding its status and a context blob; you own the transitions — simplest, and great for most cases. Event-sourced: append each step as an event and rebuild state by replay — full audit history, fits naturally with the Kafka log. A workflow engine like Temporal or Camunda: durable execution with retries and timers built in — most power, least code, but a new dependency to run. The cross-cutting rule on screen: the state transition and the outbound command must commit together (the outbox pattern again), or you get lost or duplicated steps. + +## [122] APPENDIX D · SAGAS · A persisted saga state machine (Panache) (sldId 377) + +[chip] java · Hibernate Panache + +```java +static final List STEPS = + List.of("charge_payment", "reserve_stock", "book_shipping"); +@Transactional // state + command commit together +public void advance(String sagaId) { + Saga s = Saga.findById(sagaId, LockModeType.PESSIMISTIC_WRITE); // row lock + if (!"RUNNING".equals(s.status)) return; // idempotent: already done/failed + String step = STEPS.get(s.stepIndex); + try { + var result = invoke(step, s.context); // call the service + s.context.put(step, result); // accumulate output + if (++s.stepIndex == STEPS.size()) s.status = "COMPLETED"; + } catch (StepException e) { + s.status = "COMPENSATING"; // hand off to the unwinder + } + s.persist(); // durable after every step +} +``` + +One locked row per saga, advanced one durable step at a time. A crash just means 'load the row and call advance again.' + +p. 103 + +> This is the DB-backed option made concrete with Panache, and the default choice when the team wants to own the transitions. Key ideas: one Saga entity row, loaded with a PESSIMISTIC_WRITE lock so two workers can't advance the same saga concurrently. The status guard makes advance() idempotent — calling it on a finished saga is a no-op, which matters because retries and redeliveries will call it again. Each successful step accumulates its result into the context map (the data-dependency slide builds on it) and bumps the step index; @Transactional makes the persist durable after every step, atomic with the work record. Recovery needs no special path: a scheduled sweeper loads RUNNING sagas and calls advance() again. The managed alternative is Quarkus Flow, which provides this state machine, retries and timers out of the box; the data-dependency slide shows it. + +## [123] APPENDIX D · SAGAS · Compensating transactions unwind a failed saga (sldId 378) + +Each committed step has an inverse; on failure they run in reverse order — there is no cross-service rollback. + +p. 104 + +> Compensation is how a saga 'rolls back' when there's no actual rollback available across services. Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release. When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first. Two things that bite people, both on screen: compensations must themselves be idempotent and durable, because a crash mid-unwind has to resume cleanly; and a compensation can itself fail, so it needs its own retries. The saga ends in a persisted COMPENSATED state with a full audit trail of what was done and undone. + +## [124] APPENDIX D · SAGAS · Running compensations in reverse (sldId 379) + +[chip] java · Hibernate Panache + +```java +static final Map COMPENSATIONS = Map.of( // inverse per step + "charge_payment", "refund_payment", + "reserve_stock", "release_stock", + "book_shipping", "cancel_shipping"); +@Transactional +public void compensate(String sagaId) { + Saga s = Saga.findById(sagaId, LockModeType.PESSIMISTIC_WRITE); + // undo only the steps that completed, newest first + for (String step : reversed(STEPS.subList(0, s.stepIndex))) { + invoke(COMPENSATIONS.get(step), s.context, + sagaId + ":" + step); // idempotency key — safe to retry + } + s.status = "COMPENSATED"; + s.persist(); +} +``` + +Compensate only completed steps, in reverse, with an idempotency key so a retry never double-refunds. + +p. 105 + +> The compensation logic. A simple Map gives each forward step its inverse. We iterate the completed steps (subList up to stepIndex) in reverse, newest first, and invoke each compensation. The idempotency key built from sagaId plus step is the line that prevents the nightmare scenario: a retry double-refunding a customer. The compensating service dedupes on that key. In production you'd track per-step compensation status too, so if compensation itself crashes halfway you resume from the right place — but the idempotency key makes even naive re-runs safe. This is why compensations are designed up front, as part of each step, not bolted on after. If you use Quarkus Flow instead, you model these as the workflow's try/catch compensation tasks and the engine drives the unwind. + +## [125] APPENDIX D · SAGAS · When step B needs data only step A produced (sldId 380) + +Don't make B call A. Persist A's output into the saga context and pass it forward; on resume, read it back. + +p. 106 + +> This case is a common source of bad designs. The orchestrator calls service A, then service B — but B needs a value that only A produced (say A returns a reservation_id and a warehouse region that B requires to proceed). The tempting wrong answer is to have B call A directly — now you've created a hidden synchronous dependency between B and A, exactly the chatty coupling the whole architecture fights. The right answer is on screen: the orchestrator owns a saga context (a 'blackboard'), persists A's output into it after step A, and passes the needed values into B's call. B never knows A exists. And note this is already in our state-machine code — that 's.context.put(step, result)' line is exactly this, and Quarkus Flow does it for you via the workflow data document. + +## [126] APPENDIX D · SAGAS · Carrying context forward with Quarkus Flow (sldId 381) + +[chip] java · Quarkus Flow + +```java +import io.quarkiverse.flow.Flow; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.*; +@ApplicationScoped +public class FulfillmentFlow extends Flow { // CNCF Serverless Workflow + @Override public Workflow descriptor() { + return FuncWorkflowBuilder.workflow("fulfillment") + // each task's output becomes the next task's input (the data document) + .tasks( + callFn("reserveStock", inventory::reserve), // A -> {reservationId, region} + callFn("bookShipping", shipping::book)) // B reads A's output as input + .build(); + } +} +// The engine persists the data document + position after every task, so a +// crash between A and B resumes at B with A's result intact — A is never re-run. +``` + +Flow's data document carries A's output into B; durable state means a crash resumes at B with A's data intact. + +p. 107 + +> This closes the loop and answers your question directly, using Quarkus Flow (the managed option from the state-options slide). In the CNCF Serverless Workflow model that Flow implements, each task's output becomes the next task's input: the workflow data document is the saga context, carried forward automatically. So bookShipping (B) receives reserveStock's (A's) output as its input; B never calls A, avoiding the hidden synchronous coupling. Quarkus Flow persists the data document and the current position after every task, so the crash scenario you raised resolves cleanly: the engine reloads the workflow, sees A is done and its result is in the data document, and resumes at B — A is never re-invoked, nothing is lost. The fluent task API here is illustrative; the point is the data-flow-plus-durable-state semantics. If you hand-roll it instead, that's the Panache 's.context.put(step, result)' line in the persisted-state code — same idea, you just own the plumbing. + +## [127] Appendix E · API Error Handling (sldId 382) + +E + +One discipline, four wire formats. + +> Appendix E. Error handling is where cloud-native systems fall apart, more often than the happy path. The thesis: agree one error contract (a stable machine code, a safe human message, a correlation id, a retryable flag, field details) and then map that same model onto REST, gRPC, Kafka and GraphQL. We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it. + +## [128] APPENDIX E · ERRORS · One error model, four wire formats (sldId 383) + +The same five facts in every error; only the encoding changes per protocol. + +p. 108 + +> Start with what every error must carry, regardless of protocol: a stable machine-readable code clients can branch on (not a localized string), a human-readable message safe to show, a correlation/trace id that ties the error to the distributed trace from section 11, a retryable flag (with retry-after when known), and structured field-level details. The four protocols just encode those same five facts differently — REST as problem+json, gRPC as status+details, Kafka via headers and a DLQ record, GraphQL as an errors[] entry with extensions. Agree this once as a shared contract; never leak stack traces or internal type names to callers. + +## [129] APPENDIX E · ERRORS · REST: status, then Problem Details (sldId 384) + +The status code is the category; the problem+json body carries the machine code and specifics. + +p. 109 + +> REST has two layers. First the HTTP status class, which is the category and drives client behaviour: 4xx means the client must fix the request and should not blindly retry; 429 and 503 mean 'busy' and must be paired with a Retry-After header so clients back off; 5xx means a server fault that idempotent calls may retry with backoff. Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId. Status for the category, body for the specifics. + +## [130] APPENDIX E · ERRORS · REST errors with Quarkus REST (sldId 385) + +[chip] java · Quarkus REST + +```java +public record Problem(String type, String title, int status, + String detail, String code, String traceId) {} +@ServerExceptionMapper // one place: domain -> wire +public RestResponse mapStock(StockException e) { + var body = new Problem( + "https://errors.acme.io/stock", "Insufficient stock", 409, + e.getMessage(), + "STOCK_UNAVAILABLE", // stable machine code + Span.current().getSpanContext().getTraceId()); // correlate + return RestResponse.ResponseBuilder + .create(Response.Status.CONFLICT, body) + .type("application/problem+json") // RFC 9457 + .build(); +} +``` + +Map every domain error to problem+json in one @ServerExceptionMapper — stable code, safe message, trace id; never a raw stack trace. + +p. 110 + +> A single @ServerExceptionMapper is the Quarkus REST pattern: domain code throws a typed exception, and one method maps it to the wire format, so you never scatter ad-hoc error JSON across resources. Note the media type is application/problem+json (RFC 9457), the status matches the category (409 here — a conflict the client can act on), and the body carries the stable code and the OpenTelemetry traceId for correlation. What's absent: any stack trace, exception class name, or SQL; those leak internals and are an attack surface. Register one mapper per error family, or a base-class mapper that switches on a code. + +## [131] APPENDIX E · ERRORS · gRPC: status codes + rich details (sldId 386) + +A small fixed set of canonical codes, plus structured google.rpc.Status details in the trailers. + +p. 111 + +> gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it. INVALID_ARGUMENT and NOT_FOUND are client bugs: don't retry. FAILED_PRECONDITION and ALREADY_EXISTS are state issues: don't retry. UNAVAILABLE and ABORTED are transient: retry with backoff. RESOURCE_EXHAUSTED means you're being rate-limited — honour the RetryInfo delay. DEADLINE_EXCEEDED means tune the timeout. Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else. + +## [132] APPENDIX E · ERRORS · gRPC errors with rich status details (sldId 387) + +[chip] java · Quarkus gRPC + +```java +import io.grpc.protobuf.StatusProto; +import com.google.rpc.*; +public Uni reserveStock(ReserveRequest req) { + if (inventoryDown) { + var status = com.google.rpc.Status.newBuilder() + .setCode(Code.UNAVAILABLE_VALUE) // canonical code + .setMessage("inventory temporarily down") + .addDetails(Any.pack(ErrorInfo.newBuilder() + .setReason("STOCK_DOWN").setDomain("inventory").build())) + .addDetails(Any.pack(RetryInfo.newBuilder().setRetryDelay( + Duration.newBuilder().setSeconds(2)).build())) // back off 2s + .build(); + return Uni.createFrom().failure( + StatusProto.toStatusRuntimeException(status)); + } + return stock.reserve(req).map(this::toReply); +} +``` + +Pick the canonical code (UNAVAILABLE = retryable) and attach ErrorInfo + RetryInfo so clients back off correctly. + +p. 112 + +> This is grpc-java's rich-error model via StatusProto. The canonical code UNAVAILABLE tells the client and the mesh this is transient and retryable; the google.rpc.Status carries structured details — ErrorInfo with a machine reason and domain, and RetryInfo telling the client exactly how long to wait. StatusProto.toStatusRuntimeException packs it into the trailers, and we fail the Mutiny Uni with it. The key discipline: don't overload a generic INTERNAL code for everything — the code is how automated clients and Istio decide whether and when to retry, so getting it right is what prevents both lost work and retry storms. + +## [133] APPENDIX E · ERRORS · Kafka: retry topics and a dead-letter queue (sldId 388) + +Commit the offset, route the failed record aside, and keep consuming — a poison record never blocks the partition. + +p. 113 + +> Kafka error handling has a failure mode unique to logs: a single poison record can block the whole partition forever if you keep retrying it in place, because the offset never advances — head-of-line blocking that stalls every record behind it. The fix is to move failures aside: try a bounded number of in-place retries, then route the record to a delayed-retry topic (reprocessed after a delay without blocking the live stream), and after N attempts send it to a dead-letter queue for humans or tooling. You commit the offset either way, so the main consumer keeps making progress. With SmallRye Reactive Messaging this is configuration, not hand-rolled plumbing. + +## [134] APPENDIX E · ERRORS · Kafka retry + DLQ with SmallRye Messaging (sldId 389) + +[chip] java · SmallRye Reactive Messaging + +```java +# application.properties — SmallRye Reactive Messaging failure handling +mp.messaging.incoming.orders.failure-strategy=dead-letter-queue +mp.messaging.incoming.orders.dead-letter-queue.topic=orders.DLQ +# or retry with delay first, DLQ only after the retry topics are exhausted: +# ...orders.failure-strategy=delayed-retry-topic +# ...orders.delayed-retry-topic.topics=orders.retry.5s,orders.retry.30s +@Incoming("orders") +@Retry(maxRetries = 3, delay = 200, jitter = 100) // in-place retries first +public Uni consume(Order order) { + return handle(order); // exhausted retries -> nack -> failure-strategy +} +// the offset is committed by the strategy, so head-of-line is never blocked +``` + +Bounded @Retry first, then a dead-letter-queue or delayed-retry-topic strategy — declaratively, so a poison record can't stall the partition. + +p. 114 + +> Quarkus makes this declarative. @Retry from SmallRye Fault Tolerance gives bounded in-place retries with jitter; when those are exhausted the method nacks, and the connector's failure-strategy takes over. dead-letter-queue commits the offset and writes the record to orders.DLQ; delayed-retry-topic instead forwards it through a series of delay topics (5s, 30s) for later reprocessing without blocking the live stream, DLQ-ing only after those are exhausted. Either way the offset advances, so one poison record never causes head-of-line blocking. Make handle() idempotent — at-least-once delivery means retried records may have partially applied. This is the framework version of the manual retry/DLQ loop. + +## [135] APPENDIX E · ERRORS · GraphQL: partial success with errors[] (sldId 390) + +One HTTP 200; the failed field is null and explained in errors[] with a machine code in extensions. + +p. 115 + +> GraphQL breaks the one-request-one-status assumption: a single query can touch many resolvers, so the response is almost always HTTP 200 and carries both a data object and an errors array. When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId. The rest of the data still returns. The anti-pattern to call out: blanket-failing the whole query (or returning a 500) when only one field broke — that throws away useful data the client already asked for. Partial success is a feature; let the client decide whether to retry just the failed field. + +## [136] APPENDIX E · ERRORS · GraphQL field errors with SmallRye GraphQL (sldId 391) + +[chip] java · SmallRye GraphQL + +```java +import io.smallrye.graphql.api.ErrorCode; +@ErrorCode("UNAVAILABLE") // -> errors[].extensions.code +public class InventoryUnavailable extends RuntimeException { + public InventoryUnavailable(String m) { super(m); } +} +@GraphQLApi +public class OrderApi { + public Integer stock(@Source Order order) { // one field's resolver + try { return inventory.remaining(order.sku()); } + catch (Unavailable e) { + throw new InventoryUnavailable("inventory unavailable"); // nulls field + } + } +} +# config: surface the code (and more) in errors[].extensions +quarkus.smallrye-graphql.error-extension-fields=code,retryable,traceId +``` + +Throw from the field resolver: SmallRye nulls just that field and adds it to errors[] with @ErrorCode in extensions — the rest of data still returns. + +p. 116 + +> Throwing from a single field's resolver is exactly right: SmallRye GraphQL catches it, sets that field to null, and appends an entry to errors[] with the message and path — while every other field resolves and returns normally. The @ErrorCode annotation on the exception maps straight to extensions.code, and quarkus.smallrye-graphql.error-extension-fields controls which fields (code, retryable, traceId) appear in the extensions object. Resist letting the exception bubble to the top and fail the whole operation — that's the blanket-failure anti-pattern. The client sees the order and total, sees stock is null, reads extensions.code, and decides whether to retry just stock. + +## [137] APPENDIX E · ERRORS · How a retry storm forms — and how to stop it (sldId 392) + +Naive retries multiply load on a struggling service; bounded, spaced, circuit-broken retries break the feedback loop. + +p. 117 + +> This is the failure mode that turns a brief blip into a full outage. The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries. It's a positive feedback loop, and naive retry logic is the amplifier. The controls that break it, bottom of the slide: bound every call with a timeout; cap retries to a small number and only for idempotent calls; space them with exponential backoff plus jitter (so clients don't retry in lockstep); add a circuit breaker to fail fast while the dependency is down; enforce a retry budget (cap retries as a percentage of traffic); and shed load early with 429/UNAVAILABLE rather than queueing work you can't do. In Quarkus, SmallRye Fault Tolerance (@Timeout, @Retry with jitter, @CircuitBreaker, @Bulkhead) plus the Istio config from section 2 give you these at both the code and mesh layers. + +## [138] APPENDIX E · ERRORS · Error-handling heuristics across all four (sldId 393) + +- One contract, four encodings: a stable code, a safe message, a trace id, a retryable flag, field details — everywhere. +- The category lives in the protocol: HTTP status, gRPC code, GraphQL errors[] entry, Kafka DLQ vs retry topic. +- Make retryability explicit and honour it: Retry-After, RetryInfo, extensions.retryable — don't make clients guess. +- Never leak internals: no stack traces, SQL, or class names on the wire; map them to a code and log the detail server-side. +- Bound every retry (@Timeout, @Retry + jitter, @CircuitBreaker, retry budget) and make handlers idempotent. + +p. 118 + +> The cross-cutting checklist to leave on screen. One contract expressed four ways — the same five facts whether it's problem+json, google.rpc.Status, a GraphQL extensions object, or a Kafka DLQ header. Let each protocol carry the category natively rather than inventing your own. Make retryability explicit and machine-readable so clients and the mesh back off correctly instead of guessing. Never leak internals — that's both a security issue and a coupling issue. And the bold line is the one that prevents outages: every retry is bounded and spaced (SmallRye Fault Tolerance gives you @Timeout, @Retry with jitter and @CircuitBreaker declaratively), protected by a retry budget, and every handler idempotent so a redelivered or retried request is safe. Get these right and your failure paths become as boring as your happy paths. + +## [139] Appendix F · DDD & Hexagonal Architecture (sldId 394) + +F + +Where the boundaries and the contracts come from. + +> Appendix F. The whole talk assumes you've drawn good service boundaries and know what each API should say — this is where those come from. Domain-Driven Design gives you the strategic tools to place boundaries (subdomains, bounded contexts) and the tactical tools to model what's inside (aggregates, domain events); hexagonal architecture keeps the four protocols we've discussed as mere adapters around a clean domain core. Together they answer: where does an API boundary go, what should its contract say, and how do I keep REST/gRPC/GraphQL/Kafka out of my business logic. The reference is Vlad Khononov's Learning Domain-Driven Design. + +## [140] APPENDIX F · DDD · Why DDD belongs in an API talk (sldId 395) + +- An API is a contract between two bounded contexts — so “where's the boundary?” and “what does the contract say?” are DDD questions before they're API questions. +- Strategic DDD places the boundaries: subdomains (core, supporting, generic) and bounded contexts, each with its own model and ubiquitous language. +- Tactical DDD models what's inside: aggregates (consistency boundaries), entities, value objects and domain events — which become your resources and your Kafka events. +- Hexagonal architecture keeps it clean: the domain core is isolated, and REST, gRPC, GraphQL and Kafka are just adapters plugged into ports. +- Get the boundaries right and the APIs almost design themselves; get them wrong and no amount of REST polish will save you. + +p. 119 + +> Set up why a DDD appendix belongs in an API talk. The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries. Distinguish strategic DDD (where the lines go) from tactical DDD (how you model inside a line), and preview that hexagonal architecture is what stops the four protocols from contaminating the domain. The closing line is the one to land: boundaries dominate outcomes far more than protocol polish. + +## [141] APPENDIX F · DDD · Strategic DDD: subdomains & bounded contexts (sldId 396) + +Place boundaries by business value and rate of change; each context owns one model and exposes it through an API. + +p. 120 + +> Strategic DDD first. Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications). Each subdomain becomes one or more bounded contexts — a boundary inside which one model and one ubiquitous language are consistent. The punchline here is the bottom box: that boundary is exactly where the API sits, and the contract translates between two contexts' models rather than sharing internals. Map it to our system: order and pricing are core, inventory and shipping supporting, auth and notification generic. + +## [142] APPENDIX F · DDD · A bounded context is a service-and-API boundary (sldId 397) + +- A bounded context is the scope within which one model and one ubiquitous language stay consistent — the natural unit for a service and its API. +- The same word means different things across contexts: an “order” in fulfillment is not an “order” in billing. Don't force one shared model across the system. +- The context's published API is its only public surface; the schema, storage and internal types stay private — this is the “no leaky internal models” rule from anti-patterns, restated. +- Put boundaries where the business naturally splits and where a team can own them end-to-end (Conway's law) — that's where the integration cost is worth paying. + +p. 121 + +> Make the bounded context concrete as the service/API boundary. The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead. Tie back explicitly to the anti-patterns section: 'no leaky internal models' is just 'keep the bounded context's internals private.' And invoke Conway's law: boundaries that don't match team ownership generate constant cross-team coordination. + +## [143] APPENDIX F · DDD · Context mapping: how contexts integrate (sldId 398) + +The relationship patterns between contexts, and which ones your API implements. + +p. 122 + +> Context mapping catalogues the ways two contexts relate. Walk the starred ones because they're what APIs implement: an Open-Host Service is a context that, because many others integrate with it, publishes a stable public API; the Published Language is the shared, versioned schema that API speaks; and an Anticorruption Layer is the translation you build when consuming someone else's context so their model doesn't leak into yours. The others are trade-offs: Conformist means you just accept the upstream model; Shared Kernel means you share a model and therefore change in lockstep — high coupling, use sparingly; Partnership means two contexts co-evolve; Separate Ways means don't integrate. The starred three are the ones to design explicitly. + +## [144] APPENDIX F · DDD · Open-Host Service & Published Language (sldId 399) + +- Open-Host Service: a context integrated by many publishes a stable, documented API as its public protocol — that's your REST / gRPC / GraphQL surface. +- Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry. +- Anticorruption Layer: when you consume another context, translate their model into yours at the edge so their concepts never leak into your domain. +- Conformist is the cheap opposite — accept their model wholesale and inherit their changes; choose an ACL instead when the upstream model is messy or volatile. +- So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer. + +p. 123 + +> This slide does the real work of connecting DDD vocabulary to the platform we built. Open-Host Service = the public API; Published Language = the schema in the registry; Anticorruption Layer = the translation at your edge. The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory. Emphasize the ACL-vs-Conformist choice as a real decision: conform when the upstream is clean and stable, build an ACL when it's messy or changes a lot, because the ACL localizes the blast radius of their changes to one translation layer. + +## [145] APPENDIX F · HEXAGONAL · Hexagonal architecture: ports & adapters (sldId 400) + +The domain core is isolated; REST, gRPC, GraphQL and Kafka are just adapters that plug into ports. + +p. 124 + +> This is the architectural payoff of the whole protocols discussion. The domain core in the middle holds entities, aggregates and domain logic and depends on nothing — no framework, no I/O. It declares ports: inbound ports (what it can be asked to do) and outbound ports (what it needs from the world). Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port. Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports. The arrows all point inward: adapters depend on the domain, never the reverse. The consequence we care about: the four protocols we spent the talk on are interchangeable adapters; you can add gRPC alongside REST, or a Kafka consumer, without touching a line of domain logic. + +## [146] APPENDIX F · HEXAGONAL · Ports & adapters in code (sldId 401) + +[chip] java · ports & adapters + +```java +// domain — the core declares the interfaces (ports) it needs; no framework +public interface OrderRepository { void save(Order order); } // outbound +public interface EventPublisher { void publish(DomainEvent e); } // outbound +@ApplicationScoped // pure application logic +public class PlaceOrder { + @Inject OrderRepository repo; + @Inject EventPublisher events; + public Order handle(PlaceOrderCmd cmd) { + Order order = Order.create(cmd); // aggregate enforces invariants + repo.save(order); + events.publish(new OrderPlaced(order.id())); + return order; + } +} +// adapter — a driving adapter; gRPC/GraphQL adapters look the same +@Path("/orders") +public class OrderResource { + @Inject PlaceOrder placeOrder; + @POST public Order place(OrderIn body) { return placeOrder.handle(body.toCmd()); } +} +``` + +The domain depends only on ports it declares; CDI injects the adapters (Panache repo, Kafka emitter). Swap or add one without touching PlaceOrder. + +p. 125 + +> Walk the three blocks. Ports: the domain declares plain Java interfaces, a repository and an event publisher, and depends on nothing else. The application service PlaceOrder is pure logic: it creates the aggregate, saves via the port, publishes a domain event via the port, and never references Quarkus REST, Hibernate or Kafka types directly. The REST resource is a thin driving adapter — turn the HTTP body into a command and call the use case; a gRPC or GraphQL adapter is the same shape. The outbound ports are implemented by adapters (a Panache-backed OrderRepository, a SmallRye Kafka EventPublisher) and CDI injects them at startup. That's dependency inversion serving the boundary: protocols are replaceable, the domain stays stable. + +## [147] APPENDIX F · DDD · Aggregates and domain events (sldId 402) + +- An aggregate is a cluster of objects treated as one consistency unit — the boundary inside which invariants hold and a single transaction commits. It's usually what sits behind one API resource. +- That makes the aggregate the natural transaction boundary, and therefore the outbox boundary: the state change and the domain event commit together (section 4). +- Domain events (“OrderPlaced”) are first-class in DDD; they become the Kafka events other contexts react to (section 5) — choreography between bounded contexts. +- Keep aggregates small: one aggregate per transaction, reference others by id, and let events connect them — which is exactly the saga story in Appendix D. + +p. 126 + +> Tactical DDD, and it ties three earlier sections together. The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource. Because it's the transaction boundary, it's also the outbox boundary from section 4: state plus event commit atomically. Domain events are the DDD concept that becomes your Kafka topic payloads, enabling choreography across contexts (section 5). And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D). This slide is where DDD stops being abstract and visibly underpins the data, event and saga designs. + +## [148] APPENDIX F · DDD · How DDD maps to our API stack (sldId 403) + +- Bounded context → a service and its API boundary. +- Open-Host Service / Published Language → the REST/gRPC/GraphQL contract + the Apicurio registry. +- Anticorruption Layer → model translation at the gateway / consumer edge. +- Aggregate → an API resource and the transaction/outbox boundary behind it. +- Domain event → a Kafka event other contexts consume. + - Reference: Vlad Khononov — Learning Domain-Driven Design (O'Reilly, 2021). + +p. 127 + +> The summary map — leave it up while you recap. Every DDD concept on the left has shown up concretely on the right somewhere in the talk: contexts are services, the OHS/Published Language is the registry-backed contract, the ACL is edge translation, aggregates are resources and outbox boundaries, domain events are Kafka events. The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on. Point people to Khononov's Learning Domain-Driven Design for the full treatment. + +## [149] Appendix G · Balancing Coupling (sldId 404) + +G + +Coupling isn't the enemy — imbalance is. + +> Appendix G. We've thrown around “coupled” and “decoupled” throughout as if loose is always better. This sharpens it, using Vlad Khononov's Balancing Coupling model. The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it. Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together. This gives precise language for why a shared database is bad but a registry-backed API contract is fine, and it ties every anti-pattern from section 12 back to one model. + +## [150] APPENDIX G · COUPLING · Coupling is necessary — balance it, don't eliminate it (sldId 405) + +- Components must share some knowledge to collaborate; a system with zero coupling also has zero behaviour. “Loosely coupled” is too blunt a goal. +- Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility. +- The headline: a coupling is only painful when strength, distance and volatility are all high at once — reduce any one and the pain drops. +- This is why “just split it into microservices” can backfire: spreading strong, volatile coupling across a network maximises all three dimensions — a distributed monolith. + +p. 128 + +> Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real skill is balancing it. Credit Khononov's Balancing Coupling model and stress that it measures the connection between components, not the components, along three dimensions. The headline sentence is the whole model in one line — pain needs all three of strength, distance and volatility to be high. The bold line is the cautionary tale that resonates with anyone burned by microservices: splitting strongly- and volatile-coupled code across a network doesn't remove the coupling, it just adds distance to it — the distributed monolith. + +## [151] APPENDIX G · COUPLING · Coupling has three dimensions (sldId 406) + +Integration strength × distance × volatility — a coupling only hurts when all three are high. + +p. 129 + +> The model in one picture. Integration strength is how much knowledge two parts share — the more they share, the more likely a change in one forces a change in the other. Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary. Volatility is how often the coupled thing changes. The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile. A strong, distant coupling on something that never changes costs you nothing. That's why all three dimensions have to be considered together rather than chasing “lose coupling” in the abstract. + +## [152] APPENDIX G · COUPLING · The three dimensions, in practice (sldId 407) + +- Integration strength: how much knowledge two parts share — from intrusive (private internals) down to contract (an explicit interface). Less shared knowledge = looser coupling. +- Distance: how far apart they sit — method, class, module, service, system. Greater distance raises the cost of any change that crosses the boundary. +- Volatility: how often the coupled thing changes. Khononov derives it from DDD subdomain type — core is volatile, generic is stable. +- Modularity = strength XOR distance: keep strong coupling local, and make anything distant a weak (contract) coupling. Being both strong and distant is the trap. + +p. 130 + +> Unpack each dimension with a concrete handle. Strength is a ladder (intrusive to contract). Distance is intuitive — the further apart, the more it costs to change across the seam. Volatility is the clever part of the model: you can't know the future, but you can predict change-rate from DDD subdomain type (core changes a lot, generic barely changes), which connects this appendix straight back to Appendix F. The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith. + +## [153] APPENDIX G · COUPLING · Integration strength: a ladder, worst to best (sldId 408) + +From intrusive (shared internals) to contract (a versioned schema) — across distance, aim for the bottom rung. + +p. 131 + +> Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the classic case is two services on one database, where either can break the other invisibly. Functional coupling: they share business logic or rules and must change together for functional reasons. Model coupling: they share a data model or DTOs — a shared library of types. Contract coupling: they integrate only through an explicit, stable, versioned contract — and that's our REST, gRPC, GraphQL and Kafka schemas in the registry. Lower on the ladder means less shared knowledge and looser coupling. The rule that matters: the greater the distance between two components, the further down this ladder you need to be. + +## [154] APPENDIX G · COUPLING · Strength in our system (sldId 409) + +- Intrusive (worst): two services sharing one database, or reaching into another's internals — any change can break the other invisibly. This is the shared-database anti-pattern. +- Functional / Model: sharing business logic or a model — e.g. a shared library of types. Fine within a context; risky across teams because it forces lockstep change. +- Contract (best across distance): integrate only through an explicit, versioned schema — our REST/gRPC/GraphQL APIs and Kafka event schemas, governed by the registry. +- The rule: the greater the distance, the weaker the coupling must be. Across services that means contract coupling — never shared state. + +p. 132 + +> Map the ladder onto decisions in our system. The shared database is intrusive coupling and it's the worst precisely because the integration strength is maximal and invisible — neither side knows what the other depends on. Shared libraries are model/functional coupling: perfectly fine inside one bounded context or team, dangerous when they force two independently-deployed teams to release together. Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance. The bold rule is the actionable takeaway and it's exactly why every cross-service integration in the talk went through a registered contract rather than a shared table. + +## [155] APPENDIX G · COUPLING · Balancing coupling: strength vs. distance (sldId 410) + +Strong or distant, rarely both — and put any unavoidable strong-and-distant coupling where volatility is low. + +p. 133 + +> This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module — perfectly healthy, keep it local. Low strength and distant is healthy microservices — services integrating through contracts. Low strength and close is needless indirection — you've split things that could just be merged. And the danger quadrant, top-right, is high strength at high distance: the distributed monolith, global complexity, where one change ripples across services and teams. The right-hand panel adds the third dimension: even a top-right coupling is tolerable if it never changes, but on volatile core-domain code it's where slow delivery and outages come from. So the formula: balance equals strength-XOR-distance, OR low volatility. + +## [156] APPENDIX G · COUPLING · Distance and volatility (sldId 411) + +- Distance is the cost multiplier: a change contained in one module is cheap; the same change spanning two services and two teams is slow and expensive. +- Volatility is the trigger: a strong, distant coupling on code that never changes is harmless; on volatile core-domain code it's where outages and slow delivery originate. +- Predict volatility from the domain: core subdomains change often — keep their couplings weak and local; generic subdomains are stable, so you can afford more coupling. +- Balance = (strength XOR distance) OR low volatility — put any unavoidable strong-and-distant coupling where the code rarely changes. + +p. 134 + +> Deepen distance and volatility. Distance is why the same logical change can be a one-line edit or a multi-team, multi-deploy project — it multiplies cost. Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt. The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas. The bold formula is Khononov's balance condition — worth writing on a whiteboard in a design review. + +## [157] APPENDIX G · COUPLING · Coupling and our APIs (sldId 412) + +- Every protocol choice we've made is a coupling choice: REST, gRPC, GraphQL and events are all contract coupling — the loosest kind that still lets services collaborate. +- The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration strength on its consumers. +- Connascence (Page-Jones) is a finer lens on the same idea — API versioning is managing connascence of name and position in your schemas (why protobuf field numbers matter). +- Every anti-pattern from section 12 is an imbalance: shared DB = intrusive at distance; chatty sync chains = strong + distant + volatile; leaky models = model coupling across a boundary. + +p. 135 + +> Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing contract coupling because it's the loosest that still works across distance. The registry's compatibility gate is, in this language, a guard against a producer silently increasing integration strength on consumers — that's what a breaking change is. Mention connascence as the finer-grained classical model (Page-Jones) that versioning manages — protobuf field numbers are connascence of position made explicit and stable. The bold line re-diagnoses the anti-patterns section through the coupling model, which is a satisfying way to show these aren't separate lists of rules but one underlying principle. + +## [158] APPENDIX G · COUPLING · Heuristics for balanced coupling (sldId 413) + +- Don't chase zero coupling — chase balance. Strong coupling is fine when it's local and stable. +- As distance grows, drive strength down: across services, integrate only through versioned contracts, never shared state. +- Keep strong, volatile couplings inside a single bounded context or aggregate, where distance is small. +- Use DDD subdomain types to predict volatility, and put your weakest couplings around the parts that change most. + - References: Vlad Khononov — Learning Domain-Driven Design (O'Reilly) and Balancing Coupling in Software Design (Addison-Wesley). + +p. 136 + +> The closing heuristics — the slide to leave up. Don't eliminate coupling, balance it; lower strength as distance rises; keep strong-and-volatile couplings local (inside an aggregate or context); and use subdomain types to predict volatility so your loosest seams are around your most-changing code. These five lines are a usable design-review checklist. Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices. + +## [159] Appendix H · Graceful Shutdown (sldId 414) + +H + +Disposability, kept as a promise. + +> Appendix H. This is the operational flip side of factor 9 (disposability) and the cloud-native pillars: pods are killed and replaced constantly, so a service has to be able to go away cleanly. Ungraceful exits are the source of the 5xx blips users see during otherwise-healthy rolling deploys. The thesis: graceful shutdown isn't one setting, it's a short protocol between your app and Kubernetes — stop new traffic, finish in-flight work, drain consumers and sockets, then exit before the grace period runs out. + +## [160] APPENDIX H · SHUTDOWN · Disposability is a promise you have to keep (sldId 415) + +- Pods are cattle: every deploy, scale-down, autoscale event or reschedule kills and replaces them — often many times a day. +- An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout. +- Graceful shutdown is the flip side of factor 9, disposability: stop taking new work, finish what you accepted, release resources, then exit. +- It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in. + +p. 137 + +> Set up the appendix by connecting it to two earlier ideas: factor 9 (disposability) from the twelve-factor arc, and the resilience pillar. The motivating story is the one every team has lived: a rolling deploy that looks healthy in tests but throws a scatter of 5xx errors in production because pods were torn down mid-request. Stress that this is not about a magic flag — it's a sequence the app and Kubernetes perform together, on a clock. This appendix walks that sequence, then shows the code on both sides of it. + +## [161] APPENDIX H · SHUTDOWN · What happens when Kubernetes stops a pod (sldId 416) + +SIGTERM starts a race; readiness-down plus a short preStop keep new traffic off until you've drained. + +p. 138 + +> Walk the two lanes. Top, Kubernetes: it sends SIGTERM to PID 1, starts the terminationGracePeriodSeconds clock, and will SIGKILL you if you overrun. The catch is the red box — removing the pod from the Service endpoints is asynchronous (kube-proxy, kubelet, the mesh all have to catch up), so for a moment after SIGTERM traffic can still arrive. Bottom, your pod: the response is to flip readiness DOWN immediately (which starts the deregistration) and run a short preStop sleep to cover the lag, then stop accepting, drain in-flight work, stop consumers and close resources, and exit — ideally well before the grace period ends. The red arrows are the whole point: readiness + preStop are what keep new traffic off your dying pod during the race. + +## [162] APPENDIX H · SHUTDOWN · The contract with Kubernetes (sldId 417) + +- SIGTERM is the signal: Kubernetes sends it to PID 1, then waits terminationGracePeriodSeconds (default 30s) before SIGKILL. +- Readiness DOWN is how you stop new traffic — the pod leaves the Service endpoints but keeps running to finish in-flight work. +- There's a race: endpoint removal is asynchronous, so traffic can still arrive briefly after SIGTERM — a short preStop sleep covers the lag. +- Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down. +- Set the grace period larger than your longest acceptable in-flight operation — too short cuts work off, too long stalls deploys. + +p. 139 + +> These five points are the rules of the protocol. The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining). The asynchronous-deregistration race is the subtle one — it's why a preStop sleep exists at all; it's not about doing work, it's about waiting for the rest of the cluster to stop routing to you. And the sizing rule on the grace period is the practical lever: it must exceed your real worst-case in-flight operation, with headroom, or you'll be SIGKILLed mid-request. + +## [163] APPENDIX H · SHUTDOWN · Graceful means something different per workload (sldId 418) + +Only HTTP drains automatically; Kafka consumers, WebSockets and jobs each need their own drain. + +p. 140 + +> The trap is assuming 'graceful shutdown' is one thing. Only HTTP request draining tends to be automatic (the server stops accepting and finishes in-flight). Everything else you wire up yourself in your shutdown hook. Kafka consumers: stop polling, finish the records already in hand, commit offsets, and leave the group so the rebalance is clean. WebSockets (from Appendix C): you can't finish a long-lived connection, so send a close/go-away and rely on client reconnection plus the per-connection seq and backplane. Jobs and schedulers: stop starting new work and either let running jobs finish or checkpoint so they resume after restart. The green footer is the safety net under all of it — because a hard SIGKILL is always possible, idempotent handlers make a redelivered request or message harmless. + +## [164] APPENDIX H · SHUTDOWN · Graceful shutdown in Quarkus (sldId 419) + +[chip] java · Quarkus + +```java +// SignalHandler.java (excerpt), examples/21-graceful-shutdown/quarkus +// application.properties: quarkus.shutdown.timeout=20s (bounds the in-flight wait) +@ApplicationScoped +public class SignalHandler { + @Inject ShutdownState shutdownState; // GET /readyz answers 503 once it is set + void onStart(@Observes StartupEvent ev) { // replaces the JVM's default SIGTERM handler + Signal.handle(new Signal("TERM"), signal -> { + if (!shutdownState.markShuttingDown()) return; // one SIGTERM drives the drain + long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5 + new Thread(() -> { + try { Thread.sleep(delay * 1000); } // keep serving: endpoints update + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + Quarkus.asyncExit(0); // stop accepting, finish in-flight, close the pool + }, "drain").start(); + }); + } +} +``` + +One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, Quarkus.asyncExit(0) drains in-flight HTTP within quarkus.shutdown.timeout (20 s). + +p. 141 + +> Example 21 handles the signal itself, so the whole sequence is visible. A sun.misc.Signal handler, registered on StartupEvent, replaces the JVM's default SIGTERM handler, which would start the shutdown hooks at once. One SIGTERM, the only signal Kubernetes sends before SIGKILL, drives everything: the handler flips the ShutdownState flag, so the hand-written /readyz answers 503 and the Service stops routing, and a drain thread keeps the service serving for DRAIN_DELAY_SECONDS (5 s; it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls Quarkus.asyncExit(0): Quarkus stops accepting connections, waits for in-flight requests up to quarkus.shutdown.timeout (20 s), closes the datasource and exits 0, inside the 30-second grace period. A ShutdownEvent observer is the hook for what Quarkus leaves to you: Kafka consumers, the scheduler, WebSockets. Quarkus also has a built-in pre-shutdown delay (quarkus.shutdown.delay-enabled, a build-time flag) that reports readiness down through SmallRye Health; the example keeps its own /readyz, as Example 01 does. Pair it with the Kubernetes side: probes, preStop and the grace period. + +## [165] APPENDIX H · SHUTDOWN · The Kubernetes side: probes, preStop, grace period (sldId 420) + +[chip] yaml · Kubernetes + +```yaml +# deployment.yaml — give the pod time to deregister and drain +spec: + terminationGracePeriodSeconds: 30 # >= preStop + drain + buffer + containers: + - name: order-service + readinessProbe: # flips the pod out of the Service + httpGet: { path: /readyz, port: 8080 } + periodSeconds: 5 + failureThreshold: 2 # ~10s to stop routing after DOWN + livenessProbe: # keeps answering during the drain + httpGet: { path: /healthz, port: 8080 } + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] # cover async endpoint removal +``` + +Readiness removes you from the Service; preStop covers the dereg lag; terminationGracePeriodSeconds must exceed your full drain or you get SIGKILLed mid-flight. + +p. 142 + +> The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover. +> +> Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages. +> +> The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number. + +## [166] APPENDIX H · SHUTDOWN · Beyond HTTP: messages, sockets, jobs (sldId 421) + +- Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook. +- Kafka consumers: stop polling, finish the records in hand, commit offsets, and leave the group so the rebalance is clean (ties to Event-Driven). +- WebSockets: send a close / go-away, let clients reconnect to another pod, and lean on the per-connection seq + backplane from Appendix C. +- Jobs and schedulers: don't start new work; let running jobs finish or checkpoint so they resume after restart. +- A hard SIGKILL is always possible — idempotent handlers make a redelivered request or message safe, which is your real safety net. + +p. 143 + +> Reinforce the per-workload point from the diagram with the concrete drain steps. The Kafka sequence is the one to emphasize because it connects to the at-least-once delivery discussion from the Event-Driven section: committing offsets on the way out minimizes redelivery, and leaving the group makes the rebalance fast. WebSockets can't be 'finished,' so graceful there means handing the client off, which is exactly what the Appendix C reconnection design enables. The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare. + +## [167] APPENDIX H · SHUTDOWN · Graceful shutdown checklist (sldId 422) + +- Handle SIGTERM; flip readiness DOWN first so the Service stops routing before you stop accepting. +- Add a short preStop sleep to cover asynchronous endpoint removal; keep liveness UP throughout the drain. +- Drain in-flight HTTP, commit consumer offsets, close sockets and pools — all bounded by terminationGracePeriodSeconds. +- Make the work idempotent so the inevitable occasional SIGKILL is survivable. +- Test it under a real SIGTERM during a rolling deploy — not just a happy-path integration test. + - References: Quarkus lifecycle guide & M. Eisele, “Quarkus Graceful Shutdown” (2026); uvicorn — --timeout-graceful-shutdown. + +p. 144 + +> The leave-up summary. Read it as an ordered runbook: readiness first, preStop to cover the race, then the actual drains, all inside the grace budget. The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event. And the bold final point is the one teams skip: graceful shutdown that's never tested under a real SIGTERM during a rollout is just hopeful configuration. Encourage a small script (or a chaos test) that kills a pod mid-request and asserts the client still got an answer — exactly how the referenced Quarkus write-up proves its recipe. + +## [168] Appendix I · L7 Routing & Traffic Management (sldId 423) + +I + +Software-defined, north/south and east/west. + +> Appendix I. This one is large enough to be its own talk — the user explicitly suggested it could be cut later for that. The thesis: L7 routing isn't one box in a rack any more; it's a four-layer stack of software (edge, gateway, mesh, in-app) that makes decisions on every HTTP and gRPC envelope crossing the network, north/south and east/west. We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with Quarkus + Apache Camel + Drools, then circle back to east/west, where most routing decisions happen. + +## [169] APPENDIX I · L7 ROUTING · L7 routing: software, not silicon (sldId 424) + +- The hardware load balancer used to be where routing decisions lived. In a cloud-native system that work has moved into software — programmable, observable, and runnable anywhere. +- L7 means application layer: the router sees the HTTP or gRPC envelope (host, path, method, headers, cookies, gRPC service.method) and can route on any of it. +- It lives in four layers — edge / ingress, API gateway, service mesh, and (sometimes) inside the app itself. Each makes a different type of L7 decision. +- It runs both directions: north/south (clients into your system) and east/west (service to service). Most L7 decisions happen on internal traffic. +- The non-hardware stack is open source and composable: HAProxy, Nginx, Envoy, Istio, Linkerd, Kong, Traefik, Apache Camel, Kogito DMN, Drools — plus a YAML or two. + +p. 145 + +> Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else. The four-layer mental model (edge, gateway, mesh, in-app) is the backbone of the whole appendix — we'll touch each layer in turn. Stress north/south plus east/west early; people default to thinking about ingress, but inside a microservices system the volume of east/west calls dwarfs north/south, and that's where mTLS, retries and locality routing earn their keep. Name the open-source pieces explicitly so the audience knows the talk is about real, free, runnable software. + +## [170] APPENDIX I · L7 ROUTING · L4 vs L7: what each layer can decide on (sldId 425) + +L4 sees connections; L7 sees the conversation — paths, headers, methods, cookies, gRPC service.method. + +p. 146 + +> Anchor the discussion in the OSI model just enough to be useful. L4 is the layer of IPs, ports and TCP — a classic load balancer can pick a backend by 5-tuple and hash-balance traffic, but it cannot read URLs, headers, cookies, methods or gRPC metadata. L7 sees the full HTTP/gRPC envelope: host, path, headers, cookies, JWT claims, query parameters, methods, and the gRPC service.method pair. That's the entire menu of inputs the routing patterns in this appendix use to make routing decisions. The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower). + +## [171] APPENDIX I · L7 ROUTING · The L7 capability menu (sldId 426) + +- Routing rules: path / host / header / method / cookie / gRPC method → backend. +- TLS termination: decrypt at the edge, re-encrypt (or use mTLS) inward. +- Traffic management: canary, blue/green, A/B, header-based, geo, shadow. +- Payload modification: inject / strip headers, rewrite URLs, transform bodies. +- Resilience: timeouts, retries with backoff + jitter, circuit breakers, outlier ejection — the same toolkit from Appendix E, applied at the network. +- Cross-cutting: authn/authz, rate limiting, WAF, request mirroring, full traces and metrics on every hop. + +p. 147 + +> Read this as the menu of L7 features you can mix and match across layers. The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section. Most of these features used to be hardware appliances; now they're an annotation or a YAML block away. The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification. + +## [172] APPENDIX I · L7 ROUTING · Where L7 lives: the layered stack (sldId 427) + +Four layers, north/south at the top, east/west at the bottom, each making a different type of L7 decision. + +p. 148 + +> This is the backbone diagram for the whole appendix. North/south traffic (clients into the system) enters through the edge/ingress layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API gateway (Kong, Istio gateway, Spring Cloud Gateway), which handles product-level concerns like auth, rate limiting and transformation. Once inside the cluster, east/west traffic is governed by the service mesh (Istio + Envoy sidecars, or Linkerd) — every internal call gets mTLS, retries, outlier detection and locality-aware load balancing. Finally, when L7 decisions need to consider business rules the platform doesn't know about, you push routing into the app itself (Quarkus + Apache Camel + Drools + Kogito DMN). Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them. + +## [173] APPENDIX I · L7 ROUTING · A request's L7 path (sldId 428) + +One HTTP request is a chain of L7 decisions made by four independent components. + +p. 149 + +> Walk a single request left to right. It starts at the client — say a browser or another service. The edge load balancer terminates TLS, does host/path routing and maybe WAF; the API gateway authenticates the caller, applies rate limits and may transform; the mesh sidecar adds mTLS for the cluster's internal hop, applies retries and locality routing; and finally the service can do its own in-app routing if the business rules demand it. Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end. This is the request lifecycle from section 11, viewed through the L7 lens. + +## [174] APPENDIX I · L7 ROUTING · Open-source L7 building blocks (sldId 429) + +- HAProxy: fast, battle-tested L7 LB; ACL-driven config; classic ingress. +- Nginx: ubiquitous web server / LB; location-block routing; widely used as ingress controller. +- Envoy: the modern L7 proxy; HTTP/2, gRPC, dynamic config; the sidecar of choice. +- Istio: a control plane built on Envoy; CRDs for routing, security, observability across the mesh. +- Kong, Traefik, Spring Cloud Gateway: opinionated API gateways with plugin ecosystems. +- Apache Camel + Drools / Kogito DMN: when the routing logic is a business rule, push it into the app — EIP DSL + a rule engine. + +p. 150 + +> Differentiate the pieces so the audience knows when to reach for which. HAProxy and Nginx are the workhorses of the edge — mature, fast, predictable. Envoy is the modern choice for sidecars because it speaks HTTP/2 and gRPC natively and supports dynamic configuration via xDS. Istio (and Linkerd) wrap Envoy in a control plane and give you the mesh CRDs (VirtualService, DestinationRule). Kong, Traefik and Spring Cloud Gateway are API gateways with plugin ecosystems and a more product-aware feature set. The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster. + +## [175] APPENDIX I · L7 ROUTING · Content-based routing with HAProxy (sldId 430) + +[chip] haproxy.cfg + +```haproxy.cfg +frontend http_in + bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 # TLS here; h2 for gRPC + acl is_api path_beg /api/ + acl is_grpc req.hdr(content-type) -m beg application/grpc + acl is_internal req.hdr(x-internal) -m str 1 + acl is_canary hdr(cookie) -m sub canary=on + use_backend grpc_pool if is_grpc # by content-type + use_backend order_v2_pool if is_canary # by cookie + use_backend order_int_pool if is_internal # by header + use_backend order_pool if is_api # by path + default_backend web_pool +backend order_v2_pool + balance roundrobin + server v2a 10.1.0.10:8080 check + server v2b 10.1.0.11:8080 check +``` + +ACLs read the L7 envelope (path, header, cookie, content-type) and use_backend dispatches — classic, fast, predictable. + +p. 151 + +> HAProxy is the lingua franca of L7 LBs. The pattern is always the same: declare ACLs that read parts of the L7 envelope (path_beg for URL prefixes, hdr/req.hdr for headers, content-type for the body type), then use_backend dispatches based on those ACLs in order. This single frontend shows four different content-based routing decisions — by content-type (gRPC traffic goes to a gRPC pool), by cookie (canary users), by header (internal users), by path. Notice TLS terminates at this frontend; everything inside is plaintext within the cluster (or re-encrypted via mTLS at the mesh layer). HAProxy's reload semantics are graceful by default — it can drain old workers while new ones take traffic, which matters for Appendix H's shutdown discipline. + +## [176] APPENDIX I · L7 ROUTING · Content-based routing with Nginx (sldId 431) + +[chip] nginx.conf + +```nginx.conf +# map directive — declarative routing table on a header value +map $http_x_app_version $upstream_pool { + default order_v1; + "v2" order_v2; + "v2beta" order_v2_beta; +} +server { + listen 443 ssl; http2 on; + ssl_certificate /etc/nginx/certs/tls.crt; + ssl_certificate_key /etc/nginx/certs/tls.key; + location /api/orders/ { # path-based routing + proxy_pass http://$upstream_pool; # picked by the map + proxy_set_header X-Request-Id $request_id; + } + location ~ ^/api/grpc/ { # gRPC traffic + grpc_pass grpc://orders_grpc_pool; + } +} +``` + +map turns a header value into an upstream pool name; location blocks dispatch by path and protocol — HTTP and gRPC side by side. + +p. 152 + +> Nginx's content-based routing is built around location blocks and the map directive. The map is the elegant bit: it converts a header value into a variable, which then drives proxy_pass — you express the routing table declaratively, then point a single location at the variable. Location blocks split HTTP from gRPC (grpc_pass) so you can run both protocols through one server. The X-Request-Id injection is a tiny example of payload modification — the L7 router can inject correlation headers so downstream traces can be stitched together. Nginx as an ingress controller in Kubernetes (the ingress-nginx project) is one of the most-deployed L7 routers in the world. + +## [177] APPENDIX I · L7 ROUTING · Content-based routing in the mesh: Istio VirtualService (sldId 432) + +[chip] yaml · Istio VirtualService + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: # gRPC by service.method + - uri: { prefix: /acme.orders.v1.OrderService/ } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by header + - headers: { x-internal: { exact: "1" } } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by cookie + - headers: { cookie: { regex: ".*canary=on.*" } } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] # default +``` + +Match by URI prefix (gRPC service.method), header, or cookie — same L7 inputs, expressed as Kubernetes CRDs. + +p. 153 + +> The same content-based routing primitives, but expressed as Kubernetes CRDs that the mesh control plane (Istio) compiles down to Envoy sidecar configuration. Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie. The default route at the bottom is critical; without it, unmatched requests have nowhere to go. The CRD lives in Git, gets reviewed like any other change, and Istio reconfigures every sidecar in the cluster within seconds. This is the modern face of L7 routing — declarative, GitOps-managed, distributed across thousands of pods. + +## [178] APPENDIX I · L7 ROUTING · SSL / TLS termination at L7 (sldId 433) + +- Edge terminates TLS so the L7 router can read and route on the request — you cannot inspect headers or paths through an encrypted connection. +- Re-encrypt or mTLS inward: terminate at the edge, then either plaintext within the cluster (rare today) or mTLS via the mesh (the modern default). +- Certificate management: cert-manager + Let's Encrypt for public certs; SPIFFE / SDS for service identity inside the mesh; Istio rotates pod certs automatically. +- TLS passthrough (no termination) when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only. +- Cost: TLS handshakes are real CPU. Modern stacks use session resumption, TLS 1.3 and HTTP/2 to amortise the cost across many requests. + +p. 154 + +> Cover the three modes of TLS at the edge: terminate-and-decrypt (the common case, lets you do L7 things), terminate-and-re-encrypt (most secure default in a mesh, since mTLS inside is automatic), and passthrough (when you must not see the payload, e.g. client mTLS for regulated traffic — route by SNI only). cert-manager is the standard cluster-side answer for public certificates; SPIFFE-style identity and Envoy's SDS handle service-to-service certs automatically inside the mesh. Mention the CPU cost briefly so the L7 latency budget makes sense — a TLS handshake is milliseconds, and you want to amortise it via session resumption, TLS 1.3 and HTTP/2 multiplexing. + +## [179] APPENDIX I · L7 ROUTING · Sticky sessions: cookie vs IP-hash vs none (sldId 434) + +Convenient for legacy state, hostile to elastic scaling — prefer stateless behind a JWT. + +p. 155 + +> Three modes worth knowing about. No affinity is the default and what you want — any pod gets any request, load is even, and client state lives somewhere else (Redis, the JWT, the database). Cookie-based stickiness is what you fall back to when something stateful (a shopping cart in memory, a legacy session) has to stay with one pod; the LB sets a cookie on the first response and uses it to route subsequent requests to the same pod. It works behind any L7 LB but ties the user's session to one pod's lifetime — restart that pod, lose the session. IP-hash hashes the source IP to a pod; it needs no cookies but is fragile to NAT and mobile networks where many users share an IP, leading to skewed load. The red footer is the real cost: stickiness fights elastic scaling, hurts pod restarts and breaks scale-to-zero. Use it only for a known constraint, never as a default. + +## [180] APPENDIX I · L7 ROUTING · Sticky sessions in practice (sldId 435) + +- When you need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod. +- Cookie-based is almost always the right choice over IP-hash: it survives NAT and lets clients explicitly drop the cookie. +- What stickiness breaks: even load balancing, fast pod restarts, scale-to-zero, blue/green flips, and a clean Appendix H graceful shutdown for any stateful pod. +- The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable. + +p. 156 + +> Make stickiness a last resort, not a first reach. Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it. The list of things stickiness breaks is the real argument against it: it actively fights the entire cloud-native delivery model — elastic scaling, fast restarts, graceful shutdown. The bold final point is the architectural fix: externalise the state. Redis for session data, JWT claims for user identity, the WebSocket backplane from Appendix C for fan-out. With that done, your LB is back to no-affinity and the platform works the way it wants to. + +## [181] APPENDIX I · L7 ROUTING · Intelligent traffic steering (sldId 436) + +Six familiar shapes — each is a header or weight rule away. + +p. 157 + +> Six patterns that all share the same L7 primitives. Canary: 90/10 weighted routing to a new version, watching metrics and ramping. Blue/green: two identical environments, traffic flipped atomically (and rolled back atomically). A/B test: a stable cohort maps to a variant via a user identifier so the same user always sees the same variant — critical for valid measurement. Header-based: dark launches and internal-only previews by routing on x-internal or similar. Geo / latency: send EU users to EU pods for RTT and data residency. Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load. The point is they're not different things mechanically; they're the same routing rule expressed with different inputs and weights. The difficult part is the deploy discipline around them: alerts that trigger an automatic rollback, gradual ramps, and the metrics that tell you the canary is bad before users do. + +## [182] APPENDIX I · L7 ROUTING · Canary release: weighted Istio routing (sldId 437) + +[chip] yaml · Istio canary + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - route: + - destination: { host: orders, subset: v1 } + weight: 90 # 90% on the stable version + - destination: { host: orders, subset: v2 } + weight: 10 # 10% on the canary +--- +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: orders } +spec: + host: orders + subsets: + - name: v1 + labels: { version: v1 } + - name: v2 + labels: { version: v2 } +``` + +VirtualService splits traffic by weight; DestinationRule defines the subsets by pod labels — the canonical canary release pattern. + +p. 158 + +> The canonical Istio canary. Two CRDs working together: DestinationRule defines subsets by pod label (version=v1 and version=v2), and VirtualService routes by weight to those subsets. Changing the weights in Git and reconciling the cluster ramps the canary — typically through 10%, 25%, 50%, 100% over hours or days, with metric gates between each step. If error rate or latency on v2 crosses a threshold, an automated controller (Flagger, Argo Rollouts) flips the weights back to 100/0 in seconds. The same weighted-routing primitive does shadow traffic, geo balance, and blue/green flips — the only thing that changes is which subsets you target and what the weights are. + +## [183] APPENDIX I · L7 ROUTING · Header-based steering: dark launches (sldId 438) + +[chip] yaml · Istio header-based + +```yaml +# route internal beta users to v2; everyone else stays on v1 +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: + - headers: + x-acme-beta: { exact: "true" } # set by your auth proxy + route: [{ destination: { host: orders, subset: v2 } }] + - match: + - headers: + user-agent: { regex: ".*AcmeInternal.*" } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] +``` + +First match wins; an internal-only header (set upstream by auth) hands real production traffic to v2 — with zero blast radius. + +p. 159 + +> Header-based steering is how you do dark launches and betas safely. An upstream identity proxy sets an x-acme-beta header for opted-in users, and the VirtualService routes those users to v2 — in real production traffic, on real data, but with zero exposure to anyone outside the beta. First match wins, so order the rules from most-specific to least, ending with the catch-all default. This is also how you do internal-only previews (route by an internal user-agent string) and per-tenant feature gates (route by an x-tenant-id). Combined with weighted routing, you can do 'roll the canary forward only for the beta cohort,' which is the most surgical release pattern in the toolbox. + +## [184] APPENDIX I · L7 ROUTING · Payload modifications (sldId 439) + +- Header injection: X-Request-Id, traceparent, X-Forwarded-For, identity claims — propagate context downstream so the trace stitches together. +- Header stripping: drop Authorization or PII headers before forwarding to a less-trusted backend; strip Server / X-Powered-By outbound. +- URL rewrites: /api/v1/orders → /orders internally; clean public URLs over messy service paths. +- Body transformation: rare but possible — protocol translation, redaction, format conversion (Envoy filters, Camel transforms). +- Cost: body inspection means reading the full request into memory — the slowest L7 thing you can do. Use it sparingly, never on every request. + +p. 160 + +> Distinguish the cheap modifications from the expensive ones. Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers. Body transformation is the expensive one because it forces the router to buffer the full payload, which kills streaming, blows memory on large requests and can become a bottleneck under load. Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate). The bold point is the practical advice: if you can do it in the headers, do it in the headers. + +## [185] APPENDIX I · L7 ROUTING · Header injection and URL rewrites (sldId 440) + +[chip] yaml · Envoy / Istio + +```yaml +# Envoy / Istio: inject identity headers, strip secrets, rewrite paths +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders.acme.io] + http: + - match: [{ uri: { prefix: /api/v1/orders } }] + rewrite: + uri: /orders # external -> internal path + route: [{ destination: { host: orders } }] + headers: + request: + set: + x-client-ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" # caller address + x-peer-identity: "%DOWNSTREAM_PEER_URI_SAN%" + remove: [ authorization, cookie ] # don't leak inward + response: + remove: [ server, x-powered-by ] # don't leak outward +``` + +Rewrite paths, inject the caller address and identity, strip secrets — all declaratively in the VirtualService. + +p. 161 + +> Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders. +> +> The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none. +> +> The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved. + +## [186] APPENDIX I · L7 ROUTING · Beyond the edge: routing inside the app (sldId 441) + +- Sometimes the routing decision is a business rule the platform doesn't know about: route 'high-value-customer claims' here, 'European HL7 messages of type A03' there. +- Pushing those rules into HAProxy ACLs or VirtualService matches does not scale: domain experts own them, they change often, and they depend on the payload, not just the envelope. +- The pattern: an in-app router (Apache Camel, Spring Integration, a custom dispatcher) calls a rule engine (Drools, Kogito DMN) and routes on the result. +- It runs inside one service, behind the mesh — not on the network. The mesh still does mTLS, retries and locality; the rule engine just decides where to send the message next. + +p. 162 + +> Pivot from network-layer routing to in-app routing. The threshold is roughly: if the decision can be expressed in a few YAML lines and depends only on the envelope, do it at the mesh. If it depends on the payload, requires a decision table that domain experts edit, or involves complex conditions, push it into the app. The classic example (and the one the code models) is HL7 healthcare messages: route by patient class, encounter type, message subtype — dozens of intersecting conditions a non-developer needs to maintain. That belongs in a rule engine, not a VirtualService. Stress that this isn't an alternative to the mesh; it sits behind the mesh, complementing it. + +## [187] APPENDIX I · L7 ROUTING · Quarkus + Camel + Drools: rule-based routing (sldId 442) + +Camel is the EIP DSL; Drools is the decision engine; Quarkus glues them together. + +p. 163 + +> This is the canonical Java pattern (verified from the Apache Camel and KIE community examples). Inputs arrive over REST, gRPC or Kafka. They land in an Apache Camel route, which is a fluent DSL for Enterprise Integration Patterns: from(...) declares the source, .enrich() gathers any context needed for the decision, .process(rulesEvaluator) calls into Drools (rules) or Kogito DMN (decision tables), which sets a 'whereTo' header on the message. Then .routingSlip(header("whereTo")) dynamically dispatches to one or more destinations based on that header. The Drools rule unit is a separate, version-controlled artefact — .drl rules or DMN decision tables that domain experts can edit independently of the Camel route. The result: the routing logic is data, not code; reroutes don't need redeploys; and the heavy network plumbing stays in the mesh. + +## [188] APPENDIX I · L7 ROUTING · A Camel route in Quarkus, calling Drools (sldId 443) + +[chip] java · Quarkus + Camel + +```java +// pom.xml: camel-quarkus-platform-http, camel-quarkus-kafka, drools-quarkus +@ApplicationScoped +public class OrderRoutes extends RouteBuilder { + @Inject RulesEvaluator rules; // wraps a Drools KieSession + @Override public void configure() { + from("kafka:orders.inbound") + .enrich("direct:lookupCustomer", aggregateStrategy) // context + .process(rules::evaluate) // sets "whereTo" header + .log("routing ${header.whereTo}") + .routingSlip(header("whereTo")) // dynamic dispatch + .end(); + from("direct:lookupCustomer") + .to("http:customer-svc/lookup") // enrich w/ HTTP call + .unmarshal().json(); + } +} +``` + +from → enrich → rules.evaluate → routingSlip — the canonical content-based-router EIP, with the decision externalised to Drools. + +p. 164 + +> This is the structure used in the official Camel + Quarkus + Drools content-based-routing example (from Red Hat's iDaaS-style use case). from() picks up Kafka messages. enrich() makes an HTTP call to a customer service to gather additional context and aggregates it onto the exchange. process(rules::evaluate) hands the enriched message to the rule engine, which fires .drl rules against the facts and sets a 'whereTo' header listing one or more destination URIs. routingSlip then dispatches the message to each of those destinations in sequence. The whole pattern is 15 lines of fluent Java; the routing logic lives in the .drl rules, owned by domain experts and editable without a Quarkus rebuild. + +## [189] APPENDIX I · L7 ROUTING · A Drools rule: the decision, externalised (sldId 444) + +[chip] drl · Drools rules + +```drl +// orders.drl — edited by domain experts, not developers +package com.acme.orders; +import com.acme.orders.OrderFact; +rule "High-value VIP orders go to the priority queue" + when + $o : OrderFact( amountCents > 50_000, customer.tier == "VIP" ) + then + $o.setWhereTo("kafka:orders.priority,http:notify-vip"); +end +rule "European orders go to the EU region" + when + $o : OrderFact( customer.region == "EU" ) + then + $o.setWhereTo("kafka:orders.eu"); +end +rule "Default route" + salience -100 // last resort + when $o : OrderFact( whereTo == null ) + then $o.setWhereTo("kafka:orders.default"); +end +``` + +Each rule is a small business statement: when X is true, then route to Y. Edited and reviewed by domain experts. + +p. 165 + +> Drools rules are 'when ... then ...' statements. The 'when' is a pattern match against facts in the working memory (here, OrderFact). The 'then' is the action — in our case, setting the whereTo header that the Camel routingSlip will consume. Two real rules and a default with low salience (so it fires last if nothing else matched). This file is owned by domain experts, the people who decide what 'VIP' or 'high-value' mean, and can be redeployed without a developer touching Camel or Quarkus code. When the rules get complex, Drools/Kogito has a DMN (Decision Model and Notation) flavour with decision tables in a spreadsheet form that non-developers find easier still. + +## [190] APPENDIX I · L7 ROUTING · Every L7 hop costs time (sldId 445) + +Typical p50/p99 added per stage — the budget you spend before your service even runs. + +p. 166 + +> Set expectations with the numbers. Each L7 capability has a typical cost: TLS termination is sub-2ms, path/header routing is sub-1ms, auth/JWT validation is a few ms, WAF a few more, mesh sidecar another millisecond per hop, rate-limit Redis lookup is a couple of ms. Body inspection is the outlier — it can be 5 to 50 ms depending on size, which is why we say to use it sparingly. The numbers are typical, not authoritative; profile your own stack. The right-side card has the working heuristic: stay under ~10ms total L7 on hot paths. With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency. The retry-storm discipline from Appendix E applies here too — timeouts must account for the total L7 budget plus the service's own work, or retries will pile up. + +## [191] APPENDIX I · L7 ROUTING · L7 pitfalls and how to avoid them (sldId 446) + +- Too many hops: edge → gateway → ingress → mesh sidecar in → mesh sidecar out adds up fast. Collapse layers that aren't earning their place. +- Regex-heavy ACLs / matches: each regex evaluation is real CPU. Prefer exact / prefix matches; use regex only when needed. +- Sticky sessions break elasticity without any error: the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone. +- Body inspection on every request: blows memory, kills streaming. If you must transform bodies, do it on a small percentage or just at the edge. +- Sync auth lookups on the hot path: cache validated tokens, validate JWT signatures locally, never do a per-request DB lookup for identity. +- Misaligned timeouts and retries between layers — a 30s gateway timeout + 3 mesh retries with 10s each = a retry storm at the bottom (see Appendix E). + +p. 167 + +> Run quickly through the failure modes. The hop-count one is the most common: every layer feels free until you measure p99 — then you realise the request crossed seven proxies. The regex point is a perf footgun many people miss. The sticky-session one ties back to the sticky-sessions comparison. Body inspection is the latency-budget killer. The auth-lookup one is the most common cause of cascading slowdowns under load. And the timeout-misalignment is the gateway-to-mesh-to-service version of the retry storm we covered in Appendix E — the lesson is identical: bound retries, align timeouts top-down, give the deepest layer the strictest budget. + +## [192] APPENDIX I · L7 ROUTING · East / West: L7 between services, inside the mesh (sldId 447) + +Every service-to-service call is L7-routable through the sidecars. + +p. 168 + +> Most L7 decisions in a microservices system don't happen at the edge — they happen east/west, between services, every time service A calls service B. The mesh's sidecar pair handles that traffic: mTLS between services (encryption and identity), HTTP/2 multiplexing, native gRPC support, retries with backoff and jitter, outlier detection (eject misbehaving pods automatically), locality-aware load balancing (prefer same-zone pods to keep RTT low), header-based routing and canary weights for east/west versions, plus rich per-call traces and metrics. The headline is the second box: all of that, per call, with no app code. Application services keep talking plain HTTP or gRPC; the sidecars make it secure, reliable and observable in flight. This is the modern answer to 'how do you build a microservices system that works in production.' + +## [193] APPENDIX I · L7 ROUTING · Why east/west L7 matters (sldId 448) + +- Internal traffic dominates: in a microservices system the volume of east/west calls dwarfs north/south by 10x or more. +- Security: mTLS everywhere by default — every internal call has an identity, encryption and an audit trail without per-service code. +- Resilience: timeouts, retries, circuit breakers and outlier ejection live in the sidecar, applied uniformly to every internal hop. +- Observability: every internal call gets a span, latency histogram and per-route success rate — the request lifecycle from section 11, populated automatically. +- Locality: prefer same-zone or same-region pods so internal RTT stays in single-digit milliseconds, and graceful failover when a zone goes. +- Routing decisions: header-based canaries and weighted releases work east/west too — you can canary a backend service that has no external traffic at all. + +p. 169 + +> Hammer the volume point first: most production L7 routing happens between services, not at the edge. The security argument is the one that justifies the mesh on its own — mTLS everywhere is otherwise hard to retrofit. The resilience and observability arguments are the operational ones; you stop writing the same boilerplate in every service. Locality is the perf argument: with the right Istio config, two pods in the same zone talk over a few milliseconds; cross-region traffic gets degraded gracefully. And the last point is non-obvious but important: canary releases of a deeply internal service (say, a payments calculation engine that no client touches directly) are possible because the mesh can split east/west traffic by weight — you cannot canary internal services through an edge LB. + +## [194] APPENDIX I · L7 ROUTING · Istio for east/west: DestinationRule + VirtualService (sldId 449) + +[chip] yaml · Istio east/west + +```yaml +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: payments } +spec: + host: payments.acme.svc.cluster.local + trafficPolicy: + # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10 + outlierDetection: # eject bad pods + consecutive5xxErrors: 5 + interval: 30s + baseEjectionTime: 30s + # ... loadBalancer: localityLbSetting enabled (prefer same zone) + subsets: + - { name: v1, labels: { version: v1 } } + - { name: v2, labels: { version: v2 } } +--- +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: payments } +spec: + hosts: [payments.acme.svc.cluster.local] # internal DNS — east/west + http: + - route: + - { destination: { host: payments, subset: v1 }, weight: 95 } + - { destination: { host: payments, subset: v2 }, weight: 5 } +``` + +Same CRDs as north/south, but pointed at an internal host — outlier detection, locality LB, canary weights, all between services with no app code. + +p. 170 + +> Same Istio primitives as the weighted canary, but applied to an internal service (note the cluster.local host). DestinationRule sets up the connection pool (HTTP/2 multiplexing for gRPC), enables outlier detection (5 consecutive 5xxs in 30s ejects the pod), enables locality-aware load balancing (same-zone preferred), and defines version subsets. VirtualService weights east/west traffic 95/5 between v1 and v2 — a canary release of a backend service that no external client knows exists. This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars. + +## [195] APPENDIX I · L7 ROUTING · gRPC east/west specifics (sldId 450) + +- gRPC is HTTP/2 under the hood, so it works natively in any L7 router that speaks HTTP/2 (Envoy, Istio, modern HAProxy/Nginx). +- Route by gRPC service.method: the URI path is /package.Service/Method — use a uri prefix match in Istio, grpc_pass in Nginx, or path_beg in HAProxy. +- Header-based balancing matters more for gRPC: connections are long-lived and multiplexed, so least-request or per-request load balancing beats round-robin. +- Streaming RPCs (server-streaming, client-streaming, bidi): retries are dangerous if the stream is non-idempotent — cap retries to UNARY calls in the mesh policy. +- Trailers carry status: a 200 + a non-OK trailer is the gRPC equivalent of a 5xx — outlier detection has to be configured to read trailers, not just the status line. + +p. 171 + +> gRPC has a few sharp edges of its own. Route on the gRPC URI prefix to do per-method canaries. Use least-request (or random-3-choices) load balancing instead of round-robin because gRPC connections are long-lived — a few long-running streams can starve other backends if you round-robin connections instead of requests. Be careful with retries on streaming RPCs: the mesh can retry a UNARY call safely if it's idempotent, but retrying a half-completed streaming RPC will replay half the work — Istio lets you scope retries to specific gRPC status codes and request kinds. And the trailer point is subtle: gRPC returns its real status in HTTP trailers (a 200 with a non-OK gRPC status is a failure), so outlier detection has to know to read those, or it will think a failing service is healthy. + +## [196] APPENDIX I · L7 ROUTING · When to use which L7 layer (sldId 451) + +- Edge / ingress: TLS termination, host/path routing, WAF, public-facing rate limits. Anything north/south that affects all clients. +- API gateway: cross-cutting product policy — auth, per-consumer rate limits, request transformation, monetisation, key management. +- Service mesh: east/west routing, mTLS, retries, outlier detection, canaries between internal services. The default home for service-to-service L7. +- In-app router (Apache Camel + Drools): when routing depends on business rules, payload content, or decisions that domain experts must own. +- Rule of thumb: prefer the lowest layer that can fully express the decision, and never push business rules into mesh CRDs. + +p. 172 + +> This is the decision guide to leave on screen for a beat. The mental model: each layer has a job it's good at, and pushing work to the wrong layer is where complexity comes from. Edge/ingress for the things every client crosses. API gateway for product-shaped policy. Mesh for east/west and everything internal. In-app for business rules. The bold rule of thumb prevents the common antipattern of growing increasingly baroque Istio VirtualService configs to encode rules that domain experts need to maintain — if it's a business rule, it belongs in a rule engine inside a service, not in a network CRD. + +## [197] APPENDIX I · L7 ROUTING · L7 routing checklist (sldId 452) + +- Pick one ingress, one gateway, one mesh — don't run three competing L7 layers because each team had a favourite. +- TLS terminates at the edge; mTLS between services in the mesh; cert rotation is automatic (cert-manager + SDS), not a quarterly outage. +- Routing rules live in Git, reviewed like code; GitOps reconciles them; rollback is a git revert. +- Every L7 layer emits traces and metrics — sample the spans, alert on per-route SLOs, watch for retry budget exhaustion. +- Stickiness is an exception, not a default; business rules belong in a rule engine inside a service, not in network YAML. +- Test under load and under failure: bring a pod down, eject a zone, ramp a canary, verify the mesh does what the YAML says. + - References: HAProxy / Nginx / Envoy docs; Istio traffic management guide; Apache Camel EIP guide; Drools / Kogito DMN docs; the tarilabs/quarkus-content-based-routing example. + +p. 173 + +> Leave the checklist up at the end. The first point is the most common organisational failure — a cluster ends up running three ingress controllers because three teams each picked one. Pick one and standardise. The cert-rotation point is the one teams underestimate — expired certs are still a top-3 outage cause in many shops. GitOps for routing is the operational discipline that makes the whole thing reviewable. Observability and the load/failure-test point are non-negotiable. The references sub-bullet is the reading list: official docs for each piece and the Camel/KIE content-based routing example (the canonical demo for the Quarkus + Apache Camel + Drools pattern shown). + +## [198] Appendix K · Monolith to Microservices (sldId 453) + +K + +Patterns that survive the migration with the system still running. + +> Appendix K covers the migration patterns. The thesis: you don't decompose a monolith by stopping it and rewriting; you decompose it incrementally, one bounded asset at a time, with the system serving users the whole way through. We'll walk through the monolith as the starting point, modular monoliths as a frequently-correct destination, the strangler-fig family of patterns for progressive extraction, content-based routing for fine-grained cutover, and the decorating collaborator pattern for wrapping legacy code you can't touch. Code in this appendix uses Quarkus REST + REST Client Reactive + Mutiny, but the patterns are language-neutral. + +## [199] APPENDIX K · MONOLITH · A monolith is a unit of deployment (sldId 454) + +One process, one binary, one heartbeat. All modules live or die together. Often the right starting point — don't be ashamed of it. + +p. 174 + +> Define the term before fixing it. A monolith is not a code-organisation problem — it's a deployment-organisation property. Even a well-organised codebase with clear modules, layers, and tests is a monolith if it ships as one binary on one heartbeat. The defining property is on the slide: change anywhere, redeploy everything; bug in Reporting can take down Payments; one scaling unit for workloads that scale differently. But the second italic line is what to leave audience with: monoliths are not always wrong. For new products, small teams, or anything that fits in one team's head, they're often the simplest and fastest path to value. Premature decomposition is its own anti-pattern. The conversation is: when does this monolith start hurting more than it helps? — and that's when these patterns earn their keep. + +## [200] APPENDIX K · MODULAR MONOLITH · The modular monolith — often the right destination (sldId 455) + +Still one deployable, but with strict in-process module boundaries enforced by build tooling — not by gentlemen's agreement. + +p. 175 + +> The intermediate pattern that's often the right destination, not a stepping stone. Modules within one process have clear public APIs (PlaceOrder, ReserveStock, ChargeCard) and private internals that other modules cannot reach. Enforcement is the key bit: in Quarkus you can split the codebase into multi-module Maven/Gradle builds with explicit dependency declarations — ArchUnit or jdeps in CI fails the build if Orders.internals is imported outside the orders module. Modules can share a database, but each owns a schema and no cross-schema joins are allowed. Why this is often the destination: you get most of the benefits of microservices (clear boundaries, replaceable modules, independent reasoning) at a fraction of the operational cost — one Pod, one deployment, one log stream, one debugger session. If you do need to split later, you have a small refactor, not a rewrite. The cost of being wrong about boundaries is also lower. + +## [201] APPENDIX K · DECOMPOSED DBS · Modular monolith with decomposed databases (sldId 456) + +Data ownership becomes per-module — the hardest part of the split, done while still one process. The next step is mechanical. + +p. 176 + +> Splitting the database is the hard part of decomposition. The insight of this pattern: do that work while you're still one process. Each module gets its own Postgres / Mongo / whatever, its own connection pool, its own Liquibase or Flyway migration history. Quarkus makes this natural — multiple datasources are first-class via 'quarkus.datasource..*' configuration, and each Panache repository binds to its named datasource. Cross-module data is now async or event-driven — you replace a JOIN with a service call or an event subscription (typically via SmallRye Reactive Messaging). Same discipline microservices require, but with one operational target instead of N. Once this is done, promoting a module to a separate service is a refactor of the deployment topology, not a data-migration project. + +## [202] APPENDIX K · STRANGLER FIG · Identify → move → redirect (sldId 457) + +Named after the strangler fig vine: grow the new system around the old until the old can be removed. + +p. 177 + +> The Martin Fowler strangler-fig pattern, named for the vine that grows around its host tree until the host is gone. The three steps are the unit of work you repeat. Identify: pick a bounded asset — 'order creation' or 'order status query' specifically. The smaller and more bounded, the safer the move. Move: build the new Quarkus service alongside the monolith, replicate the behaviour, test it in parallel without production traffic. REDIRECT: switch callers to the new service. Each cycle is independently reversible. There are three ways to do the redirect: via a proxy, via a proxy plus partial routing, or via a shared database. The proxy is the foundation for everything else. + +## [203] APPENDIX K · STRANGLER FIG · With a proxy — step zero of every migration (sldId 458) + +Introduce the routing layer first, before extracting anything. Then redirection becomes a config change, not a release. + +p. 178 + +> The pre-condition for incremental migration: clients hit the proxy, the proxy forwards to the monolith. Day zero, behaviour is unchanged. But you now have the lever — the proxy is where you'll later route 1% of /orders/* traffic to the extracted Quarkus service, then 5%, then 100%. Without this step, every cutover requires coordinated client releases. With it, every cutover is a proxy config change. For Quarkus deployments this is typically nginx, Envoy, or Istio gateway as a Kubernetes Service ahead of your application Pods. The proxy is also the right place for TLS termination, auth, rate-limiting, and observability — the strangler-fig literature calls this 'event interception.' + +## [204] APPENDIX K · STRANGLER FIG · With proxy + redirection (sldId 459) + +The proxy splits traffic between the new service and the shrinking monolith. Roll forward by widening the route; roll back by narrowing. + +p. 179 + +> Now the proxy routes: by path (/orders/* → new Quarkus service), by header (X-Tenant: acme → new service), by percentage (1% → new), or by feature flag (beta users → new). The new service handles its slice; the monolith handles everything else. Clients still see one URL. Roll forward by widening the new-service route in proxy config; roll back by narrowing. The new service can fail completely and the monolith picks up — the bounded blast radius is the entire point. Instrument both paths so you can verify the new service produces the same outcomes as the monolith before widening. For Quarkus, this typically lives in Istio VirtualService rules or nginx server blocks — same patterns from Appendix I, used for migration instead of canary release. + +## [205] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 460) + +Process boundary first, data boundary later. Useful intermediate state, not a destination. Schema coupling will bite eventually. + +p. 180 + +> Sometimes the data split is too risky to do at the same time as the process split. In that case the new Quarkus service and the monolith share a database for a while. Both apps read and write the same tables; the new service owns the API but not the data. Useful for extracting reads first (the new service is a read API over the shared store), or as a stepping stone when the data migration is a separate project. A stage, not a destination: every schema change now needs both apps to agree, and the coordination tax grows. The next step is to give the new service its own store: CDC out of the monolith's DB into the new service's via Debezium, or dual-writing during a brief sync window, or event-sourcing the data into the new service. Plan that step before starting the shared-DB phase. + +## [206] APPENDIX K · ROUTING · Content-based routing during migration (sldId 461) + +[chip] java · Quarkus REST + REST Client + +```java +// Quarkus REST + MicroProfile REST Client — a content-aware proxy that reads +// the body and forwards to the new service or the monolith. In production this +// typically lives in a sidecar; the resource shows the shape cleanly. +@Path("/orders") +public class OrderRouter { + @Inject @RestClient MonolithClient monolith; // legacy + @Inject @RestClient OrdersClient newSvc; // extracted + @Inject @RestClient OrdersEuClient euSvc; // regional + @POST + @Consumes("application/json") + @Produces("application/json") + public JsonObject route(JsonObject body) { + String tenant = body.getString("tenant", ""); // decide by content, not URL + String region = body.getString("region", ""); + if ("acme".equals(tenant)) return newSvc.placeOrder(body); // cut Acme over + if ("EU".equals(region)) return euSvc.placeOrder(body); // regional + return monolith.placeOrder(body); // default: legacy + } +} +@RegisterRestClient(configKey = "orders-new") +public interface OrdersClient { + @POST JsonObject placeOrder(JsonObject body); +} +``` + +Read the body, decide by content (tenant / region / feature flag), forward through a typed REST Client. + +p. 181 + +> The Quarkus shape. The proxy is a Quarkus REST resource that reads the body and decides which downstream to call. The MicroProfile REST Client (@RegisterRestClient) gives typed outbound calls; declare Uni on the client interface when the proxy should not block a worker thread. +> +> The decision is by payload content (tenant, region, feature flag), not by URL pattern. That makes the pattern more flexible than path-based proxying: you can roll the new service out to one tenant at a time, watch it under production traffic, then widen. Behind a proxy (nginx, Envoy, Istio) the same logic becomes a Lua or WASM filter or an EnvoyFilter at the mesh layer; the resource version shows the shape. +> +> Parsing the body costs CPU. Over a migration of months that cost adds up; over a migration of days it is small next to the risk control it buys. + +## [207] APPENDIX K · DECORATOR · The decorating collaborator (sldId 462) + +The new service wraps the legacy one, adds capability, then becomes the API. Useful when you can't modify the legacy code. + +p. 182 + +> Different shape from the proxy patterns: the decorator owns behaviour, not just routing. The new Quarkus service sits in front of the legacy API and adds cross-cutting capability: caching, audit, validation, enrichment, fan-out to Kafka, rate-limiting, metrics. The legacy service is unaware. Clients now talk to the decorator's API, which delegates for the underlying work but contributes everything else. Over time, the decorator can take ownership of more behaviour. Particularly useful when the legacy system is a 3rd-party product, a vendor system, or a piece of code so fragile that touching it is forbidden. The decorator is a real Quarkus microservice (its own deploy, its own scaling, its own data) — not just a router. Common implementation: Quarkus + REST Client Reactive + Redis client + Reactive Messaging emitter. + +## [208] APPENDIX K · DECORATOR · A decorating collaborator in Quarkus (sldId 463) + +``` +java · Quarkus REST + REST Client + Redis + Reactive Messaging +``` + +``` +@Path("/orders") +public class OrdersDecorator { + @Inject @RestClient LegacyOrdersClient legacy; + @Inject @Channel("order-placed") Emitter events; + final ValueCommands cache; + public OrdersDecorator(RedisDataSource redis) { + this.cache = redis.value(Order.class); + } + @GET @Path("/{id}") + public Order getOrder(String id) { + Order cached = cache.get("order:" + id); // 1. cache hit + if (cached != null) return cached; + Order order = legacy.get(id); // 2. legacy on miss + cache.setex("order:" + id, 60, order); // 3. populate + return order; + } + @POST + public Order placeOrder(Order body) { + Order order = legacy.create(body); // 1. legacy first + events.send(order); // 2. emit event + cache.del("order:" + order.id()); // 3. invalidate + return order; + } +} +``` + +The decorator adds caching, event emission — capabilities the legacy service can't add on its own. + +p. 183 + +> The Quarkus decorator. RedisDataSource is the Quarkus Redis client (quarkus-redis-client) with type-safe commands; value(Order.class) serializes the Order for you. The GET path tries the cache, falls back to the legacy service through the REST Client on a miss, and populates the cache with a 60-second TTL. +> +> The POST path delegates the write to the legacy service, which stays the source of truth, then emits a Kafka event through a Reactive Messaging Emitter on the order-placed channel, then invalidates the cache key. The legacy service does not know it is wrapped, and clients do not know they talk to a decorator. +> +> Over time more behaviour moves into the decorator: validation rules, idempotency keys, deduplication, multi-source reads. To retire the legacy service, implement the order storage inside the decorator (or in a new service it calls) and stop forwarding; the API stays the same for clients. + +## [209] APPENDIX K · DECISIONS · Choosing the right migration pattern (sldId 464) + +- Modular monolith first, microservices later — the cheapest decomposition is often inside one Quarkus process. Many systems never need to leave this state. +- Decompose the database before decomposing the deployment. The data split is the hard part; doing it with multiple Quarkus datasources is much cheaper than coordinating across services. +- Always introduce the proxy first — the routing layer is what makes incremental cutover possible. No proxy = no incremental migration. +- Strangler with shared database is a transition state. Plan its end before you start it. Schema coupling is debt that accrues. +- Content-based routing when you need to cut over by tenant / region / segment instead of by URL. Costs CPU at the proxy; buys precise risk control. +- Decorating collaborator when you need to add behaviour to a system you can't modify. Most useful for vendor / 3rd-party / legacy-mainframe wrappers. +- Reversibility is the design property to optimise for. Every migration step should be undo-able by a config change, not a release. + +p. 184 + +> Close the appendix with the decision rules. The first point is the most important and most often skipped: a modular monolith is frequently a fine destination. Microservices have real operational cost, and not every system warrants paying it. The second point is the most underrated: the database split is where projects get stuck for years — do it early, while still one Quarkus process, when it's cheaper to redo. The third point is the platform discipline: no proxy, no incremental migration. The fourth point is the schedule discipline: shared-database strangler is OK as a means but never as an end. The fifth and sixth are about choosing the right pattern. The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release. Migrations fail when they reach an irreversible step too early. + +## [210] Appendix L · Caching patterns (sldId 465) + +L + +Six patterns for keeping a cache useful and your data correct. + +> Appendix L is the caching reference. Five reasons to cache and six patterns to do it well. The patterns differ in who reads/writes the cache, when, and who owns the consistency story. We use Redis as the canonical cache because it's the default open-source choice on Kubernetes; same patterns apply to in-memory caches or any KV store. CDN and HTTP-layer caching is mentioned in passing but isn't this appendix's focus. Code uses quarkus-redis-client (Mutiny reactive API) throughout. + +## [211] APPENDIX L · WHY CACHE · Five things a cache earns you (sldId 466) + +- Application performance — a cache hit is microseconds; a database round trip is milliseconds. The hot path becomes ~100x faster on hits. +- Backend load — caching shields the database from repeated reads of unchanged data. Read-heavy workloads can offload 80-95% of queries with a sensible TTL. +- Throughput — the same Pod fleet serves dramatically more requests because it spends less time waiting on the DB. Vertical scaling without buying anything. +- Predictable performance — p99 latency stops being a function of DB load. Tail latencies flatten because the hot path doesn't contend on the same lock-bound resource as everyone else. +- Database cost — fewer queries means smaller (or fewer) DB instances, smaller IOPS budgets, less replication bandwidth. Cache is almost always cheaper than the DB tier it offloads. + - Trade-off: every cache adds a consistency story, an invalidation story, and a failure-mode story. Pick the pattern that matches the consistency you need from the six patterns in this appendix. + +p. 185 + +> Five concrete reasons that justify the caching investment. Start with application performance — the user-facing speedup is the visible win. Backend load matters more in steady state: shielding the database is what lets your DB tier stay smaller and cheaper than your app tier. Throughput follows from both. Predictable performance is the operational win that is hard to measure: a cache layer flattens p99. Database cost is the financial win that often pays for the entire caching infrastructure. The sub-bullet at the end is what trips teams up: every cache adds complexity; pick the pattern that matches the consistency you need. + +## [212] APPENDIX L · CACHE-ASIDE · Cache-aside (look-aside) — the default (sldId 467) + +The application reads cache, falls back to DB, populates cache. Explicit and simple; the most common pattern. + +p. 186 + +> The pattern everyone starts with. The Quarkus application is in charge: try the cache first, on a miss query Postgres via Panache, populate the cache on the way back, return. Every other pattern in this appendix differs from this one in who does the work — cache-aside is the baseline. Pros: explicit, debuggable, no magic. Cons: every read has the if/else; cache miss costs two round trips; cache logic is repeated across services if you don't extract it. The TTL is the consistency knob — longer = fewer DB hits, more stale-read risk. Use this when you want explicit control and reads dominate writes. + +## [213] APPENDIX L · CACHE-ASIDE · Cache-aside in Quarkus with Redis (sldId 468) + +[chip] java · Quarkus REST + Panache + Redis + +```java +@Path("/products") +public class ProductResource { + private static final int TTL_S = 60; + private final ValueCommands cache; + public ProductResource(RedisDataSource redis) { + this.cache = redis.value(Product.class); + } + @GET @Path("/{id}") + public Product get(String id) { + String key = "product:" + id; + Product cached = cache.get(key); // 1. cache lookup + if (cached != null) return cached; // hit + Product p = Product.findById(id); // 2. DB on miss (Panache) + if (p == null) throw new WebApplicationException(404); + cache.setex(key, TTL_S, p); // 3. populate + return p; + } + @PUT @Path("/{id}") @Transactional + public Product update(String id, Product body) { + Product p = Product.findById(id); + p.update(body); + cache.del("product:" + id); // 4. invalidate on write + return p; + } +} +``` + +Try cache; on miss, Panache query and populate. Invalidate on write. Three operations; the rest is naming conventions and TTL choices. + +p. 187 + +> Quarkus cache-aside. ValueCommands is the typed value-command interface of quarkus-redis-client, and the Product class is serialized for you. The GET path looks in the cache, falls back to a Panache query on a miss, and populates the cache with a 60-second TTL. The PUT path updates the row in a transaction and then deletes the cache key; setting the new value instead is write-through. +> +> A Redis outage should not take reads down: wrap the cache calls so a failure falls through to the database and is logged, as the example's SafeRedis wrapper does. For high-cardinality keys, set a maxmemory-policy on Redis (allkeys-lru is typical) so the cache cannot grow without bound. + +## [214] APPENDIX L · READ-THROUGH · Read-through — the cache fetches on miss (sldId 469) + +Application sees one interface; the cache library handles DB fallback transparently. + +p. 188 + +> The variation where the cache layer, not the application, calls the database on a miss. The app calls cache.get() and gets the value back — doesn't know or care whether the cache had it or had to fetch. For Quarkus, the cleanest expression is via Quarkus Cache annotations (@CacheResult / @CacheInvalidate from quarkus-cache, which can be backed by Caffeine or Redis). The cache library handles miss-time loading; you write the loader as a normal method, annotated. Pros: cleaner application code, single read interface. Cons: less control over what triggers a DB hit. Use when read paths are repetitive across the codebase and you want to eliminate the if/else from every caller. + +## [215] APPENDIX L · READ-THROUGH · Read-through in Quarkus with @CacheResult (sldId 470) + +[chip] java · Quarkus Cache + quarkus-redis-cache + +```java +// Quarkus Cache annotations give read-through semantics declaratively. +// quarkus-cache + quarkus-redis-cache; loader = the method body. +@ApplicationScoped +public class ProductService { + @CacheResult(cacheName = "product-cache") // runs only on a miss + public Product findById(String id) { + Product p = Product.findById(id); // Panache + if (p == null) throw new WebApplicationException(404); + return p; + } + @CacheInvalidate(cacheName = "product-cache") + @Transactional + public Product update(String id, Product body) { + Product p = Product.findById(id); + p.update(body); + return p; + } +} +// ProductResource calls products.findById(id): no if/else; the cache is invisible. +// application.properties: +// quarkus.cache.type=redis +// quarkus.cache.redis.product-cache.expire-after-write=60S +``` + +@CacheResult turns the method body into a loader. Callers see one interface; the cache miss path is invisible. + +p. 189 + +> Quarkus Cache is the idiomatic read-through expression — the @CacheResult annotation transforms findById() into a cache-then-load pattern: cache lookup first, method body runs only on miss. The cache name (product-cache) gives you per-cache TTL and configuration via application.properties. For Redis backing, add quarkus-redis-cache; for in-memory Caffeine, use quarkus-cache-caffeine. The @CacheInvalidate on update() removes the cache entry after a successful write — standard write-with-invalidate paired with read-through. The trade vs explicit cache-aside: you trade explicit if/else for declarative annotations and the discipline of using the service layer (never calling Panache directly from the resource). For a large codebase that's worth it; for a tiny one it's overengineering. The annotation-based approach also gives you @CacheKey for fine-grained key construction when the method takes multiple parameters. + +## [216] APPENDIX L · WRITE-THROUGH · Write-through — cache and DB written synchronously (sldId 471) + +Every write hits both the cache and the DB. Cache is never stale; writes pay both latencies. + +p. 190 + +> When stale reads are not acceptable, write-through forces every write to update both stores. The Quarkus app calls cache.set(...) and Panache update(...) and both must succeed before the response returns. Pros: a read immediately after a write sees the new value; the cache is never stale for the writer's view. Cons: write latency is the sum of both stores; you have to handle the case where the cache write succeeds but the DB write fails (or vice versa). The usual discipline: write the DB first inside a @Transactional boundary, then write the cache. Accept that a failure between them means the cache may briefly hold a stale value. Pair with a short TTL so any inconsistency self-heals. + +## [217] APPENDIX L · WRITE-THROUGH · Write-through in Quarkus (sldId 472) + +[chip] java · Quarkus REST + Panache + Redis + +```java +// Quarkus blocking: write the DB first, SET the cache second. +@Path("/products") +public class ProductResource { + private final ValueCommands cache; + public ProductResource(RedisDataSource redis) { + this.cache = redis.value(Product.class); + } + @PUT @Path("/{id}") @Transactional + public Product update(String id, Product body) { + Product p = Product.findById(id); // 1. DB first (source of truth) + p.update(body); + cache.setex("product:" + id, 60, p); // 2. cache second — SET, not invalidate + return p; + } +} +``` + +DB first (source of truth), cache second: the managed entity is what gets cached. + +p. 191 + +> Write-through in Quarkus. The update loads the row through Panache, applies the change inside the transaction, and caches the managed entity: the canonical value, including server-side defaults. The DB write comes first because the DB is the source of truth; if the cache write fails, you lose cache freshness, not data. +> +> Writing the request body straight to the cache would save a read but can diverge from the row when the database applies defaults, triggers or computed columns. The TTL is the safety net: if cache and DB drift briefly, they converge when the entry expires and the next read repopulates it. + +## [218] APPENDIX L · WRITE-AROUND · Write-around — writes skip the cache (sldId 473) + +Writes go to the DB only. The next read populates the cache lazily. Good for write-heavy, low-re-read workloads. + +p. 192 + +> When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful: most cached entries expire before being read again. Write-around solves this: write the DB, leave the cache alone. The next reader pays the cache-miss cost and populates the cache. Pros: no cache pollution by write-only data. Cons: the first read after every write is slow (cache miss); reads of recently-written data are slower than under cache-aside. Pair with cache-aside or read-through on the read side. Common use: any 'append-only' or 'mostly-written' workload — events, log lines, audit entries, IoT telemetry where you read summaries, not individual entries. + +## [219] APPENDIX L · WRITE-AROUND · Write-around in Quarkus (sldId 474) + +[chip] java · Quarkus REST + Panache + Redis + +```java +// Quarkus blocking: write the DB only; read side is cache-aside. +@Path("/events") +public class EventResource { + private final ValueCommands cache; + public EventResource(RedisDataSource redis) { + this.cache = redis.value(Event.class); + } + @POST @Transactional + public Response record(Event body) { + body.persist(); // DB only — cache untouched + return Response.created(URI.create("/events/" + body.id)).entity(body).build(); + } + @GET @Path("/{id}") + public Event get(String id) { + String key = "event:" + id; + Event cached = cache.get(key); // standard cache-aside read + if (cached != null) return cached; + Event e = Event.findById(id); + if (e == null) throw new WebApplicationException(404); + cache.setex(key, 60, e); + return e; + } +} +``` + +Writes skip the cache; first read populates. Avoids cache thrash from one-time writes. + +p. 193 + +> Write-around: the write goes to the database through Panache and the cache is not touched; the read side is plain cache-aside, so the first read populates the entry. If a concurrent reader may have cached a stale sibling entry, delete that key after the insert; it is cheap insurance on high-contention paths. +> +> The pattern fits workloads where most writes are never read back. A security event log might take 10,000 writes a minute and 100 reads a day for active alerts; caching every write would churn entries. A product catalog is the opposite: every read is a probable hit, so write-through makes the writer's update visible to the next reader. + +## [220] APPENDIX L · WRITE-BACK · Write-back (write-behind) — fast writes, eventual durability (sldId 475) + +Writes return immediately after touching the cache; a background flusher persists to the DB asynchronously. + +p. 194 + +> The fastest-write pattern, with the most caveats. The Quarkus app writes to the cache and returns an immediate ack — the cache is the source of truth for that window. A background @Scheduled drains the cache to the DB, batching writes for efficiency. Pros: write latency equals cache latency — microseconds, not milliseconds; massive throughput. Cons: a cache crash before the flusher catches up loses unflushed writes; the cache must be configured for persistence (Redis AOF or RDB), and even then you might lose seconds of data. Use this pattern only when (a) write throughput is the constraint, (b) eventual durability is acceptable, and (c) you've worked out what 'lost the last 5 seconds of writes' means for your business. + +## [221] APPENDIX L · WRITE-BACK · Write-back with @Scheduled in Quarkus (sldId 476) + +[chip] java · Quarkus + @Scheduled + Redis + JDBC + +```java +// CacheResource + BackgroundTasks (excerpt), examples/25-caching/quarkus: write-back +@PUT @Path("write-back/metrics/{mid}") // CacheResource +public Response writeBackWrite(@PathParam("mid") String mid, Map body) { + // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down) + return Response.accepted(Map.of("ok", true, "pattern", "write-back", + "action", "cached-for-flush")).build(); // 202: queued, not yet durable +} +@Scheduled(every = "${cache-service.flush-period:1s}") // concurrent runs are skipped +void flusher() { // BackgroundTasks + if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure + Set ids = cache.spop("metric:dirty", FLUSH_BATCH); + if (ids == null || ids.isEmpty()) return; + List failed = new ArrayList<>(); + for (String mid : ids) { + try (var conn = dataSource.getConnection(); var ps = conn.prepareStatement( + "INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " + + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()")) { + // ... bind mid and the cached hash as JSON; executeUpdate (one row per id) + cache.hdel("metric:attempts", mid); + } catch (Exception e) { failed.add(mid); } // Log.errorf: flush failed for + } + if (failed.isEmpty()) { failures = 0; return; } + nextFlushAt = System.currentTimeMillis() + Math.min(FLUSH_PERIOD_MS << ++failures, 8000); + requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5 +} +``` + +A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed. + +p. 195 + +> Quarkus write-back, as Example 25 runs it. (1) PUT /write-back/metrics/{mid} writes the metric to a Redis hash, adds the id to a dirty set, which tells the flusher what still has to reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) A @Scheduled method runs every second (a run is skipped while one is in flight), pops up to FLUSH_BATCH dirty ids with SPOP (atomic, so two flusher instances get distinct subsets and the flusher scales out) and upserts each one over JDBC with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice stays one row. (3) An id whose upsert fails is re-queued: its count in the metric:attempts hash goes up and it goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in the cache and the failure in the log. After a failed run the flusher waits two seconds, then four, then eight before it tries again. +> +> This is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale. + +## [222] APPENDIX L · REFRESH-AHEAD · Refresh-ahead — keep hot keys warm (sldId 477) + +A background task refreshes hot keys before their TTL expires. Users never see a cold miss on popular items. + +p. 196 + +> The pattern for eliminating the 'cache miss latency cliff' on popular keys. Plain cache-aside has a sharp behaviour: while a key is cached, reads are fast; the moment its TTL expires, the next reader pays the slow DB-fetch path. For homepage data, top-products, frequently-queried entities, that latency spike is user-visible. Refresh-ahead pre-empts this: a background task watches the cache (or maintains a set of 'hot' keys) and refreshes their values from the DB before the TTL fires. Users always read warm cache. Pros: no user-visible misses on the keys you mark as hot. Cons: extra DB load for keys that may not be requested again; you need a way to identify which keys are hot. + +## [223] APPENDIX L · REFRESH-AHEAD · Refresh-ahead in Quarkus (sldId 478) + +[chip] java · Quarkus + @Scheduled + Redis sorted set + +```java +// Quarkus blocking: @Scheduled refreshes hot keys before TTL. +@ApplicationScoped +public class ProductService { + private static final int TTL_S = 60, REFRESH_BEFORE_S = 10; + private final ValueCommands cache; + private final SortedSetCommands hot; + private final KeyCommands keys; + public ProductService(RedisDataSource redis) { + this.cache = redis.value(Product.class); + this.hot = redis.sortedSet(String.class); + this.keys = redis.key(); + } + // findById(): zadd("product:hot", now, id) on every read, then cache-aside + @Scheduled(every = "5s") + void refreshHot() { + long cutoff = System.currentTimeMillis() - 5 * 60_000L; + hot.zremrangebyscore("product:hot", 0, cutoff); + for (String id : hot.zrange("product:hot", 0, -1)) { + long ttl = keys.ttl("product:" + id); + if (ttl > 0 && ttl < REFRESH_BEFORE_S) { // danger zone + Product p = Product.findById(id); + if (p != null) cache.setex("product:" + id, TTL_S, p); // refresh + } + } + } +} +``` + +Hot keys live in a Redis sorted set scored by last-access time. Refresh those with low TTL. + +p. 197 + +> Refresh-ahead in Quarkus. A Redis sorted set tracks which keys are hot, scored by the last access time; findById touches it on every read. A @Scheduled task runs every 5 seconds: it drops keys not read in the last 5 minutes (ZREMRANGEBYSCORE), then for each remaining key checks the TTL and, when it is under REFRESH_BEFORE_S, re-reads the row and resets the entry. +> +> A variant subscribes to Redis keyspace notifications for expiry events and refreshes on the event; it is cleaner but needs more setup. The pattern fits read-heavy workloads where a small set of keys serves most traffic and miss latency is visible to users. + +## [224] APPENDIX L · ANTI-PATTERNS · When not to cache, and what to watch for (sldId 479) + +- Don't cache by default. Caching adds a consistency story, an invalidation story, and a failure-mode story. If reads are already fast, just don't add a cache — the cheapest cache is no cache. +- Don't cache rarely-read data. Caching write-once-read-never data thrashes your cache and gains nothing. Profile your read patterns first. +- Don't cache without a TTL. Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable. Always set explicit expire-after-write or maxmemory-policy on Redis (allkeys-lru typical). +- Avoid cache stampedes (thundering herd): when a hot key expires and many concurrent Quarkus Pods all miss at once, they all hit the DB. Mitigate with a Redis lock per key (only one fetches, rest wait), or refresh-ahead, or jittered TTLs. +- Watch for cache aside + write-around without invalidation — the write goes to the DB but a stale cached value persists for the full TTL. Invalidate on every write, or use a short TTL. +- Treat the cache as a performance optimisation, never as the source of truth. A Redis outage must mean slower reads, not data loss — always have a DB fallback path. Test this by stopping Redis in staging. + +p. 198 + +> The six things that go wrong with caching. The first point is the most important and most often missed: caching is a complexity tax, only worth paying when reads hurt. The second is the write-around lesson: profile first. The third is operational — an unbounded Redis with no eviction policy will OOM-kill itself. The fourth is the cache stampede problem: hot key expires → 100 concurrent Quarkus Pods all miss → 100 simultaneous DB reads → DB tips over. Mitigations: distributed lock per key (only one fetches, the rest wait); refresh-ahead; TTL jitter (add random ms to TTL so similar keys don't expire together). The fifth is the most common stale-data bug. The bold final line is the architecturally important one: the cache is never the source of truth. If Redis dies, you must serve slower reads, not return errors. Build the DB fallback path; test it by killing Redis in staging. + +## [225] APPENDIX L · DECISIONS · Choosing the right caching pattern (sldId 480) + +- Cache-aside — the default. Use when you want explicit control and reads dominate writes. The pattern everyone should know first. +- Read-through (Quarkus Cache annotations) — use when the read path is repetitive across the codebase and you want declarative caching. Same semantics as cache-aside; cleaner code. +- Write-through — use when zero stale-read risk after a write matters more than write latency. Read-heavy with infrequent writes is the sweet spot. +- Write-around — use when writes far outnumber re-reads. Avoids cache pollution by write-once data. +- Write-back — use only when extreme write throughput is the constraint, eventual durability is acceptable and your Redis is configured for persistence. +- Refresh-ahead — use when a small set of hot keys serves most traffic and miss latency is unacceptable. Adds DB load for the hot set; eliminates user-visible misses. +- Most production Quarkus systems use cache-aside (or @CacheResult) for 95% of cases, with refresh-ahead on a small set of identified hot keys and write-through where stale-read is forbidden. Mix patterns; don't pick one as the rule. + +p. 199 + +> The decision rules to leave audience with. Cache-aside (or Quarkus @CacheResult read-through) is the default and the right pattern for most reads. Write-through trades write latency for read consistency, which pays off when stale reads after writes are harmful (financial, healthcare, regulatory). Write-around is the right pattern for telemetry / events / logs / any append-heavy workload. Write-back is the niche extreme-throughput pattern, with serious caveats. Refresh-ahead is the hot-key pattern. The bold final line is the take-home: don't pick one pattern as 'the' cache strategy. Use cache-aside as the default, layer refresh-ahead on the hot keys you identify, use write-through where consistency demands it, write-around for telemetry. Different read/write profiles want different patterns; matching them is the engineering work. + +## [226] Appendix M · Failure modes (sldId 481) + +M + +Where it all breaks — and the patterns that hold. + +> Appendix M is the synthesis appendix — it brings together error handling, graceful shutdown, observability and the resilience patterns from across the deck, and points them at the specific ways distributed systems fail. If you can only take one slide from this appendix, take the matrix at the end: it maps each failure mode to the patterns that address it. This appendix references section 11 (Observability), section 12 (Security — bulkheads), and Appendix H (Graceful shutdown); it is the place where those threads are tied together. If this runs long in your session, it stands alone and can be presented on its own. + +## [227] APPENDIX M · Distributed systems fail differently (sldId 482) + +- In a single process, an operation succeeds or it throws. Across a network, a call can succeed, fail, hang forever, succeed-but-the-response-is-lost, or succeed-slowly — and you often cannot tell which. +- Partial failure is the defining trait: some dependencies are up, some down, some degraded, all at the same time. There is no global 'the system is up'. +- Failures are not independent: one slow component can exhaust a caller's resources and take down healthy services around it. Failure spreads. +- Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and what users experience diverge. +- The goal is not zero failures; that is impossible. The goal is to bound the blast radius and recover automatically. All management is risk management. + - This appendix: six failure modes, the defensive patterns that address them (with code), Istio's mesh-level role, graceful shutdown, how to observe failures, and a mapping matrix. + +p. 200 + +> Set the frame. The single most important conceptual shift for developers moving from monoliths is partial failure: in a monolith a function call either returns or throws, synchronously, in-process. Across a network there is a whole spectrum of failure, including the worst kind — the call that hangs, or the one that succeeded on the server but whose response was lost, leaving the client unsure whether to retry. Emphasise that failures are correlated (one failure causes others) and that some failures are actively deceptive (gray failures). Land the bold point hard: we are not trying to prevent failure, we are trying to bound it and recover. This reframes everything that follows from 'how do we stop failures' to 'how do we contain and survive them.' + +## [228] APPENDIX M · A taxonomy of failure modes (sldId 483) + +Network failures, component failures, load failures — and the defensive toolkit that addresses them. + +p. 201 + +> The map for the appendix. Three families. NETWORK failures: partition (nodes alive but unreachable) and split-brain (both sides act as leader). COMPONENT failures: partial (some deps fail) and gray (some degrade and are perceived differently by different observers). LOAD failures: amplification (a failure feeds on itself) and cascading (failure spreads to neighbours). Underneath sits the defensive toolkit — timeouts and deadlines, retry with backoff and jitter, circuit breakers, bulkheads (section 12), load shedding, fallback/degradation, quorum/consensus, Istio mesh policies, graceful shutdown (Appendix H), and observability (section 11). Every pattern addresses specific failure modes; the closing matrix maps which to which. Reconnect to the section-1 maxim: all management is risk management — you manage the distribution of failures, not their existence. + +## [229] APPENDIX M · NETWORK · Network partition (sldId 484) + +Nodes are alive and healthy; they cannot reach each other. CAP forces a choice: consistency or availability. + +p. 202 + +> See the diagram notes. The key teaching point: a partition is not a crash. Every node is healthy and running; the network between them is the thing that failed. This is what makes partitions so confusing to debug — every individual component looks fine. CAP theorem becomes concrete here: while partitioned, you choose to stay consistent (the minority side refuses writes, sacrificing availability) or available (both sides accept writes, sacrificing consistency, reconciling later). Most data systems let you tune this. Kubernetes itself uses etcd, which chooses consistency (the minority side of a control-plane partition stops accepting writes). Address with quorum/consensus, idempotent writes (so a retried write after heal is safe), and partition-tolerant design. + +## [230] APPENDIX M · NETWORK · Split-brain (sldId 485) + +A partition where both sides think they are the leader and both accept writes. State diverges; the worst partition outcome. + +p. 203 + +> See the diagram notes. Split-brain is the specific catastrophic outcome of a partition in a system that elects a leader or primary. Both sides lose contact, each assumes the other died, each promotes itself, and now you have two primaries accepting conflicting writes. On heal you have divergent state and no clean way to merge. The prevention is quorum: an action requires a majority (N/2 + 1) of nodes to agree, so a minority partition mathematically cannot act — it knows it is the minority and steps down. This is why cluster sizes are odd (3, 5, 7): to always have a clear majority. Fencing tokens (monotonically increasing IDs that let resources reject a stale leader's writes) and leases are complementary. etcd/Raft give the Kubernetes control plane this property out of the box. + +## [231] APPENDIX M · COMPONENT · Partial failure (sldId 486) + +The defining trait of distributed systems: some dependencies fail while others keep working. Handle each leg. + +p. 204 + +> See the diagram notes. This is the slide for developers moving from monoliths. In a monolith, the order service and the inventory service are function calls in the same process — if one is 'down' the whole process is down. Distributed, the order service calls inventory (healthy), payment (down), and shipping (slow) and must make a per-dependency decision for each: use the healthy result, fall back or fail for the down one, time out and fall back for the slow one. The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller. Address with per-call timeouts, per-dependency circuit breakers, fallbacks, and bulkheads. The next several slides are exactly these patterns, with code. + +## [232] APPENDIX M · COMPONENT · Gray failure (sldId 487) + +The insidious one: a component is degraded and is perceived differently by different observers. Liveness is green; users suffer. + +p. 205 + +> See the diagram notes. Gray failure is the failure mode that defeats naive health checking. The classic shape: a node is slow or dropping a fraction of requests, but its liveness probe (GET /healthz returning 200 from a trivial handler) still passes, so Kubernetes keeps it in rotation and the load balancer keeps sending it traffic. The system believes it is healthy; users experience errors and latency. This is 'differential observability' — the observer that matters (the client) sees something different from the observer that decides (the probe). The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims. This ties directly to section 11. + +## [233] APPENDIX M · LOAD · Amplification loops & retry storms (sldId 488) + +A failure triggers retries, which add load, which cause more failures. Backoff, jitter and retry budgets break the loop. + +p. 206 + +> See the diagram notes. Amplification is a positive feedback loop: the system's own defensive behaviour (retrying) becomes the attack. Two compounding effects. First, retry multiplication across layers: if the gateway retries 3x, and it calls a service that retries 3x, which calls a service that retries 3x, one user request becomes 27 requests to the bottom of the stack. Second, synchronisation: every client that failed at the same moment retries at the same moment, creating a thundering herd. The result can be metastable — the system stays collapsed even after the original trigger is gone, because the retry load alone sustains the failure. Breaking it: exponential backoff with jitter (de-synchronise), retry budgets (cap retries to a small percentage of live traffic so they can never multiply unboundedly), retry at exactly one layer, and circuit breakers to stop the doomed calls entirely. This is the failure mode most often caused by well-intentioned resilience code. + +## [234] APPENDIX M · LOAD · Cascading failure (sldId 489) + +One component's failure overloads its neighbours, which fail in turn — the domino effect up the call graph. + +p. 207 + +> See the diagram notes. Cascading failure is how a single slow dependency takes down an entire system. The mechanism: a slow database causes the calling service's request threads (or async tasks, or connection pool) to fill up waiting; with its resources exhausted, that service now fails or slows for all callers, not just the ones touching the database; its callers' resources then fill up waiting on it; and so on up the graph until the user-facing edge is down. The amplifiers are synchronous call chains and unbounded resources (unbounded queues, unbounded thread pools, no timeouts). Each defensive pattern breaks one link: timeouts stop threads blocking forever; bulkheads isolate the pool so the database calls can't consume all of it; circuit breakers stop calling the slow dependency entirely; load shedding rejects excess work at the edge before it propagates. This is the failure mode bulkheads (section 12) were invented for. + +## [235] APPENDIX M · The defensive toolkit — patterns and what they buy (sldId 490) + +- Timeout + deadline propagation — never wait forever; pass the remaining budget downstream so nobody does doomed work. Defends: partial, cascading. +- Retry with backoff + jitter — ride out transient failures without building a storm; only for idempotent calls; use a retry budget. Defends: partial, amplification (if done right). +- Circuit breaker — stop calling a failing dependency; fail fast; test for recovery. Defends: cascading, amplification, gray. +- Bulkhead (§12) — isolate resources by tenant/call/priority so one failure can't starve the rest. Defends: partial, cascading. +- Load shedding — when overloaded, reject excess early (503) rather than collapsing. Defends: amplification, cascading. +- Quorum / consensus — majority-required action; the only real answer to split-brain. Defends: partition, split-brain. +- Fallback / graceful degradation — return a useful degraded response (cache, default, queue-for-later) instead of an error. Defends: partial, gray. +- Istio mesh policies + graceful shutdown + observability: the platform layer underneath all of these patterns. Each one gets code, and a matrix maps patterns to modes. + +p. 208 + +> The toolkit overview before the deep dives. Each line names a pattern and the failure modes it defends against — this is the verbal version of the closing matrix. The thing to stress: no pattern is sufficient alone, and several patterns interact (retry without a circuit breaker amplifies; a circuit breaker without a fallback just fails faster; bulkheads without timeouts still let threads block within a pool). Production resilience is the composition of several of these, tuned together. Each gets its own diagram and per-language code so developers can implement them; the bold line flags that the platform-level pieces (Istio, shutdown, observability) sit underneath the application-level patterns. + +## [236] APPENDIX M · TIMEOUTS · Timeouts + deadline propagation (sldId 491) + +Every remote call needs a timeout; the remaining budget should travel downstream so nobody does doomed work. + +p. 209 + +> See the diagram notes. Two ideas. First, every remote call must have a timeout — the default in many HTTP clients is infinite, which is how a single slow dependency hangs a whole service. Second, deadline propagation: the original deadline (say 1000ms) travels with the request, and each hop subtracts the time it has used and passes the remainder downstream. When the remaining budget is too small to plausibly finish, the downstream fails immediately rather than starting work that will be thrown away. gRPC does this natively with deadlines; for HTTP you pass a deadline header and honour it. The waste this prevents is real: without it, your database spends 500ms on a query for a request the user abandoned 450ms ago, and that wasted work is exactly what tips an overloaded system over. The code example shows the language-specific mechanics. + +## [237] APPENDIX M · TIMEOUTS · Timeouts + deadlines (sldId 492) + +[chip] java · Quarkus + MicroProfile Fault Tolerance + +```java +@ApplicationScoped +public class PaymentService { + @Inject @RestClient PaymentClient client; + @Timeout(value = 2000) // MicroProfile: hard 2s ceiling + public Uni charge(Order o, long deadlineMs) { + long remaining = deadlineMs - elapsedMs(); + if (remaining < 50) // too little budget to start + return Uni.createFrom().failure(new DeadlineExceeded()); + return client.charge(o, remaining); // propagate remaining budget + } +} +// application.properties — client-level timeouts (the real defence) +// quarkus.rest-client.payment.connect-timeout=500 +// quarkus.rest-client.payment.read-timeout=2000 +``` + +p. 210 + +> @Timeout from MicroProfile Fault Tolerance puts a hard ceiling on the method. The more important defence is the REST client's own connect/read timeouts in application.properties — without those, the @Timeout fires but a socket can still hang a thread underneath. The method also honours an inbound deadline and propagates the remaining budget to the downstream client call. Quarkus REST Client propagates OpenTelemetry context automatically; the deadline is an explicit business parameter here. + +## [238] APPENDIX M · RETRY · Retry with exponential backoff + jitter (sldId 493) + +Retry transient failures — but spread retries with exponential backoff + jitter, cap them, and only for idempotent calls. + +p. 211 + +> See the diagram notes. Retry is the most abused resilience pattern because the naive version (retry immediately, a fixed number of times, at every layer) is precisely how you build the amplification storm from the amplification-loop discussion. The correct version has four properties: exponential backoff (each retry waits longer: 100ms, 200ms, 400ms), jitter (add randomness so clients don't synchronise), a cap on both attempts and total time, and a retry budget (retries may never exceed, say, 10% of live traffic). And it only applies to idempotent operations — retrying a non-idempotent POST without an idempotency key can double-charge a customer. Always pair retry with a circuit breaker so that once a dependency is clearly down, you stop retrying entirely. The code example shows the idiomatic library for each language. + +## [239] APPENDIX M · RETRY · Retry with backoff + jitter (sldId 494) + +[chip] java · Quarkus + MicroProfile Fault Tolerance + +```java +@ApplicationScoped +public class QuoteService { + @Inject @RestClient QuoteClient client; + @Retry(maxRetries = 3, // hard cap on attempts + delay = 100, delayUnit = MILLIS, // base backoff + jitter = 50, jitterUnit = MILLIS, // de-synchronise clients + retryOn = TransientException.class) // transient failures only + @CircuitBreaker(requestVolumeThreshold = 10, // always pair with a breaker + failureRatio = 0.5, delay = 5000) + public Quote quote(String symbol) { // idempotent GET + return client.getQuote(symbol); + } +} +// Retry wraps the breaker; an open breaker fails each retry fast. +// Exponential backoff: add @ExponentialBackoff (SmallRye extension). +``` + +p. 212 + +> MicroProfile @Retry declares max attempts, base delay, jitter, and which exceptions to retry on — the four correct properties declaratively. Stacking @CircuitBreaker on the same method is the canonical pairing: the breaker wraps the retry, so once the dependency trips the breaker, retries stop entirely. SmallRye adds @ExponentialBackoff for true exponential growth (the base @Retry is linear delay + jitter). As always: idempotent operations only. + +## [240] APPENDIX M · CIRCUIT BREAKER · Circuit breaker — the state machine (sldId 495) + +Closed → Open (fail fast) → Half-Open (test) → Closed. Stops doomed calls; gives the dependency room to recover. + +p. 213 + +> See the diagram notes. The circuit breaker is the pattern that most directly stops cascading and amplification. Three states. CLOSED: calls pass through, failures are counted over a rolling window. When the failure ratio crosses the threshold, the breaker trips to OPEN: calls fail immediately without even attempting the downstream, which both gives a fast answer to the caller and gives the struggling dependency room to recover instead of being hammered. After a reset timeout the breaker goes HALF-OPEN: it allows a few trial calls; if they succeed it closes (recovered), if they fail it re-opens. Crucial details: one breaker per dependency (never a single global breaker), and pair it with a fallback so that an open breaker degrades gracefully rather than just erroring faster. Libraries differ per language; the code example shows each. + +## [241] APPENDIX M · CIRCUIT BREAKER · Circuit breaker + fallback (sldId 496) + +[chip] java · Quarkus + MicroProfile Fault Tolerance + +```java +@ApplicationScoped +public class PaymentService { + @Inject @RestClient PaymentClient client; + @Inject Outbox outbox; + @CircuitBreaker( + requestVolumeThreshold = 10, // rolling window of 10 calls + failureRatio = 0.5, // open at 50% failures + delay = 5000, // stay open 5s, then half-open + successThreshold = 2) // 2 trial successes → close + @Fallback(fallbackMethod = "queueForLater") // degrade gracefully + public Receipt charge(Order o) { + return client.charge(o); // while OPEN: skipped, fallback runs + } + Receipt queueForLater(Order o) { // graceful degradation + outbox.enqueue(o); + return Receipt.pending(o.id()); + } +} +``` + +p. 214 + +> MicroProfile @CircuitBreaker declares the rolling window, failure ratio to trip, the open-state delay, and how many half-open successes are needed to close. @Fallback names the method that runs when the breaker is open (or the call fails) — here it enqueues to an outbox and returns a pending receipt, turning a hard failure into graceful degradation. This is the canonical breaker+fallback pairing: the breaker decides to stop calling, the fallback decides what to return instead. + +## [242] APPENDIX M · BULKHEAD · Bulkhead — isolate so one failure can't sink the rest (sldId 497) + +- Covered in depth in section 12 (Security) as a blast-radius pattern; it is equally a resilience pattern, so it appears here in the failure-mode context. +- Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. A storm in one pool exhausts only that pool. +- Against cascading failure: if database calls have their own bounded pool, a slow database exhausts that pool and returns errors for DB-backed calls — but the service's other endpoints keep serving from their own pools. +- Against partial failure: a single misbehaving tenant or dependency is contained to its own compartment instead of consuming the shared resource. +- Implementation (from §12): SmallRye Fault Tolerance @Bulkhead — declarative concurrency + waitingTaskQueue caps, paired with @Timeout. At the mesh: Istio DestinationRule connectionPool gives the same isolation at the network layer. + - Pair with timeouts: a bulkhead bounds how many calls run concurrently, but without a timeout those calls can still block forever and keep the bulkhead full. The two patterns need each other. + +p. 215 + +> Bulkhead got full treatment in section 12, so here we just re-frame it in the failure-mode context and cross-reference. The key insight to land: a bulkhead bounds concurrency, but concurrency-bounding alone doesn't help if the bounded calls block forever — you must combine bulkhead (limit how many) with timeout (limit how long). Point back to section 12 for the per-language code (we don't repeat it here to save slides), and forward to the Istio slide for mesh-level bulkheading via connectionPool limits. + +## [243] APPENDIX M · SHEDDING & FALLBACK · Load shedding and graceful degradation (sldId 498) + +- Load shedding — when you are over capacity, reject excess work immediately (HTTP 503 + Retry-After) at the edge, before it consumes resources. A system that sheds 20% of load stays up for the other 80%; a system that accepts everything collapses for 100%. +- Shed by priority: drop health-check-style or low-value traffic first, protect revenue-critical paths. Adaptive concurrency limits (e.g. Netflix's, or Envoy's) shed automatically based on observed latency. +- Graceful degradation — when a dependency is unavailable, return a useful reduced response instead of an error: serve stale cache, omit the personalised section, queue the write for later, show a default recommendation. +- Degradation turns a hard failure into a soft one the user may barely notice. A product page that can't reach the reviews service should still show the product — just without reviews. +- Both are the application-level complement to the circuit breaker: the breaker decides to stop calling; the fallback decides what to return instead; load shedding decides what not to accept in the first place. + - Reference §11: you cannot shed or degrade intelligently without observability — you shed based on measured saturation, and you alert when degradation rates climb. + +p. 216 + +> Two related patterns that often get skipped. Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses. The maths is simple: if you are at 120% of capacity and accept everything, everything is slow and timing out (0% goodput); if you shed the excess 20%, the 80% you accept is served well. Adaptive limiters do this automatically based on latency. Graceful degradation is the product-level decision about what to return when you can't return everything — it requires the product/design team's input on what's essential vs nice-to-have. The bold line ties shedding, breakers and fallback together as a system: stop accepting (shed), stop calling (break), return something useful (fall back). + +## [244] APPENDIX M · ISTIO · Istio — resilience at the mesh layer (sldId 499) + +Timeouts, retries, outlier detection (gray-failure ejection) and circuit breaking — uniform, config-driven, no app code. + +p. 217 + +> See the diagram notes. The service mesh gives you a large slice of the resilience toolkit without writing code in every service, in every language. VirtualService configures per-route timeouts and retries (with Envoy adding backoff and jitter automatically, and retryOn conditions controlling which failures are retried). DestinationRule configures connectionPool limits (mesh-level bulkheading) and outlierDetection — which is the mesh's answer to gray failure: it watches each host, and when one returns consecutive errors it ejects that host from the load-balancing pool for a cooldown period, regardless of what the host's own health check claims. That is exactly the gray-failure defence the health-check can't provide. The important discipline: don't double up. If Istio is retrying, don't also retry in the app (that's amplification). Decide where each concern lives — typically: timeouts and outlier detection at the mesh, business-aware retries and fallbacks in the app. + +## [245] APPENDIX M · SHUTDOWN · Graceful shutdown (see App. H) (sldId 500) + +[chip] java · Quarkus + +```java +// OrderResource + SignalHandler (excerpt), examples/21-graceful-shutdown/quarkus +@GET @Path("readyz") // OrderResource +public Response readyz() { + if (shutdownState.isShuttingDown()) // 1. readiness flips: 503 + return Response.status(503) + .entity(Map.of("ready", false, "reason", "shutting down")).build(); + // ... SELECT 1 on the datasource: 200 {"ready": true}, or 503 "db unreachable" +} +@GET @Path("healthz") // liveness stays 200 while draining +public Map healthz() { return Map.of("status", "ok"); } +void onStart(@Observes StartupEvent ev) { // SignalHandler + Signal.handle(new Signal("TERM"), signal -> { // one SIGTERM drives the drain + if (!shutdownState.markShuttingDown()) return; + new Thread(() -> { + try { Thread.sleep(drainDelaySeconds() * 1000); } // 2. the LB stops routing + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + Quarkus.asyncExit(0); // 3. drain in-flight HTTP (quarkus.shutdown.timeout=20s) + }, "drain").start(); // 4. Quarkus closes pools, exit 0 + }); +} +``` + +p. 218 + +> Example 21 handles SIGTERM itself. A sun.misc.Signal handler, registered on StartupEvent, takes the single SIGTERM: (1) it flips the ShutdownState flag, so the hand-written /readyz answers 503 and Kubernetes stops routing new traffic, while /healthz stays 200; (2) a drain thread keeps the service serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) it calls Quarkus.asyncExit(0), and Quarkus stops accepting and drains in-flight HTTP within quarkus.shutdown.timeout (20 s); (4) Quarkus closes the datasource and the process exits 0. Appendix H covers the Kubernetes-side preStop and terminationGracePeriodSeconds. + +## [246] APPENDIX M · OBSERVE · Observing failure modes (see §11) (sldId 501) + +- Network partition — watch peer-connectivity and cross-zone error metrics; alert on quorum-loss events from etcd / your datastore. +- Split-brain — alert on more-than-one-leader conditions, divergent-replica metrics, and fencing-token rejections in logs. +- Partial failure — RED metrics per dependency (rate, errors, duration), not just per service. A spike in errors to one downstream is the early signal. +- Gray failure — the hard one: compare health-check status against real-traffic SLOs. Latency percentiles (p99) diverging from the average, and Istio outlier-ejection counts, are your tells. +- Amplification — watch request-rate against user-action-rate (if requests >> actions, retries are multiplying), retry-count metrics, and goodput falling while load rises. +- Cascading — a distributed trace shows the error spreading service-to-service; saturation metrics (thread pools, queue depth, connection pools) climb together across services. + - The unifying tool is the trace_id from §11: when an alert fires, pivot from the metric to an exemplar trace to its logs to find which leg failed first and how it spread. + +p. 219 + +> Tie the appendix back to section 11. Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop. The most important specific guidance: measure RED per dependency (so partial failures are visible), compare health-check status to real-traffic SLOs (so gray failures are visible), and watch request-rate vs user-action-rate (so amplification is visible). The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response. + +## [247] APPENDIX M · Which pattern addresses which failure mode (sldId 502) + +Read a row to defend a failure mode; read a column to see what a pattern buys you. Production layers several. + +p. 220 + +> The synthesis slide — if the audience keeps one thing, keep this. Reading across a row tells you the portfolio of patterns that defend a given failure mode (e.g. cascading failure is addressed by timeouts, circuit breakers, bulkheads, load shedding, and graceful shutdown together). Reading down a column tells you what a given pattern buys you across failure modes. Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule. Second, almost every other cell is about assembling a portfolio — there is no single pattern that handles partial, gray, amplification and cascading failures; you layer timeouts, retries-with-budget, breakers, bulkheads, shedding and fallbacks, and you observe the result. Defence in depth, exactly as in the security section, applied to reliability. + +## [248] APPENDIX M · Take-aways and references (sldId 503) + +- Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often infinite. +- Retry only idempotent calls, with backoff + jitter + a budget, at one layer, always paired with a circuit breaker. Naive retries cause the outages they were meant to prevent. +- Bound the blast radius: bulkheads isolate, circuit breakers stop the bleeding, load shedding protects capacity, fallbacks keep the user served. +- Quorum is the only cure for split-brain. Let etcd / Raft / your datastore handle consensus; don't invent your own. +- Push uniform concerns (timeouts, outlier detection, mesh bulkheads) to Istio; keep business-aware concerns (fallbacks, idempotency) in the app. Don't double-retry. +- Observe from the client's vantage point (RED per dependency, SLOs, outlier ejections) — gray failures are invisible to naive health checks. Drain on shutdown (App. H). + - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkhead origin); the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling); Istio traffic-management docs; the metastable-failures research (Bronson et al., 2021). + +p. 221 + +> Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: design for partial failure, timeout everything, retry carefully, bound the blast radius, use quorum for consensus, split concerns between mesh and app, and observe from the client's perspective. The references are the canonical sources: the Google SRE book's cascading-failures and overload chapters are the single best free resource; Michael Nygard's 'Release It!' is where the circuit breaker and bulkhead patterns were named for software; the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol; and the metastable failures paper (Bronson et al.) is the modern formalisation of amplification loops. Encourage the audience to read the SRE cascading-failures chapter as the next step. + +## [249] Feature Flags (sldId 504) + +N + +Progressive delivery, decoupled from deploy — OpenFeature + flagd + +> This appendix adds the runtime switch that separates shipping code from releasing a feature. It is a deep-dive you keep open when you are about to roll something out gradually. The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, like every other example in the book. The arc: why deploy is not release, the four kinds of flag, how evaluation works, the code to evaluate and target and roll out, flags as a control plane over patterns we already covered, and the lifecycle discipline that keeps flags from becoming debt. + +## [250] APPENDIX N · FEATURE FLAGS · Deploy is not release (sldId 505) + +p. 222 + +the artifact ships dark; releasing is a runtime flag flip — and so is rollback + +> The reframe that makes everything else work: deploying an artifact and releasing a feature are different events, and a flag is what splits them. The artifact ships to production with the new path turned off - a non-event, visible only in the deploy log. Releasing is then a runtime flag flip: on for one percent, then a wider cohort, then everyone, each step reversible by narrowing the flag rather than rolling back a deployment. This is what lets a team merge to main many times a day yet release on its own cadence, and it is the mechanism behind every canary and every safe cutover in the book. Rollback stops being a redeploy and becomes a config change measured in seconds. + +## [251] APPENDIX N · FEATURE FLAGS · Four kinds of flag (sldId 506) + +p. 223 + +by lifetime and owner — each wants different defaults and lifecycle + +> Feature flag covers four different tools, and conflating them is the root of most flag messes. They differ along two axes - how long the flag lives, and who decides to flip it. Release flags are temporary and developer-owned: they ship new code dark, ramp it, and are then removed. Experiment or A/B flags are temporary and data-owned: they split traffic across variants so a metric can be measured, and they require sticky assignment. Ops or kill-switch flags are long-lived and SRE-owned: a permanent control to disable a feature or dependency fast under load or incident. Permission or entitlement flags are long-lived and product-owned: they gate functionality by plan, tier, or region. The defaults differ too - a release flag fails off, so half-shipped code stays dark, while a kill-switch usually fails on, so the feature survives a flag-system outage. + +## [252] APPENDIX N · FEATURE FLAGS · Evaluating a flag: OpenFeature + flagd (sldId 507) + +p. 224 + +local evaluation, vendor-neutral SDK, fail-safe to the default + +> Evaluation should not couple your code to a vendor. OpenFeature is the CNCF vendor-neutral API standard - one SDK surface across languages - and flagd is its lightweight open-source backend that evaluates flags from a file, ConfigMap, or synced source, in-cluster. Together they keep the whole feature-flag story open-source and running on plain Kubernetes, with no managed SaaS, and swapping flagd for another provider later is a configuration change, not a code change. Two properties matter most. First, evaluation returns the default if the provider is unreachable - a flag-system outage must never take the application down. Second, the SDK is vendor-neutral: the app calls getBooleanValue with a flag key, a default, and an evaluation context, and never names the backend. The context carries the targeting inputs - a stable key plus attributes like plan and region - that the rules act on. + +## [253] APPENDIX N · FEATURE FLAGS · Evaluating a flag (sldId 508) + +p. 225 + +[chip] java · Quarkus + +```java +// OpenFeature SDK 1.23.0 + flagd provider 0.14.2 (as examples/27-feature-flags) +// startup: register the provider once (flagd.host / flagd.port from config) +void onStart(@Observes StartupEvent ev) { + OpenFeatureAPI.getInstance().setProvider(new FlagdProvider( + FlagdOptions.builder().host(flagdHost).port(flagdPort).build())); +} +@Path("/checkout") +public class CheckoutResource { + @POST + public Receipt checkout(Cart cart, @HeaderParam("X-User") String user) { + Client flags = OpenFeatureAPI.getInstance().getClient(); + var ctx = new MutableContext(user) // targeting key + .add("plan", cart.plan()).add("region", cart.region()); + boolean useNew = flags.getBooleanValue("new-checkout", false, ctx); + return useNew ? newCheckout(cart) : legacyCheckout(cart); + } +} +``` + +The same one line of code serves an on/off, a per-plan entitlement, or a 25% rollout. + +> The provider is registered once at startup; from then on the application asks the client for a value. The crucial thing to notice is that the application code is the same one line regardless of whether the flag is a simple on/off, a per-plan entitlement, or a 5 percent rollout. All of that lives in the flag's configuration, not the code. The default is what gets returned if flagd is unreachable, so the new path stays dark on a flag-system outage rather than erroring. The targeting key is the user id, which is what makes a percentage rollout sticky. Across every language the shape is identical: register a flagd provider, get a client, build an evaluation context with a targeting key and attributes, and call getBooleanValue with a safe default. + +## [254] APPENDIX N · FEATURE FLAGS · Targeting and rollout live in config (sldId 509) + +p. 226 + +[chip] json · flagd + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "==": [ {"var": "plan"}, "enterprise" ] }, "on", + { "fractional": [ {"var": "targetingKey"}, + ["on", 25], ["off", 75] ] } + ] + } + } + } +} +``` + +Enterprise plans always on (entitlement); everyone else split 25/75 (sticky rollout) — no code change. + +> Because the rules live in flagd, changing who sees a feature never touches the application. A flag definition is a set of variants, a default, and a targeting expression. Two operators do most of the work: a conditional for segmentation - here, the enterprise plan always gets the new checkout, an entitlement - and the fractional operator for percentage rollouts, here splitting everyone else 25/75. The fractional operator buckets on the targeting key, which is what gives sticky assignment: the same user hashes to the same bucket every time. Ramping the rollout is editing the 25 upward; rolling back is editing it down - both take effect in the running fleet within flagd's sync interval, with no redeploy. A kill switch is the same file: set a flag's state to DISABLED and it stops evaluating entirely. + +## [255] APPENDIX N · FEATURE FLAGS · Percentage rollout via sticky bucketing (sldId 510) + +p. 227 + +consistent per-user assignment; instant ramp and rollback + +> Sticky bucketing matters for two reasons. For a rollout, it prevents flicker - a user who got the new checkout on their first visit keeps getting it, rather than bouncing between variants on refresh. For an experiment, it is a correctness requirement: a user must stay in one arm of the test for the measurement to mean anything. The mechanism is simple - hash a stable key, the user id, into a bucket from 0 to 99, and compare it to the rollout threshold. Because the hash is stable, the same user always lands in the same bucket. Ramping the rollout is moving the threshold up; rolling back is moving it down, and both take effect in the running fleet within the sync interval with no deploy on the path of either. + +## [256] APPENDIX N · FEATURE FLAGS · A kill switch (sldId 511) + +p. 228 + +[chip] java · Quarkus + +```java +// ops kill-switch - default true (fail open): on until an operator kills it +Recommendations recommend(String user) { + if (!flags.getBooleanValue("recommendations-enabled", true, ctx(user))) + return Recommendations.empty(); // degrade - still serve the page + return recsClient.fetch(user); // pairs with a circuit breaker +} +``` + +Default true = fail open: only an operator flipping the flag disables it. The default flips with the flag type. + +> A kill switch is the operational flag you reach for during an incident: wrap a risky or expensive dependency in a flag so it can be disabled instantly without a deploy. It is the human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; an operator flips the kill switch by hand. The default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only an explicit flip removes it. When it is killed, the page still renders - just without the recommendations - which is graceful degradation, the same idea as the load-shedding discussion in the Failure Modes appendix. + +## [257] APPENDIX N · FEATURE FLAGS · Flags over the patterns you already have (sldId 512) + +p. 229 + +a flag is a runtime control surface + +> Feature flags are not a new subsystem so much as a runtime control plane over patterns already in this book. A canary is a percentage flag deciding which build a request exercises, often paired with the L7 routing from the routing appendix. A strangler-fig cutover is a flag, or the proxy's percentage, steering a widening slice from the monolith to the extracted service. A saga step can be guarded by a flag to switch a new compensation path on cautiously. And an experiment is a sticky-bucketed variant flag with a metric attached. Recognising flags as the control surface for all of these keeps you from building a bespoke mechanism for each - the same decoupling, expressed four ways. + +## [258] APPENDIX N · FEATURE FLAGS · The flag lifecycle (sldId 513) + +p. 230 + +a release flag is born with a removal ticket + +> The failure mode of feature flags is not technical, it is organisational: flags that are never removed. Every flag is a branch in your code and a dimension in your test matrix, and a stale one is dead code, a hidden conditional, and a live risk surface that nobody remembers the meaning of. The discipline that keeps it healthy: give every release flag an owner and an expiry from the moment it is created, and treat removing it as part of finishing the feature, not optional cleanup. Long-lived ops and entitlement flags are exempt - they are permanent by design - which is exactly why distinguishing the four types matters. A short-lived flag that has been at 100 percent for two weeks should be deleted along with the branch it guarded. + +## [259] APPENDIX N · FEATURE FLAGS · Testing, observing, and anti-patterns (sldId 514) + +p. 231 + + - Test both sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag. + - Put the resolved variant on your telemetry - a span attribute and a metric label - so a latency or error change attributes to a specific flag and cohort. +- Anti-patterns: flags as a config store; release flags that never die; stacks of flags whose combinations no one reasons about; experiments without sticky assignment; business logic buried in flag rules. + +> A flag multiplies the states your system can be in, so test both sides of every flag you depend on; a test suite that only exercises the default path will not catch a bug behind the flag. Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them explicitly. Observability ties flags back to the Observability section: record the resolved variant as a span attribute and a metric label, so a latency or error change can be attributed to a specific flag and cohort. The recurring anti-patterns: using flags as a general configuration store - they are for release control, not for tuning timeouts; letting release flags become permanent; stacking many flags whose combinations no one reasons about; running experiments without sticky assignment, which makes the data meaningless; and putting real business logic inside flag rules instead of in code. + +## [260] APPENDIX N · FEATURE FLAGS · Take-aways (sldId 515) + +p. 232 + +- Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. +- Distinguish the four flag types - they want different owners, defaults, and lifecycles. +- Evaluate through a vendor-neutral SDK (OpenFeature) against an OSS backend (flagd), always with a safe default. +- Keep targeting and rollout in configuration, not code, and make percentage rollouts sticky. +- Treat release flags as debt with a removal ticket; put the resolved variant on your telemetry. + +References: OpenFeature spec & flagd docs (CNCF) · Hodgson, “Feature Toggles” · Accelerate / DORA · Unleash (OSS alternative). + +> Close on the imperatives. Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. Distinguish the four flag types, because they want different owners, defaults, and lifecycles. Evaluate through a vendor-neutral SDK against an open-source backend, always with a safe default so a flag outage cannot take the app down. Keep targeting and rollout rules in configuration, not code, and make percentage rollouts sticky. Treat release flags as debt with a removal ticket attached, and put the resolved variant on your telemetry so you can attribute a regression to a cohort. The canonical references are the OpenFeature specification and the flagd documentation, Pete Hodgson's Feature Toggles article for the taxonomy of flag types, and the Accelerate and DORA research for the deploy-versus-release distinction; Unleash is a capable open-source alternative backend behind the same OpenFeature SDK. + +## [261] API Testing with Newman (sldId 516) + +O + +Postman collections as an automated gate on Docker Engine + +> This appendix turns the Postman collections the team already pokes at by hand into an automated test gate, using Newman, Postman's command-line runner. It is a deep-dive you keep open when you are wiring API tests into CI. The key idea that makes it cheap: Newman tests the API as a black box over HTTP, so one collection is the conformance suite for every implementation in the book, and the only language-specific step is bringing the service under test up. The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid. + +## [262] APPENDIX O · API TESTING · Why Newman (sldId 517) + +p. 233 + +the headless runner turns a Postman collection into CI-readable results + +> A Postman collection is an executable contract, not just a saved list of requests. Newman runs it without the GUI and emits machine-readable results: a cli reporter for a human watching, a junit reporter that CI turns into a test report, and an html reporter to share. There is no new test framework to learn - the tests are the JavaScript assertions already supported inside Postman, and Newman is the bridge from works-when-I-click-it to verified-on-every-pull-request. The same file serves three audiences: a developer in Postman, a reviewer reading the tests, and a pipeline failing the build when an assertion breaks. + +## [263] APPENDIX O · API TESTING · Anatomy of a collection (sldId 518) + +p. 234 + +folders of requests, each with assertions; the environment supplies the variables + +> A collection nests folders and requests; each request can carry a pre-request script, the HTTP call itself, and a block of tests that run against the response. The tests are small JavaScript blocks using Postman's pm API - asserting the status, the response time, the shape of the body. Environment variables supply the parts that change between runs, like the base URL and an auth token, resolved at run time. Storing a value from one response - a new order id - in a variable lets a later request chain off it, which is how a collection models a whole flow rather than isolated calls. + +## [264] APPENDIX O · API TESTING · Tests live in the request (sldId 519) + +p. 235 + +[chip] json · Postman + +{% raw %} +```json +{ + "name": "Create order", + "request": { + "method": "POST", + "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }], + "url": "{{base_url}}/orders", + "body": { "mode": "raw", "raw": "{\"sku\":\"A-1\",\"qty\":2}" } + }, + "event": [{ + "listen": "test", + "script": { "exec": [ + "pm.test('201 Created', () => pm.response.to.have.status(201));", + "pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));", + "const body = pm.response.json();", + "pm.test('returns an id', () => pm.expect(body).to.have.property('id'));", + "pm.collectionVariables.set('order_id', body.id);" + ] } + }] +} +``` +{% endraw %} + +Assertions run after the response; the saved order_id chains into later requests. + +> Here is a request that creates an order and asserts on the result. The test block asserts a 201 Created, parses the body, asserts it carries an id, and stores that id in a collection variable so a later request - fetch, cancel, or pay for the order - can use it. Beyond status codes, a test can validate the response shape against a JSON Schema, which is how you turn the metadata and error-format work from earlier appendices into an enforced contract: assert that an error body matches the Problem Details schema rather than checking exact strings, so the test stays stable as data changes but still catches a removed field or a wrong type. + +## [265] APPENDIX O · API TESTING · One collection, six backends (sldId 520) + +p. 236 + +Newman tests over HTTP, so one collection validates every implementation + +> Because Newman exercises the API as a black box over HTTP, the collection does not know or care which language implements the service. The same collection is the conformance suite for every implementation in this book - point it at whichever service is running and the assertions are identical. The collection is the API specification made executable. Bringing the service under test up is the only language-specific step; the Newman command that follows is the same everywhere. + +## [266] APPENDIX O · API TESTING · Launch the service under test (sldId 521) + +p. 237 + +[chip] bash · Quarkus + +```bash +# Quarkus — dev mode, then the same collection +./mvnw quarkus:dev & # http://localhost:8080 +newman run orders.postman_collection.json -e local.postman_environment.json +``` + +Only this step is language-specific; the Newman command is identical for all six. + +> The only per-language step is starting the service. Each implementation exposes the same HTTP API on the same port, so the base URL in the environment file is identical and the Newman command that follows does not change. That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it. In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop. + +## [267] APPENDIX O · API TESTING · Running Newman & shaping output (sldId 522) + +p. 238 + +[chip] bash + +{% raw %} +```bash +# install once (Node.js): +# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1 +newman run orders.postman_collection.json \ + --environment local.postman_environment.json \ + --reporters cli,junit,htmlextra \ + --reporter-junit-export results/newman.xml \ + --timeout-request 5000 \ + --bail +# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure + +# data-driven: one iteration per CSV row; the columns become {{variables}} +newman run orders.postman_collection.json -e local.postman_environment.json \ + --iteration-data cases.csv +``` +{% endraw %} + +Reporters target the audience; --bail gates fast; --iteration-data drives edge cases. + +> The runner takes the collection and an environment file, and you choose reporters for the audience: cli for a human, junit for the XML CI turns into a report, htmlextra for a shareable page. --bail stops at the first failed assertion, which is what you usually want in a gate; drop it to see every failure in one run. A single set of requests can be run repeatedly over a table of inputs with --iteration-data, which is how you cover boundary values, bad input, and edge cases without duplicating requests - and the expected status can travel in the data too, so one request becomes a table of positive and negative cases. + +## [268] APPENDIX O · API TESTING · Data-driven tests (sldId 523) + +p. 239 + +one request set, many inputs — boundary values and negative cases from a table + +> Data-driven testing runs the same requests once per row of a CSV. The columns become variables in the requests, and a column can carry the expected status so a single assertion checks status against the row's expectation. This is how a handful of requests becomes broad coverage: the happy path, a zero-quantity order that must be rejected with a 422, an over-stock order that must also be rejected - all without copy-pasting requests. Keeping the cases in data rather than in duplicated requests is what makes the suite maintainable as the API grows. + +## [269] APPENDIX O · API TESTING · Newman in CI (sldId 524) + +p. 240 + +[chip] yaml · GitHub Actions + +```yaml +# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js) +jobs: + api-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: docker compose up -d --build --wait # order, payment, inventory, ... + - run: ./scripts/wait-for-ready.sh # poll /readyz before testing + - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1 + - run: | + newman run orders.postman_collection.json \ + -e ci.postman_environment.json \ + --reporters cli,junit \ + --reporter-junit-export results/newman.xml + - uses: actions/upload-artifact@v7 # keep the report even on failure + if: always() + with: { name: newman-results, path: results/ } +``` + +Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build. + +> Newman exits non-zero when any assertion fails, which is all a pipeline needs to gate a merge. The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them. The readiness poll matters: start testing only once the services report ready, using the readiness endpoint from the Observability and Graceful Shutdown material, or the first requests race the boot and fail spuriously. Upload the junit report as an artifact even on failure so the failing assertion is visible in the run. + +## [270] APPENDIX O · API TESTING · The gate on Docker Engine (sldId 525) + +p. 241 + +the same stack you run locally, gating every pull request + +> The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks. The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock. + +## [271] APPENDIX O · API TESTING · Where Newman fits (sldId 526) + +p. 242 + +the API/contract tier: on top of unit tests, under a thin end-to-end layer + +> Newman does not replace unit tests; it is the layer on top of them. It owns the API/contract tier: real HTTP against a running service, fast enough to run on every pull request. Keep the base of fast unit tests broad, use Newman for the contract and integration behaviour that only shows up over real HTTP, and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system. Pushing everything into Newman makes a slow, brittle suite; ignoring it leaves the contract untested. Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material. + +## [272] APPENDIX O · API TESTING · Take-aways (sldId 527) + +p. 243 + +- Treat the Postman collection as the executable specification - Newman runs it headlessly to guard every change. +- One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box. +- Validate response shape against a schema, not just status codes; drive edge cases from data, not duplicated requests. +- Assert the failure paths - 401, 403, 409/422, 429, idempotency - as carefully as the happy path. +- Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier. + +References: Postman docs & Newman CLI docs (reporters, --iteration-data, --bail) · Postman Learning Center (pm API, JSON-schema validation). + +> Close on the imperatives. Treat the Postman collection as the executable specification of your API, and let Newman run it headlessly so the same assertions guard every change. Because Newman tests over HTTP, one collection is the conformance suite for all six implementations. Validate response shape against a schema, not just status codes; drive edge cases from data rather than duplicated requests; and assert the failure paths as carefully as the happy path. Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer. The references are the Postman documentation and the Newman command-line documentation, and the Postman Learning Center guides on writing tests with the pm API and on JSON-schema validation. + +## [273] Example 05 on minikube (sldId 528) + +R + +The same services and the same verify.sh on a single-node Kubernetes cluster + +> Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod. +> +> The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results. + +## [274] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 529) + +p. 244 + +- Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress. +- Scope: Example 05 (event-driven fan-out), three services, all six languages, one node. +- One Kafka broker, one Postgres instance, no service mesh: a local topology, not production. +- The contract carries over: Service names, the Secret, environment variables, the health endpoint. +- The same verify.sh runs against compose and the cluster: ten checks per language. + +Site: Appendix R · Code: examples/05-event-driven/k8s/ + +> What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it. +> +> What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged. +> +> The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec. + +## [275] APPENDIX R · MINIKUBE · The minikube runtime (sldId 530) + +p. 245 + +- Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5. +- 8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services. +- NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation. +- No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. +- Every command passes --context cndp-k8s; setup-profile.sh restores the prior context. + +minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 + +> minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version. +> +> Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1. +> +> The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag. + +## [276] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 531) + +p. 246 + +The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec + +> The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret. +> +> From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs. + +## [277] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 532) + +p. 247 + +- kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1). +- postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw. +- Credential → Secret cndp-postgres-app, read through secretKeyRef. +- build: → docker build, then minikube image load; imagePullPolicy: Never. +- healthcheck → startup and readiness probes on the order-service /healthz. +- ports: 8080:8080 → NodePort 30080. + +The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses. + +> Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts. +> +> The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing. + +## [278] APPENDIX R · MINIKUBE · One overlay per language (sldId 533) + +p. 248 + +[chip] yaml · kustomize + +```yaml +# k8s/overlays/quarkus/kustomization.yaml (excerpt) +images: + - name: order-service + newName: localhost/cndp-05/quarkus-order-service + newTag: v1 + # shipping-consumer and notification-consumer map the same way +# appended to each Deployment's environment by a JSON patch +- name: QUARKUS_DATASOURCE_JDBC_URL + value: "jdbc:postgresql://cndp-postgres-rw:5432/appdb" +- name: QUARKUS_DATASOURCE_USERNAME + value: "$(PG_USER)" +- name: QUARKUS_DATASOURCE_PASSWORD + value: "$(PG_PASSWORD)" +- name: KAFKA_BOOTSTRAP_SERVERS + value: "cndp-kafka-bootstrap:9092" +``` + +base/ is language-neutral; each overlay maps the images and appends the variables its language reads. + +> The Quarkus overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service. +> +> Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base. + +## [279] APPENDIX R · MINIKUBE · Images built on the host (sldId 534) + +p. 249 + +docker build on the host, minikube image load into the node; pods never pull + +> The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load. +> +> The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest. + +## [280] APPENDIX R · MINIKUBE · Run it (sldId 535) + +p. 250 + +[chip] bash · minikube + +```bash +# from examples/05-event-driven/, with no other profile or container running +k8s/setup-profile.sh # create cndp-k8s; check the published ports +k8s/build-images.sh quarkus # docker build, then minikube image load +k8s/deploy.sh quarkus # Strimzi, Kafka, Postgres, LGTM, then the overlay +RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh + +# all six languages, a fresh namespace for each +OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh + +# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile +k8s/teardown.sh --operator +minikube stop -p cndp-k8s +``` + +With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged. + +> The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Quarkus images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay. +> +> verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0. +> +> What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images. + +## [281] APPENDIX R · MINIKUBE · Recorded results (sldId 536) + +p. 251 + +- 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). +- Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s. +- Tempo received traces from order-service, shipping-consumer and notification-consumer in every language. +- shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset. +- Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them. + +Times cover the image build and load, the deploy, the checks and the namespace teardown. + +> The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language. +> +> The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster. diff --git a/lgtm-presentation/text/Designing-Cloud-Native-APIs-Spring.md b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Spring.md new file mode 100644 index 00000000..a269c9be --- /dev/null +++ b/lgtm-presentation/text/Designing-Cloud-Native-APIs-Spring.md @@ -0,0 +1,4274 @@ +# Designing-Cloud-Native-APIs-Spring + +Source: Designing-Cloud-Native-APIs-Spring.pptx · 285 slides + +## [001] CLOUD-NATIVE ARCHITECTURE · Designing Cloud-Native APIs (sldId 256) + +From principles to a running system on Kubernetes — communication, composition, data, events, and the platform that governs them. + +Robert Sedor + +Chief Architect, Application Development + +> Welcome and framing. This is a working architect's tour of designing cloud-native APIs on Spring Boot 4.1.1. We build one realistic system (five services and a gateway) and re-examine it section by section; the appendices go deep on protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, migration, caching, failure modes, feature flags and API testing, and run Example 05 on minikube. The whole deck is a reference the audience can keep. +> +> The running stack: Java 25 with Spring Boot 4.1.1 on Spring Framework 7.0.9 (Spring Web MVC, gRPC on grpc-java, Spring for GraphQL, Spring for Apache Kafka with Kafka Streams, Spring WebSocket, Jackson 3), on Kubernetes with Kafka, Istio and KEDA, traced by the OpenTelemetry Java agent. The platform around the code (Strimzi, Apicurio, OpenMetadata, the LGTM stack) is the same whatever the language; Spring Boot is how these services are written. + +## [002] AGENDA · Agenda (sldId 257) + +- Setting up · Patterns in the age of AI +- 01 · Cloud-Native Principles +- 02 · Communications +- 03 · Composition +- 04 · Data +- 05 · Event-Driven +- 06 · Stream Processing +- 07 · Workflows & Jobs +- 08 · API Management +- 09 · API Registry +- 10 · API Metadata +- 11 · Observability +- 12 · Security +- 13 · Anti-Patterns +- Appendices A–D · Protocols, Versioning, WebSockets, Sagas +- Appendices E–I · Errors, DDD, Coupling, Shutdown, L7 Routing +- Appendices K–L · Monolith → Microservices, Caching Patterns +- Appendices M–O · Failure modes, Feature Flags, API Testing +- Appendix R · Example 05 on minikube +- Glossary of acronyms + +p. 1 + +> The agenda: a short setup, thirteen sections, and the deep-dive appendices. The setup motivates why patterns matter in the age of AI. Sections 1 to 6 build the system from principles up through data and streaming; sections 7 to 13 cover the operational platform around it (workflows, management, registry, metadata, observability), then security, and close on anti-patterns. +> +> The appendices are reference material for the questions that come up: protocols, versioning, WebSockets, sagas, errors, DDD, coupling, shutdown, L7 routing, monolith-to-microservices decomposition, caching, failure modes, feature flags and API testing. Appendix R runs Example 05 on minikube with the same verify.sh as compose. A glossary of acronyms closes the deck. +> +> Tell the audience which sections you will cover given the session length. The overview that follows reframes the agenda as the narrative arc: what we build and in what order. + +## [003] OVERVIEW · What we'll build, and the order we'll build it (sldId 258) + +- Foundations — cloud-native principles and the canonical system we carry throughout +- Communications — REST, gRPC, GraphQL, and synchronous vs. asynchronous +- Composition & Data — gateways, federation, database-per-service, outbox, sagas +- Event-driven & Stream processing — the log, schemas, derived streams, KEDA scaling +- Workflows & Jobs — orchestration, choreography, batch and scheduled work +- The API platform — gateway, registry (Apicurio), metadata (OpenMetadata), observability +- Anti-patterns — and the heuristics that keep you out of them + +p. 2 + +> Walk the arc quickly; don't dwell. The order follows dependency: principles first because they justify every later choice, then the synchronous surface (comms, composition), then data ownership, then the asynchronous backbone (events, streams), then the operational concerns (workflows, the platform planes), and finally anti-patterns as a reflective close. Tell people the deep-dive appendices A to R (protocols, versioning, WebSockets, sagas, errors, caching, failure modes, feature flags, API testing, minikube) are there to jump to. For a 90-minute talk, cover the main twelve; for the full three hours, work through the appendices too. + +## [004] SETTING UP · Why patterns matter more in the age of AI (sldId 259) + +- AI tooling (Claude, Claude Code, frontier models) turns intent into code at a rate that was not possible before. The bottleneck shifted: it's no longer typing, it's knowing what to ask for and what to accept. +- Patterns are your vocabulary at three layers: design patterns (factory, strategy, observer), architecture patterns (hexagonal, microservices, event-driven), system design patterns (sagas, caching, strangler fig — the scope of this deck). +- Without that vocabulary you can't specify precisely, can't evaluate what comes back, can't tell good code from plausible-looking code, or notice when an anti-pattern is dressed as a solution. The AI is fluent; you still need to be literate. +- All architecture is the least-worst decision — every choice trades something away. +- All management is risk management — you manage the distribution of failures, not their existence. +- There is no such thing as a free lunch — every pattern has a cost; ask whether you can afford it. +- There is no problem which cannot be made worse — the wrong fix amplifies the original pain. + +p. 3 + +> Open the deck by naming the elephant. AI coding tools (Claude, Claude Code, Cursor, Copilot, every frontier model) are now in production engineering workflows. They produce code fluently. From a one-sentence prompt they will produce a microservices decomposition, a saga, a cache layer, a strangler fig wrapper, a CQRS write side: plausible enough to compile, deploy and run. The risk is also new: a developer who doesn't have the vocabulary to evaluate what comes back is rolling dice in production. The patterns in this deck are not nostalgic computer-science trivia; they are the literacy that lets you direct AI tooling instead of being directed by it. Walk the three layers of patterns briefly: design patterns are code-level vocabulary (Gang of Four); architecture patterns describe how applications fit together (hexagonal, microservices, BFF, event-driven); system design patterns, the focus of this deck, span services, data and runtime (sagas, caching, the strangler fig, outbox, decomposition, L7 routing). The four maxims are the philosophical spine. ‘Least-worst decision’: every architecture choice is a set of trades (latency for throughput, consistency for availability, simplicity for flexibility), and the job is not to find the optionless option but to make the trades visible and explicit. ‘Risk management’: you manage the distribution of failures, not their existence. ‘No free lunch’: every pattern (cache, saga, eventing, decomposition) has a cost — a stale read window, a compensation path, a coordination tax, an operational complexity. The question is whether you are buying the right thing for what you are paying. ‘No problem cannot be made worse’ is the warning corollary: a confidently-wrong intervention amplifies the original pain, and AI tooling makes confident interventions fast and cheap. The closing line is the audience take-home: AI is the amplifier; the patterns are the literacy that decides which direction it amplifies. + +## [005] SETTING UP · Three layers of patterns literacy (sldId 260) + +p. 4 + +> The visual companion to the patterns-literacy framing. Three stacked boxes: design patterns at the bottom (Gang of Four: factory, strategy, observer, decorator, repository, etc.); architecture patterns in the middle (hexagonal, microservices, modular monolith, BFF, federation, pipes-and-filters, event-driven, layered); and at the top, system design patterns, the scope of this deck (sagas, caching, the strangler fig, outbox, event sourcing, CQRS, decomposition, back-pressure, bulkhead, L7 routing, rule-based dispatch). The arrows up the middle show how each lower layer informs the next: you can't reason about a saga (system layer) without knowing what a state machine and a compensation are (architecture / design layer); you can't reason about content-based routing without understanding the strategy pattern. The blue caption in the middle is the through-line: knowing this vocabulary is what lets you ask AI tooling for the right thing, recognise what it produces, and judge why it is or isn't fit. The four maxims at the bottom in red are the philosophical anchors for every section: each concrete choice in the deck is an exercise in those four principles. Make the audience comfortable with the maxims, especially the last one (no problem cannot be made worse) — it's the warning at the heart of using AI tooling well. + +## [006] THE RUNNING EXAMPLE · One system, carried end to end (sldId 261) + +We will design every concern against these five services and a gateway — the same example, re-examined from each angle. + +p. 5 + +> This is the single most important slide to anchor on — every later example refers back to it. Walk each service: order-service owns the order lifecycle and exposes REST at the edge; inventory, payment and shipping are internal, reached by gRPC and contributing to GraphQL reads; notification-service is Kafka-only, consuming order.placed. The graphql-gateway composes reads across them. Emphasize that each service exposes only the protocols that fit its role — not a uniform surface. Promise the audience we'll return to this picture a dozen times. + +## [007] Cloud-Native Principles (sldId 262) + +01 + +Properties first, tools second. + +> Transition into section one. The core message: cloud-native is not a stack you buy, it is a set of runtime properties your service must exhibit. Tools (Kubernetes, Istio, KEDA) exist to make those properties cheap to obtain — but if your service isn't written to lean on them, no platform can rescue it. + +## [008] CLOUD-NATIVE PRINCIPLES · Cloud-native is a set of runtime properties (sldId 263) + +If your API isn't disposable, observable, resilient, elastic, automatable and loosely coupled, the platform can't help it. + +p. 6 + +> Six pillars. Spend a sentence on each: disposable (12-factor, fast start, clean stop, no local state), observable (traces/metrics/logs, health probes), resilient (timeouts, retries, circuit breakers, graceful degradation), elastic (scale to load and to zero), automatable (declarative config, GitOps, self-healing), loosely coupled (contracts not calls, async where it fits). The pillars are the rubric for the whole talk: every design decision in it either earns or spends one of these properties. + +## [009] CLOUD-NATIVE PRINCIPLES · The platform makes the properties the default (sldId 264) + +- Kubernetes gives you the disposable process model: liveness/readiness probes, rolling deploys, self-healing. +- KEDA (and the http-add-on) make elasticity real — scale on lag or RPS, all the way to zero. +- Istio supplies resilience and security at the wire: timeouts, retries, circuit breaking, mTLS. +- OpenTelemetry + Tempo / Mimir / Prometheus / Grafana / Kiali make every request observable. +- Your job is to write services that lean on these — not to reimplement them per service. + +p. 7 + +> The punchline is the bold line: your job is to lean on the platform, not reimplement it. Concretely — don't write your own retry library when Istio does it at the wire; don't build a bespoke autoscaler when KEDA scales on lag and RPS; don't hand-roll metrics plumbing when OpenTelemetry plus the LGTM stack gives it to you. Every line of resilience code you write per service is a line you maintain forever and that drifts between teams. The platform makes the good behaviour the default. + +## [010] CLOUD-NATIVE PRINCIPLES · Design-first: the contract precedes the code (sldId 265) + +- Agree the interface before implementing it — OpenAPI for REST, .proto for gRPC, AsyncAPI for events. +- The contract is a versioned artifact in the registry, gated in CI, owned by the producing domain. +- Consumers code against the contract and generated stubs, never against your database or internals. +- This is what makes services independently deployable — the promise the contract encodes is the boundary. + - Tooling: Maven with spring-boot-starter-parent for reproducible builds; springdoc-openapi emits the OpenAPI spec; buf / protoc for gRPC; Spring for GraphQL exports SDL from .graphqls files. + +p. 8 + +> Design-first is the cultural prerequisite for everything that follows. The contract (OpenAPI, .proto, AsyncAPI) is agreed and registered before code is written, owned by the producing domain, and gated in CI. Consumers code against the contract and generated stubs, never against your database. Make the point that this is what delivers independent deployability: the promise the contract encodes is the service boundary. If you skip this, you get distributed coupling that's worse than a monolith because it fails over the network. In the Spring Boot world the contract artifacts come naturally: springdoc-openapi emits the REST spec from your @RestController methods, the protobuf-maven-plugin compiles the .proto, and Spring for GraphQL loads the .graphqls schema (or generates SDL from your @SchemaMapping methods if you go code-first) — all checked into the registry. + +## [011] CLOUD-NATIVE PRINCIPLES · The twelve-factor app, revisited (sldId 266) + +- The twelve-factor app (2011) is the original recipe for software that's disposable, scalable and portable — the discipline beneath the runtime properties we just saw. +- It predates Kubernetes but maps onto it almost one-to-one: most factors are now things the platform provides, not code you write. +- So read the factors by ownership — a few you still author (config from the environment, statelessness, clean start/stop, logs as streams) and the rest the platform hands you (scaling, build/release/run, attached backing services). +- In a microservices, event-driven system they get richer: “backing services” means Postgres, Kafka and the schema registry as swappable URLs; “logs” becomes correlated traces, metrics and logs. +- Treat it as a pre-flight check (“will this survive the platform?”): we list the twelve factors and map each to where it lives in our architecture. + +p. 9 + +> Frame the twelve-factor app for the audience: it's the 2011 Heroku methodology that first codified what we now call cloud-native, and it's worth revisiting because it predates Kubernetes yet maps onto it almost perfectly. The key reframing here is ownership: don't recite the list; split it into the few factors you still write (config from the environment, stateless processes, fast clean startup and shutdown, logs as event streams) versus the many the platform now provides by default (build/release/run via images and GitOps, concurrency via KEDA, attached backing services). In a microservices/event-driven world several factors deepen: backing services now includes Kafka and the registry, and 'logs' has become full observability. End by teeing up the list and the mapping diagrams. + +## [012] CLOUD-NATIVE PRINCIPLES · What the twelve factors are (sldId 267) + +The original 2011 checklist — most are now platform concerns; only a few are yours to write. + +p. 10 + +> A lot of people nod along to ‘twelve-factor’ without being able to name the twelve, so here they are at a glance, one line each. Don't read them aloud — let people scan. Two takeaways to say: first, this 2011 list predicted cloud-native a decade early; second, many factors are now handled by the platform (build/release/run, concurrency, disposability, port binding, backing services) versus the few you still actively write (config from the environment, statelessness, logs as streams). That sets up the mapping of each factor onto our architecture. + +## [013] CLOUD-NATIVE PRINCIPLES · Twelve factors, mapped to our system (sldId 268) + +Most factors are now platform defaults you adopt; a few are discipline you still write. + +p. 11 + +> This is the payoff: not the traditional twelve-factor table, but a map of where each factor lives in the system we're building. Read it left to right: the twelve factors cluster into five things they ask of you, and each maps to a concrete piece of our stack and the section that covers it. Build & ship (codebase, dependencies, build/release/run, parity) becomes one repo, one image per release, the same image everywhere (§1). Configure becomes environment config (§1). Run on the platform (stateless processes, concurrency, disposability, port binding) becomes stateless pods, KEDA scaling, fast start/stop, and services behind Istio (§1, 2, 6). Attach resources becomes Postgres, Kafka and Apicurio as swappable URLs (§4–5). Operate (logs, admin processes) becomes OpenTelemetry into the LGTM stack and admin work as Jobs/CronJobs (§7, 11). The caption is the message: most of these are now defaults you adopt, not code you write — which is exactly what 'cloud-native' buys you. + +## [014] CLOUD-NATIVE PRINCIPLES · A 12-factor Spring Boot service, ready for the platform (sldId 269) + +[chip] java · Spring Boot 4 + +```java +// HealthController.java (excerpt), examples/01-cloud-native-principles/spring-boot +// config from env (factor III): SPRING_DATASOURCE_URL, SERVICE_VERSION -> service.version +@RestController +public class HealthController { + private final DataSource dataSource; + @Value("${service.version}") + private String serviceVersion; + public HealthController(DataSource dataSource) { this.dataSource = dataSource; } + @GetMapping("/healthz") // liveness: the process is up + public Map healthz() { return Map.of("status", "ok"); } + @GetMapping("/readyz") // readiness: SELECT 1 on the pool + public ResponseEntity> readyz() { + try (var conn = dataSource.getConnection()) { + conn.createStatement().executeQuery("SELECT 1").close(); + } catch (Exception e) { // down: 503, the pod leaves the Service + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("status", "down", "checks", Map.of("database", "unreachable"))); + } + return ResponseEntity.ok(Map.of("status", "ready", + "checks", Map.of("database", "ok"))); + } +} +``` + +Probes, env-config and an explicit application name are the price of admission for a Kubernetes Deployment. + +p. 12 + +> Walk the code: config comes from the environment through Spring's property resolution (factor III): SPRING_DATASOURCE_URL and its credentials configure the DataSource and SERVICE_VERSION feeds service.version, never a baked-in file; Spring profiles override per environment. The application name (spring.application.name) shows up in Micrometer metrics and traces. The two probes are a plain @RestController in Example 01: /healthz is liveness (am I alive? if not, restart me) and never checks a dependency; /readyz is readiness (can I serve?): it runs SELECT 1 on a pooled connection and, when that fails, answers 503 with status down, which takes the pod out of rotation without killing it. The probe reads the status code, not the body: Kubernetes treats any answer from 200 to 399 as ready. Spring Boot Actuator offers the same pair as /actuator/health/liveness and /actuator/health/readiness with HealthIndicator beans; the example writes them by hand so every language serves the same paths and bodies. Getting liveness vs readiness wrong is a top cause of cascading restarts, so stress the distinction. Maven with spring-boot-starter-parent pins every dependency reproducibly. Constructor injection is the idiomatic Spring style: the bean stays immutable and is unit-testable without a container. + +## [015] Communications (sldId 270) + +02 + +Match the interaction style to the call. + +> Section two. The thesis: there is no single right protocol — there is a right protocol per interaction. Resist the urge to standardize on one wire for everything. What follows gives you a decision rule and then shows each protocol in code against the running system. + +## [016] COMMUNICATIONS · Pick the protocol for the interaction (sldId 271) + +REST for reach, gRPC for internal speed, GraphQL for client-driven reads, async for decoupled facts. + +p. 13 + +> Four interaction styles, four defaults. REST for the public/edge surface where reach and caching matter; gRPC for internal service-to-service where latency and streaming matter; GraphQL for client-driven reads that compose many sources in one round trip; async events for decoupled facts that fan out. Most real systems use all four — the skill is matching each call to the right one. Note the sync/async chips at the bottom. This is the map for the whole section. + +## [017] COMMUNICATIONS · REST at the edge with Spring Web (sldId 272) + +[chip] java · Spring Boot 4 + Spring Web + +```java +@RestController +@RequestMapping("/orders") +public class OrderController { // Spring Web MVC (or WebFlux) + private final OrderService orders; + private final EventPublisher events; + public OrderController(OrderService orders, EventPublisher events) { + this.orders = orders; this.events = events; + } + record OrderIn(@NotBlank String sku, @Positive int quantity) {} // validated + @PostMapping + @ResponseStatus(HttpStatus.CREATED) // correct status, not 200 + public Order place(@Valid @RequestBody OrderIn body) { + Order o = orders.create(body); + events.publish("order.placed", o); // emit the fact + return o; + } + @GetMapping // cursor pagination, not offset + public Page list(@RequestParam(required = false) String after, + @RequestParam(defaultValue = "50") int limit) { + return orders.page(after, limit); + } +} +``` + +Resource-shaped URLs, Bean-Validation contracts, correct status codes, cursor pagination — the edge surface other teams depend on. + +p. 14 + +> This is the edge surface. Point out four things teams routinely get wrong: (1) resource-shaped URLs (nouns, not verbs); (2) request contracts validated declaratively, a Java record with Bean Validation 3.1 annotations (@NotBlank, @Positive, @Valid), where Spring's @Valid triggers the validator so bad input never reaches your logic; (3) correct status codes: HttpStatus.CREATED on create, not 200; (4) cursor pagination, not offset, because offset breaks under concurrent writes and gets slow at depth. Constructor injection is the idiomatic Spring style (no @Autowired needed, fields can be final). For non-blocking I/O, swap @RestController for WebFlux's reactive style with Mono and Flux: same controller, reactive runtime. springdoc-openapi generates the OpenAPI spec from the controllers, and that spec is what we register and gate. + +## [018] COMMUNICATIONS · gRPC for internal service-to-service calls (sldId 273) + +[chip] protobuf + java · Spring Boot 4 + grpc-java + +```protobuf+java +// inventory.proto — the contract, registered in Apicurio +syntax = "proto3"; +service Inventory { rpc ReserveStock (ReserveRequest) returns (ReserveReply); } +message ReserveRequest { string sku = 1; int32 quantity = 2; } +message ReserveReply { bool reserved = 1; int32 remaining = 2; } +// inventory-service (excerpt): grpc-java server; protobuf-maven-plugin generates the stubs +@SpringBootApplication +public class InventoryServiceApplication extends InventoryGrpc.InventoryImplBase + implements CommandLineRunner { + @Override public void run(String... args) throws Exception { + server = ServerBuilder.forPort(grpcPort).addService(this).build().start(); + server.awaitTermination(); + } + @Override public void reserveStock(ReserveRequest req, StreamObserver obs) { + int remaining = stock.get(req.getSku()) - req.getQuantity(); + if (remaining >= 0) stock.put(req.getSku(), remaining); // reserve + obs.onNext(ReserveReply.newBuilder().setReserved(remaining >= 0) + .setRemaining(remaining >= 0 ? remaining : stock.get(req.getSku())).build()); + obs.onCompleted(); + } +} +``` + +Schema-first, binary, multiplexed over HTTP/2 — the right default for chatty internal paths. Test with ghz. + +p. 15 + +> gRPC for the internal paths. The .proto is the contract, and it lives in the registry like OpenAPI. The protobuf-maven-plugin runs protoc at build time; the service extends the generated InventoryImplBase and fills in the one method, never touching the wire format. +> +> The example starts a plain grpc-java server from a CommandLineRunner inside the Spring Boot application, so no gRPC starter sits between the code and the library. The field numbers (=1, =2) are the wire contract; Appendix B explains why they can never be reused. +> +> gRPC is binary and multiplexed over HTTP/2, so it is fast and supports streaming: the right default for chatty service-to-service calls where you control both ends. Load-test it with ghz. + +## [019] COMMUNICATIONS · Synchronous coupling vs. asynchronous facts (sldId 274) + +A synchronous call shares fate; an event shares a fact. Reach for async wherever immediacy isn't required. + +p. 16 + +> The central trade-off of the whole talk in one picture. Top row: a synchronous call couples caller to callee in time and availability — if payment-service is down, the order request fails too, and availability multiplies downward across a chain. Bottom row: emitting order.placed as a fact lets the order succeed immediately; consumers catch up from the log if they were down. The rule: reach for async wherever immediacy isn't strictly required. This sets up the entire event-driven section later. + +## [020] COMMUNICATIONS · The mesh handles the wire, not your code (sldId 275) + +Istio sidecars give you mTLS, retries, timeouts and telemetry — uniformly, without per-service code. + +p. 17 + +> Service mesh in one picture. Your app speaks plain HTTP or gRPC to localhost; the Istio sidecar (Envoy) intercepts and adds mTLS, retries, timeouts and telemetry on the wire — uniformly, for every service, with zero application code. istiod is the control plane distributing policy and identity (SPIFFE). The key insight ties back to principles: resilience and security become platform defaults, not per-service reimplementations. We then show the actual config. + +## [021] COMMUNICATIONS · Resilience as configuration, not per-call code (sldId 276) + +[chip] yaml · istio + +```yaml +# Istio: bounded retries and timeout for inventory-service +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: inventory } +spec: + hosts: [inventory] + http: + - timeout: 2s + retries: + attempts: 2 # bounded — never an unbounded retry storm + perTryTimeout: 800ms + retryOn: connect-failure,unavailable + route: + - destination: { host: inventory } +``` + +Bounded retries with backoff live in the mesh. Unbounded client retries are how an incident becomes an outage. + +p. 18 + +> Resilience as configuration. This VirtualService gives inventory a 2-second timeout, two retry attempts with an 800ms per-try timeout, retrying only on connect-failure and unavailable. The critical word is bounded — attempts: 2, not infinite. Tell the war story: unbounded client-side retries are how a brief blip becomes a full outage, because every caller piles on the struggling service (a retry storm). Putting this in the mesh means it's consistent across every service and tunable without redeploying code. + +## [022] Composition (sldId 277) + +03 + +Aggregate at the edge, not inside services. + +> Section three. The question: when a client needs data from several services, where does the joining happen? The answer that keeps services decoupled: at the edge, in a gateway or BFF — never by services calling each other in deep chains. That chaining is how you accidentally rebuild a monolith over the network. + +## [023] COMPOSITION · One query, many services (sldId 278) + +The gateway presents one schema and fans out to REST and gRPC back-ends, then joins and shapes the result. + +p. 19 + +> GraphQL federation against the running system. The gateway presents one schema to clients and fans out to REST and gRPC back-ends, then joins and shapes the result. The client makes one round trip and asks for exactly the fields it needs. The services stay unaware of each other; the gateway owns the composition. Contrast this with services calling services, which we'll warn against on the patterns slide. We then show the resolver code. + +## [024] COMPOSITION · A composing resolver in Spring for GraphQL (sldId 279) + +[chip] java · Spring for GraphQL + +```java +@Controller // spring-boot-starter-graphql +public class OrderGraphQLController { + private final OrderClient orderClient; + private final InventoryGrpcClient inventory; + public OrderGraphQLController(OrderClient o, InventoryGrpcClient i) { + this.orderClient = o; this.inventory = i; + } + @QueryMapping // REST call to order-service + public Order order(@Argument String id) { + return orderClient.get(id); + } + @SchemaMapping(typeName = "Order", field = "stock") // resolved on demand + public Integer stock(Order order) { // via gRPC + return inventory.remaining(order.sku()); + } + // @BatchMapping("stock") batches N orders into one gRPC call: no N+1 +} +``` + +The gateway owns aggregation; each field resolves against the owning service. @SchemaMapping attaches a field fetched from elsewhere. + +p. 20 + +> The resolver makes federation concrete. The top-level order query calls order-service over REST via a typed @QueryMapping; the stock method, annotated @SchemaMapping with typeName=Order and field=stock, adds a stock field to the Order type that resolves on demand by calling inventory-service over gRPC. Each field resolves against its owning service — the gateway is just the composition layer. Mention the N+1 risk: naive per-field resolution can explode into many back-end calls, so production gateways batch (Spring for GraphQL's @BatchMapping does exactly this, batching N orders into one upstream call). Spring for GraphQL is built on graphql-java and ships as a first-class Spring Boot starter; the schema lives in a .graphqls file under resources and is exported to the registry automatically by the build. + +## [025] COMPOSITION · Three ways to compose — and where each belongs (sldId 280) + +Push aggregation to a gateway or BFF. Chaining calls inside services rebuilds the monolith over the network. + +p. 21 + +> Three composition patterns: an API gateway (one composed schema for everyone), a backend-for-frontend (a tailored aggregate per client type — web vs mobile), and GraphQL federation (a distributed graph). All three push aggregation outward to the edge. The anti-pattern to call out explicitly: service-to-service call chains, where order calls inventory calls pricing calls... — synchronous, deeply coupled, and impossible to reason about. That rebuilds the monolith with network latency between the pieces. + +## [026] Data (sldId 281) + +04 + +Own your data; share it through contracts. + +> Section four, and the hardest discipline to hold. The rule: each service owns its data and shares it only through APIs and events — never through a shared database or by reaching into another service's tables. This is what makes independent deployment and independent scaling real. The slides cover ownership, the dual-write trap, and the saga. + +## [027] DATA · A database per service (sldId 282) + +Each service owns its schema and storage engine; no other service touches its tables directly. + +p. 22 + +> Database-per-service. Each service owns its schema and even its storage engine — order and payment on Postgres, something else on a document store if that fits. No other service touches those tables directly; the only doors in are the API and the events. The hard part is social, not technical: someone will always want to 'just join across two services' databases' for a report. That shortcut is the single most common way microservice independence dies. Reporting needs go through the metadata/lineage layer or a dedicated read model, not direct table access. + +## [028] DATA · CQRS — Command Query Responsibility Segregation (sldId 283) + +Separate the write model from the read model. They have different shapes, scales and even different stores. + +p. 23 + +> CQRS (Command Query Responsibility Segregation) is the pattern of using different models for writes and reads. The write side optimises for invariants and consistency: a normalised schema in a transactional store (Postgres, MySQL). The read side optimises for query shape: denormalised wide tables, an Elasticsearch index, a Mongo document, or whatever fits the question being asked. The two are linked by a stream of events: either domain events emitted by the write side, or the database commit log itself via CDC, the log-tailing pattern. The cost: eventual consistency, a small lag between the write committing and the read model catching up. The benefit: each side can scale, schema-evolve and even be stored independently. Use this pattern when reads and writes have different shapes and access patterns. Avoid it when a single store handles both; CQRS adds operational complexity that isn't free. The classic motivating case: a service that takes thousands of writes per second but is read by analytics, dashboards and search — the write model serves the writes, the read model serves the rest. CQRS pairs naturally with event sourcing on the write side and event streaming for projections; the Event-Driven section draws both distinctions. + +## [029] DATA · CQRS in practice (sldId 284) + +[chip] java · Spring Boot 4 + Spring Data JPA + Spring Kafka + +```java +@RestController @RequestMapping("/orders") +public class OrderWriteController { + // ... final fields: OrderRepository writes, KafkaTemplate kafka + @PostMapping @Transactional + public Order create(@RequestBody OrderIn body) { + Order order = writes.save(new Order(body)); // write model + kafka.send("order.placed", order.id(), + new OrderCreatedEvent(order)); // → projection + return order; + } +} +@RestController @RequestMapping("/orders") +public class OrderReadController { + private final OrderSummaryRepository reads; + @GetMapping("/{id}/summary") + public OrderSummary summary(@PathVariable String id) { + return reads.findById(id) // read store only + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND)); + } +} +@Service +public class OrderSummaryProjection { + private final OrderSummaryRepository reads; + @KafkaListener(topics = "order.placed", groupId = "order-summary") + @Transactional("readDbTxManager") + public void on(OrderCreatedEvent ev) { reads.save(OrderSummary.from(ev)); } +} +``` + +p. 24 + +> The Spring shape. Two @RestController classes: OrderWriteController handles writes, OrderReadController handles reads. Each can have its own DataSource bean wired through @Qualifier (or two separate Spring contexts in a modulith). KafkaTemplate publishes to Kafka after the DB write — in production you'd use the outbox and Debezium combination shown in the outbox write instead of direct publishing. The projection is a @Service with a @KafkaListener that consumes the order.created topic with its own consumer group, then upserts into the read-side OrderSummaryRepository. The read-side table is denormalised: customer_name, line_count, total_cents, status — whatever the UI query needs without a JOIN. Different scaling (read replicas for the read side), different team ownership possible. + +## [030] DATA · Log tailing — the database commit log is the event stream (sldId 285) + +Debezium tails the WAL and publishes every committed row as a Kafka event. No dual-write. + +p. 25 + +> The mechanism that makes CQRS projections and the outbox pattern work: Change Data Capture, or CDC. The application writes ordinary SQL. The database commits and the change lands in the write-ahead log (WAL on Postgres, redo log on Oracle, binlog on MySQL, transaction log on SQL Server). Debezium, an Apache 2.0 open-source project running as a set of Kafka Connect connectors, tails that log via the database's native replication protocol (logical replication slots on Postgres, etc.), translates row-level deltas into Kafka events, and publishes them with commit ordering preserved. The application doesn't know Debezium exists. There is no dual-write — the commit is the single source of truth, and the event is a strictly downstream consequence. This is why log tailing matters before the outbox: the outbox is a targeted use of CDC for a specific case (publishing events that aren't naturally row-level), and CQRS projections are the other big use. Debezium supports Postgres, MySQL, MariaDB, SQL Server, Oracle, MongoDB, Db2, Cassandra, and Vitess out of the box. Run mode: deploy as Kafka Connect workers, or use Debezium Server (standalone) if you don't want a Kafka Connect cluster. WAL = Write-Ahead Log: the database's commit-ordered durable log that already exists for replication and crash recovery; Debezium just subscribes to it. + +## [031] DATA · Dual-write is unsafe — use the outbox (sldId 286) + +Write state and the outbox row in one transaction; CDC relays the committed fact to Kafka. + +p. 26 + +> This is the slide that prevents a class of production bugs. The dual-write problem: if you write to your database and then publish to Kafka as two separate operations, a crash between them leaves you inconsistent — the order exists but the event was never sent, or vice versa. The fix is the transactional outbox: write the state change and an outbox row in one local transaction, then a change-data-capture tool (Debezium) tails the database log and relays the committed row to Kafka. The database commit is the single source of truth. We then show the actual transaction. + +## [032] DATA · The outbox write — one transaction, no lost events (sldId 287) + +[chip] java · Spring Boot 4 + JdbcClient + Debezium CDC + +```java +// order-service OrderController.java (excerpt): order row and outbox row, one transaction +@PostMapping("/orders") +@ResponseStatus(HttpStatus.CREATED) +@Transactional // single local transaction +public Map createOrder(@RequestBody Map body) { + String orderId = UUID.randomUUID().toString(); + // sku, quantity and status come from the body (elided) + jdbc.sql("INSERT INTO orders (id, sku, quantity, status) VALUES (?, ?, ?, ?)") + .param(orderId).param(sku).param(quantity).param(status) + .update(); // state change + String payloadJson = objectMapper.writeValueAsString(payload); // Jackson 3 + jdbc.sql("INSERT INTO outbox (aggregate_id, event_type, payload) VALUES (?, ?, ?::jsonb)") + .param(orderId).param("order.placed").param(payloadJson) + .update(); // same transaction: outbox row + return Map.of("id", orderId, "sku", sku, "quantity", quantity, "status", status); +} +// No Kafka call on the request path: Debezium tails the WAL and publishes order.placed. +``` + +The database commit is the source of truth. The outbox + CDC turns it into a guaranteed, ordered event. + +p. 27 + +> The whole trick is the @Transactional method: the insert of the order row and the insert of the outbox row happen in one local transaction, through Spring's JdbcClient. There is no Kafka call inside the request path: if the broker is down, the order still commits and the event ships later when Debezium tails the write-ahead log and relays the outbox row in commit order. This decouples request latency and availability from Kafka's. +> +> There is no Spring-specific outbox starter: the outbox is a small table (aggregate id, event type, JSON payload, created_at), the payload is serialized with Jackson 3's JsonMapper, and a standard Debezium Postgres connector handles the CDC. The Debezium Outbox Event Router single-message transform unwraps the row into the event envelope on the connector side. The same commit-together discipline reappears in the saga appendix. + +## [033] DATA · Consistency without distributed transactions: the saga (sldId 288) + +A chain of local transactions; failures trigger compensating actions, never a cross-service rollback. + +p. 28 + +> The saga is how you get consistency across services without a distributed transaction (there is no two-phase commit here, and you don't want one). It's a sequence of local transactions, each emitting the event that triggers the next: order created, payment taken, stock reserved, shipment booked. When a step fails (payment fails), you don't roll back; you run compensating actions for the steps that already committed (cancel the order). Flag that Appendix D goes deep on saga state, compensation and a tricky data-dependency case, for anyone who wants the mechanics. + +## [034] Event-Driven (sldId 289) + +05 + +Emit facts; let consumers decide. + +> Section five — the asynchronous backbone we kept pointing at. The mental shift: a producer emits a fact about something that happened (order.placed) and does not know or care who consumes it. Consumers decide what to do. This inverts the dependency direction, and it is what decouples services in time and ownership. + +## [035] EVENT-DRIVEN · The event backbone — one fact, many reactions (sldId 290) + +order.placed fans out to consumers the producer never names. Add a consumer without touching the producer. + +p. 29 + +> order.placed fans out to payment, shipping and notification — and the producer names none of them. The power move: you can add a new consumer (say, a fraud-check service) without touching or redeploying the producer. Contrast with the synchronous world, where adding a dependency means changing the caller. This is the open/closed principle at the architecture level. Kafka (run by Strimzi on Kubernetes) is the backbone; Pulsar is an alternative with similar semantics. + +## [036] EVENT-DRIVEN · Producing and consuming with Spring Kafka (sldId 291) + +[chip] java · Spring Boot 4 + Spring Kafka + +```java +// producer — order-service (or the Debezium relay) emits the fact +@Service +public class OrderEvents { + private final KafkaTemplate kafka; + public OrderEvents(KafkaTemplate k) { this.kafka = k; } + public void publish(Order o) { + kafka.send("order.placed", o.id(), o); // keyed; schema-aware serde + } +} +// consumer — notification-service, Kafka-only by design +@Service +public class Notifier { + @KafkaListener(topics = "order.placed", + containerFactory = "manualAckFactory") + public void onOrder(ConsumerRecord rec, Acknowledgment ack) { + sendEmail(rec.value()); // do the work first + ack.acknowledge(); // ack after — at-least-once + } +} +``` + +Ack after the side-effect, and make handlers idempotent — at-least-once delivery is the contract. + +p. 30 + +> Spring Kafka is the canonical Spring Boot starter for Apache Kafka, and Spring Boot 4 keeps it as the default. The producer holds a KafkaTemplate, which is auto-configured from spring.kafka.* properties; the send keys the message by order id (so all events for one order land on the same partition and stay ordered). The consumer is annotated @KafkaListener and the manualAckFactory is a ConcurrentKafkaListenerContainerFactory configured with AckMode.MANUAL. Two things to drill home: we ack only once sendEmail has succeeded; ack first and then crash, and the work is lost. Because we ack after, a crash mid-handler means redelivery, so delivery is at-least-once and your handlers must be idempotent (dedupe by message key or an idempotency key). Exactly-once is mostly at-least-once plus idempotency. The serde is wired to Apicurio's Avro / Protobuf SerDes, so the registry enforces the schema. Reactor Kafka is discontinued, so the examples use the imperative @KafkaListener. + +## [037] EVENT-DRIVEN · Schemas are the contract — the registry enforces them (sldId 292) + +Apicurio holds versioned schemas; compatibility is checked at publish time so breaking changes are rejected early. + +p. 31 + +> Events need contracts as much as REST and gRPC do, or more, because consumers are anonymous and you can't call them to coordinate. The schema (Avro, Protobuf or JSON Schema) lives in Apicurio. Producers and consumers fetch it; compatibility is checked at publish time so a breaking change is rejected before it ever reaches the topic. This is what makes adding a consumer safe. We come back to event versioning rules in Appendix B. + +## [038] EVENT-DRIVEN · Event sourcing vs. event streaming (sldId 293) + +Two patterns, often confused. Sourcing: the events are the state. Streaming: events are the transport. + +p. 32 + +> The vocabulary clarification most teams need before they go further with events. Event sourcing means events are the database — there is no 'current state' row anywhere; current state is computed by folding the entire event history. Adding a credit to an account isn't UPDATE balance SET balance = balance + 100; it's INSERT INTO events VALUES ('credit', 100), and reading the balance means summing every event for that account (with snapshots for performance). Strong fit for audit-heavy systems (banking, healthcare records, regulatory), temporal queries ("what was the balance on March 3rd?"), and any domain where the history is itself a primary asset. Tools: Marten (Postgres-backed, .NET), EventStoreDB, Axon (JVM). Event streaming means events are how facts move between services — each service still keeps its current state in a normal database, but emits events ("order.placed", "payment.processed") that other services subscribe to. The event is a transport mechanism, not a storage mechanism. Strong fit for integration, fan-out, decoupling. Tools: Kafka, Pulsar, NATS JetStream. These are not mutually exclusive. A common combination is full CQRS: the write side is event-sourced (so you have an audit trail of state changes), and the read-side projections are fed by streaming those events to consumers that build denormalised views. The acid test: is the event itself the source of truth (sourcing), or is it a propagated fact about a change that happened in a store (streaming)? In most enterprise architectures, streaming is far more common than sourcing; sourcing fits domains where audit and replay are core. + +## [039] EVENT-DRIVEN · Event-driven tools landscape — Kafka, Pulsar, Flink, NiFi (sldId 294) + +Different jobs, often together: substrate (Kafka, Pulsar), compute (Flink), movement (NiFi, Debezium). + +p. 33 + +> Each of the four tools does a different job. Kafka is the substrate: a partitioned, ordered, durable log. Consumers track their own offsets and can replay. It's the de facto event spine for cloud-native architectures; Strimzi runs it natively on Kubernetes. Pulsar is also substrate, in the same broad category as Kafka but with native pub/sub plus queue semantics in the same product, tiered storage that pushes cold data to cheap object stores automatically, and multi-tenancy built into the broker. Pulsar has a Kafka-protocol shim so consumers can speak Kafka while writing to Pulsar. Flink is compute: not a transport layer, but a stateful stream-processing engine. Flink sits on top of Kafka or Pulsar, consumes streams, applies operators (windowed aggregations, joins, filters, stateful transformations) with exactly-once semantics via checkpointing, and produces new streams. Alternatives: Kafka Streams (lighter, JVM-only, embedded library), Spark Structured Streaming (heavier, batch-mindset). NiFi is movement: a visual dataflow / ETL tool with 200+ source-and-sink connectors and back-pressure built in. Think of it as the answer to 'I need to get data from SFTP to Kafka to an object store with these transforms in the middle' — you draw the flow in the UI rather than writing a pipeline. Debezium (covered in the Data section) is the CDC variant of movement. Together they compose: typical pipeline is source DB → Debezium or NiFi → Kafka or Pulsar → Flink for windowed aggregations → sink. The mistake is treating these as competitors. They complement each other; pick by the question your team is asking: durably hold events (substrate), compute over them (Flink), move them around (NiFi/Debezium). + +## [040] EVENT-DRIVEN · The log is the source of truth (sldId 295) + +Consumers track their own offset; replay is just rewinding. Kafka (Strimzi) and Pulsar are logs, not queues. + +p. 34 + +> The mental model correction most people need: Kafka is a log, not a queue. Messages are retained after they are read, and each consumer group tracks its own offset. That means replay is just rewinding an offset: a new consumer can process all of history, and a buggy consumer can be fixed and replayed. This is the foundation for event sourcing and for rebuilding stream-processing state, the subject of stream processing. Strimzi runs Kafka on Kubernetes; Pulsar offers similar log semantics with tiered storage. + +## [041] Stream Processing (sldId 296) + +06 + +Streams in, derived streams out. + +> Section six builds directly on the log. Once events are a durable, replayable stream, you can compute over them continuously (filtering, joining, windowing, aggregating) and emit new derived streams. This is where real-time analytics, materialized views and complex event reactions live. + +## [042] STREAM PROCESSING · Derive new streams from streams (sldId 297) + +A streaming app is a topology of sources, stateful processors and sinks — joins, windows and aggregates. + +p. 35 + +> A stream-processing app is a topology: sources (input topics), stateful processors (filter, join, window, aggregate) and sinks (output topics or stores). The output of one topology is just another stream that other services consume. Emphasize stateful — unlike a simple consumer, these processors keep local state (running totals, window buffers) that must survive restarts. That state-management problem is what the next code slide and KEDA slide address. + +## [043] STREAM PROCESSING · A windowed aggregation with Kafka Streams (sldId 298) + +[chip] java · Spring Kafka Streams + +```java +// stream-processor RevenueTopology.java (excerpt): order.placed -> revenue.by-merchant +@Configuration +@EnableKafkaStreams +public class RevenueTopology { + @Bean + public KStream revenueStream(StreamsBuilder builder) { + JacksonJsonSerde accSerde = new JacksonJsonSerde<>(Acc.class).noTypeInfo(); + var revenue = builder.stream(ORDERS, Consumed.with(Serdes.String(), Serdes.String())) + .map((key, json) -> parse(json)) // re-key by merchant_id + .groupByKey(Grouped.with(Serdes.String(), Serdes.String())) + .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofSeconds(windowSeconds))) + .aggregate(() -> new Acc(0, BigDecimal.ZERO), // local, changelog-backed + (merchant, amount, acc) -> add(merchant, acc, amount), + Materialized.with(Serdes.String(), accSerde)) + .toStream() + .map((window, acc) -> KeyValue.pair(window.key(), toJson(window, acc))); + revenue.to(REVENUE, Produced.with(Serdes.String(), Serdes.String())); + return revenue; + } +} +``` + +Local state + a changelog topic gives fault-tolerant aggregation. The output is itself a stream other services can consume. + +p. 36 + +> Kafka Streams is the JVM-native stream processor; on Spring Boot you get it through spring-kafka with @EnableKafkaStreams, which auto-configures a StreamsBuilderFactoryBean from spring.kafka.streams.* properties and injects the StreamsBuilder. The topology is a regular @Bean. +> +> The example re-keys each order.placed event by merchant, groups by key and aggregates into a tumbling window (window.seconds, 10 seconds by default). The accumulator, a count and a BigDecimal total, is serialized with Spring Kafka 4's JacksonJsonSerde on Jackson 3. The Materialized state store is local state backed by a Kafka changelog topic, so if the pod dies the state rebuilds by replaying the changelog: fault-tolerant local state with no external database in the hot path. +> +> Each update to a (merchant, window) row is mapped to a JSON record with the window start, merchant_id, order_count and the revenue rounded HALF_UP, and written to revenue.by-merchant, a stream other services consume. A total that is not a number is skipped instead of killing the stream thread. If you would rather express this as routing than as a topology, Spring Integration is the alternative; Flink is the heavyweight option. + +## [044] STREAM PROCESSING · KEDA scales workers on the signal that matters (sldId 299) + +Scale Kafka workers on consumer lag and HTTP services on request rate — both down to zero when idle. + +p. 37 + +> KEDA closes the elasticity loop. The right scaling signal for an event consumer is consumer lag, how far behind the log it is, not CPU. KEDA reads that lag and scales workers up, and down to zero when there's no lag. For synchronous HTTP services, the KEDA http-add-on scales on request concurrency/rate. One mechanism, two signals. Scale-to-zero is a real cost lever for spiky workloads. We then show both configs. + +## [045] STREAM PROCESSING · Scale on lag (events) and on RPS (HTTP) (sldId 300) + +[chip] yaml · KEDA + +```yaml +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: { name: payment-consumer } +spec: + scaleTargetRef: { name: payment-consumer } + minReplicaCount: 0 + maxReplicaCount: 20 + triggers: + - type: kafka + metadata: { bootstrapServers: "cndp-kafka-bootstrap:9092", topic: order.placed, + consumerGroup: payment, lagThreshold: "100" } +--- +# HTTP: scale the Spring Boot service on concurrent requests (KEDA http-add-on) +apiVersion: http.keda.sh/v1alpha1 +kind: HTTPScaledObject +metadata: { name: order-service } +spec: + hosts: [orders.example.com] + scaleTargetRef: { name: order-service, service: order-service, port: 8080 } + replicas: { min: 0, max: 30 } +``` + +One mechanism, two signals: queue depth for async workers, concurrency for synchronous APIs. + +p. 38 + +> Two manifests, one idea. The ScaledObject scales payment-consumer on Kafka lag for the order.placed topic, with minReplicaCount: 0 — true scale to zero when the topic is quiet, springing back when lag builds. The HTTPScaledObject does the same for the synchronous order-service based on request load via the http-add-on. The lagThreshold of 100 is the tuning knob: how many messages of backlog before adding a worker. Point out that scale-to-zero plus a cold-start budget is the trade-off to discuss with the audience for latency-sensitive paths. + +## [046] Workflows and Jobs (sldId 301) + +07 + +Not every unit of work is a request. + +> Section seven. A lot of real work is not a synchronous request at all: it's a multi-step business process, or batch and scheduled work. Two topics: how to coordinate multi-step flows (orchestration vs choreography) and where long-running and periodic work belongs on Kubernetes. For orchestration, Appendix D sets two approaches against the saga from the data section: the hand-rolled, database-backed orchestrator that Example 17 runs, and Temporal (Java SDK + Spring Boot) for durable execution when the saga outgrows it. + +## [047] WORKFLOWS AND JOBS · Orchestration vs. choreography (sldId 302) + +A coordinator that commands keeps the flow in one place; events that trigger each other decouple but hide the flow. + +p. 39 + +> The two ways to coordinate a multi-step process. Orchestration: a central coordinator explicitly commands each service in turn, so the flow is visible and observable, but the coordinator can become a god-service. Choreography: each service reacts to the previous one's event with no central brain — maximally decoupled, but the end-to-end flow is emergent and hard to see. Neither is universally right; we then give the selection criteria. This connects straight to the saga discussion and Appendix D. + +## [048] WORKFLOWS AND JOBS · Choosing between the two (sldId 303) + +Orchestration + +- One coordinator owns the saga and its state +- Flow is explicit and centrally observable +- One place to reason about, change and audit +- Risk: the orchestrator becomes a god-service +- Good for complex, long-lived business processes + +Choreography + +- Each service reacts to the previous event +- Maximally decoupled; no central bottleneck +- Flow is emergent — harder to see end-to-end +- Risk: nobody owns the overall process +- Good for simple, additive event reactions + +p. 40 + +> The decision heuristic: use orchestration for complex, long-lived business processes where you need auditability and a clear owner (order fulfillment, loan approval). Use choreography for simple, additive reactions where decoupling matters more than visibility (send a welcome email when a user signs up). The failure modes are mirror images: orchestration risks a god-service; choreography risks nobody owning the process and no one able to answer 'where is order 42 stuck?'. Observability (section 11) is what makes choreography survivable. + +## [049] WORKFLOWS AND JOBS · Jobs, CronJobs and queue workers (sldId 304) + +Long, batch or periodic work belongs in Jobs/CronJobs or a scaled queue worker — never on the request path. + +p. 41 + +> Where non-request work lives on Kubernetes. A Job runs a task to completion (a data migration, a nightly export). A CronJob schedules Jobs (the reconciliation that runs at 2am). A queue worker continuously drains a topic and is scaled by KEDA on lag. The anti-pattern: doing long or heavy work inside an HTTP handler, blocking the request and timing out the client. The rule of thumb on screen — long, batch or periodic work never belongs on the request path. Hand it to a Job or a worker and return immediately, ideally with a status URL the client can poll. In-app, Spring's @Scheduled (with ShedLock for cluster-aware locking) covers recurring work without leaving the JVM; Kubernetes Jobs and CronJobs own the heavier or isolated batch runs; and KEDA's Cron scaler is the declarative way to wake a worker on a schedule. + +## [050] API Management (sldId 305) + +08 + +Treat the API surface as a platform. + +> Section eight zooms out from individual services to the API surface as a whole. Management is four cooperating concerns, not a product you buy: runtime control (gateway and mesh), contracts (registry), discovery (metadata), and signals (observability). The next three sections each take one of these in depth. + +## [051] API MANAGEMENT · Four cooperating planes (sldId 306) + +Gateway and mesh at runtime; registry for contracts; metadata for discovery; observability for signals. + +p. 42 + +> The four planes, and they map exactly to the rest of the platform sections. Runtime plane: the gateway and Istio mesh handle traffic, auth, rate-limiting and resilience. Contract plane: the registry (section 9) holds every API and event schema, versioned and gated. Discovery plane: metadata/catalog (section 10) lets people find and trust APIs. Signal plane: observability (section 11) tells you what is happening. The point: these are distinct responsibilities with distinct owners, not one monolithic 'API gateway product' that tries to do everything. + +## [052] API MANAGEMENT · Versioning and evolution are part of management (sldId 307) + +- Evolve additively — new optional fields and endpoints; never repurpose or remove without a new version. +- Carry a major version in the path (/v2) or media type; deprecate with headers and a sunset date. +- The registry's compatibility rules (BACKWARD / FORWARD / FULL) enforce this mechanically at publish time. +- Run old and new side by side; migrate consumers off the old contract before you retire it. +- Lifecycle: design → register → publish → consume → deprecate → retire — each step has an owner and a gate. + +p. 43 + +> Versioning is a management concern, not an afterthought. The core discipline: evolve additively; add optional fields and new endpoints, never repurpose a field or remove one without a deprecation period. When you must break, carry a major version (in the path or media type) and run old and new side by side with a published sunset date, migrating consumers off before retiring. The registry's compatibility rules enforce the additive discipline mechanically at publish time. The bold lifecycle line is the takeaway: every contract moves design → register → publish → consume → deprecate → retire, and each transition has an owner and a gate. Appendix B drills into how REST, gRPC and GraphQL each handle this differently. + +## [053] API Registry (sldId 308) + +09 + +One versioned home for every contract. + +> Section nine. If contracts are the boundary, they need one authoritative home. The registry is where every OpenAPI spec, .proto, AsyncAPI doc and event schema lives — versioned, compatibility-checked, and gated in CI. This is the difference between 'we have contracts somewhere in git' and 'contracts are enforced before code ships'. + +## [054] API REGISTRY · Apicurio: contracts, versioned and gated (sldId 309) + +OpenAPI, AsyncAPI, proto and Avro/JSON schemas live in one registry with compatibility rules and CI gating. + +p. 44 + +> Apicurio is the concrete registry in this stack. Every contract type lands here: REST OpenAPI, GraphQL SDL, gRPC .proto, and event schemas (Avro/JSON). Each artifact has versions and a compatibility rule (BACKWARD, FORWARD, FULL). At publish time the registry checks the new version against the rule and rejects incompatible changes. Producers register; consumers fetch and generate stubs. The serde libraries for Kafka pull schemas from here by ID at runtime. We then show the CI gate that turns this into an automatic guardrail. + +## [055] API REGISTRY · Gate breaking changes in CI with Maven (sldId 310) + +[chip] xml · Maven + Apicurio + +```xml + + + io.apicurio + apicurio-registry-maven-plugin + 3.3.3 + + register + + http://apicurio.registry.svc/apis/registry/v3 + true + + orders + order-placed + AVRO + CREATE_VERSION + src/main/avro/order-placed.avsc + + + + +``` + +The dry-run register fails the build on an incompatible change — the contract is enforced before code ships, not after. + +p. 45 + +> This is the guardrail that makes the whole contract story real, done the Maven way. The apicurio-registry-maven-plugin's register goal with dryRun set checks the new schema against the artifact's compatibility rule in the registry without storing it, and fails the build (and the merge) if it is incompatible. The older test-update goal still exists in 3.3.3 but is deprecated in favour of dryRun. The point to make: the contract is enforced before code ships, not discovered broken in production by a downstream team. The rule (e.g. BACKWARD — new consumers can read old data) is set once per artifact and enforced automatically forever; a separate register goal publishes on the main branch after merge. Tie it back: this is design-first from section 1, now mechanized in the build. + +## [056] API Metadata (sldId 311) + +10 + +If they can't find it, it doesn't exist. + +> Section ten. A contract that's enforced but undiscoverable is half useless. Metadata is the discovery and trust layer: a searchable catalog of every API, topic and table, with ownership, lineage and quality signals. If a consumer can't find your API and trust it, they'll build their own or screen-scrape — and your boundary erodes. + +## [057] API METADATA · Discovery, lineage and ownership (sldId 312) + +OpenMetadata ingests from services, the registry and Kafka to build a searchable catalog with lineage. + +p. 46 + +> OpenMetadata is the catalog in this stack. It ingests from many sources (OpenAPI specs, the Apicurio registry, Kafka topics, Postgres schemas) and builds one searchable catalog. The differentiator over a wiki is automated lineage: it knows that this topic feeds that table feeds this API, so it can answer 'if I change this field, who breaks?' before you change it. Ingestion is a scheduled connector, so the catalog stays current without manual upkeep. We then list what consumers get from it. + +## [058] API METADATA · What the catalog gives every consumer (sldId 313) + +- Discovery — search across APIs, topics and tables; find the owning team and how to get access. +- Trust — published SLOs, data-quality test results, freshness, and a tier/criticality rating. +- Lineage — column- and topic-level lineage answers 'if I change this, who breaks?' before you change it. +- Ownership & governance — tags, classifications (PII), and accountable owners attached to every asset. + - Ingestion runs as a scheduled connector against OpenAPI specs, the registry, Kafka and Postgres. + +p. 47 + +> Four concrete payoffs for consumers. Discovery: search across APIs, topics and tables, and find the owning team and how to get access — no more asking around in Slack. Trust: published SLOs, data-quality results, freshness and a criticality tier tell you whether to depend on it. Lineage: column- and topic-level impact analysis answers the change-safety question proactively. Ownership and governance: tags, PII classifications and accountable owners on every asset — which is also what your compliance team needs. The throughline of sections 9–10: a contract is only as good as your ability to find it, trust it, and know who owns it. + +## [059] Observability (sldId 314) + +11 + +Instrument once; correlate everything. + +> Section eleven, and the one that makes everything else operable. In a distributed system you cannot debug by reading one log file — a single user action touches five services. Observability is how you reconstruct that story. The key word is correlate: traces, metrics and logs become useful together only when they share a trace_id so you can pivot between them. + +## [060] OBSERVABILITY · Three signals, one correlated story (sldId 315) + +OpenTelemetry emits traces, metrics and logs; the LGTM-style stack stores them and Grafana joins them. + +p. 48 + +> Three signals, each answering a different question. Traces: where did the time go and which service failed in this one request? Metrics: what are the aggregate rates, errors and latencies over time? Logs: what exactly happened in this component? OpenTelemetry is the vendor-neutral way to emit all three from your code. The LGTM-style stack stores them (Tempo for traces, Mimir/Prometheus for metrics, Loki for logs), and Grafana joins them. The magic is the shared trace_id linking a metric spike to the exact traces and logs behind it. We then show the instrumentation, then the trace. + +## [061] OBSERVABILITY · The signals in depth — traces, metrics, logs, baggage, profiles (sldId 316) + +Three classic pillars plus two that matter more every year. OpenTelemetry emits all five; one trace_id correlates them. + +p. 49 + +> Expand the 'three pillars' framing into the five signals modern observability uses. Traces answer where the time went and which hop failed in one request. Metrics answer the trend question — aggregate rates, errors, latencies over time (the RED method: Rate, Errors, Duration; the USE method: Utilisation, Saturation, Errors). Logs answer what exactly happened in one component. Baggage is the signal most people miss — it's key-value context (tenant_id, user_tier, feature_flag) that propagates along the whole trace, so a value set at the edge is readable in a Kafka consumer three hops downstream. Profiles are the newest signal — continuous CPU / memory / lock sampling per service, answering which function is burning the CPU (Pyroscope, Parca, Grafana Phlare). The correlation bar at the bottom is the whole point: traces, metrics, logs, baggage and profiles all share trace_id + span_id + resource attributes (service.name, k8s.pod.name, deployment.version), so you can pivot — a metric spike leads to an exemplar trace, which leads to its logs, which leads to its CPU profile. OpenTelemetry is the vendor-neutral SDK and wire format for all five; traces/metrics/logs are GA, baggage is stable, profiling is the newest (the OTel profiling signal reached the ecosystem in 2024). + +## [062] OBSERVABILITY · What each signal answers — and when to reach for it (sldId 317) + +- Traces — 'where did the time go in this request, which hop failed?' Reach for it when debugging latency or a specific failed request. Stored in Tempo / Jaeger. +- Metrics — 'what is the aggregate rate / error / latency trend, is it getting worse?' Reach for it for dashboards, alerting, capacity. RED (Rate, Errors, Duration) for request-driven services; USE (Utilisation, Saturation, Errors) for resources. Stored in Prometheus / Mimir. +- Logs — 'what exactly happened in this component at this instant?' Reach for it for detail the trace doesn't carry. Structured (JSON), keyed by trace_id. Stored in Loki / ELK. +- Baggage — 'carry a value (tenant_id, user_tier) along the whole request so downstream services and their telemetry can use it.' Set once at the edge, readable everywhere downstream. W3C Baggage spec. +- Profiles — 'which function is burning CPU / allocating memory / holding locks, continuously, in production?' Reach for it for the latency the trace shows but doesn't explain. Pyroscope / Parca. + - The discipline: emit traces + metrics + logs always; add baggage when downstream needs request-scoped context; add profiling when you have CPU/memory mysteries a trace can't resolve. + +p. 50 + +> Walk each signal and the question it answers — the framing 'what question does this answer' is more useful to an engineer than 'what is this signal.' Traces are per-request; metrics are aggregate; logs are detail. The two newer ones deserve emphasis. Baggage is underused: most teams propagate trace context but never realise they can also propagate their own key-values (tenant, user tier, AB-test cohort) along the same mechanism, and then filter traces and logs by tenant. Profiling is the frontier: continuous profilers (Pyroscope, Parca, Grafana Phlare) run in production at low overhead and answer the 'the trace says this span took 200ms but why' question by showing the actual call stack burning the CPU. The sub-bullet is the practical guidance on which to adopt when. + +## [063] OBSERVABILITY · Auto-injection vs. instrumentation — three distinct things (sldId 318) + +- Manual instrumentation — you write the spans. tracer.spanBuilder(...).startSpan(), span.setAttribute(...), span.addEvent(...). Needed for business operations the libraries can't know about ('reserve_stock', 'score_fraud'). The most signal per line, the most effort. +- Auto-instrumentation — a library / agent wraps known frameworks for you. HTTP servers and clients, gRPC, JDBC / database drivers, Kafka, Redis — each becomes spans with zero code change. Covers the plumbing; misses your domain. +- Auto-injection — the platform puts the auto-instrumentation agent into the pod for you. The OpenTelemetry Operator watches for an annotation (instrumentation.opentelemetry.io/inject) and mutates the pod at admission — no agent in your image, no Dockerfile change, no redeploy of the app to add tracing. +- How they layer: auto-injection delivers the agent → the agent provides auto-instrumentation of frameworks → you add manual spans for business logic on top. All three coexist in the same trace. + - Trade-off: auto-instrumentation is convenient but can be noisy (every framework call becomes a span) and slightly slower. Manual is precise but is work you own. Most teams: auto for the plumbing, manual for the domain, injection to roll it out fleet-wide. + +p. 51 + +> Auto-injection, auto-instrumentation and manual instrumentation are three things people conflate. Manual: you call the tracer API yourself; necessary for domain operations. Auto-instrumentation: the OTel instrumentation libraries (or a language agent) wrap known frameworks: your HTTP handler, your DB driver, your Kafka client all emit spans without you touching them. Auto-injection: a Kubernetes-level mechanism; the OpenTelemetry Operator sees a pod annotation and injects the language agent + config as an init container / env vars at admission time, so you don't even put the agent in your image. Per-language specifics: Python: 'opentelemetry-instrument' wrapper or the operator injects it; instrumentation packages per library. Java/Quarkus: the OTel Java agent (-javaagent) auto-instruments everything; Quarkus also has build-time OTel extension. Spring: Micrometer Tracing + the OTel bridge, or the Java agent. .NET: OTel auto-instrumentation via the OpenTelemetry.AutoInstrumentation package or the operator's .NET injection; manual via System.Diagnostics.ActivitySource. The bold line is the mental model: injection delivers the agent, the agent instruments frameworks, you add manual spans on top — they layer into one trace. + +## [064] OBSERVABILITY · Auto-instrument with the OpenTelemetry Java agent (sldId 319) + +[chip] java · Spring Boot 4 + OpenTelemetry Java agent + +```java +# Containerfile — attach the OpenTelemetry Java agent (Spring Boot 4 needs no starter) +ENV JAVA_TOOL_OPTIONS="-javaagent:/deployments/opentelemetry-javaagent.jar" +# environment: OTEL_SERVICE_NAME=order-service +# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 +# OTEL_TRACES_SAMPLER=parentbased_traceidratio, OTEL_TRACES_SAMPLER_ARG=1.0 +// The agent instruments Spring MVC, RestClient, gRPC, JDBC and the Kafka clients, +// and puts trace_id/span_id in the logback MDC. Custom business spans use the +// OpenTelemetry API annotation (io.opentelemetry.instrumentation.annotations): +@Service +public class OrderService { + private final InventoryGrpcClient inventory; + public OrderService(InventoryGrpcClient i) { this.inventory = i; } + @WithSpan("reserve_stock") // custom span + public ReserveReply reserve(String sku, int qty) { + return inventory.reserveStock(sku, qty); + } + // trace_id flows: REST -> gRPC -> Kafka -> consumer +} +``` + +The agent exports OTLP: traces to Tempo, metrics to Mimir, logs to Loki, all keyed by trace_id. + +p. 52 + +> The payoff line is how little code this takes. The examples attach the OpenTelemetry Java agent 2.32.0 with JAVA_TOOL_OPTIONS in the Containerfile; Spring Boot 4 needs no tracing starter. The Containerfile downloads the agent jar from Maven Central and checks it against a pinned SHA-256. +> +> Every Spring MVC endpoint, RestClient call, gRPC invocation, JDBC query and Kafka producer and consumer is instrumented: spans are created and W3C trace context is propagated with zero code, and the agent writes trace_id and span_id into the logback MDC. Configuration is environment only: the service name, the OTLP endpoint and the sampler. +> +> You hand-write spans only for business operations worth measuring, and even that is declarative: @WithSpan from the OpenTelemetry instrumentation annotations. The trace_id flows REST to gRPC to Kafka to the consumer with no manual plumbing, because the agent injects and extracts context at each hop. Instrument once, correlate everything. + +## [065] OBSERVABILITY · How correlation works — context propagation (sldId 320) + +The trace context rides in-band with the request (W3C traceparent). The same trace_id at every hop is what stitches the story. + +p. 53 + +> Demystify the magic word 'correlation.' It is not a backend join — it's the trace context physically travelling with the request, in-band, at every hop. The W3C traceparent header is the standard: version-traceid-parentspanid-flags. The trace_id (16 bytes) is constant across every hop of the request — that constancy is literally what stitches the distributed story into one trace. The span_id is new per operation, and each span records its parent span_id, which is how the backend rebuilds the tree. The flags byte carries the sampling decision (01 = sampled) so every hop honours the same decision. Alongside traceparent rides tracestate (vendor-specific) and baggage (your key-values). The transport differs per hop but the mechanism is identical: HTTP uses the traceparent header, gRPC uses metadata, Kafka uses message headers. The SDK injects the context on the way out and extracts it on the way in — automatically, which is why auto-instrumentation 'just works' across services. This propagated context is exactly why the same trace_id shows up on the trace waterfall, on every structured log line, and as an exemplar on your metrics — they all read it from the same in-band context. + +## [066] OBSERVABILITY · Baggage in practice — carry request context downstream (sldId 321) + +[chip] java · Spring Boot 4 + OpenTelemetry API + +```java +// OpenTelemetry Baggage rides the same context propagation as the trace; +// the Java agent carries the W3C baggage header over REST, gRPC and Kafka. +@Component +public class TenantBaggageFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, + FilterChain chain) throws ServletException, IOException { + try (Scope s = Baggage.current().toBuilder() + .put("tenant.id", req.getHeader("X-Tenant")) + .build() + .makeCurrent()) { + chain.doFilter(req, res); // downstream calls carry tenant.id + } + } +} +// read it downstream, in a consumer +@KafkaListener(topics = "order.placed") +public void handle(OrderEvent ev) { + String tenant = Baggage.current().getEntryValue("tenant.id"); + Span.current().setAttribute("tenant.id", tenant); // baggage is not an attribute +} +``` + +Set baggage once at the edge; every downstream hop carries it, and a span attribute makes it queryable. + +p. 54 + +> Baggage in practice. OpenTelemetry Baggage rides the same context propagation as the trace: the Java agent's default propagators are tracecontext and baggage, so the W3C baggage header travels on every REST, gRPC and Kafka hop with no extra code. +> +> The OncePerRequestFilter puts tenant.id into the current baggage at the edge, and the try-with-resources scope restores the previous context when the request ends. A @KafkaListener downstream reads it with Baggage.current().getEntryValue(...). Baggage is not recorded on spans automatically, so the consumer copies it onto its span as an attribute, which is what makes traces filterable by tenant. +> +> Caution: baggage goes on the wire to every hop, including third parties you call. No secrets, no PII. + +## [067] OBSERVABILITY · A single trace stitches the whole request (sldId 322) + +One trace_id propagates from the edge REST call through gRPC, Kafka and the consumer — five services, one story. + +p. 55 + +> This is what the instrumentation buys you, visually. One trace_id propagates from the edge REST POST through the gRPC ReserveStock call, onto the published order.placed event, and into the consumer that sends the notification. Five services, one readable waterfall. Point at the time axis: you can see exactly which span dominated latency. That same trace_id appears as an exemplar on your metrics and on every log line, so a latency alert is one click from the offending trace and its logs. This is what makes the choreographed, event-driven design from earlier sections debuggable. + +## [068] OBSERVABILITY · Sampling — head vs. tail (sldId 323) + +You can't store every trace at scale. Head decides at the start (cheap, blind); tail decides at the end (smart, costly). + +p. 56 + +> The cost-control reality: at scale you cannot store every trace, so you sample. The key question is when the keep/drop decision is made. Head sampling decides at the start, in the SDK, before the trace even completes — typically 'keep 1 in N' (probabilistic) or 'keep N per second' (rate-limiting). It's cheap and low-overhead, but it is blind at decision time: it can't keep a trace because it errored or was slow, because it decides before knowing. Tail sampling decides at the end, in the Collector, after the whole trace has been seen — the Collector buffers spans, waits for the trace to complete, then applies rules: keep all errors, keep all traces over 500ms, keep rare routes, sample the boring successful ones at 1%. Tail is smart but costs memory (buffering in-flight traces) and CPU (the Collector does real work). The strategies, named: probabilistic (keep X%), rate-limiting (cap per second), parent-based (follow the upstream's decision so a trace isn't half-sampled), and tail rules (error / latency / attribute based). The common production setup is both: a parent-based head sample for a representative baseline, plus tail sampling at the Collector to guarantee every error and every slow trace is captured regardless of the baseline rate. Two rules to drill home: always sample by trace, not by span (a half-sampled trace is worse than no trace), and propagate the sampling flag (the 01 in traceparent) so every service agrees. Metrics and logs are usually not sampled the same way: metrics aggregate everything (you don't sample a counter), and logs are typically kept in full with retention limits or sampled on their own policy. + +## [069] OBSERVABILITY · Choosing a sampling strategy (sldId 324) + +- Probabilistic (head) — keep a fixed percentage (e.g. 10%). Simplest; predictable volume; blind to errors. Good baseline for high-traffic, low-stakes services. +- Rate-limiting (head) — keep at most N traces/sec per service. Protects your backend from traffic spikes; good when load is bursty. +- Parent-based (head) — honour the upstream service's sampling decision (carried in the traceparent flags). Essential in distributed systems: it stops a trace being sampled at service A but dropped at service B, which would give you broken half-traces. +- Tail-based (Collector) — buffer the whole trace, then keep by rule: all errors, all traces over a latency threshold, rare routes, plus a small % of the rest. The gold standard for keeping what matters, at the cost of Collector memory + CPU. +- Recommended production setup: parent-based head sampling at a modest baseline (1-10%) for representativeness, plus tail sampling at the Collector to guarantee every error and slow trace is kept. + - Don't sample metrics the way you sample traces (metrics aggregate everything). Keep logs in full with retention limits, or sample them on their own policy keyed by trace_id so a kept trace keeps its logs. + +p. 57 + +> The decision guide for sampling. Probabilistic is the simplest and the most common starting point — keep 10%, predictable volume. Rate-limiting is better when traffic is bursty because it caps absolute volume. Parent-based is non-negotiable in a distributed system: without it, service A samples a trace but service B independently drops it, and you get a broken trace with holes — worse than no trace. Tail-based sampling gives the most control: because the Collector sees the whole trace before deciding, it can guarantee you keep every error and every slow request, which are the traces you want during an incident. The cost is the Collector must buffer in-flight traces (memory) and evaluate rules (CPU), and it must see all spans of a trace (which constrains how you load-balance the Collector — use a trace-ID-aware load balancer). The recommended setup combines head (for a cheap representative baseline) and tail (for guaranteed capture of the interesting traces). The sub-bullet is the reminder: traces, metrics and logs are sampled differently — you never sample a metric counter, and logs typically follow their own retention or are sampled to match their parent trace. + +## [070] OBSERVABILITY · Prove it with simple tools before you ship (sldId 325) + +- curl / Postman — exercise REST contracts and inspect status codes, headers and pagination. +- hey — quick HTTP load to watch latency, error rate and the KEDA http-add-on scale the service. +- ghz — load-test gRPC endpoints and confirm timeouts and retries behave under pressure. +- GraphQL UI (GraphiQL) — explore the composed schema and watch the gateway fan out in Tempo. +- Kiali — read the live service-mesh topology, mTLS status and request flow during a load test. + +p. 58 + +> Keep testing pragmatic: you don't need a heavy framework to validate this architecture. curl and Postman exercise REST contracts and check status codes and pagination. hey throws quick HTTP load so you can watch latency and error rate and see the KEDA http-add-on add replicas. ghz is the gRPC load tester: confirm your Istio timeouts and retries behave under pressure. The GraphQL UI (GraphiQL, which Spring for GraphQL serves at /graphiql in dev) explores the composed schema and, with tracing on, shows the gateway fanning out in Tempo. Kiali visualizes the live mesh: topology, mTLS status and real-time request flow during a load test. The message: prove the behaviour with simple tools before you ship, not after an incident. + +## [071] Security (sldId 326) + +12 + +Defence in depth, in layers. + +> Section 12 — security. The Anti-Patterns section that used to be 12 has shifted to 13. We organise this section around two ideas: the 4 C's of cloud-native security (concentric layers, each protecting what is inside it), and the seven patterns that you'll meet over and over in practice. The bias of this section: security is not a single product or a single team. It's a property of how the system is designed and run, owned at every layer. Each pattern here exists because no single layer is enough on its own. + +## [072] SECURITY · The 4 C's of cloud-native security (sldId 327) + +Cloud → Cluster → Container → Code. Concentric layers, each layer protects what is inside. + +p. 59 + +> The mental model from the CNCF security paper. Four concentric layers of responsibility, each enforcing controls appropriate to its scope. Cloud is the outermost layer (IAM, VPC, KMS, audit logs, region selection), owned by the platform team or cloud admin. Cluster is the Kubernetes layer (RBAC, NetworkPolicy, Pod Security Standards, admission control), owned by platform/SRE. Container is the workload (minimal base images, non-root users, signed images, vulnerability scanning, secret mounting), owned jointly by app teams and platform. Code is the innermost layer (secure defaults, input validation, dependency scanning, secrets handling), owned by app teams. The point of the concentric metaphor: a weakness in any layer leaks into the inner ones. Strong code on a weak container is still vulnerable. Strong container on a weak cluster is still vulnerable. We design controls at all four. We then cover what each layer's controls are. + +## [073] SECURITY · The 4 C's — what each layer enforces, and who owns it (sldId 328) + +- Cloud — IAM (principles of least privilege), VPC isolation, KMS for keys, audit logging, region & data-residency controls. Owner: platform / cloud admin. +- Cluster — Kubernetes RBAC, NetworkPolicy (default-deny + targeted allows), Pod Security Standards (restricted profile), admission control (OPA Gatekeeper / Kyverno), secret encryption at rest, etcd encryption. Owner: platform / SRE. +- Container — minimal base images (distroless, UBI-minimal), non-root users, read-only root filesystems, image signing (Sigstore / Cosign), vulnerability scanning (Trivy, Grype) on every build, secret mounting via projected volumes (never bake secrets into images). Owner: app team + platform. +- Code — secure defaults (parameterised queries, output encoding), input validation at the trust boundary, dependency scanning (Dependabot, Snyk, OSV-Scanner), secrets handling (no plaintext in source, no logging secrets), CSRF / SSRF / XXE awareness, threat modeling. Owner: app team. +- Cross-cutting: observability (audit who did what when), incident response runbooks, threat modeling per service, regular dependency updates. + - The pattern: a control at the wrong layer is the most common source of false confidence. Network ACLs cannot save you from an SQL injection. Code-level input validation cannot save you from a compromised image. Defence in depth is real. + +p. 60 + +> Walk each layer. The Cloud layer is where you control the perimeter and the identities and the keys; failures here often look like 'someone left a storage bucket open to the internet' or 'an over-permissive IAM role enabled lateral movement to another account.' The Cluster layer is the Kubernetes operating system for your workloads; failures look like 'the developer Pod could reach the database Pod' (no NetworkPolicy) or 'a privileged container was admitted' (no Pod Security Standards). The Container layer is about the image and runtime; failures look like 'a CVE in the base image' or 'the container ran as root and got out via a kernel exploit' or 'a secret was in the image.' Code is where most developer focus already lives. The sub-bullet is the take-home: a control at the wrong layer is false confidence. We then open the pattern catalog — seven patterns that show up across these four layers. + +## [074] SECURITY · Seven patterns to know (sldId 329) + +- Sidecar — move security concerns out of the app into a companion process. mTLS, JWT, audit, rate-limiting belong there. +- Valet Key — hand the client a short-lived signed token to access a resource directly. Less app proxying, less attack surface. +- Strangler Fig (for security modernisation) — encapsulate legacy auth behind an API gateway, replace pieces over time. +- Zero-Trust / Identity-Aware Proxy — every request authenticated and authorised. Perimeter trust is dead. +- Policy-as-Code — security gates declared in Git, evaluated at admission and at build. No human runs a checklist. +- Bulkhead — isolate resources so one failure (or compromise) cannot starve or cascade into others. +- Claim-Check — send a reference, not the payload. Keeps PII out of message bus history and limits the blast radius if a topic leaks. + - The seven appear in different layers and combine. Each is presented as pattern, then implementation — platform config where it's mostly YAML, code where it's mostly code. + +p. 61 + +> The roadmap for the next fourteen slides. These patterns span the 4 C's: Zero-Trust and Policy-as-Code are cluster-layer; Sidecar straddles cluster and container; Valet Key, Bulkhead, and Claim-Check are mostly code-layer; Strangler Fig is architecture-layer (modernisation). You'll see each pattern with a diagram explaining how it works, then either a YAML configuration block (for the platform-heavy patterns) or language-specific code (for the code-heavy patterns). Use the sub-bullet to set audience expectation that what follows alternates between pattern-and-implementation pairs. + +## [075] SECURITY · SIDECAR · Sidecar pattern — push security out of the app (sldId 330) + +mTLS, JWT validation, rate limiting, audit logging — a companion process in the same Pod handles them all. + +p. 62 + +> The sidecar pattern — a separate container running in the same Pod as the application, sharing localhost. The sidecar handles cross-cutting security concerns: terminates mTLS, validates JWTs, rate-limits, audits, enforces policy. The app receives only authenticated, authorised requests with trusted headers attached (X-Forwarded-Client-Cert, the validated JWT claims, the request ID). Why this matters: one security policy applied uniformly across every workload regardless of language. Same Envoy sidecar protects your Java app, your Python app, your Go app. App teams write business code, not crypto code. The sidecar is enforced by the platform (Istio injects them automatically) so it can't be disabled by accident. Examples in the field: Istio Envoy sidecars (the dominant choice), Linkerd2-proxy, OPA Sidecar for policy decisions, Cilium L7 with per-endpoint policy. + +## [076] SECURITY · SIDECAR · Reading the sidecar's trusted headers (sldId 331) + +- The sidecar handles mTLS, JWT validation, and policy decisions — the app trusts the headers the sidecar attaches and reads them as plain values. +- X-Forwarded-Client-Cert — the validated client identity from mTLS. Includes the SPIFFE ID (spiffe://cluster.local/ns/orders/sa/order-service) or the X.509 Subject. Set only by the sidecar; the app must reject requests that don't have it. +- JWT claims — the sidecar validates the signature, then passes the claims forward as a header or sets request.auth.claims via an envoy filter. The app reads sub, aud, scope, custom claims; it never re-validates the signature. +- Request ID — the sidecar attaches X-Request-Id for traceability. Log it; propagate it to downstream calls; correlate it with audit logs. +- Mode: only trust these headers when the sidecar is in front. In direct-to-Pod traffic (port-forward, debugging, mistakes), they could be spoofed. Use a NetworkPolicy to ensure traffic only enters via the sidecar's mTLS port. + - Examples: Istio Envoy sidecars (auto-injected), Linkerd2-proxy, OPA Sidecar (policy decisions), Cilium L7 policy. License: all Apache 2.0. + +p. 63 + +> The audience point: the sidecar pattern works only if the app uses the headers the sidecar attaches. The most common failure mode is an app that ignores X-Forwarded-Client-Cert and falls back to 'allow if you got here.' Make the headers required, not optional. The second-most-common failure mode is forgetting that traffic can sometimes bypass the sidecar (port-forward for debugging, host networking, direct pod-to-pod calls within the same node). NetworkPolicy is what closes that gap — only allow inbound traffic on the sidecar's mTLS port, never the app's container port directly. With both the sidecar and the NetworkPolicy, the app can fully trust the headers and become much simpler — no crypto code, no JWT parsing library, just header reads. + +## [077] SECURITY · VALET KEY · Valet key pattern — short-lived signed token, direct access (sldId 332) + +App mints a scoped, time-bound token; client uses it directly against the resource. App never proxies the bytes. + +p. 64 + +> The valet key pattern: instead of streaming a large file through your application, the application mints a scoped, time-bound, operation-restricted token (a 'valet key') and the client uses it to interact directly with the storage system. Pros: dramatically less data through the application (memory, bandwidth, latency); no long-lived credentials at the client; no app-side credential to leak; the token can expire and be revoked. Cons: requires the storage side to validate tokens; the token's permissions and TTL must be carefully scoped — a leaked token still has its window. Every implementation has the same shape: sign the operation, the object key and the expiry with a key the storage side can check. Example 12 mints and verifies such a ticket in the service itself (HMAC-SHA256, an HS256 JWT in Go and C++), and most object stores, open-source ones included, accept signed URLs of the same shape. We then show the code. + +## [078] SECURITY · VALET KEY · Valet key — scoped, time-bound, op-restricted (sldId 333) + +[chip] java · Spring Boot 4 + javax.crypto (HmacSHA256) + +```java +// ValetKeyService.java (excerpt), examples/12-security/spring-boot: HMAC-SHA256 valet key +@Service +public class ValetKeyService { + private static final int TTL_SECONDS = 300; // time-bound + private final byte[] secret; // VALET_SECRET, injected through @Value + public Map mint(String resource, String operation) { + long expires = System.currentTimeMillis() / 1000 + TTL_SECONDS; + String payload = resource + ":" + operation + ":" + expires; // one object, one op + Map result = new LinkedHashMap<>(); + result.put("resource", resource); + result.put("operation", operation); + result.put("expires", expires); + result.put("token", hmac(payload)); // HmacSHA256, hex-encoded + return result; + } + public boolean verify(String resource, String operation, long expires, String token) { + if (System.currentTimeMillis() / 1000 > expires) return false; // expired + String expected = hmac(resource + ":" + operation + ":" + expires); + return MessageDigest.isEqual(token.getBytes(), expected.getBytes()); // constant time + } +} +// OrderController maps POST /valet-key to mint() and GET /verify-valet to verify() +``` + +p. 65 + +> The handler mints a ticket that authorises one operation (GET or PUT) on one resource for 300 seconds; after that it is worthless. In Example 12 the ticket is an HMAC-SHA256 signature over resource:operation:expires, keyed with VALET_SECRET (javax.crypto.Mac with HmacSHA256). POST /valet-key returns the resource, the operation, the expiry and the token. +> +> The verifier (GET /verify-valet in the example, the storage gateway in a real system) rejects an expired ticket first, recomputes the signature and compares the two with MessageDigest.isEqual, a constant-time comparison. A changed resource, operation or expiry gives a different signature, so any tampering is a 403. +> +> The application stays out of the data path: the client presents the ticket to the storage side directly. Most object stores, open-source ones included, accept signed URLs of the same shape: the method, the object key and the expiry signed into the URL. A user-scoped resource path also lets the store's policy restrict each user to their own prefix as defence in depth. + +## [079] SECURITY · STRANGLER FIG · Strangler Fig for security modernisation (sldId 334) + +- Use case: encapsulate legacy authentication (LDAP-only, HTTP Basic, hardcoded session cookies) behind a modern API gateway, then replace the legacy auth path one service at a time. +- Step 0: introduce the gateway in front of the monolith. Clients now hit the gateway, which forwards to the legacy auth. Day-zero behaviour unchanged. +- Step 1: the gateway terminates TLS, validates JWTs from a modern IdP (Keycloak, Dex), and translates to whatever the legacy expects; it speaks both languages. +- Step 2: per route, switch the gateway from 'forward legacy headers' to 'forward only the JWT claims to the new service'. Legacy auth is no longer in the path for that route. +- Step 3: when no routes still need legacy auth, remove it. +- Bounded blast radius: each route's auth migration is a config change in the gateway, reversible without a release. + - See Appendix K for the general strangler-fig family (identify → move → redirect, with proxy, with shared database, with content-based routing). Same mechanics, applied to auth instead of business logic. + +p. 66 + +> The strangler fig was covered as a general pattern in Appendix K — here it shows up specifically for security modernisation, which is a common need: most enterprises have legacy auth (LDAP-only, custom SSO, NTLM, hardcoded API keys) buried in critical systems, and a 'big bang' replacement is too risky. The gateway becomes the bilingual translator: it validates modern JWTs from your IdP at the edge and translates to whatever the legacy backend expects (X-Remote-User header, basic auth, NTLM, custom session cookie). Per route, you migrate when the new path is verified — if anything breaks, flip the gateway config back to forwarding legacy headers. The sub-bullet points to Appendix K for the full mechanics; this is the application of those mechanics to a specific security need. + +## [080] SECURITY · ZERO-TRUST · Zero-trust / Identity-Aware Proxy (sldId 335) + +Every request authenticated and authorised, on every hop. Perimeter trust is dead. SPIFFE inside, IAP at the edge. + +p. 67 + +> The model: there is no trusted network. Every request, from any source, to any service, on every hop — authenticated and authorised. The old model (top of the diagram) was 'get inside the VPN, everything inside is trusted': once an attacker is in, they move laterally with little resistance. The new model (bottom): the user authenticates to an IAP (Identity-Aware Proxy) which evaluates identity, device posture, location, MFA. The IAP then forwards to a policy engine (OPA, Cedar) which returns an allow/deny per-request. The downstream service still authenticates the caller (mTLS + SPIFFE ID) and re-authorises against policy. Inside the cluster: mTLS via SPIFFE identity (every workload has an X.509 cert identifying its service account), AuthorizationPolicy per workload restricting which other workloads can call which endpoints. A breach in one service gives you nothing because the next call still has to prove identity and pass policy. + +## [081] SECURITY · ZERO-TRUST · Implementing zero-trust (sldId 336) + +- Edge (IAP): Pomerium or OAuth2 Proxy in front of an OIDC provider such as Keycloak — they terminate TLS, validate identity (OAuth/OIDC against your IdP), apply the route's access policy, then issue a JWT the downstream service sees in X-Forwarded-* headers. +- Inside the cluster (mTLS): Istio + SPIFFE assigns every workload an X.509 cert identifying its service account (spiffe://cluster.local/ns/orders/sa/order-service). Every service-to-service call is mutually authenticated automatically. +- Authorization: Istio AuthorizationPolicy declares 'order-service can call payment-service /charge but nothing else, only when carrying a valid customer JWT.' Per-workload, fine-grained, declarative. +- Policy decisions: OPA / Cedar evaluate context (user, time, device, target, action) and return allow/deny. Out-of-process so policy can change without redeploying every service. +- App-side discipline: read the validated peer identity from X-Forwarded-Client-Cert; never accept calls that arrive without it; log every authorization decision. + - License notes: Istio, SPIFFE/SPIRE, OPA, Cedar — all open source (Apache 2.0 or similar). Pomerium (Apache 2.0) and OAuth2 Proxy (MIT) are open source too. + +p. 68 + +> Concrete implementations to ground the model. The edge IAP is what your users hit — they authenticate against your IdP (Keycloak, or Dex federating an existing directory), the IAP issues a session, every request from then on carries identity. Inside the cluster, SPIFFE is the service-identity mechanism that makes mTLS work without manual cert management — it's what gives every Pod a verifiable identity. Istio's AuthorizationPolicy is the declarative layer on top: 'who can call what.' OPA / Cedar is the more flexible policy engine when your decision logic is complex (per-tenant, per-time-of-day, per-data-classification). The bold line is the app-side discipline: don't make these external policies redundant by accepting unauthenticated calls in your code; use the peer identity from the headers as a required input. + +## [082] SECURITY · POLICY-AS-CODE · Policy-as-Code — security gates in Git (sldId 337) + +OPA Gatekeeper / Kyverno enforce declarative policies at admission. CI runs the same policies pre-merge. + +p. 69 + +> Security policies as code: declarative, version-controlled, tested, applied automatically. The flow on the diagram: a developer commits manifests; CI runs conftest or Kyverno against the manifests pre-merge (catches violations before they reach the cluster); on `kubectl apply` (or ArgoCD / Flux), the Kubernetes API server calls the admission controller (OPA Gatekeeper / Kyverno) which evaluates the ConstraintTemplate / ClusterPolicy against the incoming object. Non-conformant resources are rejected — they never become running Pods. The policy file lives in Git, reviewed like code, applied by the cluster. No human runs a checklist; consistency is enforced uniformly. Example policies: images must come from approved registries, no privileged containers, no host network, no host path mounts, all containers have resource limits, all images are signed. + +## [083] SECURITY · POLICY-AS-CODE · OPA Gatekeeper ConstraintTemplate (Rego) (sldId 338) + +[chip] yaml · OPA Gatekeeper (language-neutral) + +```yaml +# ConstraintTemplate — reusable policy logic in Rego +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: + name: k8srequiresignedimages +spec: + # ... crd: kind K8sRequireSignedImages, parameter allowedRegistries (string array) + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiresignedimages + violation[{"msg": msg}] { + container := input.review.object.spec.containers[_] + not startswith(container.image, input.parameters.allowedRegistries[_]) + msg := sprintf("image %v not from an allowed registry", [container.image]) + } +--- +# Constraint — applies the policy to namespaces +apiVersion: constraints.gatekeeper.sh/v1beta1 +kind: K8sRequireSignedImages +metadata: { name: must-be-signed } +spec: + match: + kinds: [{apiGroups: [""], kinds: ["Pod"]}] + excludedNamespaces: ["kube-system", "gatekeeper-system"] + parameters: + allowedRegistries: ["registry.acme.com/", "quay.io/redhat/"] +``` + +p. 70 + +> The two-piece pattern: ConstraintTemplate defines the logic (in Rego, OPA's policy language), Constraint is the instance that applies the logic to specific resources with specific parameters. The Rego in violation[{msg}] reads as 'for every container in the Pod, if its image doesn't start with any of the allowed registries, emit a violation with this message.' Gatekeeper translates that into an admission webhook that rejects non-conformant Pods before they're scheduled. The Constraint binds the template to the kinds (Pods) and namespaces (everything except system namespaces), with the allowedRegistries list as the parameter. Lives in Git, applied via `kubectl apply` (or ArgoCD), versioned and reviewed like code. An equivalent Kyverno expression is more YAML-native if your team prefers it; Rego is more expressive for complex logic. + +## [084] SECURITY · BULKHEAD · Bulkhead — isolate resources, bound the blast radius (sldId 339) + +Per-tenant / per-call / per-priority pools. One tenant's storm cannot starve others. One compromise cannot cascade. + +p. 71 + +> Bulkhead: borrowed from naval architecture, where watertight compartments stop one breach from sinking the ship. In software, you isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. The left side of the diagram (red) shows the default: one shared pool means a single tenant's traffic storm starves everyone else's. The right side: separate pools, each with its own bounded size. A storm from tenant A consumes tenant A's pool and rejects further A requests — tenants B and background workloads keep running. Why this matters for security as well as resilience: a compromised tenant or a compromised endpoint can be used to mount a denial-of-service against the shared system; bulkheads cap that to its own pool. Implementations: Resilience4j Bulkhead (JVM), Polly (.NET), asyncio.Semaphore (Python), Istio per-destination connection pools at the mesh layer. + +## [085] SECURITY · BULKHEAD · Resilience4j bulkheads in Spring Boot (sldId 340) + +[chip] java · Spring Boot 4 + Resilience4j + +```java +@Service +public class OrderService { + private final BulkheadRegistry tenantBulkheads; + private final PaymentClient payments; + // ... constructor injects PaymentClient and the BulkheadRegistry + @Bulkhead(name = "orders", type = Bulkhead.Type.SEMAPHORE, + fallbackMethod = "placeFallback") + @TimeLimiter(name = "orders") + public CompletableFuture place(OrderRequest req) { + return payments.chargeAsync(req).thenApply(p -> Order.from(req, p)); + } + public CompletableFuture placeFallback(OrderRequest req, Throwable t) { + return CompletableFuture.failedFuture( + new ResponseStatusException(SERVICE_UNAVAILABLE, "bulkhead saturated")); + } + // Per-tenant bulkheads via programmatic registry + public CompletableFuture placeForTenant(String tenantId, OrderRequest req) { + io.github.resilience4j.bulkhead.Bulkhead bh = + tenantBulkheads.bulkhead("tenant-" + tenantId); // maxConcurrentCalls: 5 + return Decorators.ofCompletionStage(() -> payments.chargeAsync(req)) + .withBulkhead(bh).get().toCompletableFuture() + .thenApply(p -> Order.from(req, p)); + } +} +``` + +p. 72 + +> Resilience4j (the resilience4j-spring-boot4 starter) is the Spring Boot answer to fault tolerance; Hystrix is in maintenance. @Bulkhead declares a named bulkhead; the configuration (maxConcurrentCalls, maxWaitDuration) lives in application.yml under resilience4j.bulkhead.instances.orders. @TimeLimiter pairs with it for fail-fast on slow calls. fallbackMethod runs when the bulkhead rejects: return a sentinel value, a cached response, or fail with a 503. For per-tenant bulkheads, the BulkheadRegistry creates named instances dynamically; metrics are exposed via Micrometer with bulkhead.name labels. As with Quarkus, Istio's DestinationRule gives you mesh-level bulkheading on top of these. + +## [086] SECURITY · CLAIM-CHECK · Claim-check — send a reference, not the payload (sldId 341) + +Large or sensitive payload goes to a secure store; the message bus carries only an opaque token to fetch it. + +p. 73 + +> The claim-check pattern: a producer puts the (large or sensitive) payload into a secure object store and publishes only a claim ticket (an opaque token plus a fetch URI) to the message bus. The consumer receives the ticket, fetches the payload from the store with the token. Two big security wins: PII never sits in Kafka's topic history (which is replicated, retained, and possibly visible to operators and other tools); and the token can expire and be revoked, while a Kafka message cannot. The pattern also has performance benefits (you don't push 50MB messages through the message bus), but this section frames it for security. Compare to the write-around caching pattern: same shape, different motivation. Where: any flow where sensitive payloads (health records, financial documents, customer PII) cross messaging infrastructure that shouldn't see them; any flow where payloads are large enough that the bus would be the bottleneck. + +## [087] SECURITY · CLAIM-CHECK · Producer mints claim, consumer fetches payload (sldId 342) + +[chip] java · Spring Boot 4 + Spring Kafka + +```java +// claim-check sketch: store is any encrypted object store; the ticket: Example 12's valet key +@Service +public class ClaimPublisher { + private final ObjectStore store; // any encrypted object store + private final KafkaTemplate> kafka; + private final ValetKeyService valet; // examples/12-security + // constructor injection omitted + public void publish(String orderId, byte[] body) { + String key = "payloads/" + orderId; + store.put(key, body, true); // encrypted at rest, ACL'd + Map claim = new LinkedHashMap<>(valet.mint(key, "GET")); + claim.put("order_id", orderId); + claim.put("uri", "https://objects.internal/" + key); + kafka.send("order.documents", orderId, claim); // only the claim on the topic + } +} +// consumer: read the claim, fetch the body out of band +@KafkaListener(topics = "order.documents", groupId = "documents") +public void onClaim(Map claim) { + byte[] body = store.get((String) claim.get("uri"), (String) claim.get("token")); + handle(body); // PII never touched the topic +} +``` + +p. 74 + +> Two halves. The producer writes the payload to an encrypted object store under payloads/, mints a GET-only ticket for that key with Example 12's ValetKeyService.mint (300 seconds), and publishes only a small claim (order id, URI and the ticket) to order.documents with KafkaTemplate. The PII is never on Kafka's wire. +> +> The consumer decodes the claim, fetches the payload with the token, processes it, then commits the offset (at-least-once delivery, so the handler must be idempotent). The store verifies the ticket before it serves the object and logs every fetch with the principal, an audit trail plain Kafka does not give. +> +> Size the ticket's lifetime to the consumer's worst-case lag. No runnable example covers claim-check; store stands for any encrypted object store. Set a lifecycle rule that expires claims after the retention period, so a replayed message points at nothing. + +## [088] SECURITY · Choosing the right pattern (sldId 343) + +- Sidecar — when you want one security policy uniformly across every workload, regardless of language. Cost: an extra container per Pod (memory + startup time). +- Valet Key — when clients need direct access to a large or specific resource (file upload, image download). Cost: storage system must validate tokens (most do). +- Strangler Fig (auth) — when you have legacy authentication you can't change all at once. Cost: a gateway running translation logic during the transition. +- Zero-Trust / IAP — the default model for any system with users outside your immediate control. Cost: mTLS everywhere has real CPU and operational overhead. +- Policy-as-Code — always. Always. The cost of not having it (drift, inconsistency, forgotten review steps) is much higher than the cost of adopting OPA / Kyverno. +- Bulkhead — any multi-tenant system, any system with mixed-priority workloads. Cost: pools are extra moving parts to size and tune. +- Claim-Check — when payloads carry PII or are large enough to bloat the message bus. Cost: an extra fetch hop and the store-and-bus must agree on token semantics. +- Most production systems combine all of these, not as a checklist but as a decision per concern. + +p. 75 + +> Close the section with the decision rules. The cost lines are there because every pattern is a trade. Sidecar costs a container per Pod (a few MB of memory, a couple hundred ms of startup); for most systems the security uniformity makes it worth that. Valet key requires storage-side validation: the store, or a small gateway in front of it, must check the signature, the operation and the expiry. Strangler fig (auth) is a deliberate transition strategy; the gateway running translation is the cost. Zero-trust / mTLS everywhere has real CPU overhead (TLS handshakes are not free) — mesh implementations like Istio amortise it well but it's not zero. Policy-as-code's cost is the upfront investment in writing policies; the ongoing cost is near zero. Bulkheads are extra moving parts to tune. Claim-check adds a hop. The bold line is the take-home: production systems combine all seven, chosen per concern, not applied as a one-size-fits-all checklist. + +## [089] SECURITY · Take-aways and references (sldId 344) + +- Defence in depth across the 4 C's: cloud, cluster, container, code. A control at the wrong layer is false confidence. +- Identity is the new perimeter. mTLS + SPIFFE inside; IAP / OIDC at the edge. Every request authenticated and authorised, on every hop. +- Policy in Git, enforced by the cluster. OPA Gatekeeper or Kyverno at admission; conftest in CI for early feedback. +- PII never crosses your message bus in cleartext. Claim-check or end-to-end encrypt. +- Isolate by tenant, by call, by priority. Bulkheads bound the blast radius of both incidents and outages. + - References: NIST SP 800-204A (microservices security); CNCF Cloud Native Security Whitepaper; OWASP API Security Top 10; the OpenSSF SLSA framework; Istio AuthorizationPolicy docs; OPA Gatekeeper library; OWASP Microservices Security Cheat Sheet. + - Open-source pillars: Istio (Apache 2.0), SPIFFE/SPIRE (Apache 2.0), OPA / Gatekeeper (Apache 2.0), Kyverno (Apache 2.0), Sigstore (Apache 2.0), Falco (Apache 2.0), Trivy (Apache 2.0). + +p. 76 + +> Close with the take-aways the audience should remember. Defence in depth across the 4 C's is the philosophical anchor. Identity-as-perimeter is the operational anchor. Policy-in-Git is the automation anchor. The PII-never-in-cleartext and bulkhead lines are the specific applications. The reference list is what you'd recommend for further reading — NIST 800-204A is the canonical microservices security paper; CNCF's Cloud Native Security Whitepaper organises the same material; OWASP API Top 10 is the application-layer reference; SLSA covers supply-chain security (signing, provenance, build integrity). The open-source list emphasises that the entire security stack is buildable with Apache 2.0 components. + +## [090] Anti-Patterns (sldId 345) + +13 + +Most outages re-couple what you separated. + +> The final main section, and the most memorable framing of the whole talk: nearly every cloud-native failure is coupling sneaking back in. You did the work to separate things; entropy and deadlines pull them back together. This section names the specific ways that happens and gives heuristics to catch it early. + +## [091] ANTI-PATTERNS · How cloud-native API systems go wrong (sldId 346) + +Each anti-pattern reintroduces the coupling the architecture worked to remove. + +p. 77 + +> Walk the grid briefly — most of these are callbacks to earlier sections. Distributed monolith (services that must deploy together), shared database (two services on one table), chatty calls (deep synchronous chains), no contract/leaky internal models (DB rows as the public API), missing observability (debugging by guesswork), retry storms (no backoff). The unifying diagnosis is on screen: each one reintroduces the coupling the architecture worked to remove. If you only remember one slide for code review, it's this one. We then turn these into positive heuristics. + +## [092] ANTI-PATTERNS · The heuristics that keep you out of them (sldId 347) + +- If two services must deploy together, they are one service — merge them or fix the contract. +- One writer per dataset; everyone else reads through the API or subscribes to its events. +- Push aggregation to a gateway/BFF; forbid service-to-service call chains deeper than one hop. +- No contract in the registry, no merge — and every retry has a timeout and a bound. +- If you can't trace it, you can't run it: observability is a launch requirement, not a follow-up. + +p. 78 + +> These are the five tests to apply in design and code review. (1) If two services must deploy together, they are one service: merge them or fix the contract. (2) One writer per dataset; everyone else reads through the API or subscribes to events — no shared tables. (3) Push aggregation to a gateway or BFF and forbid synchronous call chains deeper than one hop. (4) No contract in the registry, no merge — and every retry has a timeout and a bound. (5) If you can't trace it, you can't run it — observability is a launch requirement, not a follow-up. Offer these as a checklist the team can literally paste into a PR template. + +## [093] IN CLOSING · The whole talk, in five lines (sldId 348) + +- Cloud-native is a set of properties; the platform exists to make them the default. +- Match each interaction to its style (REST, gRPC, GraphQL or async) and compose at the edge. +- Own your data; share it as contracts and facts, never as shared tables. +- Make the contract a governed artifact: registry to enforce it, metadata to find it, observability to run it. +- Every anti-pattern is coupling sneaking back in. Design to keep things apart. + +p. 79 + +> The recap — read these five as the entire talk compressed. If the audience remembers nothing else: cloud-native is properties not tools; match the protocol to the interaction and compose at the edge; own your data and share it as contracts and facts; make the contract a governed artifact (registry, metadata, observability); and watch for coupling sneaking back in. Pause here before References and Q&A. If it's the long-form session, this is the natural point to say 'and now the appendices go deeper on four things people always ask about' and continue. + +## [094] REFERENCES · The shoulders this stands on (sldId 349) + +- Cloud-Native Patterns and Architecture +- Building Microservices, 2nd ed. — Sam Newman (O'Reilly) +- Designing Data-Intensive Applications, 2nd ed. — Martin Kleppmann & Chris Riccomini (O'Reilly) +- Building Event-Driven Microservices — Adam Bellemare (O'Reilly) +- Cloud Native Patterns — Cornelia Davis (Manning) +- Patterns of Distributed Systems — Unmesh Joshi (Addison-Wesley) +- Architecture Modernization — Nick Tune & Jean-Georges Perrin (Manning) +- Cloud Native Anti-Patterns — Gerald Bachlmayr +- Principles of Web API Design — James Higginbotham (Addison-Wesley) +- Patterns for API Design — Olaf Zimmermann et al. (Addison-Wesley) +- API Design Patterns — JJ Geewax (Manning) +- Learning Domain-Driven Design — Vlad Khononov (O'Reilly) +- Balancing Coupling in Software Design — Vlad Khononov (Addison-Wesley) +- Spring Boot 4 reference (docs.spring.io/spring-boot), Temporal Java SDK docs (docs.temporal.io/dev-guide/java), Spring for GraphQL, Spring Kafka, and Debezium docs — for the runtime specifics + +p. 80 + +> The reading list this talk stands on. Call out a couple of pairings: Newman's Building Microservices for the boundaries and integration story; Kleppmann's Designing Data-Intensive Applications for the data and consistency foundations behind the outbox and saga; Bellemare for the event-driven backbone; and the API-design trio (Higginbotham, Zimmermann et al., Geewax) for contract and versioning depth. The architecture books are language-agnostic — they apply whether you build on Spring, Quarkus, or anything else. The Spring Boot reference docs, Temporal Java SDK guides, and Debezium docs are the practical references for the runtime specifics shown in the code. The slides and these references are enough to build the system, not only to discuss it. + +## [095] Thank you (sldId 350) + +Questions? + +> End of the main talk. Thank the audience and open for questions. The appendices are deep-dive backup material: pull them up on demand during Q&A, or work through them if you're running the full-length session. They cover the questions that always come up: how the three protocols differ, how to version each one, how to run WebSockets on Kubernetes, and the hard parts of saga state and compensation. + +## [096] REFERENCE · Glossary of acronyms (1 of 2) — protocols, data, events (sldId 351) + +- Protocols & APIs — REST (Representational State Transfer); RPC (Remote Procedure Call); gRPC (Google's RPC framework, over HTTP/2 + Protobuf); HTTP (HyperText Transfer Protocol); TLS / mTLS (Transport Layer Security / mutual TLS, where both peers present certificates); SSE (Server-Sent Events); BFF (Backend-For-Frontend); SDK (Software Development Kit); API (Application Programming Interface). +- Messaging & integration — AMQP (Advanced Message Queuing Protocol); ESB (Enterprise Service Bus); DLQ (Dead-Letter Queue, where un-processable messages go); SerDe (Serializer / Deserializer); pub/sub (publish-subscribe). +- Data & persistence: CQRS (Command Query Responsibility Segregation); CDC (Change Data Capture); WAL (Write-Ahead Log, the DB's commit-ordered durable log); ACID (Atomicity, Consistency, Isolation, Durability); TTL (Time-To-Live: how long a cached key lives before expiring); LRU (Least Recently Used, a cache eviction policy); SETEX (the Redis "SET with EXpiry" command, which sets a key and its TTL atomically); ORM (Object-Relational Mapping); JPA (Jakarta Persistence API); Spring Data (Spring's repository abstraction, not an acronym). +- Domain modelling & flow — DDD (Domain-Driven Design); OHS (Open Host Service: a published, stable interface a context offers others); ACL (Anti-Corruption Layer in DDD, which translates between models; ACL also means Access Control List in security); Saga (not an acronym: a sequence of local transactions with compensating actions). + +p. 81 + +> Glossary part 1, the protocol/data/event vocabulary. Call out the ones most likely to trip people up: gRPC is recursively 'gRPC Remote Procedure Call' but practically it's Google's high-performance RPC over HTTP/2 with Protocol Buffers. mTLS (mutual TLS) is the one people often gloss — ordinary TLS authenticates the server to the client; mutual TLS also authenticates the client to the server, which is the basis of zero-trust service identity. The data row has the SETEX and TTL the audience asked about explicitly: TTL is the expiry duration on a cached key; SETEX is the Redis command that sets a value and its TTL in one atomic operation (SETEX key seconds value). LRU is the eviction policy that drops the least-recently-touched key when the cache is full. CDC and WAL go together — CDC tails the WAL. Call out the ACL ambiguity: in the DDD/data sections ACL means Anti-Corruption Layer; in the security section it means Access Control List. + +## [097] REFERENCE · Glossary of acronyms (2 of 2) — platform, security, telemetry (sldId 352) + +- Kubernetes & cloud — K8s (Kubernetes); RBAC (Role-Based Access Control); HPA (Horizontal Pod Autoscaler); KEDA (Kubernetes Event-Driven Autoscaling); PSS (Pod Security Standards); CRD (Custom Resource Definition); IAM (Identity & Access Management); KMS (Key Management Service); VPC (Virtual Private Cloud); CNI (Container Network Interface). +- Identity & security — SPIFFE (Secure Production Identity Framework For Everyone); SPIRE (the SPIFFE Runtime Environment); OPA (Open Policy Agent); IAP (Identity-Aware Proxy); JWT (JSON Web Token); OIDC (OpenID Connect); OAuth (Open Authorization); MFA (Multi-Factor Authentication); PII (Personally Identifiable Information); SLSA (Supply-chain Levels for Software Artifacts); CVE (Common Vulnerabilities & Exposures); SBOM (Software Bill of Materials). +- Observability — OTel (OpenTelemetry); RED (Rate, Errors, Duration: the request-driven metrics method); USE (Utilisation, Saturation, Errors: the resource metrics method); MDC (Mapped Diagnostic Context, per-thread log context); LGTM (Loki, Grafana, Tempo, Mimir); SLO / SLI / SLA (Service-Level Objective / Indicator / Agreement); p99 (99th-percentile latency). +- Runtime & build: JVM (Java Virtual Machine); JIT (Just-In-Time compilation); AOT (Ahead-Of-Time compilation: GraalVM / Spring AOT); GC (Garbage Collection); IoC (Inversion of Control); DI (Dependency Injection); DTO (Data Transfer Object); POJO (Plain Old Java Object); SpEL (Spring Expression Language). + +p. 82 + +> Glossary part 2, the platform/security/observability vocabulary. The Kubernetes row: HPA scales on CPU/memory, KEDA scales on event sources (queue depth, Kafka lag, HTTP rate) — the distinction matters in the autoscaling discussion. PSS (Pod Security Standards) replaced the deprecated PodSecurityPolicy. The security row has the terms from section 12: SPIFFE/SPIRE give workloads cryptographic identities for mTLS; OPA is the policy engine behind policy-as-code; IAP is the zero-trust edge proxy; JWT/OIDC/OAuth are the token/identity standards; SLSA/CVE/SBOM are supply-chain security. The observability row has RED and USE, the two metrics methods worth memorising: RED (Rate, Errors, Duration) for services, USE (Utilisation, Saturation, Errors) for resources. p99 means the 99th-percentile latency — 99% of requests are faster than this; it's what you alert on, not the average. The runtime row is tailored to this deck's language. + +## [098] Appendix A · Protocols in Depth (sldId 353) + +A + +REST, gRPC and GraphQL: how they differ. + +> Appendix A. This is the deep dive behind the 'pick the protocol' slide from section 2. We compare the three on three axes: the mental model each imposes, how they handle fetching and round trips, and what travels on the wire. By the end the audience should be able to defend a protocol choice on technical grounds, not fashion. + +## [099] APPENDIX A · PROTOCOLS · Three paradigms and their mental models (sldId 354) + +REST models resources, gRPC models procedures, GraphQL models a typed graph — the same intent, three shapes. + +p. 83 + +> Start with the mental model, because it drives everything else. REST is resource-oriented: nouns and HTTP verbs, you transfer representations of state. gRPC is procedure-oriented: you call typed methods, it's behaviour not state, and the .proto is the contract. GraphQL is graph-oriented: one typed schema the client traverses, picking exactly the fields it wants. The same place_order intent takes three different shapes. Most arguments about 'which is best' are arguments about which mental model fits the problem, and the answer often differs per interaction. + +## [100] APPENDIX A · PROTOCOLS · Over-fetching, under-fetching and round trips (sldId 355) + +REST trades round trips or bespoke endpoints for simplicity; GraphQL shapes one response; gRPC is a typed call. + +p. 84 + +> This is the practical difference clients feel. With REST you often need several round trips (order, then its items, then stock, then shipping) or you build a bespoke aggregate endpoint — and you still get fixed payload shapes that over- or under-fetch. GraphQL collapses that into one tailored query where the client names the fields, so no over- or under-fetching and the client controls the cost. gRPC is a single typed, binary call with a fixed reply shape: no field selection, but the fastest of the three. The trade: GraphQL gives clients flexibility at the cost of gateway complexity; gRPC gives speed at the cost of rigidity; REST gives reach and simplicity at the cost of round trips. + +## [101] APPENDIX A · PROTOCOLS · The wire: transport, encoding and streaming (sldId 356) + +Reach and caching, or raw speed, or client-shaped responses — choose on the wire properties you need. + +p. 85 + +> What travels on the wire, and why it constrains your choice. REST: HTTP/1.1 or /2, JSON text, request/response, cacheable via standard HTTP, browser-native — maximum reach. gRPC: HTTP/2 only, protobuf binary, full uni- and bidirectional streaming, no HTTP caching, and it needs grpc-web to work from a browser. GraphQL: HTTP POST to a single URL, JSON, request/response plus subscriptions, caching is application-level (not HTTP), browser-native. The decision rule: if you need broad reach and HTTP caching, REST; if you need raw speed and streaming between services you control, gRPC; if you need client-shaped responses over many sources, GraphQL. + +## [102] APPENDIX A · PROTOCOLS · Choosing between them in practice (sldId 357) + +- Public or partner-facing, browser or third-party clients, caching matters → REST. +- Internal service-to-service, low latency, high call volume, streaming → gRPC. +- Aggregating many back-ends for varied clients that each want different fields → GraphQL at the gateway. +- A fact that many consumers react to, decoupled in time → none of these; emit an event. +- Real systems use all four; standardizing on one wire for everything is itself an anti-pattern. + +p. 86 + +> The decision guide. Public/partner/browser with caching → REST. Internal, hot, streaming → gRPC. Client-driven aggregation across back-ends → GraphQL at the gateway. A fact many consumers react to → don't make it a call at all, emit an event. The bold closing point ties back to the whole talk: forcing one protocol everywhere ('we're a gRPC shop', 'everything is GraphQL') is itself an anti-pattern, because you pay gRPC's browser tax or GraphQL's gateway complexity where a plain cacheable GET would have done. + +## [103] APPENDIX A · PROTOCOLS · Common mistakes with each (sldId 358) + +- REST: verbs in URLs (/createOrder), 200-for-everything, offset pagination, and ignoring cache headers. +- gRPC: exposing it directly to browsers without grpc-web; treating it as REST-with-protobuf and ignoring streaming. +- GraphQL: the N+1 resolver explosion; no query depth/complexity limits; using it for simple single-resource reads. +- All three: leaking internal database models straight onto the wire, so you can never evolve them. +- Async: using a queue when you needed a log (no replay), or a request when you needed a fact. + +p. 87 + +> The failure modes, drawn from real review comments. REST: verbs in URLs, returning 200 with an error body, offset pagination that breaks under load, and throwing away free HTTP caching. gRPC: trying to call it straight from a browser without grpc-web, or using it as 'REST with protobuf' and never touching its streaming superpower. GraphQL: the classic N+1 resolver explosion (fix with DataLoader batching), no depth or complexity limits so a malicious query melts the gateway, and reaching for it when a plain GET would do. The universal one is the last bold-adjacent: leaking your database schema onto the wire kills your ability to evolve — the same leaky-internal-models anti-pattern from section 12. + +## [104] Appendix B · Versioning & Evolution (sldId 359) + +B + +REST vs RESTful, proto breaking changes, GraphQL evolution. + +> Appendix B answers the question every team fights about: how do we version this without breaking everyone? The answer differs sharply by protocol. REST has several strategies with real trade-offs; gRPC has precise wire-compatibility rules; GraphQL rejects versioning entirely in favour of evolution. We'll see each, with code. + +## [105] APPENDIX B · VERSIONING · Four ways to version a REST API (sldId 360) + +URI versioning is common and visible; media-type versioning keeps URLs stable — the more RESTful choice. + +p. 88 + +> REST has four strategies, ordered from coarse to fine. URI path (/v2/orders): visible, trivial to route, but couples the version to the URL and breaks REST's idea that a URL identifies a resource, not a representation. Query param: simple to default but muddies caches. Custom header: keeps URLs clean but is invisible and often forgotten. Media-type versioning (Accept: application/vnd.acme.order.v2+json): content negotiation keeps the URL stable as the identifier of the resource, and the representation is what's versioned. This is the RESTful approach. In practice, URI versioning is the most common because it's pragmatic; media-type is the most correct. Know which you're choosing and why. The code follows. + +## [106] APPENDIX B · VERSIONING · Media-type versioning with Spring Web (sldId 361) + +[chip] java · Spring Boot 4 + Spring Web + +```java +@RestController +@RequestMapping("/orders") +public class OrderController { + static final String V1 = "application/vnd.acme.order+json"; // legacy + static final String V2 = "application/vnd.acme.order.v2+json"; // adds currency + @GetMapping(value = "/{id}", produces = V2) // one stable URL — the resource + public OrderV2 getV2(@PathVariable String id) { // chosen by Accept: …v2+json + return OrderV2.from(orders.get(id)); + } + @GetMapping(value = "/{id}", produces = V1) // same path, different representation + public OrderV1 getV1(@PathVariable String id) { // default for old clients + return OrderV1.from(orders.get(id)); + } +} +// Spring's content negotiation routes on Accept; the URL never changes. +// Spring Boot 4 also ships a new API versioning support if you prefer header- or +// path-based versioning declared centrally rather than per-handler. +``` + +The resource URL is stable; the representation is negotiated. New clients ask for v2; old clients keep getting v1. + +p. 89 + +> This makes 'RESTful versioning' concrete in Spring Web. There's one URL for the order resource — it never changes. Two handler methods share the path but declare different produces media types; Spring's content negotiation picks the handler matching the client's Accept header and sets the Content-Type. New clients ask for the v2 media type and get the currency field; old clients send the default and get v1, completely unaffected. Contrast with /v2/orders, where you've now got two URLs for the same underlying thing. The OrderV1/OrderV2 records are explicit representation DTOs — exactly the pattern that keeps your wire contract decoupled from your entity. Mention that Spring Boot 4 adds first-class API versioning support if you prefer to declare versions centrally (annotation- or filter-based) instead of per-handler. + +## [107] APPENDIX B · VERSIONING · gRPC / protobuf: what is a breaking change (sldId 362) + +Field numbers are the contract — names and order are not. Never reuse or renumber a field. + +p. 90 + +> gRPC's rules are precise because the wire format is positional. Safe, wire-compatible changes: add a field with a new number, add an rpc method, add an enum value, rename a field (the number is what matters, not the name), mark fields deprecated, and reserve removed tags. Breaking changes: change a field's number or type, reuse a removed number, remove a field without reserving its tag, rename or move a service or package, or change the request/response message type. The one people miss is on screen at the bottom: changing a method between unary and any streaming mode changes the HTTP/2 framing and is always breaking. The discipline: field numbers are forever; never reuse or renumber. Code shows safe evolution next. + +## [108] APPENDIX B · VERSIONING · Evolving a .proto safely (sldId 363) + +[chip] protobuf + +```protobuf +syntax = "proto3"; +message Order { + string id = 1; + string sku = 2; + int32 qty = 3; + reserved 4; // tag 4 once held 'notes' — never reuse it + reserved "notes"; + string currency = 5; // safe: brand-new field, new tag number + PaymentState state = 6; // safe: new field; old clients ignore it +} +enum PaymentState { PENDING = 0; PAID = 1; REFUNDED = 2; } // 0 must exist +// breaking (don't): qty -> int64, or moving 'sku' to tag 7, or reusing tag 4 +``` + +Add with new tags, reserve what you remove, and never change an existing field's number or type. + +p. 91 + +> Walk the safe pattern. We removed an old 'notes' field, so we reserve both its tag number (4) and its name permanently: nobody can reuse them and silently corrupt old data on the wire. We add currency and state as brand-new fields with new tag numbers; old clients ignore unknown fields and new clients read them, which gives forward and backward compatibility without extra work. Note the enum must keep a zero value as its default. The comment at the bottom lists the things that would break the wire: widening qty's type, moving an existing field to a new tag, or reusing a reserved tag. Apicurio's compatibility check (section 9) enforces exactly these rules in CI. + +## [109] APPENDIX B · VERSIONING · GraphQL versions by evolution, not by number (sldId 364) + +There is no /v2 in GraphQL: add fields, mark old ones @deprecated, watch usage, then retire. + +p. 92 + +> GraphQL takes a completely different stance: there are no versions. There's one schema that evolves continuously. The lifecycle on screen: add new fields and types (additive is always safe, because existing queries did not ask for the new field); mark superseded fields @deprecated with a reason and a pointer to the replacement, while still serving them; watch field-level usage metrics to see who still queries the old field and drive their migration; and retire, the single breaking step, only once usage hits zero. The insight: GraphQL pushes you toward data-driven deprecation instead of big-bang version cutovers. Code shows the @deprecated directive next. + +## [110] APPENDIX B · VERSIONING · Deprecating a field in Spring for GraphQL (sldId 365) + +[chip] graphql + java · Spring for GraphQL + +```graphql+java +# schema.graphqls — the GraphQL contract (schema-first) +type Order { + id: ID! + total: Float # still served + @deprecated(reason: "Use totalMinor (integer cents) instead.") + totalMinor: Int! # replacement, added additively +} +// Java record matches the schema; both fields resolve. +public record Order(String id, double total, int totalMinor) { } +// @deprecated surfaces in introspection and in /graphiql with the reason. +// Field-level usage metrics (Micrometer + Apollo's @apollo/usage-reporting, +// or graphql-java-extended-validation) tell you when 'total' has zero callers — +// only then do you remove it. No /v2 endpoint ever exists. +``` + +Additive change plus @deprecated plus usage metrics replaces versioning entirely. + +p. 93 + +> The mechanics in Spring for GraphQL. Spring for GraphQL is schema-first: the .graphqls file is the contract. We add totalMinor additively and mark the old total field with GraphQL's native @deprecated directive, carrying a human reason. The Java record on the data side contains both fields and Spring binds them when the field is queried (custom resolvers are needed only when a field comes from a different source). The field keeps working and nothing breaks, but the deprecation shows up in introspection, in the GraphiQL UI Spring serves at /graphiql, and in any IDE plugin that reads the schema. The crucial operational piece is the comment: you instrument field-level usage (Micrometer's Observation API hooks DataFetchingEnvironment so you can record per-field counters), and you only remove total when its usage drops to zero. That's the whole GraphQL philosophy — evolution governed by data, with the breaking removal deferred until provably safe. Each protocol answers the same problem differently. + +## [111] APPENDIX B · VERSIONING · Principles that cut across all three (sldId 366) + +- Additive change is always safe; removal and mutation are where you break people. +- Make compatibility mechanical — the registry's BACKWARD/FORWARD/FULL rules, checked in CI, not by reviewers' memory. +- Never break silently: deprecate with a reason, a replacement and a sunset date; measure who's still on the old version. +- Run old and new in parallel and migrate consumers before retiring — retirement is the only step that can break. +- Version data the same way you version APIs: event schemas in the registry follow the identical rules. + +p. 94 + +> Step back to the universal principles, because the protocol-specific rules all rhyme. Additive change is safe; removal and mutation break people — so design to add. Make compatibility mechanical: the registry's rules checked in CI, never a reviewer remembering. Never break silently: deprecate with a reason, a replacement, a sunset date, and measure who's still on the old thing. Run old and new in parallel and migrate before you retire, because retirement is the only inherently breaking move. And the bold line that connects this back to the event-driven section: your event schemas version by exactly the same rules — the registry treats an Avro schema and an OpenAPI spec identically. Versioning data and versioning APIs are the same discipline. + +## [112] Appendix C · WebSockets at Scale (sldId 367) + +C + +Spring WebSocket, pod pinning, the backplane, binary framing, reconnection. + +> Appendix C covers the protocol left out of the main interaction-styles discussion, because it behaves so differently on Kubernetes. WebSockets are long-lived, stateful and pinned to a pod — the opposite of the stateless request model the platform assumes. We'll build one with Spring's WebSocket support (spring-boot-starter-websocket), see why it fights scaling, fix it with a backplane, make it fast and versioned with protobuf, and make it survivable with reconnection. + +## [113] APPENDIX C · WEBSOCKETS · A WebSocket endpoint with Spring WebSocket (sldId 368) + +[chip] java · Spring Boot 4 + spring-boot-starter-websocket + +```java +@Configuration // spring-boot-starter-websocket +@EnableWebSocket +public class WsConfig implements WebSocketConfigurer { + @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry r) { + r.addHandler(new OrderSocket(), "/ws/{userId}") + .setAllowedOriginPatterns("*"); + } +} +@Component +public class OrderSocket extends TextWebSocketHandler { + // per-pod registry — not shared! This single fact is the scaling problem. + private final Map conns = new ConcurrentHashMap<>(); + @Override public void afterConnectionEstablished(WebSocketSession s) { + conns.put(userIdFrom(s), s); // this pod owns this connection + } + @Override protected void handleTextMessage(WebSocketSession s, TextMessage msg) { + handle(parse(msg.getPayload())); // full-duplex, long-lived + } + // afterConnectionClosed cleanup removes the entry from this pod's map. +} +``` + +Declarative, but the conns map is per-pod. That single fact is the entire scaling problem. + +p. 95 + +> Spring WebSocket makes the endpoint declarative: register one handler at a path in a small @Configuration class, then extend TextWebSocketHandler (or BinaryWebSocketHandler) and override the lifecycle methods you care about — afterConnectionEstablished, handleTextMessage, afterConnectionClosed. Constructor injection brings in any beans you need. But point hard at the conns map — the registry of who's connected lives in this pod's memory. That one design fact drives everything in this appendix: the connection and the knowledge of who's connected live on exactly one pod. Everything stateless about HTTP (any replica can serve any request) is now false. We then show what that does to scaling. (If you want a higher-level pub/sub style with topics and per-user destinations, Spring also ships STOMP-over-WebSocket via @EnableWebSocketMessageBroker; the trade-offs in this appendix still apply.) + +## [114] APPENDIX C · WEBSOCKETS · Why WebSockets fight Kubernetes scaling (sldId 369) + +A long-lived connection pins to one pod; new pods get no existing connections and scale-down drops them. + +p. 96 + +> Here's the collision with Kubernetes. A WebSocket connection, once established through the load balancer, is pinned to one pod for its entire lifetime. Two problems follow. Scale-up: the HPA or KEDA adds ws-pod-3, but it gets zero existing connections, only new ones, so a hot pod stays hot and the new pod sits idle; scaling doesn't relieve the load you already have. Scale-down and rollouts: terminating a pod severs every connection it was holding, so a routine deploy disconnects thousands of users at once. Stateless HTTP shrugs both off; WebSockets don't. The fix is to stop making any single pod the home of a conversation, which is the backplane, rather than to avoid scaling. + +## [115] APPENDIX C · WEBSOCKETS · The scaling problem, stated precisely (sldId 370) + +- Pinning: a connection is bound to one pod; the LB can't move it without dropping it. +- Uneven load: new replicas only receive new connections, so existing hotspots persist. +- Rollouts hurt: every deploy or scale-down severs the connections on the affected pods. +- Cross-pod delivery: to push to user X you must reach whichever pod holds X — which you don't track. +- Connection limits & memory: each socket is held-open state; pods cap out on file descriptors, not CPU. + +p. 97 + +> Name the five distinct problems so the audience can recognize them. Pinning: the LB can't migrate a live socket. Uneven load: scaling adds capacity for new connections but doesn't rebalance existing ones. Rollouts hurt: every deploy is a mass disconnect event — you need graceful drain and client reconnection. Cross-pod delivery: to push a message to user X you must route to the specific pod holding X's socket, and nothing tracks that by default. And the resource shape is different (the bold point): you scale on open-connection count, file descriptors and memory, not CPU, so your KEDA/HPA trigger has to change too. Every one of these is solved or eased by a pub/sub backplane. + +## [116] APPENDIX C · WEBSOCKETS · Scaling out: a pub/sub backplane (sldId 371) + +Each pod owns only its own sockets; a Redis or Kafka backplane fans a message out to whichever pod holds the client. + +p. 98 + +> The pattern that makes WebSockets horizontally scalable. Each pod still owns only its own sockets, which is fine, but every pod subscribes and publishes to a shared backplane: Redis pub/sub for low latency, or a Kafka topic if you want durability and replay. When a publisher wants to push order.updated to user X, it publishes to the backplane without knowing which pod holds X; the backplane fans the message to all pods, and the one pod that holds X's socket delivers it. Now scale-up and zero-downtime rollouts work again, because no pod is the single home of a conversation — connections can land anywhere and still receive everything. Redis is the common default; Kafka if you already run it and want the durability. + +## [117] APPENDIX C · WEBSOCKETS · Binary framing with protobuf — fast and versioned (sldId 372) + +[chip] protobuf + java · Spring WebSocket + +```protobuf+java +// envelope.proto — version the message, not the URL +message Envelope { + uint32 version = 1; // bump for incompatible payload changes + string type = 2; // "order.updated", "ping", ... + bytes payload = 3; // the protobuf-encoded body for `type` + uint64 seq = 4; // monotonic per-connection — drives resume +} +// server: send compact binary frames instead of JSON text +public class OrderBinarySocket extends BinaryWebSocketHandler { + @Override protected void handleBinaryMessage(WebSocketSession s, BinaryMessage m) { + Envelope env = Envelope.parseFrom(m.getPayload()); + dispatch(env); + } +} +Envelope frame = Envelope.newBuilder().setVersion(2) + .setType("order.updated").setSeq(n) + .setPayload(order.toByteString()).build(); +session.sendMessage(new BinaryMessage(frame.toByteArray())); +// client checks env.version; unknown major version → reconnect & renegotiate +``` + +Protobuf frames are smaller and faster than JSON; the versioned envelope and seq number enable evolution and resume. + +p. 99 + +> Two wins in one slide. Performance: protobuf binary frames via Spring's BinaryWebSocketHandler and session.sendMessage(new BinaryMessage(...)) are markedly smaller and cheaper to parse than JSON text — meaningful when pushing high-frequency updates to many sockets. The server parses the raw ByteBuffer directly into the generated Envelope type. Versioning: because WebSocket messages have no URL to hang a version on, you version the envelope. A version field lets the client detect an incompatible payload format and renegotiate; the type field dispatches to the right protobuf message; and a monotonic seq number per connection is what makes reconnection and replay possible, which the failure-handling material relies on. So the envelope solves both 'make it fast' and 'how do I version a socket message' at once. + +## [118] APPENDIX C · WEBSOCKETS · Failure handling: resume, don't restart (sldId 373) + +Heartbeats detect dead links; the client reconnects with backoff and resumes from the last acknowledged sequence. + +p. 100 + +> The cloud-native posture: treat every connection as disposable and design for its loss. Heartbeats (ping/pong) detect a dead link quickly — TCP alone can hang for minutes. When the connection drops, the client reconnects with exponential backoff plus jitter (so ten thousand clients don't reconnect in lockstep and hammer your pods after a rollout). On reconnect, the client sends its last-seen sequence number (last_seq = 1042); the server replays only the events the client missed (1043 onward), and because each frame carries a seq, the client can dedupe — so the whole thing is at-least-once and idempotent. The principle on screen: resume, don't restart. A reconnect should be invisible to the user, not a full state reload. + +## [119] APPENDIX C · WEBSOCKETS · A cloud-native WebSocket checklist (sldId 374) + +- Externalize fan-out to a backplane (Redis or Kafka); never assume one pod holds the client. +- Scale and trigger on connection count and memory, not CPU; set realistic per-pod connection caps. +- Drain gracefully on shutdown: stop accepting, signal clients to reconnect, let backoff spread the herd. +- Heartbeat to detect dead links (Spring exposes ping/pong via WebSocketSession.sendMessage(new PingMessage())); reconnect with exponential backoff + jitter; resume via a per-connection seq. +- Use binary frames with a versioned envelope; authenticate on connect and re-check on resume. + +p. 101 + +> The operational checklist to leave on screen. Externalize fan-out to a backplane so no pod is special. Scale and set your KEDA/HPA trigger on connection count and memory, not CPU, and cap connections per pod realistically. Drain gracefully on shutdown — stop accepting, tell clients to reconnect, and let backoff-with-jitter spread the reconnect storm across your rollout. Heartbeat to detect dead links fast; reconnect with backoff and jitter; resume from a per-connection sequence number so the user never notices. And the security note in bold that teams forget: authenticate on connect and re-authenticate on resume, because a resume is a new TCP connection that could come from anywhere. Do these and WebSockets are just another well-behaved cloud-native workload. + +## [120] Appendix D · Saga State & Compensation (sldId 375) + +D + +Persisting state, compensating cleanly, and the step-B-needs-A's-data problem. + +> Appendix D is the deep mechanics behind the saga slide in the Data section. Three hard parts that the happy-path picture glosses over: where the orchestrator keeps its state so it survives a crash, how compensation works when a step fails midway, and a specific gnarly case people hit: when step B needs data that only step A produced, and how to resume after a crash between them. + +## [121] APPENDIX D · SAGAS · Where a saga keeps its state (sldId 376) + +Memory won't survive a pod restart — persist every step transition in a DB, an event log, or a workflow engine. + +p. 102 + +> First problem: the orchestrator must survive a crash mid-flow, so in-memory state is a non-starter — a pod restart would strand every in-flight saga with no idea what to compensate. Three real options. A DB-backed state machine: one row per saga instance holding its status and a context blob; you own the transitions — simplest, and great for most cases. Event-sourced: append each step as an event and rebuild state by replay — full audit history, fits naturally with the Kafka log. A workflow engine like Temporal: durable execution with retries and timers built in — most power, least code, but a new dependency to run. Example 17 runs the first option, and this appendix sets its code beside Temporal. The cross-cutting rule on screen: the state transition and the outbound command must commit together (the outbox pattern again), or you get lost or duplicated steps. + +## [122] APPENDIX D · SAGAS · A persisted saga state machine (Spring Data JPA) (sldId 377) + +[chip] java · Spring Data JPA + +```java +@Service +public class SagaCoordinator { + static final List STEPS = + List.of("charge_payment", "reserve_stock", "book_shipping"); + private final SagaRepository repo; + private final EntityManager em; + public SagaCoordinator(SagaRepository r, EntityManager em) { + this.repo = r; this.em = em; + } + @Transactional // state + command commit together + public void advance(String sagaId) { + Saga s = em.find(Saga.class, sagaId, LockModeType.PESSIMISTIC_WRITE); + if (!"RUNNING".equals(s.getStatus())) return; // idempotent + String step = STEPS.get(s.getStepIndex()); + try { + var result = invoke(step, s.getContext()); // call the service + s.getContext().put(step, result); // accumulate output + if (s.incStep() == STEPS.size()) s.setStatus("COMPLETED"); + } catch (StepException e) { + s.setStatus("COMPENSATING"); // hand off to the unwinder + } + repo.save(s); // durable after every step + } +} +``` + +One locked row per saga, advanced one durable step at a time. A crash just means 'load the row and call advance again.' + +p. 103 + +> This is the DB-backed option made concrete with Spring Data JPA, and the default choice when the team wants to own the transitions. Key ideas: one Saga entity row, loaded via EntityManager with a PESSIMISTIC_WRITE lock so two workers can't advance the same saga concurrently. The status guard makes advance() idempotent — calling it on a finished saga is a no-op, which matters because retries and redeliveries will call it again. Each successful step accumulates its result into the context map (the data-dependency pattern depends on it) and bumps the step index; @Transactional makes the save durable after every step, atomic with the work record. Recovery needs no special path: a Spring @Scheduled sweeper (with ShedLock so only one node runs it) loads RUNNING sagas and calls advance() again. Temporal, in this appendix, solves the same problem at a different point on the build-vs-buy spectrum; Example 17 runs the hand-rolled version with JdbcClient. + +## [123] APPENDIX D · SAGAS · Compensating transactions unwind a failed saga (sldId 378) + +Each committed step has an inverse; on failure they run in reverse order — there is no cross-service rollback. + +p. 104 + +> Compensation is how a saga 'rolls back' when there's no actual rollback available across services. Each forward step that committed (payment.charge, inventory.reserve) has a defined semantic inverse: payment.refund, inventory.release. When book_shipping fails, you don't undo a database transaction (the payment has already happened at the provider); you run the compensations for the already-committed steps, in reverse order, newest first. Two things that bite people, both on screen: compensations must themselves be idempotent and durable, because a crash mid-unwind has to resume cleanly; and a compensation can itself fail, so it needs its own retries. The saga ends in a persisted COMPENSATED state with a full audit trail of what was done and undone. + +## [124] APPENDIX D · SAGAS · Running compensations in reverse (sldId 379) + +[chip] java · Spring Data JPA + +```java +static final Map COMPENSATIONS = Map.of( // inverse per step + "charge_payment", "refund_payment", + "reserve_stock", "release_stock", + "book_shipping", "cancel_shipping"); +@Transactional +public void compensate(String sagaId) { + Saga s = em.find(Saga.class, sagaId, LockModeType.PESSIMISTIC_WRITE); + // undo only the steps that actually completed, newest first + for (String step : reversed(STEPS.subList(0, s.getStepIndex()))) { + invoke(COMPENSATIONS.get(step), s.getContext(), + sagaId + ":" + step); // idempotency key — safe to retry + } + s.setStatus("COMPENSATED"); + repo.save(s); +} +``` + +Compensate only completed steps, in reverse, with an idempotency key so a retry never double-refunds. + +p. 105 + +> The compensation logic. A simple Map gives each forward step its inverse. We iterate the completed steps (subList up to stepIndex) in reverse, newest first, and invoke each compensation. The idempotency key built from sagaId plus step is the line that prevents the nightmare scenario: a retry double-refunding a customer. The compensating service dedupes on that key. In production you'd track per-step compensation status too, so if compensation itself crashes halfway you resume from the right place — but the idempotency key makes even naive re-runs safe. This is why compensations are designed up front, as part of each step, not bolted on after. If you use Temporal instead, its Saga utility (Workflow.newSaga(...).addCompensation(...)) drives the unwind for you with the same guarantees; the Temporal slides in this appendix show it. + +## [125] APPENDIX D · SAGAS · When step B needs data only step A produced (sldId 380) + +Don't make B call A. Persist A's output into the saga context and pass it forward; on resume, read it back. + +p. 106 + +> This case is a common source of bad designs. The orchestrator calls service A, then service B — but B needs a value that only A produced (say A returns a reservation_id and a warehouse region that B requires to proceed). The tempting wrong answer is to have B call A directly — now you've created a hidden synchronous dependency between B and A, exactly the chatty coupling the whole architecture fights. The right answer is on screen: the orchestrator owns a saga context (a 'blackboard'), persists A's output into it after step A, and passes the needed values into B's call. B never knows A exists. And note this is already in our state-machine code — that 's.getContext().put(step, result)' line is exactly this. Temporal does the same through its workflow input/output passing, and Example 17 stores each step's result in the saga's JSONB context under the step name. + +## [126] APPENDIX D · SAGAS · Two ways to keep a saga durable (sldId 381) + +- Two open-source paths, both shown in this appendix with a diagram and code. +- The hand-rolled orchestrator (Example 17): one sagas row per instance (status, step_index, JSONB context) and a saga_log, written with JdbcClient inside a TransactionTemplate. No extra dependency, no extra cluster. +- Temporal: a durable execution engine (Temporal Server MIT, Java SDK Apache 2.0). Workflows and activities are plain Java; the Server persists every event and replays it on recovery. Most power, a separate cluster to run. +- Decision rule: keep the hand-rolled orchestrator while the saga is short and lives in one service; reach for Temporal when it is long, shared across services, or needs timers, retries and child workflows as first-class features. +- Spring Statemachine is not on the list: its repository is archived upstream and 4.0.x was its last open-source line. + +p. 107 + +> Frame the choice as owning the transitions against buying durable execution. Both are open source and both survive a pod crash mid-flow. +> +> The hand-rolled orchestrator is what Example 17 runs: a sagas table with one row per saga holding status, step_index and a JSONB context, and a saga_log table that records every execute, failed and compensate action. Each step runs in its own transaction that locks the row, runs the step and commits the new context and step index together. On startup the service resumes every saga still marked RUNNING. Nothing new to operate. +> +> Temporal: founded by the original Cadence team, MIT-licensed Server and Apache-2.0 Java SDK, actively maintained. It adds durable execution, deterministic replay, automatic retries with backoff, timers, a Saga compensation utility, child workflows, and a CLI and UI to inspect runs. The cost is operating a Temporal cluster, which runs on your own Kubernetes with no SaaS required. +> +> Spring Statemachine is archived upstream: github.com/spring-projects/spring-statemachine is a read-only archive whose README says the project is no longer maintained (last release 4.0.2, June 2026), and the spring.io announcement of 21 April 2025 made 4.0.x the last open-source line, with later releases for commercial Tanzu Spring customers only. That conflicts with the open-source-only rule, so the deck does not offer it. +> +> The decision rule to leave with the audience: the question is how much engine this saga needs. + +## [127] APPENDIX D · SAGAS · Temporal: a plain-Java saga that the Server makes durable (sldId 382) + +Worker (your Spring Boot app) runs Workflow + Activity code; the Temporal Server persists every event and replays deterministically. + +p. 108 + +> Walk the picture. Three boxes. On the left, your client, a REST handler in a Spring Boot app, calls WorkflowClient.start(...) to kick off a workflow. In the middle, the Temporal Server: a cluster of Frontend, Matching and History services backed by Postgres (or MySQL / Cassandra) that persists every workflow event (every activity invocation, every result, every timer firing), so the workflow can be reconstructed from scratch by replay. On the right, the Worker: another Spring Boot app where you implement the workflow as a regular Java class (FulfillmentWorkflowImpl with @WorkflowMethod) and the activities as regular Spring beans. The worker polls a task queue, gets the next workflow task, executes the next deterministic step. Crash the worker mid-flow and Temporal reissues the task to another worker, which replays history and resumes exactly where you were — no lost steps, no double-charges. That's durable execution. The bottom captions hammer the open-source posture: Temporal Server is MIT, the Java SDK is Apache 2.0, both running on your Kubernetes if you want, no SaaS lock-in. + +## [128] APPENDIX D · SAGAS · Temporal saga workflow + activity, in Spring Boot (sldId 383) + +[chip] java · io.temporal:temporal-sdk + Spring Boot + +```java +// 1) Activity interface — each is a normal Spring bean +@ActivityInterface +public interface PaymentActivity { @ActivityMethod String charge(SagaInput in); } +// ... InventoryActivity.reserve and ShippingActivity.book are declared the same way +// 2) Workflow interface + impl — plain Java, deterministic +@WorkflowInterface +public interface FulfillmentWorkflow { @WorkflowMethod String run(SagaInput in); } +public class FulfillmentWorkflowImpl implements FulfillmentWorkflow { + private final PaymentActivity payment = Workflow.newActivityStub(…); + private final InventoryActivity inventory = Workflow.newActivityStub(…); + private final ShippingActivity shipping = Workflow.newActivityStub(…); + @Override public String run(SagaInput in) { + Saga saga = new Saga(new Saga.Options.Builder().build()); // built-in compensator + try { + String paymentId = payment.charge(in); + saga.addCompensation(() -> payment.refund(paymentId)); + String reservationId = inventory.reserve(in, paymentId); + saga.addCompensation(() -> inventory.release(reservationId)); + return shipping.book(in, reservationId); // outputs flow forward + } catch (ActivityFailure e) { + saga.compensate(); // unwinds in reverse + throw e; + } + } +} +``` + +Workflow + Activities are Spring beans; Temporal's built-in Saga utility owns the unwind. State is durable on the Server. + +p. 109 + +> This is the whole story on one slide. ActivityInterfaces are plain Spring beans annotated @ActivityInterface, registered with the worker at startup. The Workflow interface is annotated @WorkflowInterface with one @WorkflowMethod entry point; the impl is plain Java that looks synchronous but every activity call goes through Temporal's task queue and is durably scheduled, retried (configurable retry policy on the stub), and timed-out on the Server. The output of each activity (paymentId, reservationId) is captured in a workflow-local variable and in Temporal's event history; if the worker dies between charge and reserve, a replacement worker replays history, recovers paymentId, and continues at reserve without re-running charge. Note the Saga utility from the Temporal SDK: addCompensation registers an undo for each completed step, and saga.compensate() runs them in reverse on failure — the same compensation discipline from earlier, but built into the SDK. Bootstrapping in Spring Boot: define a WorkflowServiceStubs bean (pointing at your Temporal Server), a WorkflowClient bean, and a WorkerFactory that starts a Worker, registering activity beans and workflow implementation types. The temporal-spring-boot-autoconfigure project does most of that wiring for you. + +## [129] APPENDIX D · SAGAS · A hand-rolled orchestrator: one row, one log, no cluster (sldId 384) + +A sagas row locked FOR UPDATE, a saga_log of every action, and a resume pass at startup: Example 17. + +p. 110 + +> Walk the picture: the saga-orchestrator service of Example 17. POST /sagas creates a sagas row (status RUNNING, step_index 0, the order in a JSONB context) and runs the steps in order: charge_payment, reserve_stock, book_shipping. Postgres holds two tables: sagas, one row per saga, and saga_log, one row per execute, failed or compensate action. +> +> Each step runs in its own transaction. The orchestrator locks the row with SELECT ... FOR UPDATE, returns at once unless the status is RUNNING (a repeated call is a no-op), runs the step, writes its output into the context and the log, and moves step_index forward, all in one commit. When the index passes the last step the status becomes COMPLETED. +> +> When a step throws, the same transaction logs the failure and sets COMPENSATING; the compensation pass then walks the completed steps in reverse (release_stock, then refund_payment) and ends in COMPENSATED. The dashed path in the picture is that unwind. +> +> Recovery is a StartupRunner: on boot it advances every saga still marked RUNNING. Everything runs in-process in the Spring Boot app, on the Postgres you already operate. + +## [130] APPENDIX D · SAGAS · The hand-rolled orchestrator: one step per transaction (sldId 385) + +[chip] java · Spring Boot 4 + JdbcClient + +```java +// SagaService.java excerpt (examples/17-sagas); helper names stand for its JdbcClient calls +private void advance(String sagaId) { + String[] statusHolder = new String[1]; + txTemplate.executeWithoutResult(status -> { // lock, run one step, commit + var row = lockRow(sagaId); // SELECT ... FROM sagas WHERE id = :id FOR UPDATE + if (row == null || !"RUNNING".equals(row[0])) return; // finished: no-op + int stepIndex = (int) row[1]; var context = fromJson((String) row[2]); + if (stepIndex >= STEPS.size()) { markStatus(sagaId, "COMPLETED"); return; } + SagaStep step = STEPS.get(stepIndex); // (name, compensation) + try { + Map result = stepExecutor.execute(step.name(), context); + context.put(step.name(), result); // output flows forward + logStep(sagaId, step.name(), "execute", result); + saveProgress(sagaId, stepIndex + 1, context); // step_index + JSONB context + statusHolder[0] = "RUNNING"; + } catch (Exception e) { + markStatus(sagaId, "COMPENSATING"); statusHolder[0] = "COMPENSATING"; + } + }); + if ("RUNNING".equals(statusHolder[0])) advance(sagaId); // next step, new transaction + else if ("COMPENSATING".equals(statusHolder[0])) compensate(sagaId); +} +``` + +The row lock, the step's output and the new state commit together; a failed step hands off to compensate(). + +p. 111 + +> The excerpt is advance() from SagaService in Example 17, condensed where it talks to Postgres: lockRow, saveProgress, logStep and markStatus stand for its JdbcClient calls, and the failed-step log row is left out. +> +> STEPS is a list of SagaStep records pairing each forward step with its compensation (charge_payment with refund_payment, reserve_stock with release_stock, book_shipping with cancel_shipping), so the unwind order is data. advance() opens a TransactionTemplate, locks the saga row with SELECT ... FOR UPDATE and returns at once unless the status is RUNNING, so a redelivered request or a second worker finds a finished saga and does nothing. +> +> Inside the transaction the step runs, its result goes into the JSONB context under the step's name (this is how book_shipping can read what reserve_stock produced), the log row is written, and step_index moves forward. The lock, the log and the new state commit together, the same rule as the outbox. +> +> After the commit, advance() calls itself for the next step in a new transaction. A crash rolls back at most the step in flight, which runs again on resume, so every step must be idempotent. A thrown exception marks the saga COMPENSATING, and compensate() runs the completed steps' compensations in reverse before setting COMPENSATED. +> +> Compared with Temporal: no extra server, but no built-in retries, timers or replay; the retry and resume logic is yours. The step executor in the example simulates the three services. + +## [131] Appendix E · API Error Handling (sldId 386) + +E + +One discipline, four wire formats. + +> Appendix E. Error handling is where cloud-native systems fall apart, more often than the happy path. The thesis: agree one error contract (a stable machine code, a safe human message, a correlation id, a retryable flag, field details) and then map that same model onto REST, gRPC, Kafka and GraphQL. We finish on the failure mode that turns a blip into an outage, the retry storm, and the handful of controls that prevent it. + +## [132] APPENDIX E · ERRORS · One error model, four wire formats (sldId 387) + +The same five facts in every error; only the encoding changes per protocol. + +p. 112 + +> Start with what every error must carry, regardless of protocol: a stable machine-readable code clients can branch on (not a localized string), a human-readable message safe to show, a correlation/trace id that ties the error to the distributed trace from section 11, a retryable flag (with retry-after when known), and structured field-level details. The four protocols just encode those same five facts differently — REST as problem+json, gRPC as status+details, Kafka via headers and a DLQ record, GraphQL as an errors[] entry with extensions. Agree this once as a shared contract; never leak stack traces or internal type names to callers. + +## [133] APPENDIX E · ERRORS · REST: status, then Problem Details (sldId 388) + +The status code is the category; the problem+json body carries the machine code and specifics. + +p. 113 + +> REST has two layers. First the HTTP status class, which is the category and drives client behaviour: 4xx means the client must fix the request and should not blindly retry; 429 and 503 mean 'busy' and must be paired with a Retry-After header so clients back off; 5xx means a server fault that idempotent calls may retry with backoff. Second, the body: RFC 9457 Problem Details (application/problem+json, which superseded RFC 7807) gives a standard envelope (type, title, status, detail, instance), and you extend it with your stable code and the traceId. Status for the category, body for the specifics. + +## [134] APPENDIX E · ERRORS · REST errors with Spring 7 ProblemDetail (sldId 389) + +[chip] java · Spring Boot 4 + Spring 7 ProblemDetail + +```java +// Spring Framework 7's built-in ProblemDetail = RFC 9457 out of the box +@RestControllerAdvice // one place: domain → wire +public class ApiExceptionHandler extends ResponseEntityExceptionHandler { + @ExceptionHandler(StockException.class) + public ProblemDetail handleStock(StockException e) { + ProblemDetail pd = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, e.getMessage()); // 409 + pd.setType(URI.create("https://errors.acme.io/stock")); + pd.setTitle("Insufficient stock"); + pd.setProperty("code", "STOCK_UNAVAILABLE"); // stable + pd.setProperty("traceId", + Span.current().getSpanContext().getTraceId()); // correlate + return pd; // Content-Type: application/problem+json + } +} +// Typed alternative — throw an ErrorResponseException subclass from anywhere +// and Spring handles the rest: +public class OrderNotFound extends ErrorResponseException { + public OrderNotFound(String id) { + super(HttpStatus.NOT_FOUND, + ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, + "Order " + id + " not found"), null); + } +} +``` + +Spring 7 ships ProblemDetail and ErrorResponseException as the RFC 9457 contract — stable code, safe message, trace id; never a raw stack trace. + +p. 114 + +> Spring Framework 6 introduced ProblemDetail and ErrorResponseException; Spring 7 (Boot 4) leans on them as the default. Two complementary patterns. (1) A @RestControllerAdvice that extends ResponseEntityExceptionHandler is the central mapping point — domain code throws typed exceptions, one @ExceptionHandler per family builds a ProblemDetail, and Spring sets the Content-Type to application/problem+json automatically. Use setType for the URI, setTitle for the human label, setProperty for the extensions (machine code, traceId, anything else). (2) For checked, well-known errors, subclass ErrorResponseException and just throw — Spring's built-in handler picks it up. What the body leaves out: any stack trace, exception class name or SQL; those leak internals and are an attack surface. You can also opt in to Spring Boot's default ProblemDetailsExceptionHandler with spring.mvc.problemdetails=true if you want all framework exceptions returned as ProblemDetail too. + +## [135] APPENDIX E · ERRORS · gRPC: status codes + rich details (sldId 390) + +A small fixed set of canonical codes, plus structured google.rpc.Status details in the trailers. + +p. 115 + +> gRPC gives you a small, fixed set of canonical status codes, and the code you choose is part of the contract, because clients (and the Istio mesh) decide retry behaviour from it. INVALID_ARGUMENT and NOT_FOUND are client bugs: don't retry. FAILED_PRECONDITION and ALREADY_EXISTS are state issues: don't retry. UNAVAILABLE and ABORTED are transient: retry with backoff. RESOURCE_EXHAUSTED means you're being rate-limited — honour the RetryInfo delay. DEADLINE_EXCEEDED means tune the timeout. Beyond the code, attach a google.rpc.Status in the trailers with rich details, ErrorInfo (machine reason + domain) and RetryInfo (how long to wait), so the client gets the same five facts as everywhere else. + +## [136] APPENDIX E · ERRORS · gRPC errors with rich status details (sldId 391) + +[chip] java · grpc-java + StatusProto + +```java +import io.grpc.protobuf.StatusProto; +import com.google.rpc.*; +public void reserveStock(ReserveRequest req, + StreamObserver responseObserver) { + if (inventoryDown) { + var status = com.google.rpc.Status.newBuilder() + .setCode(Code.UNAVAILABLE_VALUE) // canonical code + .setMessage("inventory temporarily down") + .addDetails(Any.pack(ErrorInfo.newBuilder() + .setReason("STOCK_DOWN").setDomain("inventory").build())) + .addDetails(Any.pack(RetryInfo.newBuilder().setRetryDelay( + Duration.newBuilder().setSeconds(2)).build())) // back off 2s + .build(); + responseObserver.onError( + StatusProto.toStatusRuntimeException(status)); + return; + } + responseObserver.onNext(toReply(stock.reserve(req))); + responseObserver.onCompleted(); +} +``` + +Pick the canonical code (UNAVAILABLE = retryable) and attach ErrorInfo + RetryInfo so clients back off correctly. + +p. 116 + +> This is grpc-java's standard rich-error model via StatusProto — the same API surface whether you're on Spring Boot or any other JVM stack. The canonical code UNAVAILABLE tells the client and the mesh this is transient and retryable; the google.rpc.Status carries structured details — ErrorInfo with a machine reason and domain, and RetryInfo telling the client exactly how long to wait. StatusProto.toStatusRuntimeException packs it into the trailers, and responseObserver.onError signals the failure. The key discipline: don't overload a generic INTERNAL code for everything — the code is how automated clients and Istio decide whether and when to retry, so getting it right is what prevents both lost work and retry storms. + +## [137] APPENDIX E · ERRORS · Kafka: retry topics and a dead-letter queue (sldId 392) + +Commit the offset, route the failed record aside, and keep consuming — a poison record never blocks the partition. + +p. 117 + +> Kafka error handling has a failure mode unique to logs: a single poison record can block the whole partition forever if you keep retrying it in place, because the offset never advances — head-of-line blocking that stalls every record behind it. The fix is to move failures aside: try a bounded number of in-place retries, then route the record to a delayed-retry topic (reprocessed after a delay without blocking the live stream), and after N attempts send it to a dead-letter queue for humans or tooling. You commit the offset either way, so the main consumer keeps making progress. With Spring Kafka this is configuration (DefaultErrorHandler + DeadLetterPublishingRecoverer, or RetryTopicConfigurationBuilder for delayed-retry topics), not hand-rolled plumbing. + +## [138] APPENDIX E · ERRORS · Kafka retry + DLQ with Spring Kafka (sldId 393) + +[chip] java · Spring Boot 4 + Spring Kafka + +```java +@Configuration +public class KafkaErrorConfig { + @Bean + public DefaultErrorHandler errorHandler(KafkaTemplate template) { + // route to orders.DLQ on terminal failure; commit offset; never block + var dlt = new DeadLetterPublishingRecoverer(template, + (rec, ex) -> new TopicPartition(rec.topic() + ".DLQ", rec.partition())); + // in-place retries: 3 attempts, exponential backoff with jitter + var backoff = new ExponentialBackOffWithMaxRetries(3); + backoff.setInitialInterval(200); + backoff.setMultiplier(2.0); + backoff.setMaxInterval(5_000); + var handler = new DefaultErrorHandler(dlt, backoff); + handler.addNotRetryableExceptions(ValidationException.class); // 4xx → DLQ now + return handler; + } +} +@KafkaListener(topics = "orders", containerFactory = "manualAckFactory") +public void consume(Order order) { + handle(order); // handler retries; exhausted → DLQ; offset commits either way +} +``` + +DefaultErrorHandler + DeadLetterPublishingRecoverer with backoff — declaratively, so a poison record can't stall the partition. + +p. 118 + +> Spring Kafka wires this up declaratively. DefaultErrorHandler is the SeekToCurrentErrorHandler successor that gives bounded in-place retries with ExponentialBackOffWithMaxRetries (200ms initial, 2x multiplier, capped at 5s, 3 attempts). When retries are exhausted the DeadLetterPublishingRecoverer writes the record to a sibling DLQ topic and commits the offset, so the consumer keeps making progress — a poison record never causes head-of-line blocking. The non-retryable list short-circuits validation errors straight to DLQ (no point retrying a 4xx). For multi-topic retry-with-delay flows, RetryTopicConfigurationBuilder lets you declare 5s/30s/5m retry topics fluently (analogous to SmallRye's delayed-retry-topic). Make handle() idempotent — at-least-once delivery means retried records may have partially applied. The error handler is registered on your ConcurrentKafkaListenerContainerFactory so all @KafkaListener methods inherit it. + +## [139] APPENDIX E · ERRORS · GraphQL: partial success with errors[] (sldId 394) + +One HTTP 200; the failed field is null and explained in errors[] with a machine code in extensions. + +p. 119 + +> GraphQL breaks the one-request-one-status assumption: a single query can touch many resolvers, so the response is almost always HTTP 200 and carries both a data object and an errors array. When one field fails (say stock can't reach inventory), that field is set to null, an entry is added to errors[] with a message and the path to the failed field, and the machine code goes in extensions.code along with retryable and traceId. The rest of the data still returns. The anti-pattern to call out: blanket-failing the whole query (or returning a 500) when only one field broke — that throws away useful data the client already asked for. Partial success is a feature; let the client decide whether to retry just the failed field. + +## [140] APPENDIX E · ERRORS · GraphQL field errors with Spring for GraphQL (sldId 395) + +[chip] java · Spring for GraphQL + +```java +@Component +public class GraphQLExceptionResolver + extends DataFetcherExceptionResolverAdapter { + @Override + protected GraphQLError resolveToSingleError(Throwable ex, + DataFetchingEnvironment env) { + if (ex instanceof InventoryUnavailable iu) { + return GraphqlErrorBuilder.newError(env) + .errorType(ErrorType.UNAVAILABLE) // standard category + .message(iu.getMessage()) + .path(env.getExecutionStepInfo().getPath()) + // ... .extensions(Map.of(code, retryable, traceId)) + .build(); + } + return null; // delegate to the next resolver / Spring default + } +} +@Controller +public class OrderGraphQLController { + @SchemaMapping(typeName = "Order", field = "stock") + public Integer stock(Order order) { // one field's resolver + // ... calls inventory; on Unavailable throws InventoryUnavailable (nulls the field) + } +} +``` + +Throw from the field resolver; a DataFetcherExceptionResolver nulls that field and adds it to errors[] with code + retryable + traceId in extensions. + +p. 120 + +> Throwing from a single field's resolver is exactly right: Spring for GraphQL's exception-resolution chain catches it, sets that field to null, and appends an entry to errors[] with the message and path — while every other field resolves and returns normally. The DataFetcherExceptionResolver (or its convenience adapter DataFetcherExceptionResolverAdapter) is the central mapping point — one @Component, one method per exception family, return a GraphQLError built via GraphqlErrorBuilder, and the framework places it in the response correctly. ErrorType gives you the standard categories (NOT_FOUND, UNAUTHORIZED, FORBIDDEN, BAD_REQUEST, INTERNAL_ERROR, UNAVAILABLE) that map sensibly across clients; extensions carries the machine code, retryable flag, and traceId — the same five facts as everywhere else. Resist letting the exception bubble to the top and fail the whole operation — that's the blanket-failure anti-pattern. The client sees the order and total, sees stock is null, reads extensions.code, and decides whether to retry just stock. + +## [141] APPENDIX E · ERRORS · How a retry storm forms — and how to stop it (sldId 396) + +Naive retries multiply load on a struggling service; bounded, spaced, circuit-broken retries break the feedback loop. + +p. 121 + +> This is the failure mode that turns a brief blip into a full outage. The loop: a service slows, calls start timing out, clients retry (often 3x), which triples the load on the already-struggling service, causing more timeouts, causing more retries. It's a positive feedback loop, and naive retry logic is the amplifier. The controls that break it, bottom of the slide: bound every call with a timeout; cap retries to a small number and only for idempotent calls; space them with exponential backoff plus jitter (so clients don't retry in lockstep); add a circuit breaker to fail fast while the dependency is down; enforce a retry budget (cap retries as a percentage of traffic); and shed load early with 429/UNAVAILABLE rather than queueing work you can't do. In Spring Boot 4, the built-in retry API in spring-core (with @Retryable / RetryTemplate, supporting backoff and jitter) plus Resilience4j for circuit breakers and bulkheads, plus the Istio config from section 2, give you these at both the code and mesh layers. + +## [142] APPENDIX E · ERRORS · Error-handling heuristics across all four (sldId 397) + +- One contract, four encodings: a stable code, a safe message, a trace id, a retryable flag, field details — everywhere. +- The category lives in the protocol: HTTP status, gRPC code, GraphQL errors[] entry, Kafka DLQ vs retry topic. +- Make retryability explicit and honour it: Retry-After, RetryInfo, extensions.retryable — don't make clients guess. +- Never leak internals: no stack traces, SQL, or class names on the wire; map them to a code and log the detail server-side. +- Bound every retry (@Timeout, @Retry + jitter, @CircuitBreaker, retry budget) and make handlers idempotent. + +p. 122 + +> The cross-cutting checklist to leave on screen. One contract expressed four ways — the same five facts whether it's problem+json, google.rpc.Status, a GraphQL extensions object, or a Kafka DLQ header. Let each protocol carry the category natively rather than inventing your own. Make retryability explicit and machine-readable so clients and the mesh back off correctly instead of guessing. Never leak internals — that's both a security issue and a coupling issue. And the bold line is the one that prevents outages: every retry is bounded and spaced (Spring Boot 4's built-in retry API gives you @Retryable with exponential backoff + jitter, and Resilience4j adds @CircuitBreaker and @TimeLimiter declaratively), protected by a retry budget, and every handler idempotent so a redelivered or retried request is safe. Get these right and your failure paths become as boring as your happy paths. + +## [143] Appendix F · DDD & Hexagonal Architecture (sldId 398) + +F + +Where the boundaries and the contracts come from. + +> Appendix F. The whole talk assumes you've drawn good service boundaries and know what each API should say — this is where those come from. Domain-Driven Design gives you the strategic tools to place boundaries (subdomains, bounded contexts) and the tactical tools to model what's inside (aggregates, domain events); hexagonal architecture keeps the four protocols we've discussed as mere adapters around a clean domain core. Together they answer: where does an API boundary go, what should its contract say, and how do I keep REST/gRPC/GraphQL/Kafka out of my business logic. The reference is Vlad Khononov's Learning Domain-Driven Design. + +## [144] APPENDIX F · DDD · Why DDD belongs in an API talk (sldId 399) + +- An API is a contract between two bounded contexts — so “where's the boundary?” and “what does the contract say?” are DDD questions before they're API questions. +- Strategic DDD places the boundaries: subdomains (core, supporting, generic) and bounded contexts, each with its own model and ubiquitous language. +- Tactical DDD models what's inside: aggregates (consistency boundaries), entities, value objects and domain events — which become your resources and your Kafka events. +- Hexagonal architecture keeps it clean: the domain core is isolated, and REST, gRPC, GraphQL and Kafka are just adapters plugged into ports. +- Get the boundaries right and the APIs almost design themselves; get them wrong and no amount of REST polish will save you. + +p. 123 + +> Set up why a DDD appendix belongs in an API talk. The throughline: an API is the contract between two bounded contexts, so the hardest API questions (where to split, what the contract should contain, what stays private) are boundary questions, and DDD is the discipline for boundaries. Distinguish strategic DDD (where the lines go) from tactical DDD (how you model inside a line), and preview that hexagonal architecture is what stops the four protocols from contaminating the domain. The closing line is the one to land: boundaries dominate outcomes far more than protocol polish. + +## [145] APPENDIX F · DDD · Strategic DDD: subdomains & bounded contexts (sldId 400) + +Place boundaries by business value and rate of change; each context owns one model and exposes it through an API. + +p. 124 + +> Strategic DDD first. Classify the business into subdomains by value and volatility: core subdomains are your competitive edge, complex and fast-changing, where your best people belong; supporting subdomains are necessary but not differentiating, so build them cheaply; generic subdomains are solved problems you buy or adopt (auth, notifications). Each subdomain becomes one or more bounded contexts — a boundary inside which one model and one ubiquitous language are consistent. The punchline here is the bottom box: that boundary is exactly where the API sits, and the contract translates between two contexts' models rather than sharing internals. Map it to our system: order and pricing are core, inventory and shipping supporting, auth and notification generic. + +## [146] APPENDIX F · DDD · A bounded context is a service-and-API boundary (sldId 401) + +- A bounded context is the scope within which one model and one ubiquitous language stay consistent — the natural unit for a service and its API. +- The same word means different things across contexts: an “order” in fulfillment is not an “order” in billing. Don't force one shared model across the system. +- The context's published API is its only public surface; the schema, storage and internal types stay private — this is the “no leaky internal models” rule from anti-patterns, restated. +- Put boundaries where the business naturally splits and where a team can own them end-to-end (Conway's law) — that's where the integration cost is worth paying. + +p. 125 + +> Make the bounded context concrete as the service/API boundary. The vivid example is the word 'order' meaning different things in fulfillment and in billing: that is why a single shared canonical model across the whole company fails, and why each context exposes its own model through an API instead. Tie back explicitly to the anti-patterns section: 'no leaky internal models' is just 'keep the bounded context's internals private.' And invoke Conway's law: boundaries that don't match team ownership generate constant cross-team coordination. + +## [147] APPENDIX F · DDD · Context mapping: how contexts integrate (sldId 402) + +The relationship patterns between contexts, and which ones your API implements. + +p. 126 + +> Context mapping catalogues the ways two contexts relate. Walk the starred ones because they're what APIs implement: an Open-Host Service is a context that, because many others integrate with it, publishes a stable public API; the Published Language is the shared, versioned schema that API speaks; and an Anticorruption Layer is the translation you build when consuming someone else's context so their model doesn't leak into yours. The others are trade-offs: Conformist means you just accept the upstream model; Shared Kernel means you share a model and therefore change in lockstep — high coupling, use sparingly; Partnership means two contexts co-evolve; Separate Ways means don't integrate. The starred three are the ones to design explicitly. + +## [148] APPENDIX F · DDD · Open-Host Service & Published Language (sldId 403) + +- Open-Host Service: a context integrated by many publishes a stable, documented API as its public protocol — that's your REST / gRPC / GraphQL surface. +- Published Language: the shared, versioned schema that API speaks (OpenAPI, .proto, Avro), which is exactly what lives in the Apicurio registry. +- Anticorruption Layer: when you consume another context, translate their model into yours at the edge so their concepts never leak into your domain. +- Conformist is the cheap opposite — accept their model wholesale and inherit their changes; choose an ACL instead when the upstream model is messy or volatile. +- So the registry-backed contract from section 9 is a Published Language, and edge/gateway translation is an Anticorruption Layer. + +p. 127 + +> This is where DDD vocabulary connects to the platform we built. Open-Host Service = the public API; Published Language = the schema in the registry; Anticorruption Layer = the translation at your edge. The bold line is the payoff: the audience has already built these things in sections 9 and 8 without the names; now they have the names and the theory. Emphasize the ACL-vs-Conformist choice as a real decision: conform when the upstream is clean and stable, build an ACL when it's messy or changes a lot, because the ACL localizes the blast radius of their changes to one translation layer. + +## [149] APPENDIX F · HEXAGONAL · Hexagonal architecture: ports & adapters (sldId 404) + +The domain core is isolated; REST, gRPC, GraphQL and Kafka are just adapters that plug into ports. + +p. 128 + +> This is the architectural payoff of the whole protocols discussion. The domain core in the middle holds entities, aggregates and domain logic and depends on nothing — no framework, no I/O. It declares ports: inbound ports (what it can be asked to do) and outbound ports (what it needs from the world). Driving adapters on the left (REST, gRPC, GraphQL) translate an incoming protocol into a call on an inbound port. Driven adapters on the right (a Postgres repository, a Kafka publisher) implement the outbound ports. The arrows all point inward: adapters depend on the domain, never the reverse. The consequence we care about: the four protocols we spent the talk on are interchangeable adapters; you can add gRPC alongside REST, or a Kafka consumer, without touching a line of domain logic. + +## [150] APPENDIX F · HEXAGONAL · Ports & adapters in code (sldId 405) + +[chip] java · ports & adapters in Spring Boot + +```java +// domain — the core declares the interfaces (ports) it needs; no framework +public interface OrderRepository { void save(Order order); } // outbound +public interface EventPublisher { void publish(DomainEvent e); } // outbound +@Service // pure application logic +public class PlaceOrder { + private final OrderRepository repo; + private final EventPublisher events; + // ... constructor injection: no framework leak + public Order handle(PlaceOrderCmd cmd) { + Order order = Order.create(cmd); // aggregate enforces invariants + repo.save(order); + events.publish(new OrderPlaced(order.id())); + return order; + } +} +// adapter — a driving adapter; gRPC/GraphQL adapters look the same +@RestController +@RequestMapping("/orders") +public class OrderController { + private final PlaceOrder placeOrder; + public OrderController(PlaceOrder p) { this.placeOrder = p; } + @PostMapping public Order place(@RequestBody OrderIn body) { + return placeOrder.handle(body.toCmd()); + } +} +``` + +The domain depends only on ports it declares; Spring injects the adapters (JPA repo, Kafka publisher) by constructor. Swap or add one without touching PlaceOrder. + +p. 129 + +> Walk the three blocks. Ports: the domain declares plain Java interfaces (a repository and an event publisher) and depends on nothing else (no Spring imports anywhere in the domain package). The application service PlaceOrder is pure logic: it creates the aggregate, saves via the port, publishes a domain event via the port, and never references Spring Web, JPA or Kafka types directly. Constructor injection is the key Spring-friendly choice: fields can be final, the bean is immutable and unit-testable with mocks, and there's no @Autowired anywhere in the domain core (Spring's only mark is @Service, which a @Configuration can replace if you want zero framework annotations in the domain). The REST controller is a thin driving adapter — turn the HTTP body into a command and call the use case; a gRPC or GraphQL adapter is the same shape. The outbound ports are implemented by adapters (a Spring Data JPA OrderRepository, a Spring Kafka EventPublisher) and Spring injects them at startup. That's dependency inversion serving the boundary: protocols are replaceable, the domain stays stable. + +## [151] APPENDIX F · DDD · Aggregates and domain events (sldId 406) + +- An aggregate is a cluster of objects treated as one consistency unit — the boundary inside which invariants hold and a single transaction commits. It's usually what sits behind one API resource. +- That makes the aggregate the natural transaction boundary, and therefore the outbox boundary: the state change and the domain event commit together (section 4). +- Domain events (“OrderPlaced”) are first-class in DDD; they become the Kafka events other contexts react to (section 5) — choreography between bounded contexts. +- Keep aggregates small: one aggregate per transaction, reference others by id, and let events connect them — which is exactly the saga story in Appendix D. + +p. 130 + +> Tactical DDD, and it ties three earlier sections together. The aggregate is the consistency boundary: invariants hold within it and one transaction commits it, and it typically maps to a single API resource. Because it's the transaction boundary, it's also the outbox boundary from section 4: state plus event commit atomically. Domain events are the DDD concept that becomes your Kafka topic payloads, enabling choreography across contexts (section 5). And the small-aggregate rule (one per transaction, reference others by id, connect via events) is precisely why cross-aggregate consistency needs a saga (Appendix D). This is where DDD stops being abstract and visibly underpins the data, event and saga designs. + +## [152] APPENDIX F · DDD · How DDD maps to our API stack (sldId 407) + +- Bounded context → a service and its API boundary. +- Open-Host Service / Published Language → the REST/gRPC/GraphQL contract + the Apicurio registry. +- Anticorruption Layer → model translation at the gateway / consumer edge. +- Aggregate → an API resource and the transaction/outbox boundary behind it. +- Domain event → a Kafka event other contexts consume. + - Reference: Vlad Khononov — Learning Domain-Driven Design (O'Reilly, 2021). + +p. 131 + +> The summary map — leave it up while you recap. Every DDD concept on the left has shown up concretely on the right somewhere in the talk: contexts are services, the OHS/Published Language is the registry-backed contract, the ACL is edge translation, aggregates are resources and outbox boundaries, domain events are Kafka events. The message: DDD is the naming and reasoning behind the boundaries the whole architecture already relies on, not a separate methodology bolted on. Point people to Khononov's Learning Domain-Driven Design for the full treatment. + +## [153] Appendix G · Balancing Coupling (sldId 408) + +G + +Coupling isn't the enemy — imbalance is. + +> Appendix G. We've thrown around “coupled” and “decoupled” throughout as if loose is always better. This sharpens it, using Vlad Khononov's Balancing Coupling model. The reframe: coupling is necessary, since a system with zero coupling does nothing, so the goal is to balance it, not eliminate it. Coupling has three dimensions (integration strength, distance and volatility), and a coupling only hurts when all three are high together. This gives precise language for why a shared database is bad but a registry-backed API contract is fine, and it ties every anti-pattern from section 12 back to one model. + +## [154] APPENDIX G · COUPLING · Coupling is necessary — balance it, don't eliminate it (sldId 409) + +- Components must share some knowledge to collaborate; a system with zero coupling also has zero behaviour. “Loosely coupled” is too blunt a goal. +- Khononov's model evaluates the connection between two components, not the components themselves, along three dimensions: integration strength, distance and volatility. +- The headline: a coupling is only painful when strength, distance and volatility are all high at once — reduce any one and the pain drops. +- This is why “just split it into microservices” can backfire: spreading strong, volatile coupling across a network maximises all three dimensions — a distributed monolith. + +p. 132 + +> Frame the appendix. The central, slightly contrarian idea: coupling is not a defect to be driven to zero — it's necessary, and the real skill is balancing it. Credit Khononov's Balancing Coupling model and stress that it measures the connection between components, not the components, along three dimensions. The headline sentence is the whole model in one line — pain needs all three of strength, distance and volatility to be high. The bold line is the cautionary tale that resonates with anyone burned by microservices: splitting strongly- and volatile-coupled code across a network doesn't remove the coupling, it just adds distance to it — the distributed monolith. + +## [155] APPENDIX G · COUPLING · Coupling has three dimensions (sldId 410) + +Integration strength × distance × volatility — a coupling only hurts when all three are high. + +p. 133 + +> The model in one picture. Integration strength is how much knowledge two parts share — the more they share, the more likely a change in one forces a change in the other. Distance is how far apart they sit (same method, class, module, service, or system), and it sets the cost of a change that has to cross the boundary. Volatility is how often the coupled thing changes. The cost box at the bottom is the key relationship: strong plus distant is global complexity (the distributed monolith), but it only bites when the code is volatile. A strong, distant coupling on something that never changes costs you nothing. That's why all three dimensions have to be considered together rather than chasing “lose coupling” in the abstract. + +## [156] APPENDIX G · COUPLING · The three dimensions, in practice (sldId 411) + +- Integration strength: how much knowledge two parts share — from intrusive (private internals) down to contract (an explicit interface). Less shared knowledge = looser coupling. +- Distance: how far apart they sit — method, class, module, service, system. Greater distance raises the cost of any change that crosses the boundary. +- Volatility: how often the coupled thing changes. Khononov derives it from DDD subdomain type — core is volatile, generic is stable. +- Modularity = strength XOR distance: keep strong coupling local, and make anything distant a weak (contract) coupling. Being both strong and distant is the trap. + +p. 134 + +> Unpack each dimension with a concrete handle. Strength is a ladder (intrusive to contract). Distance is intuitive — the further apart, the more it costs to change across the seam. Volatility is the clever part of the model: you can't know the future, but you can predict change-rate from DDD subdomain type (core changes a lot, generic barely changes), which connects this appendix straight back to Appendix F. The bold line is Khononov's modularity rule rendered as a slogan, strength XOR distance: you want exactly one of them, not both; strong-and-local (a cohesive module) is fine, weak-and-distant (microservices via contracts) is fine, strong-and-distant is the distributed monolith. + +## [157] APPENDIX G · COUPLING · Integration strength: a ladder, worst to best (sldId 412) + +From intrusive (shared internals) to contract (a versioned schema) — across distance, aim for the bottom rung. + +p. 135 + +> Integration strength has four levels, worst to best. Intrusive coupling: one component reaches into another's private internals — the classic case is two services on one database, where either can break the other invisibly. Functional coupling: they share business logic or rules and must change together for functional reasons. Model coupling: they share a data model or DTOs — a shared library of types. Contract coupling: they integrate only through an explicit, stable, versioned contract — and that's our REST, gRPC, GraphQL and Kafka schemas in the registry. Lower on the ladder means less shared knowledge and looser coupling. The rule that matters: the greater the distance between two components, the further down this ladder you need to be. + +## [158] APPENDIX G · COUPLING · Strength in our system (sldId 413) + +- Intrusive (worst): two services sharing one database, or reaching into another's internals — any change can break the other invisibly. This is the shared-database anti-pattern. +- Functional / Model: sharing business logic or a model — e.g. a shared library of types. Fine within a context; risky across teams because it forces lockstep change. +- Contract (best across distance): integrate only through an explicit, versioned schema — our REST/gRPC/GraphQL APIs and Kafka event schemas, governed by the registry. +- The rule: the greater the distance, the weaker the coupling must be. Across services that means contract coupling — never shared state. + +p. 136 + +> Map the ladder onto decisions in our system. The shared database is intrusive coupling and it's the worst precisely because the integration strength is maximal and invisible — neither side knows what the other depends on. Shared libraries are model/functional coupling: perfectly fine inside one bounded context or team, dangerous when they force two independently-deployed teams to release together. Contract coupling (APIs and event schemas in the registry) is the only strength level appropriate across service distance. The bold rule is the actionable takeaway and it's exactly why every cross-service integration in the talk went through a registered contract rather than a shared table. + +## [159] APPENDIX G · COUPLING · Balancing coupling: strength vs. distance (sldId 414) + +Strong or distant, rarely both — and put any unavoidable strong-and-distant coupling where volatility is low. + +p. 137 + +> This brings strength and distance together into the design rule. Read the quadrants: high strength and close together is a cohesive module — perfectly healthy, keep it local. Low strength and distant is healthy microservices — services integrating through contracts. Low strength and close is needless indirection — you've split things that could just be merged. And the danger quadrant, top-right, is high strength at high distance: the distributed monolith, global complexity, where one change ripples across services and teams. The right-hand panel adds the third dimension: even a top-right coupling is tolerable if it never changes, but on volatile core-domain code it's where slow delivery and outages come from. So the formula: balance equals strength-XOR-distance, OR low volatility. + +## [160] APPENDIX G · COUPLING · Distance and volatility (sldId 415) + +- Distance is the cost multiplier: a change contained in one module is cheap; the same change spanning two services and two teams is slow and expensive. +- Volatility is the trigger: a strong, distant coupling on code that never changes is harmless; on volatile core-domain code it's where outages and slow delivery originate. +- Predict volatility from the domain: core subdomains change often — keep their couplings weak and local; generic subdomains are stable, so you can afford more coupling. +- Balance = (strength XOR distance) OR low volatility — put any unavoidable strong-and-distant coupling where the code rarely changes. + +p. 138 + +> Deepen distance and volatility. Distance is why the same logical change can be a one-line edit or a multi-team, multi-deploy project — it multiplies cost. Volatility decides whether a less-than-ideal coupling matters: a textbook-ugly coupling on frozen code is a non-problem; a textbook-fine coupling on hot, ever-changing core code can still hurt. The DDD connection (from Appendix F) is the practical tool: subdomain type predicts volatility, so you route your strongest couplings away from core and toward stable, generic areas. The bold formula is Khononov's balance condition — worth writing on a whiteboard in a design review. + +## [161] APPENDIX G · COUPLING · Coupling and our APIs (sldId 416) + +- Every protocol choice we've made is a coupling choice: REST, gRPC, GraphQL and events are all contract coupling — the loosest kind that still lets services collaborate. +- The registry is what keeps that contract coupling stable: compatibility rules stop a producer from silently raising the integration strength on its consumers. +- Connascence (Page-Jones) is a finer lens on the same idea — API versioning is managing connascence of name and position in your schemas (why protobuf field numbers matter). +- Every anti-pattern from section 12 is an imbalance: shared DB = intrusive at distance; chatty sync chains = strong + distant + volatile; leaky models = model coupling across a boundary. + +p. 139 + +> Bring it home to the talk's content. The reframe to offer: every protocol decision was secretly a coupling decision, and we kept choosing contract coupling because it's the loosest that still works across distance. The registry's compatibility gate is, in this language, a guard against a producer silently increasing integration strength on consumers — that's what a breaking change is. Mention connascence as the finer-grained classical model (Page-Jones) that versioning manages — protobuf field numbers are connascence of position made explicit and stable. The bold line re-diagnoses the anti-patterns section through the coupling model, which is a satisfying way to show these aren't separate lists of rules but one underlying principle. + +## [162] APPENDIX G · COUPLING · Heuristics for balanced coupling (sldId 417) + +- Don't chase zero coupling — chase balance. Strong coupling is fine when it's local and stable. +- As distance grows, drive strength down: across services, integrate only through versioned contracts, never shared state. +- Keep strong, volatile couplings inside a single bounded context or aggregate, where distance is small. +- Use DDD subdomain types to predict volatility, and put your weakest couplings around the parts that change most. + - References: Vlad Khononov — Learning Domain-Driven Design (O'Reilly) and Balancing Coupling in Software Design (Addison-Wesley). + +p. 140 + +> The closing heuristics — the slide to leave up. Don't eliminate coupling, balance it; lower strength as distance rises; keep strong-and-volatile couplings local (inside an aggregate or context); and use subdomain types to predict volatility so your loosest seams are around your most-changing code. These five lines are a usable design-review checklist. Close by pointing to both Khononov books, Learning Domain-Driven Design for the boundary tools in Appendix F and Balancing Coupling in Software Design for this model, as the two references that underpin both of these appendices. + +## [163] Appendix H · Graceful Shutdown (sldId 418) + +H + +Disposability, kept as a promise. + +> Appendix H. This is the operational flip side of factor 9 (disposability) and the cloud-native pillars: pods are killed and replaced constantly, so a service has to be able to go away cleanly. Ungraceful exits are the source of the 5xx blips users see during otherwise-healthy rolling deploys. The thesis: graceful shutdown isn't one setting, it's a short protocol between your app and Kubernetes — stop new traffic, finish in-flight work, drain consumers and sockets, then exit before the grace period runs out. + +## [164] APPENDIX H · SHUTDOWN · Disposability is a promise you have to keep (sldId 419) + +- Pods are cattle: every deploy, scale-down, autoscale event or reschedule kills and replaces them — often many times a day. +- An ungraceful exit drops in-flight requests, abandons unacked messages and leaves work half-done: the 5xx blips users notice during a rollout. +- Graceful shutdown is the flip side of factor 9, disposability: stop taking new work, finish what you accepted, release resources, then exit. +- It's a protocol between your app and the platform, not a single setting, and the platform gives you a fixed budget to do it in. + +p. 141 + +> Set up the appendix by connecting it to two earlier ideas: factor 9 (disposability) from the twelve-factor arc, and the resilience pillar. The motivating story is the one every team has lived: a rolling deploy that looks healthy in tests but throws a scatter of 5xx errors in production because pods were torn down mid-request. Stress that this is not about a magic flag — it's a sequence the app and Kubernetes perform together, on a clock. This appendix walks that sequence, then shows the code on both sides of it. + +## [165] APPENDIX H · SHUTDOWN · What happens when Kubernetes stops a pod (sldId 420) + +SIGTERM starts a race; readiness-down plus a short preStop keep new traffic off until you've drained. + +p. 142 + +> Walk the two lanes. Top, Kubernetes: it sends SIGTERM to PID 1, starts the terminationGracePeriodSeconds clock, and will SIGKILL you if you overrun. The catch is the red box — removing the pod from the Service endpoints is asynchronous (kube-proxy, kubelet, the mesh all have to catch up), so for a moment after SIGTERM traffic can still arrive. Bottom, your pod: the response is to flip readiness DOWN immediately (which starts the deregistration) and run a short preStop sleep to cover the lag, then stop accepting, drain in-flight work, stop consumers and close resources, and exit — ideally well before the grace period ends. The red arrows are the whole point: readiness + preStop are what keep new traffic off your dying pod during the race. + +## [166] APPENDIX H · SHUTDOWN · The contract with Kubernetes (sldId 421) + +- SIGTERM is the signal: Kubernetes sends it to PID 1, then waits terminationGracePeriodSeconds (default 30s) before SIGKILL. +- Readiness DOWN is how you stop new traffic — the pod leaves the Service endpoints but keeps running to finish in-flight work. +- There's a race: endpoint removal is asynchronous, so traffic can still arrive briefly after SIGTERM — a short preStop sleep covers the lag. +- Liveness must stay UP during the drain, or the kubelet restarts a pod that is already winding down. +- Set the grace period larger than your longest acceptable in-flight operation — too short cuts work off, too long stalls deploys. + +p. 143 + +> These five points are the rules of the protocol. The distinction that trips people up is readiness vs liveness during shutdown: readiness should fail (it stops new traffic), but liveness must not fail (the kubelet would restart a pod that is draining). The asynchronous-deregistration race is the subtle one — it's why a preStop sleep exists at all; it's not about doing work, it's about waiting for the rest of the cluster to stop routing to you. And the sizing rule on the grace period is the practical lever: it must exceed your real worst-case in-flight operation, with headroom, or you'll be SIGKILLed mid-request. + +## [167] APPENDIX H · SHUTDOWN · Graceful means something different per workload (sldId 422) + +Only HTTP drains automatically; Kafka consumers, WebSockets and jobs each need their own drain. + +p. 144 + +> The trap is assuming 'graceful shutdown' is one thing. Only HTTP request draining tends to be automatic (the server stops accepting and finishes in-flight). Everything else you wire up yourself in your shutdown hook. Kafka consumers: stop polling, finish the records already in hand, commit offsets, and leave the group so the rebalance is clean. WebSockets (from Appendix C): you can't finish a long-lived connection, so send a close/go-away and rely on client reconnection plus the per-connection seq and backplane. Jobs and schedulers: stop starting new work and either let running jobs finish or checkpoint so they resume after restart. The green footer is the safety net under all of it — because a hard SIGKILL is always possible, idempotent handlers make a redelivered request or message harmless. + +## [168] APPENDIX H · SHUTDOWN · Graceful shutdown in Spring Boot (sldId 423) + +[chip] java · Spring Boot 4 + +```java +// OrderServiceApplication.java (excerpt), examples/21-graceful-shutdown/spring-boot +// application.properties: server.shutdown=graceful +// spring.lifecycle.timeout-per-shutdown-phase=20s +@SpringBootApplication +public class OrderServiceApplication { + public static void main(String[] args) { + // registered before Spring starts: replaces the JVM's default SIGTERM handler + Signal.handle(new Signal("TERM"), signal -> { + ShutdownState state = ShutdownState.getInstance(); // GET /readyz reads it: 503 + if (!state.markShuttingDown()) return; // one SIGTERM drives the drain + long delay = drainDelaySeconds(); // DRAIN_DELAY_SECONDS, default 5 + new Thread(() -> { + try { Thread.sleep(delay * 1000); } // keep serving: endpoints update + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + System.exit(0); // shutdown hook: stop accepting, finish in-flight, close pool + }, "drain").start(); + }); + SpringApplication.run(OrderServiceApplication.class, args); + } +} +// Kafka consumers, the scheduler and WebSockets drain in a ContextClosedEvent listener. +``` + +One SIGTERM flips readiness; after DRAIN_DELAY_SECONDS, System.exit(0) runs Spring's graceful shutdown, bounded at 20 s per phase. + +p. 145 + +> Example 21 takes the signal itself, so the whole sequence is visible. A sun.misc.Signal handler registered before Spring starts replaces the JVM's default SIGTERM handler, which would run the shutdown hooks at once. One SIGTERM, the only signal Kubernetes sends before SIGKILL, drives everything: the handler flips ShutdownState, so the hand-written /readyz answers 503 and the Service stops routing, and a drain thread keeps the service serving for DRAIN_DELAY_SECONDS (5 s; it plays the part of a preStop sleep) while the endpoint removal propagates. It then calls System.exit(0), which runs Spring's shutdown hook: with server.shutdown=graceful the embedded Tomcat, Jetty or Reactor Netty stops accepting new connections and finishes in-flight HTTP, bounded by spring.lifecycle.timeout-per-shutdown-phase (20 s here; the default is 30 s), then the context closes the pool and the process exits 0, inside the 30-second grace period. Use @EventListener(ContextClosedEvent.class), @PreDestroy or SmartLifecycle to drain what Spring does not drain for you: Kafka consumers (consumer.wakeup() then commit on close), the scheduler (stop accepting new triggers), WebSockets (send a close frame so clients reconnect elsewhere). With Actuator, publishing ReadinessState.REFUSING_TRAFFIC flips /actuator/health/readiness the same way; the example keeps its own /readyz, as Example 01 does. Pair it with the Kubernetes settings: readiness, preStop and the grace period. + +## [169] APPENDIX H · SHUTDOWN · The Kubernetes side: probes, preStop, grace period (sldId 424) + +[chip] yaml · Kubernetes + +```yaml +# deployment.yaml — give the pod time to deregister and drain +spec: + terminationGracePeriodSeconds: 30 # >= preStop + drain + buffer + containers: + - name: order-service + readinessProbe: # flips the pod out of the Service + httpGet: { path: /readyz, port: 8080 } + periodSeconds: 5 + failureThreshold: 2 # ~10s to stop routing after DOWN + livenessProbe: # keeps answering during the drain + httpGet: { path: /healthz, port: 8080 } + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] # cover async endpoint removal +``` + +Readiness removes you from the Service; preStop covers the dereg lag; terminationGracePeriodSeconds must exceed your full drain or you get SIGKILLed mid-flight. + +p. 146 + +> The platform half of the contract, the same in every language. terminationGracePeriodSeconds is the total budget: it must exceed the preStop sleep plus the worst-case drain plus a buffer. The readiness probe's periodSeconds times failureThreshold sets how fast Kubernetes stops routing after the service reports not ready, about ten seconds here, which the preStop sleep should cover. +> +> Readiness points at /readyz, which returns 503 from the moment SIGTERM arrives. Liveness points at /healthz, which keeps answering during the drain, so the kubelet does not restart a pod that is shutting down cleanly. The examples expose both endpoints in all six languages. +> +> The preStop sleep looks crude, but it is the practical fix for the asynchronous-deregistration race: align it with the probe timing rather than picking an arbitrary number. + +## [170] APPENDIX H · SHUTDOWN · Beyond HTTP: messages, sockets, jobs (sldId 425) + +- Only HTTP drains without extra code; everything else needs explicit drain code in your shutdown hook. +- Kafka consumers: stop polling, finish the records in hand, commit offsets, and leave the group so the rebalance is clean (ties to Event-Driven). +- WebSockets: send a close / go-away, let clients reconnect to another pod, and lean on the per-connection seq + backplane from Appendix C. +- Jobs and schedulers: don't start new work; let running jobs finish or checkpoint so they resume after restart. +- A hard SIGKILL is always possible — idempotent handlers make a redelivered request or message safe, which is your real safety net. + +p. 147 + +> Reinforce the per-workload point from the diagram with the concrete drain steps. The Kafka sequence is the one to emphasize because it connects to the at-least-once delivery discussion from the Event-Driven section: committing offsets on the way out minimizes redelivery, and leaving the group makes the rebalance fast. WebSockets can't be 'finished,' so graceful there means handing the client off, which is exactly what the Appendix C reconnection design enables. The closing bold point anchors the whole appendix: you cannot guarantee a graceful exit, because the kubelet will sometimes SIGKILL you, so idempotency is what makes the system safe, and graceful shutdown makes the unsafe window rare. + +## [171] APPENDIX H · SHUTDOWN · Graceful shutdown checklist (sldId 426) + +- Handle SIGTERM; flip readiness DOWN first so the Service stops routing before you stop accepting. +- Add a short preStop sleep to cover asynchronous endpoint removal; keep liveness UP throughout the drain. +- Drain in-flight HTTP, commit consumer offsets, close sockets and pools — all bounded by terminationGracePeriodSeconds. +- Make the work idempotent so the inevitable occasional SIGKILL is survivable. +- Test it under a real SIGTERM during a rolling deploy — not just a happy-path integration test. + - References: Spring Boot 4 reference — “Web / Graceful Shutdown” and “Context lifecycle”; Spring Kafka container drain semantics; Kubernetes “Termination of Pods” documentation. + +p. 148 + +> The leave-up summary. Read it as an ordered runbook: readiness first, preStop to cover the race, then the actual drains, all inside the grace budget. The fourth point turns shutdown from fragile to safe: with idempotency, the occasional hard kill is a non-event. And the bold final point is the one teams skip: graceful shutdown that's never tested under a real SIGTERM during a rollout is just hopeful configuration. Encourage a small script (or a chaos test) that kills a pod mid-request and asserts the client still got an answer — exactly how Spring Boot's reference docs recommend you validate your shutdown configuration. + +## [172] Appendix I · L7 Routing & Traffic Management (sldId 427) + +I + +Software-defined, north/south and east/west. + +> Appendix I. This one is large enough to be its own talk — the user explicitly suggested it could be cut later for that. The thesis: L7 routing isn't one box in a rack any more; it's a four-layer stack of software (edge, gateway, mesh, in-app) that makes decisions on every HTTP and gRPC envelope crossing the network, north/south and east/west. We'll walk what each layer can see and decide, show real config in HAProxy/Nginx/Istio, cover sticky sessions, traffic steering, payload modifications and latency, then go inside the app for rule-based routing with Spring Boot + Apache Camel + Drools, then circle back to east/west, where most routing decisions happen. + +## [173] APPENDIX I · L7 ROUTING · L7 routing: software, not silicon (sldId 428) + +- The hardware load balancer used to be where routing decisions lived. In a cloud-native system that work has moved into software — programmable, observable, and runnable anywhere. +- L7 means application layer: the router sees the HTTP or gRPC envelope (host, path, method, headers, cookies, gRPC service.method) and can route on any of it. +- It lives in four layers — edge / ingress, API gateway, service mesh, and (sometimes) inside the app itself. Each makes a different type of L7 decision. +- It runs both directions: north/south (clients into your system) and east/west (service to service). Most L7 decisions happen on internal traffic. +- The non-hardware stack is open source and composable: HAProxy, Nginx, Envoy, Istio, Linkerd, Kong, Traefik, Apache Camel, Kogito DMN, Drools — plus a YAML or two. + +p. 149 + +> Open the appendix by reframing what L7 routing is in 2026: not an appliance, but layered software that runs inside Kubernetes alongside everything else. The four-layer mental model (edge, gateway, mesh, in-app) is the backbone of the whole appendix — we'll touch each layer in turn. Stress north/south plus east/west early; people default to thinking about ingress, but inside a microservices system the volume of east/west calls dwarfs north/south, and that's where mTLS, retries and locality routing earn their keep. Name the open-source pieces explicitly so the audience knows the talk is about real, free, runnable software. + +## [174] APPENDIX I · L7 ROUTING · L4 vs L7: what each layer can decide on (sldId 429) + +L4 sees connections; L7 sees the conversation — paths, headers, methods, cookies, gRPC service.method. + +p. 150 + +> Anchor the discussion in the OSI model just enough to be useful. L4 is the layer of IPs, ports and TCP — a classic load balancer can pick a backend by 5-tuple and hash-balance traffic, but it cannot read URLs, headers, cookies, methods or gRPC metadata. L7 sees the full HTTP/gRPC envelope: host, path, headers, cookies, JWT claims, query parameters, methods, and the gRPC service.method pair. That's the entire menu of inputs every later example will use to make routing decisions. The TLS SNI line in both columns matters: L4 LBs can route by SNI without terminating TLS (cheap, opaque), while L7 LBs typically terminate TLS so they can see everything else (richer, slightly slower). + +## [175] APPENDIX I · L7 ROUTING · The L7 capability menu (sldId 430) + +- Routing rules: path / host / header / method / cookie / gRPC method → backend. +- TLS termination: decrypt at the edge, re-encrypt (or use mTLS) inward. +- Traffic management: canary, blue/green, A/B, header-based, geo, shadow. +- Payload modification: inject / strip headers, rewrite URLs, transform bodies. +- Resilience: timeouts, retries with backoff + jitter, circuit breakers, outlier ejection — the same toolkit from Appendix E, applied at the network. +- Cross-cutting: authn/authz, rate limiting, WAF, request mirroring, full traces and metrics on every hop. + +p. 151 + +> Read this as the menu of L7 features you can mix and match across layers. The bold last point is the operational payoff: every L7 router emits traces and metrics, which is how you see what is happening across the four layers, and it ties back to the observability section. Most of these features used to be hardware appliances; now they're an annotation or a YAML block away. The resilience capabilities (timeouts, retries, circuit breakers) overlap with what Appendix E said about avoiding retry storms: the mesh implements many of them by default, but you still need bounded retries and budgets to prevent amplification. + +## [176] APPENDIX I · L7 ROUTING · Where L7 lives: the layered stack (sldId 431) + +Four layers, north/south at the top, east/west at the bottom, each making a different type of L7 decision. + +p. 152 + +> This is the backbone diagram for the whole appendix. North/south traffic (clients into the system) enters through the edge/ingress layer (HAProxy, Nginx, Envoy, Traefik), where TLS terminates and coarse host/path routing happens, then through the API gateway (Kong, Istio gateway, Spring Cloud Gateway), which handles product-level concerns like auth, rate limiting and transformation. Once inside the cluster, east/west traffic is governed by the service mesh (Istio + Envoy sidecars, or Linkerd) — every internal call gets mTLS, retries, outlier detection and locality-aware load balancing. Finally, when L7 decisions need to consider business rules the platform doesn't know about, you push routing into the app itself (Spring Boot + Apache Camel + Drools + Kogito DMN). Each layer is the right place for a different type of decision; every topic in this appendix maps to one of them. + +## [177] APPENDIX I · L7 ROUTING · A request's L7 path (sldId 432) + +One HTTP request is a chain of L7 decisions made by four independent components. + +p. 153 + +> Walk a single request left to right. It starts at the client — say a browser or another service. The edge load balancer terminates TLS, does host/path routing and maybe WAF; the API gateway authenticates the caller, applies rate limits and may transform; the mesh sidecar adds mTLS for the cluster's internal hop, applies retries and locality routing; and finally the service can do its own in-app routing if the business rules demand it. Each box can refuse, transform or branch the request, and each emits a trace span, so the full path is observable end to end. This is the request lifecycle from section 11, viewed through the L7 lens. + +## [178] APPENDIX I · L7 ROUTING · Open-source L7 building blocks (sldId 433) + +- HAProxy: fast, battle-tested L7 LB; ACL-driven config; classic ingress. +- Nginx: ubiquitous web server / LB; location-block routing; widely used as ingress controller. +- Envoy: the modern L7 proxy; HTTP/2, gRPC, dynamic config; the sidecar of choice. +- Istio: a control plane built on Envoy; CRDs for routing, security, observability across the mesh. +- Kong, Traefik, Spring Cloud Gateway: opinionated API gateways with plugin ecosystems. +- Apache Camel + Drools / Kogito DMN: when the routing logic is a business rule, push it into the app — EIP DSL + a rule engine. + +p. 154 + +> Differentiate the pieces so the audience knows when to reach for which. HAProxy and Nginx are the workhorses of the edge — mature, fast, predictable. Envoy is the modern choice for sidecars because it speaks HTTP/2 and gRPC natively and supports dynamic configuration via xDS. Istio (and Linkerd) wrap Envoy in a control plane and give you the mesh CRDs (VirtualService, DestinationRule). Kong, Traefik and Spring Cloud Gateway are API gateways with plugin ecosystems and a more product-aware feature set. The bold last point introduces the rule-based routing material: when the decision is 'route based on a business rule a non-developer needs to change weekly,' the right place is a rule engine inside the app, not a YAML file in the cluster. + +## [179] APPENDIX I · L7 ROUTING · Content-based routing with HAProxy (sldId 434) + +[chip] haproxy.cfg + +```haproxy.cfg +frontend http_in + bind *:443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1 # TLS here; h2 for gRPC + acl is_api path_beg /api/ + acl is_grpc req.hdr(content-type) -m beg application/grpc + acl is_internal req.hdr(x-internal) -m str 1 + acl is_canary hdr(cookie) -m sub canary=on + use_backend grpc_pool if is_grpc # by content-type + use_backend order_v2_pool if is_canary # by cookie + use_backend order_int_pool if is_internal # by header + use_backend order_pool if is_api # by path + default_backend web_pool +backend order_v2_pool + balance roundrobin + server v2a 10.1.0.10:8080 check + server v2b 10.1.0.11:8080 check +``` + +ACLs read the L7 envelope (path, header, cookie, content-type) and use_backend dispatches — classic, fast, predictable. + +p. 155 + +> HAProxy is the lingua franca of L7 LBs. The pattern is always the same: declare ACLs that read parts of the L7 envelope (path_beg for URL prefixes, hdr/req.hdr for headers, content-type for the body type), then use_backend dispatches based on those ACLs in order. This single frontend shows four different content-based routing decisions — by content-type (gRPC traffic goes to a gRPC pool), by cookie (canary users), by header (internal users), by path. Notice TLS terminates at this frontend; everything inside is plaintext within the cluster (or re-encrypted via mTLS at the mesh layer). HAProxy's reload semantics are graceful by default — it can drain old workers while new ones take traffic, which matters for Appendix H's shutdown discipline. + +## [180] APPENDIX I · L7 ROUTING · Content-based routing with Nginx (sldId 435) + +[chip] nginx.conf + +```nginx.conf +# map directive — declarative routing table on a header value +map $http_x_app_version $upstream_pool { + default order_v1; + "v2" order_v2; + "v2beta" order_v2_beta; +} +server { + listen 443 ssl; http2 on; + ssl_certificate /etc/nginx/certs/tls.crt; + ssl_certificate_key /etc/nginx/certs/tls.key; + location /api/orders/ { # path-based routing + proxy_pass http://$upstream_pool; # picked by the map + proxy_set_header X-Request-Id $request_id; + } + location ~ ^/api/grpc/ { # gRPC traffic + grpc_pass grpc://orders_grpc_pool; + } +} +``` + +map turns a header value into an upstream pool name; location blocks dispatch by path and protocol — HTTP and gRPC side by side. + +p. 156 + +> Nginx's content-based routing is built around location blocks and the map directive. The map is the elegant bit: it converts a header value into a variable, which then drives proxy_pass — you express the routing table declaratively, then point a single location at the variable. Location blocks split HTTP from gRPC (grpc_pass) so you can run both protocols through one server. The X-Request-Id injection is a tiny example of payload modification — the L7 router can inject correlation headers so downstream traces can be stitched together. Nginx as an ingress controller in Kubernetes (the ingress-nginx project) is one of the most-deployed L7 routers in the world. + +## [181] APPENDIX I · L7 ROUTING · Content-based routing in the mesh: Istio VirtualService (sldId 436) + +[chip] yaml · Istio VirtualService + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: # gRPC by service.method + - uri: { prefix: /acme.orders.v1.OrderService/ } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by header + - headers: { x-internal: { exact: "1" } } + route: [{ destination: { host: orders, subset: v2 } }] + - match: # by cookie + - headers: { cookie: { regex: ".*canary=on.*" } } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] # default +``` + +Match by URI prefix (gRPC service.method), header, or cookie — same L7 inputs, expressed as Kubernetes CRDs. + +p. 157 + +> The same content-based routing primitives, but expressed as Kubernetes CRDs that the mesh control plane (Istio) compiles down to Envoy sidecar configuration. Three match rules, each sending to a different DestinationRule subset: by URI prefix (which is how you route on gRPC service.method, since gRPC paths look like /package.Service/Method), by header, and by cookie. The default route at the bottom is critical; without it, unmatched requests have nowhere to go. The CRD lives in Git, gets reviewed like any other change, and Istio reconfigures every sidecar in the cluster within seconds. This is the modern face of L7 routing — declarative, GitOps-managed, distributed across thousands of pods. + +## [182] APPENDIX I · L7 ROUTING · SSL / TLS termination at L7 (sldId 437) + +- Edge terminates TLS so the L7 router can read and route on the request — you cannot inspect headers or paths through an encrypted connection. +- Re-encrypt or mTLS inward: terminate at the edge, then either plaintext within the cluster (rare today) or mTLS via the mesh (the modern default). +- Certificate management: cert-manager + Let's Encrypt for public certs; SPIFFE / SDS for service identity inside the mesh; Istio rotates pod certs automatically. +- TLS passthrough (no termination) when you need end-to-end encryption (gRPC client mTLS, regulated payloads); route by SNI only. +- Cost: TLS handshakes are real CPU. Modern stacks use session resumption, TLS 1.3 and HTTP/2 to amortise the cost across many requests. + +p. 158 + +> Cover the three modes of TLS at the edge: terminate-and-decrypt (the common case, lets you do L7 things), terminate-and-re-encrypt (most secure default in a mesh, since mTLS inside is automatic), and passthrough (when you must not see the payload, e.g. client mTLS for regulated traffic — route by SNI only). cert-manager is the standard cluster-side answer for public certificates; SPIFFE-style identity and Envoy's SDS handle service-to-service certs automatically inside the mesh. Mention the CPU cost briefly so the L7 latency budget makes sense — a TLS handshake is milliseconds, and you want to amortise it via session resumption, TLS 1.3 and HTTP/2 multiplexing. + +## [183] APPENDIX I · L7 ROUTING · Sticky sessions: cookie vs IP-hash vs none (sldId 438) + +Convenient for legacy state, hostile to elastic scaling — prefer stateless behind a JWT. + +p. 159 + +> Three modes worth knowing about. No affinity is the default and what you want — any pod gets any request, load is even, and client state lives somewhere else (Redis, the JWT, the database). Cookie-based stickiness is what you fall back to when something stateful (a shopping cart in memory, a legacy session) has to stay with one pod; the LB sets a cookie on the first response and uses it to route subsequent requests to the same pod. It works behind any L7 LB but ties the user's session to one pod's lifetime — restart that pod, lose the session. IP-hash hashes the source IP to a pod; it needs no cookies but is fragile to NAT and mobile networks where many users share an IP, leading to skewed load. The red footer is the real cost: stickiness fights elastic scaling, hurts pod restarts and breaks scale-to-zero. Use it only for a known constraint, never as a default. + +## [184] APPENDIX I · L7 ROUTING · Sticky sessions in practice (sldId 439) + +- When you need them: legacy session state, a stateful WebSocket that hasn't been moved off-process yet, certain rate-limit windows tied to a token bucket per pod. +- Cookie-based is almost always the right choice over IP-hash: it survives NAT and lets clients explicitly drop the cookie. +- What stickiness breaks: even load balancing, fast pod restarts, scale-to-zero, blue/green flips, and a clean Appendix H graceful shutdown for any stateful pod. +- The right fix is usually upstream: move session state to Redis or the JWT, use a backplane for WebSocket fan-out (see Appendix C), and treat every pod as disposable. + +p. 160 + +> Make stickiness a last resort, not a first reach. Cookie-based affinity is the safer of the two stickiness options because clients can drop the cookie and NAT doesn't break it. The list of things stickiness breaks is the real argument against it: it actively fights the entire cloud-native delivery model — elastic scaling, fast restarts, graceful shutdown. The bold final point is the architectural fix: externalise the state. Redis for session data, JWT claims for user identity, the WebSocket backplane from Appendix C for fan-out. With that done, your LB is back to no-affinity and the platform works the way it wants to. + +## [185] APPENDIX I · L7 ROUTING · Intelligent traffic steering (sldId 440) + +Six familiar shapes — each is a header or weight rule away. + +p. 161 + +> Six patterns that all share the same L7 primitives. Canary: 90/10 weighted routing to a new version, watching metrics and ramping. Blue/green: two identical environments, traffic flipped atomically (and rolled back atomically). A/B test: a stable cohort maps to a variant via a user identifier so the same user always sees the same variant — critical for valid measurement. Header-based: dark launches and internal-only previews by routing on x-internal or similar. Geo / latency: send EU users to EU pods for RTT and data residency. Shadow / mirror: copy a percentage of real traffic to a new version, observe its behaviour, but don't return its responses to the client; this tests the new version under realistic load. The point is they're not different things mechanically; they're the same routing rule expressed with different inputs and weights. The difficult part is the deploy discipline around them: alerts that trigger an automatic rollback, gradual ramps, and the metrics that tell you the canary is bad before users do. + +## [186] APPENDIX I · L7 ROUTING · Canary release: weighted Istio routing (sldId 441) + +[chip] yaml · Istio canary + +```yaml +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - route: + - destination: { host: orders, subset: v1 } + weight: 90 # 90% on the stable version + - destination: { host: orders, subset: v2 } + weight: 10 # 10% on the canary +--- +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: orders } +spec: + host: orders + subsets: + - name: v1 + labels: { version: v1 } + - name: v2 + labels: { version: v2 } +``` + +VirtualService splits traffic by weight; DestinationRule defines the subsets by pod labels — the canonical canary release pattern. + +p. 162 + +> The canonical Istio canary. Two CRDs working together: DestinationRule defines subsets by pod label (version=v1 and version=v2), and VirtualService routes by weight to those subsets. Changing the weights in Git and reconciling the cluster ramps the canary — typically through 10%, 25%, 50%, 100% over hours or days, with metric gates between each step. If error rate or latency on v2 crosses a threshold, an automated controller (Flagger, Argo Rollouts) flips the weights back to 100/0 in seconds. The same weighted-routing primitive does shadow traffic, geo balance, and blue/green flips — the only thing that changes is which subsets you target and what the weights are. + +## [187] APPENDIX I · L7 ROUTING · Header-based steering: dark launches (sldId 442) + +[chip] yaml · Istio header-based + +```yaml +# route internal beta users to v2; everyone else stays on v1 +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders] + http: + - match: + - headers: + x-acme-beta: { exact: "true" } # set by your auth proxy + route: [{ destination: { host: orders, subset: v2 } }] + - match: + - headers: + user-agent: { regex: ".*AcmeInternal.*" } + route: [{ destination: { host: orders, subset: v2 } }] + - route: [{ destination: { host: orders, subset: v1 } }] +``` + +First match wins; an internal-only header (set upstream by auth) hands real production traffic to v2 — with zero blast radius. + +p. 163 + +> Header-based steering is how you do dark launches and betas safely. An upstream identity proxy sets an x-acme-beta header for opted-in users, and the VirtualService routes those users to v2 — in real production traffic, on real data, but with zero exposure to anyone outside the beta. First match wins, so order the rules from most-specific to least, ending with the catch-all default. This is also how you do internal-only previews (route by an internal user-agent string) and per-tenant feature gates (route by an x-tenant-id). Combined with weighted routing, you can do 'roll the canary forward only for the beta cohort,' which is the most surgical release pattern in the toolbox. + +## [188] APPENDIX I · L7 ROUTING · Payload modifications (sldId 443) + +- Header injection: X-Request-Id, traceparent, X-Forwarded-For, identity claims — propagate context downstream so the trace stitches together. +- Header stripping: drop Authorization or PII headers before forwarding to a less-trusted backend; strip Server / X-Powered-By outbound. +- URL rewrites: /api/v1/orders → /orders internally; clean public URLs over messy service paths. +- Body transformation: rare but possible — protocol translation, redaction, format conversion (Envoy filters, Camel transforms). +- Cost: body inspection means reading the full request into memory — the slowest L7 thing you can do. Use it sparingly, never on every request. + +p. 164 + +> Distinguish the cheap modifications from the expensive ones. Header inject/strip and URL rewrites cost almost nothing: the router is already parsing the HTTP line and headers. Body transformation is the expensive one because it forces the router to buffer the full payload, which kills streaming, blows memory on large requests and can become a bottleneck under load. Two examples: trace context propagation (the W3C traceparent header is how the spans from earlier in the request path stitch together with the spans downstream, which is why the request-id injection in the Nginx example mattered), and identity propagation (the auth proxy validates a JWT once at the edge and injects validated claims as headers so downstream services don't each re-validate). The bold point is the practical advice: if you can do it in the headers, do it in the headers. + +## [189] APPENDIX I · L7 ROUTING · Header injection and URL rewrites (sldId 444) + +[chip] yaml · Envoy / Istio + +```yaml +# Envoy / Istio: inject identity headers, strip secrets, rewrite paths +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: orders } +spec: + hosts: [orders.acme.io] + http: + - match: [{ uri: { prefix: /api/v1/orders } }] + rewrite: + uri: /orders # external -> internal path + route: [{ destination: { host: orders } }] + headers: + request: + set: + x-client-ip: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" # caller address + x-peer-identity: "%DOWNSTREAM_PEER_URI_SAN%" + remove: [ authorization, cookie ] # don't leak inward + response: + remove: [ server, x-powered-by ] # don't leak outward +``` + +Rewrite paths, inject the caller address and identity, strip secrets — all declaratively in the VirtualService. + +p. 165 + +> Payload modification as configuration, not code. The URL rewrite cleans up the path before forwarding: public /api/v1/orders becomes internal /orders. +> +> The request header set adds the caller's address and the identity from the client's mTLS certificate (its URI SAN, which is the SPIFFE ID inside the mesh), so identity arrives at the network layer. Envoy creates an x-request-id itself when a request has none. +> +> The request header remove strips Authorization and Cookie before the request reaches the backend, after the gateway has validated the token and turned it into trusted claims. The response header remove drops Server and X-Powered-By so responses do not reveal the stack. All of it is declarative and reconciled from Git; no service code is involved. + +## [190] APPENDIX I · L7 ROUTING · Beyond the edge: routing inside the app (sldId 445) + +- Sometimes the routing decision is a business rule the platform doesn't know about: route 'high-value-customer claims' here, 'European HL7 messages of type A03' there. +- Pushing those rules into HAProxy ACLs or VirtualService matches does not scale: domain experts own them, they change often, and they depend on the payload, not just the envelope. +- The pattern: an in-app router (Apache Camel, Spring Integration, a custom dispatcher) calls a rule engine (Drools, Kogito DMN) and routes on the result. +- It runs inside one service, behind the mesh — not on the network. The mesh still does mTLS, retries and locality; the rule engine just decides where to send the message next. + +p. 166 + +> Pivot from network-layer routing to in-app routing. The threshold is roughly: if the decision can be expressed in a few YAML lines and depends only on the envelope, do it at the mesh. If it depends on the payload, requires a decision table that domain experts edit, or involves complex conditions, push it into the app. The classic example (and the one the code models) is HL7 healthcare messages: route by patient class, encounter type, message subtype — dozens of intersecting conditions a non-developer needs to maintain. That belongs in a rule engine, not a VirtualService. Stress that this isn't an alternative to the mesh; it sits behind the mesh, complementing it. + +## [191] APPENDIX I · L7 ROUTING · Spring Boot + Camel + Drools: rule-based routing (sldId 446) + +Camel is the EIP DSL; Drools is the decision engine; Spring Boot wires them together. + +p. 167 + +> This is the canonical Java pattern, identical in Spring Boot to any other JVM stack — Apache Camel's DSL is framework-neutral. Inputs arrive over REST, gRPC or Kafka. They land in an Apache Camel route, which is a fluent DSL for Enterprise Integration Patterns: from(...) declares the source, .enrich() gathers any context needed for the decision, .process(rulesEvaluator) calls into Drools (rules) or Kogito DMN (decision tables), which sets a 'whereTo' header on the message. Then .routingSlip(header("whereTo")) dynamically dispatches to one or more destinations based on that header. The Drools rule unit is a separate, version-controlled artefact — .drl rules or DMN decision tables that domain experts can edit independently of the Camel route. The result: the routing logic is data, not code; reroutes don't need redeploys; and the heavy network plumbing stays in the mesh. + +## [192] APPENDIX I · L7 ROUTING · A Camel route in Spring Boot, calling Drools (sldId 447) + +[chip] java · Spring Boot 4 + Apache Camel + +```java +// pom.xml: camel-spring-boot-starter, camel-kafka-starter, drools-spring-boot-starter +@Component +public class OrderRoutes extends RouteBuilder { + private final RulesEvaluator rules; // wraps a Drools KieSession + public OrderRoutes(RulesEvaluator rules) { this.rules = rules; } + @Override public void configure() { + from("kafka:orders.inbound") + .enrich("direct:lookupCustomer", aggregateStrategy) // context + .process(rules::evaluate) // sets "whereTo" header + .log("routing ${header.whereTo}") + .routingSlip(header("whereTo")) // dynamic dispatch + .end(); + from("direct:lookupCustomer") + .to("http:customer-svc/lookup") // enrich w/ HTTP call + .unmarshal().json(); + } +} +``` + +from → enrich → rules.evaluate → routingSlip — the canonical content-based-router EIP, with the decision externalised to Drools. + +p. 168 + +> This is the structure used in the official Camel + Drools content-based-routing examples. The Camel DSL is identical across JVM frameworks, so a Quarkus implementation and a Spring Boot one differ only in dependency choice and DI style. camel-spring-boot-starter auto-configures the CamelContext and registers any @Component RouteBuilder beans: no manual XML, no Java config. Constructor injection brings in the rules evaluator. from() picks up Kafka messages via camel-kafka-starter. enrich() makes an HTTP call to a customer service to gather additional context and aggregates it onto the exchange. process(rules::evaluate) hands the enriched message to the rule engine, which fires .drl rules against the facts and sets a 'whereTo' header listing one or more destination URIs. routingSlip then dispatches the message to each of those destinations in sequence. The whole pattern is 15 lines of fluent Java; the actual routing logic lives in the .drl rules, owned by domain experts and editable without a Spring Boot rebuild. + +## [193] APPENDIX I · L7 ROUTING · A Drools rule: the decision, externalised (sldId 448) + +[chip] drl · Drools rules + +```drl +// orders.drl — edited by domain experts, not developers +package com.acme.orders; +import com.acme.orders.OrderFact; +rule "High-value VIP orders go to the priority queue" + when + $o : OrderFact( amountCents > 50_000, customer.tier == "VIP" ) + then + $o.setWhereTo("kafka:orders.priority,http:notify-vip"); +end +rule "European orders go to the EU region" + when + $o : OrderFact( customer.region == "EU" ) + then + $o.setWhereTo("kafka:orders.eu"); +end +rule "Default route" + salience -100 // last resort + when $o : OrderFact( whereTo == null ) + then $o.setWhereTo("kafka:orders.default"); +end +``` + +Each rule is a small business statement: when X is true, then route to Y. Edited and reviewed by domain experts. + +p. 169 + +> Drools rules are 'when ... then ...' statements. The 'when' is a pattern match against facts in the working memory (here, OrderFact). The 'then' is the action — in our case, setting the whereTo header that the Camel routingSlip will consume. Two real rules and a default with low salience (so it fires last if nothing else matched). The point isn't the syntax; it's that this file is owned by domain experts (the people who decide what 'VIP' or 'high-value' means) and can be redeployed without a developer touching Camel or Spring Boot code. When the rules get complex, Drools/Kogito has a DMN (Decision Model and Notation) flavour with decision tables in a spreadsheet form that non-developers find easier still. + +## [194] APPENDIX I · L7 ROUTING · Every L7 hop costs time (sldId 449) + +Typical p50/p99 added per stage — the budget you spend before your service even runs. + +p. 170 + +> Set expectations with the numbers. Each L7 capability has a typical cost: TLS termination is sub-2ms, path/header routing is sub-1ms, auth/JWT validation is a few ms, WAF a few more, mesh sidecar another millisecond per hop, rate-limit Redis lookup is a couple of ms. Body inspection is the outlier — it can be 5 to 50 ms depending on size, which is why we say to use it sparingly. The numbers are typical, not authoritative; profile your own stack. The right-side card has the working heuristic: stay under ~10ms total L7 on hot paths. With four layers (edge, gateway, mesh sidecar in, mesh sidecar out), every layer must be lean and justify its latency. The retry-storm discipline from Appendix E applies here too — timeouts must account for the total L7 budget plus the service's own work, or retries will pile up. + +## [195] APPENDIX I · L7 ROUTING · L7 pitfalls and how to avoid them (sldId 450) + +- Too many hops: edge → gateway → ingress → mesh sidecar in → mesh sidecar out adds up fast. Collapse layers that aren't earning their place. +- Regex-heavy ACLs / matches: each regex evaluation is real CPU. Prefer exact / prefix matches; use regex only when needed. +- Sticky sessions break elasticity without any error: the auto-scaler can't rebalance, restarts are painful, scale-to-zero is gone. +- Body inspection on every request: blows memory, kills streaming. If you must transform bodies, do it on a small percentage or just at the edge. +- Sync auth lookups on the hot path: cache validated tokens, validate JWT signatures locally, never do a per-request DB lookup for identity. +- Misaligned timeouts and retries between layers — a 30s gateway timeout + 3 mesh retries with 10s each = a retry storm at the bottom (see Appendix E). + +p. 171 + +> Run quickly through the failure modes. The hop-count one is the most common: every layer feels free until you measure p99 — then you realise the request crossed seven proxies. The regex point is a perf footgun many people miss. The sticky-session one ties back to the session-affinity trade-off. Body inspection is the latency-budget killer. The auth-lookup one is the most common cause of cascading slowdowns under load. And the timeout-misalignment is the gateway-to-mesh-to-service version of the retry storm we covered in Appendix E — the lesson is identical: bound retries, align timeouts top-down, give the deepest layer the strictest budget. + +## [196] APPENDIX I · L7 ROUTING · East / West: L7 between services, inside the mesh (sldId 451) + +Every service-to-service call is L7-routable through the sidecars. + +p. 172 + +> Most L7 decisions in a microservices system don't happen at the edge — they happen east/west, between services, every time service A calls service B. The mesh's sidecar pair handles that traffic: mTLS between services (encryption and identity), HTTP/2 multiplexing, native gRPC support, retries with backoff and jitter, outlier detection (eject misbehaving pods automatically), locality-aware load balancing (prefer same-zone pods to keep RTT low), header-based routing and canary weights for east/west versions, plus rich per-call traces and metrics. The headline is the second box: all of that, per call, with no app code. Application services keep talking plain HTTP or gRPC; the sidecars make it secure, reliable and observable in flight. This is the modern answer to 'how do you build a microservices system that works in production.' + +## [197] APPENDIX I · L7 ROUTING · Why east/west L7 matters (sldId 452) + +- Internal traffic dominates: in a microservices system the volume of east/west calls dwarfs north/south by 10x or more. +- Security: mTLS everywhere by default — every internal call has an identity, encryption and an audit trail without per-service code. +- Resilience: timeouts, retries, circuit breakers and outlier ejection live in the sidecar, applied uniformly to every internal hop. +- Observability: every internal call gets a span, latency histogram and per-route success rate — the request lifecycle from section 11, populated automatically. +- Locality: prefer same-zone or same-region pods so internal RTT stays in single-digit milliseconds, and graceful failover when a zone goes. +- Routing decisions: header-based canaries and weighted releases work east/west too — you can canary a backend service that has no external traffic at all. + +p. 173 + +> Hammer the volume point first: most production L7 routing happens between services, not at the edge. The security argument is the one that justifies the mesh on its own — mTLS everywhere is otherwise hard to retrofit. The resilience and observability arguments are the operational ones; you stop writing the same boilerplate in every service. Locality is the perf argument: with the right Istio config, two pods in the same zone talk over a few milliseconds; cross-region traffic gets degraded gracefully. And the last point is non-obvious but important: canary releases of a deeply internal service (say, a payments calculation engine that no client touches directly) are possible because the mesh can split east/west traffic by weight — you cannot canary internal services through an edge LB. + +## [198] APPENDIX I · L7 ROUTING · Istio for east/west: DestinationRule + VirtualService (sldId 453) + +[chip] yaml · Istio east/west + +```yaml +apiVersion: networking.istio.io/v1 +kind: DestinationRule +metadata: { name: payments } +spec: + host: payments.acme.svc.cluster.local + trafficPolicy: + # ... connectionPool: http2MaxRequests 1000, maxRequestsPerConnection 10 + outlierDetection: # eject bad pods + consecutive5xxErrors: 5 + interval: 30s + baseEjectionTime: 30s + # ... loadBalancer: localityLbSetting enabled (prefer same zone) + subsets: + - { name: v1, labels: { version: v1 } } + - { name: v2, labels: { version: v2 } } +--- +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: payments } +spec: + hosts: [payments.acme.svc.cluster.local] # internal DNS — east/west + http: + - route: + - { destination: { host: payments, subset: v1 }, weight: 95 } + - { destination: { host: payments, subset: v2 }, weight: 5 } +``` + +Same CRDs as north/south, but pointed at an internal host — outlier detection, locality LB, canary weights, all between services with no app code. + +p. 174 + +> Same Istio primitives as the weighted canary, but applied to an internal service (note the cluster.local host). DestinationRule sets up the connection pool (HTTP/2 multiplexing for gRPC), enables outlier detection (5 consecutive 5xxs in 30s ejects the pod), enables locality-aware load balancing (same-zone preferred), and defines version subsets. VirtualService weights east/west traffic 95/5 between v1 and v2 — a canary release of a backend service that no external client knows exists. This is what the 'mesh' is: the same routing CRDs the edge uses, applied everywhere, between every pair of services, configured centrally and enforced by the sidecars. + +## [199] APPENDIX I · L7 ROUTING · gRPC east/west specifics (sldId 454) + +- gRPC is HTTP/2 under the hood, so it works natively in any L7 router that speaks HTTP/2 (Envoy, Istio, modern HAProxy/Nginx). +- Route by gRPC service.method: the URI path is /package.Service/Method — use a uri prefix match in Istio, grpc_pass in Nginx, or path_beg in HAProxy. +- Header-based balancing matters more for gRPC: connections are long-lived and multiplexed, so least-request or per-request load balancing beats round-robin. +- Streaming RPCs (server-streaming, client-streaming, bidi): retries are dangerous if the stream is non-idempotent — cap retries to UNARY calls in the mesh policy. +- Trailers carry status: a 200 + a non-OK trailer is the gRPC equivalent of a 5xx — outlier detection has to be configured to read trailers, not just the status line. + +p. 175 + +> gRPC has a few sharp edges of its own. Route on the gRPC URI prefix to do per-method canaries. Use least-request (or random-3-choices) load balancing instead of round-robin because gRPC connections are long-lived — a few long-running streams can starve other backends if you round-robin connections instead of requests. Be careful with retries on streaming RPCs: the mesh can retry a UNARY call safely if it's idempotent, but retrying a half-completed streaming RPC will replay half the work — Istio lets you scope retries to specific gRPC status codes and request kinds. And the trailer point is subtle: gRPC returns its real status in HTTP trailers (a 200 with a non-OK gRPC status is a failure), so outlier detection has to know to read those, or it will think a failing service is healthy. + +## [200] APPENDIX I · L7 ROUTING · When to use which L7 layer (sldId 455) + +- Edge / ingress: TLS termination, host/path routing, WAF, public-facing rate limits. Anything north/south that affects all clients. +- API gateway: cross-cutting product policy — auth, per-consumer rate limits, request transformation, monetisation, key management. +- Service mesh: east/west routing, mTLS, retries, outlier detection, canaries between internal services. The default home for service-to-service L7. +- In-app router (Apache Camel + Drools): when routing depends on business rules, payload content, or decisions that domain experts must own. +- Rule of thumb: prefer the lowest layer that can fully express the decision, and never push business rules into mesh CRDs. + +p. 176 + +> This is the decision guide to leave on screen for a beat. The mental model: each layer has a job it's good at, and pushing work to the wrong layer is where complexity comes from. Edge/ingress for the things every client crosses. API gateway for product-shaped policy. Mesh for east/west and everything internal. In-app for business rules. The bold rule of thumb prevents the common antipattern of growing increasingly baroque Istio VirtualService configs to encode rules that domain experts need to maintain — if it's a business rule, it belongs in a rule engine inside a service, not in a network CRD. + +## [201] APPENDIX I · L7 ROUTING · L7 routing checklist (sldId 456) + +- Pick one ingress, one gateway, one mesh — don't run three competing L7 layers because each team had a favourite. +- TLS terminates at the edge; mTLS between services in the mesh; cert rotation is automatic (cert-manager + SDS), not a quarterly outage. +- Routing rules live in Git, reviewed like code; GitOps reconciles them; rollback is a git revert. +- Every L7 layer emits traces and metrics — sample the spans, alert on per-route SLOs, watch for retry budget exhaustion. +- Stickiness is an exception, not a default; business rules belong in a rule engine inside a service, not in network YAML. +- Test under load and under failure: bring a pod down, eject a zone, ramp a canary, verify the mesh does what the YAML says. + - References: HAProxy / Nginx / Envoy docs; Istio traffic management guide; Apache Camel Spring Boot starter guide; Drools / Kogito DMN docs; the Camel Content-Based Router EIP (choice/when/otherwise) in the official apache/camel-spring-boot-examples repo. + +p. 177 + +> Leave the checklist up at the end. The first point is the most common organisational failure — a cluster ends up running three ingress controllers because three teams each picked one. Pick one and standardise. The cert-rotation point is the one teams underestimate — expired certs are still a top-3 outage cause in many shops. GitOps for routing is the operational discipline that makes the whole thing reviewable. Observability and the load/failure-test point are non-negotiable. The references sub-bullet is the reading list: official docs for each piece and the Camel/KIE content-based routing example (the canonical demo for the Camel + Apache Drools pattern shown — wired here with camel-spring-boot-starter). + +## [202] Appendix K · Monolith to Microservices (sldId 457) + +K + +Patterns that survive the migration with the system still running. + +> Appendix K covers the migration patterns. The thesis: you don't decompose a monolith by stopping it and rewriting; you decompose it incrementally, one bounded asset at a time, with the system serving users the whole way through. We'll walk through the monolith as the starting point, modular monoliths as a frequently-correct destination, the strangler-fig family for progressive extraction, content-based routing for fine-grained cutover, and the decorating collaborator for wrapping legacy code you can't touch. Code in this appendix uses Spring Web + RestClient + Spring Kafka + Spring Data Redis, but the patterns are language-neutral. + +## [203] APPENDIX K · MONOLITH · A monolith is a unit of deployment (sldId 458) + +One process, one binary, one heartbeat. All modules live or die together. Often the right starting point — don't be ashamed of it. + +p. 178 + +> Define the term before fixing it. A monolith is not a code-organisation problem — it's a deployment-organisation property. Even a well-organised Spring Boot codebase with clear @Service layers, @RestController boundaries, and 1000 unit tests is a monolith if it ships as one fat JAR on one heartbeat. The defining property: change anywhere, redeploy everything; a bug in Reporting can take down Payments; one scaling unit for workloads that scale differently. But the second italic line is what to leave audience with: monoliths are not always wrong. For new products, small teams, or anything that fits in one team's head, they're often the simplest and fastest path to value. Premature decomposition is its own anti-pattern. + +## [204] APPENDIX K · MODULAR MONOLITH · The modular monolith — often the right destination (sldId 459) + +Still one deployable, but with strict in-process module boundaries enforced by build tooling — not by gentlemen's agreement. + +p. 179 + +> The intermediate pattern that's often the right destination, not a stepping stone. Modules within one Spring Boot process have clear public APIs and private internals that other modules cannot reach. Enforcement is the key bit: Spring Modulith (org.springframework.modulith:spring-modulith-starter-core, GA since the Spring Boot 3 line and supported on Spring Boot 4.1.1) gives you first-class module boundaries with @ApplicationModule, package-private internals, and ApplicationModuleListener for cross-module events. ArchUnit in CI is the testing-time enforcement. Modules can share a database, but each owns a schema and no cross-schema joins are allowed. Spring Modulith specifically anticipates this — each @ApplicationModule documents itself as a candidate service. + +## [205] APPENDIX K · DECOMPOSED DBS · Modular monolith with decomposed databases (sldId 460) + +Data ownership becomes per-module — the hardest part of the split, done while still one process. The next step is mechanical. + +p. 180 + +> Splitting the database is the hard part of decomposition. The insight of this pattern: do that work while you're still one Spring Boot process. Each module gets its own DataSource bean, its own JpaRepository, its own Liquibase or Flyway migration history. Spring Boot makes this clean — multiple @ConfigurationProperties-bound DataSource beans, with @Primary marking the default, and module-scoped @EnableJpaRepositories pointing at the right one. Cross-module data is now async or event-driven (typically via ApplicationModuleListener for in-process events, or KafkaTemplate for cross-service). Once this is done, promoting a module to a separate service is a refactor of the deployment topology, not a data-migration project. + +## [206] APPENDIX K · STRANGLER FIG · Identify → move → redirect (sldId 461) + +Named after the strangler fig vine: grow the new system around the old until the old can be removed. + +p. 181 + +> The Martin Fowler strangler-fig pattern. The three steps you repeat. Identify: pick a bounded asset — 'order creation' or 'order status query' specifically. The smaller the better. Move: build the new Spring Boot service alongside the monolith, replicate the behaviour, test it in parallel without production traffic (often by shadowing reads through the new service and comparing outputs). REDIRECT: switch callers to the new service. Each cycle is independently reversible — if the new service is wrong, redirect back. + +## [207] APPENDIX K · STRANGLER FIG · With a proxy — step zero of every migration (sldId 462) + +Introduce the routing layer first, before extracting anything. Then redirection becomes a config change, not a release. + +p. 182 + +> The pre-condition for incremental migration: clients hit the proxy, the proxy forwards to the monolith. Day zero, behaviour is unchanged. But you now have the lever — the proxy is where you'll later route 1% of /orders/* traffic to the extracted Spring Boot service, then 5%, then 100%. Without this step, every cutover is a coordinated client release. With it, every cutover is a proxy config change. For Spring Boot deployments this is typically nginx, Envoy, or Istio gateway as a Kubernetes Service ahead of your application Pods — or Spring Cloud Gateway as an in-app gateway if you prefer code-level routing control. + +## [208] APPENDIX K · STRANGLER FIG · With proxy + redirection (sldId 463) + +The proxy splits traffic between the new service and the shrinking monolith. Roll forward by widening the route; roll back by narrowing. + +p. 183 + +> The proxy routes by path, by header (X-Tenant: acme), by percentage (1% canary), or by feature flag. The new Spring Boot service handles its slice; the monolith handles everything else. Clients still see one URL. Roll forward by widening; roll back by narrowing. The new service can fail completely and the monolith picks up — bounded blast radius is the whole point. Instrument both paths in Micrometer so you can verify the new service produces the same outcomes as the monolith before widening. + +## [209] APPENDIX K · STRANGLER FIG · With a shared database — a deliberate intermediate state (sldId 464) + +Process boundary first, data boundary later. Useful intermediate state, not a destination. Schema coupling will bite eventually. + +p. 184 + +> Sometimes the data split is too risky to do at the same time as the process split. The new Spring Boot service and the monolith share a database for a while. Both apps read and write the same tables; the new service owns the API but not the data. Useful for extracting reads first, or as a stepping stone. It is not a destination: every Liquibase / Flyway change now needs both apps to agree. The next step is to give the new service its own store: CDC out of the monolith's DB via Debezium, or dual-writing during a sync window, or event-sourcing via Spring Modulith ApplicationModuleListener feeding the new service's DB. + +## [210] APPENDIX K · ROUTING · Content-based routing during migration (sldId 465) + +[chip] java · Spring Boot 4 + Spring Web + RestClient + +```java +// Spring Web + RestClient — a content-aware proxy that reads +// the request body and forwards to the new service or the monolith. +@RestController +@RequestMapping("/orders") +public class OrderRouter { + private final RestClient monolith; + private final RestClient newSvc; + private final RestClient euSvc; + // ... the constructor builds the monolith, orders and orders-eu RestClients + @PostMapping + public ResponseEntity route(@RequestBody Map body, + @RequestHeader Map headers) { + String tenant = (String) body.getOrDefault("tenant", ""); + String region = (String) body.getOrDefault("region", ""); + RestClient upstream = + "acme".equals(tenant) ? newSvc : + "EU".equals(region) ? euSvc : + monolith; + return upstream.post() + .headers(h -> h.setAll(headers)) + .body(body) + .retrieve() + .toEntity(Order.class); + } +} +``` + +Read body, decide by content (tenant / region / feature flag), forward via RestClient. + +p. 185 + +> The Spring shape. Proxy is a Spring REST controller that reads the body and decides which downstream to call via RestClient. Constructor injection brings in a RestClient.Builder; we build three typed clients, one per backend. Decision is by payload content: tenant, region, feature flag — not URL pattern. The pattern goes further than path-based proxying: you can roll out the new service to one tenant at a time, observe real-traffic behaviour, then widen. For Spring deployments running behind a real proxy (nginx, Envoy, Istio, Spring Cloud Gateway), express the same logic in Lua / WASM filters or RoutePredicateFactories at the gateway. The CPU cost of body parsing is the only real downside. + +## [211] APPENDIX K · DECORATOR · The decorating collaborator (sldId 466) + +The new service wraps the legacy one, adds capability, then becomes the API. Useful when you can't modify the legacy code. + +p. 186 + +> Different from the proxy patterns: the decorator owns behaviour, not just routing. The new Spring Boot service sits in front of the legacy API and adds cross-cutting capability: Redis caching, Kafka event emission, audit logging, validation, enrichment, rate-limiting, metrics via Micrometer. Legacy service unaware. Clients now talk to the decorator's API, which delegates for the underlying work but contributes everything else. Particularly useful when the legacy system is a 3rd-party product, a vendor system, or fragile code that touching is forbidden. Common implementation: Spring Boot + RestClient + Spring Data Redis + KafkaTemplate. + +## [212] APPENDIX K · DECORATOR · A decorating collaborator in Spring Boot (sldId 467) + +``` +java · Spring Boot 4 + RestClient + Spring Data Redis + Spring Kafka +``` + +``` +@RestController +@RequestMapping("/orders") +public class OrdersDecorator { + // ... fields and constructor: RestClient legacy, RedisTemplate cache, KafkaTemplate kafka, TTL 60 s + @GetMapping("/{id}") + public Order get(@PathVariable String id) { + String key = "order:" + id; + Order cached = cache.opsForValue().get(key); // 1. cache hit + if (cached != null) return cached; + Order order = legacy.get().uri("/orders/{id}", id) // 2. legacy fallback + .retrieve().body(Order.class); + cache.opsForValue().set(key, order, TTL); // 3. populate + return order; + } + @PostMapping + public Order place(@RequestBody Order body) { + Order order = legacy.post().uri("/orders") // 1. legacy first + .body(body).retrieve().body(Order.class); + kafka.send("order.placed", order.id(), order); // 2. emit event + cache.delete("order:" + order.id()); // 3. invalidate + return order; + } +} +``` + +The decorator adds caching, event emission — capabilities the legacy service can't add on its own. + +p. 187 + +> The Spring decorator is a clean @RestController. Three injected collaborators: RestClient pointed at the legacy service, RedisTemplate for the cache, KafkaTemplate for the event stream. The GET path uses cache-aside semantics. The POST path delegates to legacy (still source of truth), emits a Kafka event so downstream systems can subscribe to the new event stream, invalidates the cache. Legacy service has no idea it's been wrapped. Over time you can move more behaviour into the decorator: validation, idempotency keys, dedup. When you're ready to retire the legacy service, you implement the storage inside the decorator (or in a clean new service it calls) and stop forwarding — the API stays the same for clients. + +## [213] APPENDIX K · DECISIONS · Choosing the right migration pattern (sldId 468) + +- Modular monolith first, microservices later — with Spring Modulith giving first-class module boundaries, the cheapest decomposition is often inside one Spring Boot process. Many systems never need to leave this state. +- Decompose the database before decomposing the deployment. The data split is the hard part; doing it with multiple Spring DataSource beans is much cheaper than coordinating across services. +- Always introduce the proxy first — the routing layer is what makes incremental cutover possible. No proxy = no incremental migration. +- Strangler with shared database is a transition state. Plan its end before you start it. Schema coupling is debt that accrues. +- Content-based routing when you need to cut over by tenant / region / segment instead of by URL. Costs CPU at the proxy; buys precise risk control. +- Decorating collaborator when you need to add behaviour to a system you can't modify. Best suited to vendor / 3rd-party / legacy wrappers. +- Reversibility is the design property to optimise for. Every migration step should be undo-able by a config change, not a release. + +p. 188 + +> Close the appendix with the decision rules. First point is the most important and most often skipped — with Spring Modulith especially, a modular monolith is frequently a fine destination. Second point is the most underrated: the database split is where projects get stuck for years; do it early, while still one Spring Boot process. Third point: no proxy, no incremental migration. Fourth point is the schedule discipline. The bold final line is the architectural take-home: design for reversibility, so every step is a config change you can undo without coordinating a release. + +## [214] Appendix L · Caching patterns (sldId 469) + +L + +Six patterns for keeping a cache useful and your data correct. + +> Appendix L is the caching reference. Five reasons to cache and six patterns to do it well. The patterns differ in who reads/writes the cache, when, and who owns the consistency story. We use Redis as the canonical cache because it's the default open-source choice on Kubernetes; same patterns apply to in-memory caches or any KV store. Code uses Spring Data Redis (RedisTemplate) for explicit patterns and Spring Cache abstraction (@Cacheable) where idiomatic. + +## [215] APPENDIX L · WHY CACHE · Five things a cache earns you (sldId 470) + +- Application performance — a cache hit is microseconds; a database round trip is milliseconds. The hot path becomes ~100x faster on hits. +- Backend load — caching shields the database from repeated reads of unchanged data. Read-heavy workloads can offload 80-95% of queries with a sensible TTL. +- Throughput — the same Pod fleet serves dramatically more requests because it spends less time waiting on the DB. Vertical scaling without buying anything. +- Predictable performance — p99 latency stops being a function of DB load. Tail latencies flatten because the hot path doesn't contend on the same lock-bound resource as everyone else. +- Database cost — fewer queries means smaller (or fewer) DB instances, smaller IOPS budgets, less replication bandwidth. Cache is almost always cheaper than the DB tier it offloads. + - Trade-off: every cache adds a consistency story, an invalidation story, and a failure-mode story. Pick the pattern that matches the consistency you need. + +p. 189 + +> Five concrete reasons that justify the caching investment. Application performance is the user-facing win. Backend load matters more in steady state. Throughput follows from both. Predictable performance flattens p99. Database cost often pays for the caching infrastructure. The sub-bullet is what trips teams up: every cache adds complexity; pick the pattern that matches the consistency you need. + +## [216] APPENDIX L · CACHE-ASIDE · Cache-aside (look-aside) — the default (sldId 471) + +The application reads cache, falls back to DB, populates cache. Explicit and simple; the most common pattern. + +p. 190 + +> The pattern everyone starts with. The Spring Boot application is in charge: try the cache first via RedisTemplate, on a miss query JPA, populate the cache on the way back, return. Every other pattern differs in who does the work. Pros: explicit, debuggable, no magic. Cons: every read has the if/else; cache miss costs two round trips; cache logic is repeated across services if you don't extract it (which is what @Cacheable read-through does for you, next pattern). TTL is the consistency knob. + +## [217] APPENDIX L · CACHE-ASIDE · Cache-aside in Spring Boot with Redis (sldId 472) + +[chip] java · Spring Boot 4 + Spring Data JPA + Spring Data Redis + +```java +// Spring Web + Spring Data JPA + Spring Data Redis — explicit cache-aside. +@RestController +@RequestMapping("/products") +public class ProductController { + // ... TTL 60 s; ProductRepository repo and RedisTemplate cache injected + @GetMapping("/{id}") + public Product get(@PathVariable String id) { + String key = "product:" + id; + Product cached = cache.opsForValue().get(key); // 1. cache lookup + if (cached != null) return cached; // hit + Product p = repo.findById(id) // 2. DB on miss + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND)); + cache.opsForValue().set(key, p, TTL); // 3. populate + return p; + } + @PutMapping("/{id}") + @Transactional + public Product update(@PathVariable String id, @RequestBody Product body) { + Product p = repo.findById(id).orElseThrow(); + p.update(body); + cache.delete("product:" + id); // 4. invalidate on write + return repo.save(p); + } +} +``` + +Try cache; on miss, JPA query and populate. Invalidate on write. Three operations; the rest is naming conventions. + +p. 191 + +> Minimal cache-aside in Spring Boot. RedisTemplate is the typed Spring Data Redis client. The @Transactional on update() so the DB write is transactional; cache invalidation is outside the transaction. Key convention: 'product:{id}'. Invalidate-on-write step: if you don't invalidate, stale reads for the full TTL. Choose: invalidate (this code) or set the new value directly (write-through). For Redis failures, wrap the cache.opsForValue().get() in try/catch (or use a Resilience4j CircuitBreaker around RedisTemplate) so an outage falls back to JPA. + +## [218] APPENDIX L · READ-THROUGH · Read-through — the cache fetches on miss (sldId 473) + +Application sees one interface; the cache library handles DB fallback transparently. + +p. 192 + +> The variation where the cache abstraction, not the application, calls the database on a miss. The Spring app calls productService.findById(id) and gets the value back. For Spring, the cleanest expression is the Spring Cache abstraction with @Cacheable. Pros: cleaner application code, declarative TTL. Cons: less control over what triggers a DB hit. Use when read paths are repetitive and you want declarative caching. + +## [219] APPENDIX L · READ-THROUGH · Read-through in Spring Boot with @Cacheable (sldId 474) + +[chip] java · Spring Boot 4 + Spring Cache + Spring Data Redis + +```java +// Spring Cache with Redis backing (Spring Boot 4, Jackson 3). +@Configuration @EnableCaching +public class CacheConfig { + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory cf) { + return RedisCacheManager.builder(cf) + .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(60)) + .serializeValuesWith(SerializationPair.fromSerializer( + RedisSerializer.json()))) // Jackson 3: hits come back as Product + .build(); + } +} +@Service +public class ProductService { + private final ProductRepository repo; + public ProductService(ProductRepository repo) { this.repo = repo; } + @Cacheable(value = "products", key = "#id") // cache-then-load + public Product findById(String id) { + return repo.findById(id) // method body = loader + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND)); + } + @CacheEvict(value = "products", key = "#id") @Transactional + public Product update(String id, Product body) { /* load, apply, save */ } +} +``` + +@Cacheable turns the method body into a loader. Callers see one interface; the cache miss path is invisible. + +p. 193 + +> Idiomatic Spring read-through. @EnableCaching activates the Cache abstraction. RedisCacheManager configures Redis as backing store with a 60s default TTL and Jackson 3 JSON values (RedisSerializer.json()). @Cacheable transforms findById() into 'try cache, run method body on miss, store result'. @CacheEvict on update() removes the entry after a successful write. The @Cacheable approach requires the service layer (Spring AOP proxies don't catch self-calls), which is also good architecture. Trade vs explicit cache-aside: explicit if/else replaced by declarative annotations. SpEL: key = "#order.id + ':' + #user", condition = "#qty > 100": flexible but harder to debug. + +## [220] APPENDIX L · WRITE-THROUGH · Write-through — cache and DB written synchronously (sldId 475) + +Every write hits both the cache and the DB. Cache is never stale; writes pay both latencies. + +p. 194 + +> When stale reads are not acceptable, write-through forces every write to update both stores. The Spring app calls repo.save(...) and cache.opsForValue().set(...) and both must succeed. Pros: a read immediately after a write sees the new value. Cons: write latency is the sum of both stores. Spring's @CachePut annotation is the idiomatic shortcut — always invokes the method and writes the return value to the cache. + +## [221] APPENDIX L · WRITE-THROUGH · Write-through in Spring Boot (sldId 476) + +``` +java · Spring Cache + Spring Data Redis + @TransactionalEventListener +``` + +``` +// Spring Cache: @CachePut is the write-through annotation. +// Always runs the method body and writes the return value to the cache. +@Service +public class ProductService { + private final ProductRepository repo; + public ProductService(ProductRepository repo) { this.repo = repo; } + @CachePut(value = "products", key = "#id") // ← write-through + @Transactional + public Product update(String id, Product body) { + Product p = repo.findById(id).orElseThrow(); + p.update(body); + return repo.save(p); // saved + cached + } +} +// Explicit RedisTemplate alternative — useful when you need control: +@Service +public class ProductServiceExplicit { + // ... fields repo, cache, events; @Transactional update() saves, then publishes ProductUpdatedEvent + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void cacheAfterCommit(ProductUpdatedEvent ev) { + cache.opsForValue().set("product:" + ev.product().id(), + ev.product(), Duration.ofSeconds(60)); + } +} +``` + +@CachePut is the one-line write-through. The explicit version with @TransactionalEventListener ties cache writes to DB commit. + +p. 195 + +> Two write-through flavours. First uses @CachePut — the most concise Spring idiom. The second shows the more careful @TransactionalEventListener approach: publish an event inside the transaction, @TransactionalEventListener with phase=AFTER_COMMIT writes the cache only after the DB transaction commits. Critical when the DB transaction might roll back. Either way, DB write goes first because DB is the source of truth. Short TTL self-heals any cache/DB divergence. + +## [222] APPENDIX L · WRITE-AROUND · Write-around — writes skip the cache (sldId 477) + +Writes go to the DB only. The next read populates the cache lazily. Good for write-heavy, low-re-read workloads. + +p. 196 + +> When data is written far more often than it's read (audit logs, event records, telemetry), caching every write is wasteful. Write-around: write the DB, leave the cache alone. The next reader pays the cache-miss cost and populates the cache. Pros: no cache pollution by write-only data. Cons: the first read after every write is slow. Pair with cache-aside or @Cacheable on the read side. Common use: any 'append-only' or 'mostly-written' workload. + +## [223] APPENDIX L · WRITE-AROUND · Write-around in Spring Boot (sldId 478) + +[chip] java · Spring Boot 4 + Spring Cache + Spring Data Redis + +```java +// Append-heavy data: events, audit logs, telemetry. Writes hit the DB only. +@RestController +@RequestMapping("/events") +public class EventController { + private final EventRepository repo; + public EventController(EventRepository repo) { this.repo = repo; } + @PostMapping + @Transactional + public Event record(@RequestBody Event body) { + return repo.save(body); // DB only, cache untouched + } + @GetMapping("/{id}") + @Cacheable(value = "events", key = "#id") // read side populates lazily + public Event get(@PathVariable String id) { + return repo.findById(id) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND)); + } +} +``` + +Writes skip the cache; first read populates via @Cacheable. Avoids cache thrash from one-time writes. + +p. 197 + +> One write path and a @Cacheable read. The write just calls repo.save(): the cache is not touched. The read side is @Cacheable, which populates lazily on the first read of each id. +> +> The pattern pays off when most written records are rarely or never read again: security event logs, telemetry, audit entries. If a stale cached copy could exist for a rewritten id, delete that key after the save. Compare with a product catalog, where every read is a probable cache hit; there @CachePut write-through makes more sense. + +## [224] APPENDIX L · WRITE-BACK · Write-back (write-behind) — fast writes, eventual durability (sldId 479) + +Writes return immediately after touching the cache; a background flusher persists to the DB asynchronously. + +p. 198 + +> The fastest-write pattern, with the most caveats. The Spring app writes to the cache, returns an immediate ack — the cache is the source of truth for that window. A @Scheduled background task drains the cache to the DB. Pros: write latency equals cache latency — microseconds. Cons: a cache crash before the flusher catches up loses unflushed writes; Redis must be configured for persistence (AOF or RDB). Use only when extreme write throughput is the constraint, eventual durability is acceptable and you've thought about what 'lost the last 5 seconds of writes' means. + +## [225] APPENDIX L · WRITE-BACK · Write-back with @Scheduled in Spring Boot (sldId 480) + +``` +java · Spring Boot 4 + @Scheduled + Spring Data Redis + JdbcClient +``` + +``` +@PutMapping("/write-back/metrics/{mid}") // CacheController (excerpt) +public ResponseEntity> writeBackWrite( + @PathVariable String mid, @RequestBody Map body) { + // ... HSET metric: {value, tags}; SADD metric:dirty (503 when Redis is down) + return ResponseEntity.accepted().body(Map.of("ok", true, "pattern", "write-back", + "action", "cached-for-flush")); // 202: queued, not yet durable +} // examples/25-caching/spring-boot +@Scheduled(fixedDelayString = "${cache-service.flush-period-ms:1000}") +public void flusher() { // BackgroundTasks + if (System.currentTimeMillis() < nextFlushAt) return; // backing off after a failure + Set ids = cache.spop("metric:dirty", FLUSH_BATCH); + if (ids == null || ids.isEmpty()) return; + List failed = new ArrayList<>(); + for (String mid : ids) { + try { // upsert: an id flushed twice is one row + jdbc.sql("INSERT INTO metrics (id, payload) VALUES (?, ?::jsonb) " + + "ON CONFLICT (id) DO UPDATE SET payload = ?::jsonb, ts = NOW()") + .param(mid).param(json).param(json).update(); // json: the cached hash + cache.hdel("metric:attempts", mid); + } catch (Exception e) { failed.add(mid); } // logged: flush failed for + } + if (failed.isEmpty()) { failures = 0; return; } + nextFlushAt = System.currentTimeMillis() + Math.min(flushPeriodMs << ++failures, 8000); + requeue(failed); // HINCRBY metric:attempts; SADD metric:dirty, or metric:failed at 5 +``` + +A write answers 202 Accepted. A failed flush re-queues its ids with backoff; after five attempts an id is parked on metric:failed. + +p. 199 + +> Spring's @Scheduled drives write-back in Example 25. (1) PUT /write-back/metrics/{mid} writes the metric to a Redis hash, adds the id to a 'dirty set', which tells the flusher what still has to reach the database, and answers 202 Accepted: the value is queued, not yet durable. (2) @Scheduled(fixedDelayString = ...) runs every second, SPOPs up to FLUSH_BATCH dirty ids and upserts each one through JdbcClient with INSERT ... ON CONFLICT (id) DO UPDATE, so an id flushed twice stays one row. SPOP is atomic, so two replicas each get a distinct subset of ids and no lock is needed to scale out. (3) An id whose upsert fails is re-queued: its count in the metric:attempts hash goes up and it goes back on the dirty set, or, after five attempts, onto metric:failed with its value still in the cache and the failure in the log. After a failed run the flusher skips runs for two seconds, then four, then eight. Caveats: this is only safe with Redis persistence (AOF, appendonly yes); without it a Redis crash loses unflushed writes. For data that must not be lost, use write-through. For high-throughput telemetry where losing the last second is acceptable, write-back is what lets the service scale. + +## [226] APPENDIX L · REFRESH-AHEAD · Refresh-ahead — keep hot keys warm (sldId 481) + +A background task refreshes hot keys before their TTL expires. Users never see a cold miss on popular items. + +p. 200 + +> The pattern for eliminating the cache-miss latency cliff on popular keys. Plain cache-aside has sharp behaviour: while a key is cached, reads are fast; the moment its TTL expires, the next reader pays the slow DB-fetch path. Refresh-ahead pre-empts this: a @Scheduled task watches the cache (or maintains a Redis sorted set of 'hot' keys) and refreshes their values from the DB before the TTL fires. Users always read warm cache. Pros: no user-visible misses on hot keys. Cons: extra DB load for keys that may not be requested again. + +## [227] APPENDIX L · REFRESH-AHEAD · Refresh-ahead in Spring Boot (sldId 482) + +``` +java · Spring Boot 4 + @Scheduled + ShedLock + Spring Data Redis +``` + +``` +@Service +public class ProductService { + // ... TTL 60 s, REFRESH_BEFORE_S 10; repo, cache and hot (RedisTemplate) injected + public Product findById(String id) { + hot.opsForZSet().add("product:hot", id, System.currentTimeMillis()); + // ... then cache-aside on product:{id} with TTL + } + @Scheduled(fixedDelay = 5000) // every 5 seconds + @SchedulerLock(name = "refresh-hot") // ShedLock: only one node + public void refreshHot() { + long cutoff = System.currentTimeMillis() - 5 * 60_000L; + hot.opsForZSet().removeRangeByScore("product:hot", 0, cutoff); + Set hotIds = hot.opsForZSet().range("product:hot", 0, -1); + if (hotIds == null) return; + for (String id : hotIds) { + String key = "product:" + id; + Long ttl = cache.getExpire(key, TimeUnit.SECONDS); + if (ttl != null && ttl > 0 && ttl < REFRESH_BEFORE_S) { + repo.findById(id).ifPresent(p -> + cache.opsForValue().set(key, p, TTL)); // refresh + } + } + } +} +``` + +Hot keys live in a Redis sorted set scored by last-access time. Refresh those with low TTL. + +p. 201 + +> The Spring implementation. Redis sorted set (ZADD) tracks 'hot' keys scored by last-access timestamp. findById touches the hot-set on every read. @Scheduled runs every 5s: removes keys not accessed in 5 minutes (removeRangeByScore), then for each remaining hot key checks its TTL via getExpire and refreshes it when the TTL drops under REFRESH_BEFORE_S. ShedLock ensures only one instance refreshes — otherwise N instances do the same DB reads. Pattern shines for read-heavy workloads where a small set of keys serves most traffic — 80/20 product pages, top tweets, trending articles. + +## [228] APPENDIX L · ANTI-PATTERNS · When not to cache, and what to watch for (sldId 483) + +- Don't cache by default. Caching adds a consistency story, an invalidation story, and a failure-mode story. If reads are already fast, just don't add a cache — the cheapest cache is no cache. +- Don't cache rarely-read data. Caching write-once-read-never data thrashes your cache and gains nothing. Profile your read patterns first — Micrometer + Spring Actuator's cache metrics give you hit/miss ratios per cache. +- Don't cache without a TTL. Unbounded caches grow until they evict at random, which gives you the worst type of failure: unpredictable. Always set explicit entryTtl on RedisCacheConfiguration or maxmemory-policy on Redis (allkeys-lru typical). +- Avoid cache stampedes (thundering herd): when a hot key expires and many concurrent Spring Pods all miss at once, they all hit the DB. Mitigate with a Redisson distributed lock per key, or refresh-ahead, or jittered TTLs. +- Watch for cache aside + write-around without invalidation — the write goes to the DB, but a stale cached value can persist for the full TTL. Use @CacheEvict on writes, or short TTL. +- Treat the cache as a performance optimisation, never as the source of truth. A Redis outage must mean slower reads, not data loss — always have a JPA fallback path. Test this by stopping Redis in staging. + +p. 202 + +> Six things that go wrong with caching. First point is the most important. Second is the write-around lesson — Spring Boot Actuator's CacheMetrics gives you hit/miss ratios. Third is operational. Fourth is the cache stampede problem; mitigations include Redisson RLock per key, refresh-ahead, TTL jitter. Fifth is the most common stale-data bug. The bold final line: the cache is never the source of truth. If Redis dies, you must serve slower reads, not return errors. Test this by killing Redis in staging. + +## [229] APPENDIX L · DECISIONS · Choosing the right caching pattern (sldId 484) + +- Cache-aside — the default. Use when you want explicit control and reads dominate writes. +- Read-through (@Cacheable) — use when the read path is repetitive across the codebase and you want declarative caching. +- Write-through (@CachePut) — use when zero stale-read risk after a write matters more than write latency. +- Write-around — use when writes far outnumber re-reads. Avoids cache pollution by write-once data. +- Write-back — use only when extreme write throughput is the constraint and eventual durability is acceptable. +- Refresh-ahead — use when a small set of hot keys serves most traffic and miss latency is unacceptable. +- Most production Spring Boot systems use @Cacheable for 95% of cases, with refresh-ahead on identified hot keys and @CachePut where stale-read is forbidden. Mix patterns; don't pick one as the rule. + +p. 203 + +> Decision rules. @Cacheable (read-through) is the default. @CachePut (write-through) for read-consistency-after-write. Write-around for telemetry. Write-back is the niche extreme-throughput pattern. Refresh-ahead is the hot-key pattern. Bold final line: don't pick one pattern as 'the' cache strategy. Mix patterns by workload. + +## [230] Appendix M · Failure modes (sldId 485) + +M + +Where it all breaks — and the patterns that hold. + +> Appendix M is the synthesis appendix — it brings together error handling, graceful shutdown, observability and the resilience patterns from across the deck, and points them at the specific ways distributed systems fail. If you can only take one slide from this appendix, take the matrix at the end: it maps each failure mode to the patterns that address it. This appendix references section 11 (Observability), section 12 (Security — bulkheads), and Appendix H (Graceful shutdown); it is the place where those threads are tied together. If this runs long in your session, it stands alone and can be presented on its own. + +## [231] APPENDIX M · Distributed systems fail differently (sldId 486) + +- In a single process, an operation succeeds or it throws. Across a network, a call can succeed, fail, hang forever, succeed-but-the-response-is-lost, or succeed-slowly — and you often cannot tell which. +- Partial failure is the defining trait: some dependencies are up, some down, some degraded, all at the same time. There is no global 'the system is up'. +- Failures are not independent: one slow component can exhaust a caller's resources and take down healthy services around it. Failure spreads. +- Some failures lie: a node passes its own health check while failing real traffic (gray failure). What the system believes about itself and what users experience diverge. +- The goal is not zero failures; that is impossible. The goal is to bound the blast radius and recover automatically. All management is risk management. + - This appendix: six failure modes, the defensive patterns that address them (with code), Istio's mesh-level role, graceful shutdown, how to observe failures, and a mapping matrix. + +p. 204 + +> Set the frame. The single most important conceptual shift for developers moving from monoliths is partial failure: in a monolith a function call either returns or throws, synchronously, in-process. Across a network there is a whole spectrum of failure, including the worst kind — the call that hangs, or the one that succeeded on the server but whose response was lost, leaving the client unsure whether to retry. Emphasise that failures are correlated (one failure causes others) and that some failures are actively deceptive (gray failures). Land the bold point hard: we are not trying to prevent failure, we are trying to bound it and recover. This reframes everything that follows from 'how do we stop failures' to 'how do we contain and survive them.' + +## [232] APPENDIX M · A taxonomy of failure modes (sldId 487) + +Network failures, component failures, load failures — and the defensive toolkit that addresses them. + +p. 205 + +> The map for the appendix. Three families. NETWORK failures: partition (nodes alive but unreachable) and split-brain (both sides act as leader). COMPONENT failures: partial (some deps fail) and gray (some degrade and are perceived differently by different observers). LOAD failures: amplification (a failure feeds on itself) and cascading (failure spreads to neighbours). Underneath sits the defensive toolkit — timeouts and deadlines, retry with backoff and jitter, circuit breakers, bulkheads (section 12), load shedding, fallback/degradation, quorum/consensus, Istio mesh policies, graceful shutdown (Appendix H), and observability (section 11). Every pattern addresses specific failure modes; the closing matrix maps which to which. Reconnect to the section-1 maxim: all management is risk management — you manage the distribution of failures, not their existence. + +## [233] APPENDIX M · NETWORK · Network partition (sldId 488) + +Nodes are alive and healthy; they cannot reach each other. CAP forces a choice: consistency or availability. + +p. 206 + +> See the diagram notes. The key teaching point: a partition is not a crash. Every node is healthy and running; the network between them is the thing that failed. This is what makes partitions so confusing to debug — every individual component looks fine. CAP theorem becomes concrete here: while partitioned, you choose to stay consistent (the minority side refuses writes, sacrificing availability) or available (both sides accept writes, sacrificing consistency, reconciling later). Most data systems let you tune this. Kubernetes itself uses etcd, which chooses consistency (the minority side of a control-plane partition stops accepting writes). Address with quorum/consensus, idempotent writes (so a retried write after heal is safe), and partition-tolerant design. + +## [234] APPENDIX M · NETWORK · Split-brain (sldId 489) + +A partition where both sides think they are the leader and both accept writes. State diverges; the worst partition outcome. + +p. 207 + +> See the diagram notes. Split-brain is the specific catastrophic outcome of a partition in a system that elects a leader or primary. Both sides lose contact, each assumes the other died, each promotes itself, and now you have two primaries accepting conflicting writes. On heal you have divergent state and no clean way to merge. The prevention is quorum: an action requires a majority (N/2 + 1) of nodes to agree, so a minority partition mathematically cannot act — it knows it is the minority and steps down. This is why cluster sizes are odd (3, 5, 7): to always have a clear majority. Fencing tokens (monotonically increasing IDs that let resources reject a stale leader's writes) and leases are complementary. etcd/Raft give the Kubernetes control plane this property out of the box. + +## [235] APPENDIX M · COMPONENT · Partial failure (sldId 490) + +The defining trait of distributed systems: some dependencies fail while others keep working. Handle each leg. + +p. 208 + +> See the diagram notes. This is the slide for developers moving from monoliths. In a monolith, the order service and the inventory service are function calls in the same process — if one is 'down' the whole process is down. Distributed, the order service calls inventory (healthy), payment (down), and shipping (slow) and must make a per-dependency decision for each: use the healthy result, fall back or fail for the down one, time out and fall back for the slow one. The mistake that causes most production incidents is treating a remote call like a local one, with no timeout, no fallback and no circuit breaker, so a single down dependency hangs the caller. Address with per-call timeouts, per-dependency circuit breakers, fallbacks, and bulkheads. The next several slides are exactly these patterns, with code. + +## [236] APPENDIX M · COMPONENT · Gray failure (sldId 491) + +The insidious one: a component is degraded and is perceived differently by different observers. Liveness is green; users suffer. + +p. 209 + +> See the diagram notes. Gray failure is the failure mode that defeats naive health checking. The classic shape: a node is slow or dropping a fraction of requests, but its liveness probe (GET /healthz returning 200 from a trivial handler) still passes, so Kubernetes keeps it in rotation and the load balancer keeps sending it traffic. The system believes it is healthy; users experience errors and latency. This is 'differential observability' — the observer that matters (the client) sees something different from the observer that decides (the probe). The fixes are all about observing from the right vantage point: deep health checks that exercise real dependencies, RED metrics measured on live traffic, SLO burn-rate alerts, and, most important, Istio outlier detection, which ejects a host from the pool when it returns consecutive errors, regardless of what its health check claims. This ties directly to section 11. + +## [237] APPENDIX M · LOAD · Amplification loops & retry storms (sldId 492) + +A failure triggers retries, which add load, which cause more failures. Backoff, jitter and retry budgets break the loop. + +p. 210 + +> See the diagram notes. Amplification is a positive feedback loop: the system's own defensive behaviour (retrying) becomes the attack. Two compounding effects. First, retry multiplication across layers: if the gateway retries 3x, and it calls a service that retries 3x, which calls a service that retries 3x, one user request becomes 27 requests to the bottom of the stack. Second, synchronisation: every client that failed at the same moment retries at the same moment, creating a thundering herd. The result can be metastable — the system stays collapsed even after the original trigger is gone, because the retry load alone sustains the failure. Breaking it: exponential backoff with jitter (de-synchronise), retry budgets (cap retries to a small percentage of live traffic so they can never multiply unboundedly), retry at exactly one layer, and circuit breakers to stop the doomed calls entirely. This is the failure mode most often caused by well-intentioned resilience code. + +## [238] APPENDIX M · LOAD · Cascading failure (sldId 493) + +One component's failure overloads its neighbours, which fail in turn — the domino effect up the call graph. + +p. 211 + +> See the diagram notes. Cascading failure is how a single slow dependency takes down an entire system. The mechanism: a slow database causes the calling service's request threads (or async tasks, or connection pool) to fill up waiting; with its resources exhausted, that service now fails or slows for all callers, not just the ones touching the database; its callers' resources then fill up waiting on it; and so on up the graph until the user-facing edge is down. The amplifiers are synchronous call chains and unbounded resources (unbounded queues, unbounded thread pools, no timeouts). Each defensive pattern breaks one link: timeouts stop threads blocking forever; bulkheads isolate the pool so the database calls can't consume all of it; circuit breakers stop calling the slow dependency entirely; load shedding rejects excess work at the edge before it propagates. This is the failure mode bulkheads (section 12) were invented for. + +## [239] APPENDIX M · The defensive toolkit — patterns and what they buy (sldId 494) + +- Timeout + deadline propagation — never wait forever; pass the remaining budget downstream so nobody does doomed work. Defends: partial, cascading. +- Retry with backoff + jitter — ride out transient failures without building a storm; only for idempotent calls; use a retry budget. Defends: partial, amplification (if done right). +- Circuit breaker — stop calling a failing dependency; fail fast; test for recovery. Defends: cascading, amplification, gray. +- Bulkhead (§12) — isolate resources by tenant/call/priority so one failure can't starve the rest. Defends: partial, cascading. +- Load shedding — when overloaded, reject excess early (503) rather than collapsing. Defends: amplification, cascading. +- Quorum / consensus — majority-required action; the only real answer to split-brain. Defends: partition, split-brain. +- Fallback / graceful degradation — return a useful degraded response (cache, default, queue-for-later) instead of an error. Defends: partial, gray. +- Istio mesh policies + graceful shutdown + observability: the platform layer underneath all of these patterns. Each is covered with code; the final matrix maps patterns to modes. + +p. 212 + +> The toolkit overview before the deep dives. Each line names a pattern and the failure modes it defends against — this is the verbal version of the closing matrix. The thing to stress: no pattern is sufficient alone, and several patterns interact (retry without a circuit breaker amplifies; a circuit breaker without a fallback just fails faster; bulkheads without timeouts still let threads block within a pool). Production resilience is the composition of several of these, tuned together. What follows gives each its own diagram and per-language code so developers can implement them; the bold line flags that the platform-level pieces (Istio, shutdown, observability) sit underneath the application-level patterns. + +## [240] APPENDIX M · TIMEOUTS · Timeouts + deadline propagation (sldId 495) + +Every remote call needs a timeout; the remaining budget should travel downstream so nobody does doomed work. + +p. 213 + +> See the diagram notes. Two ideas. First, every remote call must have a timeout — the default in many HTTP clients is infinite, which is how a single slow dependency hangs a whole service. Second, deadline propagation: the original deadline (say 1000ms) travels with the request, and each hop subtracts the time it has used and passes the remainder downstream. When the remaining budget is too small to plausibly finish, the downstream fails immediately rather than starting work that will be thrown away. gRPC does this natively with deadlines; for HTTP you pass a deadline header and honour it. The waste this prevents is real: without it, your database spends 500ms on a query for a request the user abandoned 450ms ago, and that wasted work is exactly what tips an overloaded system over. The code that follows shows the language-specific mechanics. + +## [241] APPENDIX M · TIMEOUTS · Timeouts + deadlines (sldId 496) + +[chip] java · Spring Boot 4 + Resilience4j + +```java +@Service +public class PaymentService { + private final RestClient client; // built with timeouts (below) + @TimeLimiter(name = "payment") // Resilience4j: caps total time + public CompletableFuture charge(Order o, long deadlineMs) { + long remaining = deadlineMs - elapsedMs(); + if (remaining < 50) + return CompletableFuture.failedFuture(new DeadlineExceeded()); + return CompletableFuture.supplyAsync(() -> + client.post().uri("/charge") + .header("X-Deadline-Ms", Long.toString(remaining)) // propagate + .body(o).retrieve().body(Receipt.class)); + } +} +// RestClient factory: ClientHttpRequestFactorySettings +// .withConnectTimeout(500ms).withReadTimeout(2s) +// application.yml: resilience4j.timelimiter.instances.payment.timeout-duration: 2s +``` + +p. 214 + +> Two layers: Resilience4j @TimeLimiter caps the total wall-clock time of the async call, and the RestClient's own connect/read timeouts (set on its ClientHttpRequestFactorySettings) bound the underlying socket. Both matter: the TimeLimiter governs the logical operation, the factory timeouts govern the transport. The handler honours an inbound deadline and propagates the remaining budget in a header for the next hop to respect. + +## [242] APPENDIX M · RETRY · Retry with exponential backoff + jitter (sldId 497) + +Retry transient failures — but spread retries with exponential backoff + jitter, cap them, and only for idempotent calls. + +p. 215 + +> See the diagram notes. Retry is the most abused resilience pattern because the naive version (retry immediately, a fixed number of times, at every layer) is precisely how you build the amplification storm described earlier. The correct version has four properties: exponential backoff (each retry waits longer: 100ms, 200ms, 400ms), jitter (add randomness so clients don't synchronise), a cap on both attempts and total time, and a retry budget (retries may never exceed, say, 10% of live traffic). And it only applies to idempotent operations — retrying a non-idempotent POST without an idempotency key can double-charge a customer. Always pair retry with a circuit breaker so that once a dependency is clearly down, you stop retrying entirely. The code that follows shows the idiomatic library for each language. + +## [243] APPENDIX M · RETRY · Retry with backoff + jitter (sldId 498) + +[chip] java · Spring Boot 4 + Resilience4j + +```java +@Service +public class QuoteService { + private final QuoteClient client; + @Retry(name = "quote") // config in application.yml + @CircuitBreaker(name = "quote") // always pair retry with a breaker + public Quote quote(String symbol) { // idempotent GET only + return client.getQuote(symbol); + } +} +// application.yml +// resilience4j.retry.instances.quote: +// max-attempts: 3 +// wait-duration: 100ms +// enable-exponential-backoff: true +// exponential-backoff-multiplier: 2 +// enable-randomized-wait: true # jitter — do not omit +// retry-exceptions: [java.io.IOException] # transient only +``` + +p. 216 + +> Resilience4j separates the annotations (on the method) from the tuning (in application.yml). The yml encodes exponential backoff, the multiplier, randomized wait (jitter — the comment stresses not omitting it), the attempt cap, and the exception allow-list so only transient failures are retried. @CircuitBreaker on the same method gives the canonical retry+breaker pairing. Resilience4j evaluates the breaker outermost, so a tripped breaker short-circuits the retries. + +## [244] APPENDIX M · CIRCUIT BREAKER · Circuit breaker — the state machine (sldId 499) + +Closed → Open (fail fast) → Half-Open (test) → Closed. Stops doomed calls; gives the dependency room to recover. + +p. 217 + +> See the diagram notes. The circuit breaker is the pattern that most directly stops cascading and amplification. Three states. CLOSED: calls pass through, failures are counted over a rolling window. When the failure ratio crosses the threshold, the breaker trips to OPEN: calls fail immediately without even attempting the downstream, which both gives a fast answer to the caller and gives the struggling dependency room to recover instead of being hammered. After a reset timeout the breaker goes HALF-OPEN: it allows a few trial calls; if they succeed it closes (recovered), if they fail it re-opens. Crucial details: one breaker per dependency (never a single global breaker), and pair it with a fallback so that an open breaker degrades gracefully rather than just erroring faster. Libraries differ per language; we show each next. + +## [245] APPENDIX M · CIRCUIT BREAKER · Circuit breaker + fallback (sldId 500) + +[chip] java · Spring Boot 4 + Resilience4j + +```java +@Service +public class PaymentService { + private final PaymentClient client; + private final Outbox outbox; + @CircuitBreaker(name = "payment", fallbackMethod = "queueForLater") + public Receipt charge(Order o) { + return client.charge(o); // while OPEN: skipped, fallback runs + } + Receipt queueForLater(Order o, Throwable t) { // graceful degradation + outbox.enqueue(o); + return Receipt.pending(o.id()); + } +} +// application.yml +// resilience4j.circuitbreaker.instances.payment: +// sliding-window-size: 10 +// failure-rate-threshold: 50 # open at 50% +// wait-duration-in-open-state: 5s +// permitted-number-of-calls-in-half-open-state: 2 +``` + +p. 218 + +> Resilience4j @CircuitBreaker with a fallbackMethod (note the extra Throwable parameter on the fallback signature). Tuning lives in application.yml: sliding window size, failure-rate threshold to open, how long to stay open, and half-open trial count. The fallback enqueues to an outbox and returns pending — graceful degradation. Per-dependency instance ('payment'); define separate instances for separate dependencies, never share one. + +## [246] APPENDIX M · BULKHEAD · Bulkhead — isolate so one failure can't sink the rest (sldId 501) + +- Covered in depth in section 12 (Security) as a blast-radius pattern; it is equally a resilience pattern, so it appears here in the failure-mode context. +- Isolate resources (connection pools, thread pools, async semaphores, queues) by tenant, by call type, or by priority. A storm in one pool exhausts only that pool. +- Against cascading failure: if database calls have their own bounded pool, a slow database exhausts that pool and returns errors for DB-backed calls — but the service's other endpoints keep serving from their own pools. +- Against partial failure: a single misbehaving tenant or dependency is contained to its own compartment instead of consuming the shared resource. +- Implementation (from §12): Resilience4j Bulkhead (semaphore or thread-pool flavour, via BulkheadRegistry), paired with @TimeLimiter. At the mesh: Istio DestinationRule connectionPool gives the same isolation at the network layer. + - Pair with timeouts: a bulkhead bounds how many calls run concurrently, but without a timeout those calls can still block forever and keep the bulkhead full. The two patterns need each other. + +p. 219 + +> Bulkhead got full treatment in section 12, so here we just re-frame it in the failure-mode context and cross-reference. The key insight to land: a bulkhead bounds concurrency, but concurrency-bounding alone doesn't help if the bounded calls block forever — you must combine bulkhead (limit how many) with timeout (limit how long). Point back to section 12 for the per-language code (we don't repeat it here to save slides), and forward to the Istio slide for mesh-level bulkheading via connectionPool limits. + +## [247] APPENDIX M · SHEDDING & FALLBACK · Load shedding and graceful degradation (sldId 502) + +- Load shedding — when you are over capacity, reject excess work immediately (HTTP 503 + Retry-After) at the edge, before it consumes resources. A system that sheds 20% of load stays up for the other 80%; a system that accepts everything collapses for 100%. +- Shed by priority: drop health-check-style or low-value traffic first, protect revenue-critical paths. Adaptive concurrency limits (e.g. Netflix's, or Envoy's) shed automatically based on observed latency. +- Graceful degradation — when a dependency is unavailable, return a useful reduced response instead of an error: serve stale cache, omit the personalised section, queue the write for later, show a default recommendation. +- Degradation turns a hard failure into a soft one the user may barely notice. A product page that can't reach the reviews service should still show the product — just without reviews. +- Both are the application-level complement to the circuit breaker: the breaker decides to stop calling; the fallback decides what to return instead; load shedding decides what not to accept in the first place. + - Reference §11: you cannot shed or degrade intelligently without observability — you shed based on measured saturation, and you alert when degradation rates climb. + +p. 220 + +> Two related patterns that often get skipped. Load shedding is counter-intuitive to engineers, because rejecting requests feels like giving up, but it is the difference between a system that degrades and one that collapses. The maths is simple: if you are at 120% of capacity and accept everything, everything is slow and timing out (0% goodput); if you shed the excess 20%, the 80% you accept is served well. Adaptive limiters do this automatically based on latency. Graceful degradation is the product-level decision about what to return when you can't return everything — it requires the product/design team's input on what's essential vs nice-to-have. The bold line ties shedding, breakers and fallback together as a system: stop accepting (shed), stop calling (break), return something useful (fall back). + +## [248] APPENDIX M · ISTIO · Istio — resilience at the mesh layer (sldId 503) + +Timeouts, retries, outlier detection (gray-failure ejection) and circuit breaking — uniform, config-driven, no app code. + +p. 221 + +> See the diagram notes. The service mesh gives you a large slice of the resilience toolkit without writing code in every service, in every language. VirtualService configures per-route timeouts and retries (with Envoy adding backoff and jitter automatically, and retryOn conditions controlling which failures are retried). DestinationRule configures connectionPool limits (mesh-level bulkheading) and outlierDetection — which is the mesh's answer to gray failure: it watches each host, and when one returns consecutive errors it ejects that host from the load-balancing pool for a cooldown period, regardless of what the host's own health check claims. That is exactly the gray-failure defence the health-check can't provide. The important discipline: don't double up. If Istio is retrying, don't also retry in the app (that's amplification). Decide where each concern lives — typically: timeouts and outlier detection at the mesh, business-aware retries and fallbacks in the app. + +## [249] APPENDIX M · SHUTDOWN · Graceful shutdown (see App. H) (sldId 504) + +[chip] java · Spring Boot 4 + +```java +// excerpt, examples/21-graceful-shutdown/spring-boot: one SIGTERM drives the drain +# application.properties: server.shutdown=graceful, timeout-per-shutdown-phase=20s +Signal.handle(new Signal("TERM"), signal -> { // registered before SpringApplication.run + ShutdownState state = ShutdownState.getInstance(); + if (!state.markShuttingDown()) return; // 1. readiness flips: /readyz answers 503 + new Thread(() -> { + try { Thread.sleep(drainDelaySeconds() * 1000); } // 2. the LB stops routing (5 s) + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + System.exit(0); // 3. graceful shutdown drains in-flight HTTP (20 s) + }, "drain").start(); // 4. the context closes the pool, exit 0 +}); +@GetMapping("/readyz") // OrderController +public ResponseEntity> readyz() { + if (shutdownState.isShuttingDown()) + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("ready", false, "reason", "shutting down")); + // ... SELECT 1: 200 {"ready": true}, or 503 "db unreachable"; /healthz stays 200 +} +``` + +p. 222 + +> Example 21 takes SIGTERM itself. A sun.misc.Signal handler registered before Spring starts flips ShutdownState on the single SIGTERM: (1) the hand-written /readyz answers 503, so the Kubernetes readiness probe sees the pod go not-ready and stops routing, while /healthz stays 200; (2) a drain thread keeps the service serving for DRAIN_DELAY_SECONDS so the load balancer's removal propagates; (3) System.exit(0) runs Spring's graceful shutdown, which stops accepting and drains in-flight HTTP within timeout-per-shutdown-phase (20 s); (4) the context closes the pool and the process exits 0. Appendix H covers the Kubernetes preStop + terminationGracePeriodSeconds side. + +## [250] APPENDIX M · OBSERVE · Observing failure modes (see §11) (sldId 505) + +- Network partition — watch peer-connectivity and cross-zone error metrics; alert on quorum-loss events from etcd / your datastore. +- Split-brain — alert on more-than-one-leader conditions, divergent-replica metrics, and fencing-token rejections in logs. +- Partial failure — RED metrics per dependency (rate, errors, duration), not just per service. A spike in errors to one downstream is the early signal. +- Gray failure — the hard one: compare health-check status against real-traffic SLOs. Latency percentiles (p99) diverging from the average, and Istio outlier-ejection counts, are your tells. +- Amplification — watch request-rate against user-action-rate (if requests >> actions, retries are multiplying), retry-count metrics, and goodput falling while load rises. +- Cascading — a distributed trace shows the error spreading service-to-service; saturation metrics (thread pools, queue depth, connection pools) climb together across services. + - The unifying tool is the trace_id from §11: when an alert fires, pivot from the metric to an exemplar trace to its logs to find which leg failed first and how it spread. + +p. 223 + +> Tie the appendix back to section 11. Each failure mode has characteristic signals, and you must instrument for them explicitly: a default dashboard of CPU and memory will not show you a gray failure or an amplification loop. The most important specific guidance: measure RED per dependency (so partial failures are visible), compare health-check status to real-traffic SLOs (so gray failures are visible), and watch request-rate vs user-action-rate (so amplification is visible). The sub-bullet is the workflow: the trace_id is what lets you pivot from 'something is wrong' (a metric) to 'here is the request that shows it' (a trace) to 'here is what failed' (the logs on that trace), the correlation story from section 11 applied to incident response. + +## [251] APPENDIX M · Which pattern addresses which failure mode (sldId 506) + +Read a row to defend a failure mode; read a column to see what a pattern buys you. Production layers several. + +p. 224 + +> The synthesis slide — if the audience keeps one thing, keep this. Reading across a row tells you the portfolio of patterns that defend a given failure mode (e.g. cascading failure is addressed by timeouts, circuit breakers, bulkheads, load shedding, and graceful shutdown together). Reading down a column tells you what a given pattern buys you across failure modes. Two observations: first, quorum/consensus is the only real answer to split-brain; no amount of retrying or circuit-breaking helps when the problem is two leaders, so you need a majority rule. Second, almost every other cell is about assembling a portfolio — there is no single pattern that handles partial, gray, amplification and cascading failures; you layer timeouts, retries-with-budget, breakers, bulkheads, shedding and fallbacks, and you observe the result. Defence in depth, exactly as in the security section, applied to reliability. + +## [252] APPENDIX M · Take-aways and references (sldId 507) + +- Design for partial failure from the start: every remote call can succeed, fail, hang, or lie. Timeout everything; the default is often infinite. +- Retry only idempotent calls, with backoff + jitter + a budget, at one layer, always paired with a circuit breaker. Naive retries cause the outages they were meant to prevent. +- Bound the blast radius: bulkheads isolate, circuit breakers stop the bleeding, load shedding protects capacity, fallbacks keep the user served. +- Quorum is the only cure for split-brain. Let etcd / Raft / your datastore handle consensus; don't invent your own. +- Push uniform concerns (timeouts, outlier detection, mesh bulkheads) to Istio; keep business-aware concerns (fallbacks, idempotency) in the app. Don't double-retry. +- Observe from the client's vantage point (RED per dependency, SLOs, outlier ejections) — gray failures are invisible to naive health checks. Drain on shutdown (App. H). + - References: Google SRE Book (chapters on Addressing Cascading Failures, Handling Overload); 'Release It!' (Nygard — circuit breaker, bulkhead origin); the gRPC retry design, gRFC A6 (retry policy, backoff with jitter, retry throttling); Istio traffic-management docs; the metastable-failures research (Bronson et al., 2021). + +p. 225 + +> Close the appendix and, since it's the last appendix, effectively close the deck's technical content. The take-aways are the imperatives: design for partial failure, timeout everything, retry carefully, bound the blast radius, use quorum for consensus, split concerns between mesh and app, and observe from the client's perspective. The references are the canonical sources: the Google SRE book's cascading-failures and overload chapters are the single best free resource; Michael Nygard's 'Release It!' is where the circuit breaker and bulkhead patterns were named for software; the gRPC retry design (gRFC A6) writes retry policy, backoff with jitter and retry throttling down as a protocol; and the metastable failures paper (Bronson et al.) is the modern formalisation of amplification loops. Encourage the audience to read the SRE cascading-failures chapter as the next step. + +## [253] Feature Flags (sldId 508) + +N + +Progressive delivery, decoupled from deploy — OpenFeature + flagd + +> This appendix adds the runtime switch that separates shipping code from releasing a feature. It is a deep-dive you keep open when you are about to roll something out gradually. The whole section is built on two CNCF, open-source projects - OpenFeature for the vendor-neutral SDK and flagd for the backend - so it runs on plain Kubernetes with no managed SaaS, like every other example in the book. The arc: why deploy is not release, the four kinds of flag, how evaluation works, the code to evaluate and target and roll out, flags as a control plane over patterns we already covered, and the lifecycle discipline that keeps flags from becoming debt. + +## [254] APPENDIX N · FEATURE FLAGS · Deploy is not release (sldId 509) + +p. 226 + +the artifact ships dark; releasing is a runtime flag flip — and so is rollback + +> The reframe that makes everything else work: deploying an artifact and releasing a feature are different events, and a flag is what splits them. The artifact ships to production with the new path turned off - a non-event, visible only in the deploy log. Releasing is then a runtime flag flip: on for one percent, then a wider cohort, then everyone, each step reversible by narrowing the flag rather than rolling back a deployment. This is what lets a team merge to main many times a day yet release on its own cadence, and it is the mechanism behind every canary and every safe cutover in the book. Rollback stops being a redeploy and becomes a config change measured in seconds. + +## [255] APPENDIX N · FEATURE FLAGS · Four kinds of flag (sldId 510) + +p. 227 + +by lifetime and owner — each wants different defaults and lifecycle + +> Feature flag covers four different tools, and conflating them is the root of most flag messes. They differ along two axes - how long the flag lives, and who decides to flip it. Release flags are temporary and developer-owned: they ship new code dark, ramp it, and are then removed. Experiment or A/B flags are temporary and data-owned: they split traffic across variants so a metric can be measured, and they require sticky assignment. Ops or kill-switch flags are long-lived and SRE-owned: a permanent control to disable a feature or dependency fast under load or incident. Permission or entitlement flags are long-lived and product-owned: they gate functionality by plan, tier, or region. The defaults differ too - a release flag fails off, so half-shipped code stays dark, while a kill-switch usually fails on, so the feature survives a flag-system outage. + +## [256] APPENDIX N · FEATURE FLAGS · Evaluating a flag: OpenFeature + flagd (sldId 511) + +p. 228 + +local evaluation, vendor-neutral SDK, fail-safe to the default + +> Evaluation should not couple your code to a vendor. OpenFeature is the CNCF vendor-neutral API standard - one SDK surface across languages - and flagd is its lightweight open-source backend that evaluates flags from a file, ConfigMap, or synced source, in-cluster. Together they keep the whole feature-flag story open-source and running on plain Kubernetes, with no managed SaaS, and swapping flagd for another provider later is a configuration change, not a code change. Two properties matter most. First, evaluation returns the default if the provider is unreachable - a flag-system outage must never take the application down. Second, the SDK is vendor-neutral: the app calls getBooleanValue with a flag key, a default, and an evaluation context, and never names the backend. The context carries the targeting inputs - a stable key plus attributes like plan and region - that the rules act on. + +## [257] APPENDIX N · FEATURE FLAGS · Evaluating a flag (sldId 512) + +p. 229 + +[chip] java · Spring Boot + +```java +// configure the OpenFeature provider once, at startup +@Configuration +class FeatureFlags { + @Bean Client flags() { + var api = OpenFeatureAPI.getInstance(); + api.setProviderAndWait(new FlagdProvider( + FlagdOptions.builder().host("flagd").port(8013).build())); + return api.getClient(); + } +} + +@PostMapping("/checkout") +Receipt checkout(@RequestBody Cart cart, @RequestHeader("X-User") String user) { + var ctx = new MutableContext(user) // targeting key + .add("plan", cart.plan()).add("region", cart.region()); + boolean useNew = flags.getBooleanValue("new-checkout", false, ctx); + return useNew ? newCheckout(cart) : legacyCheckout(cart); +} +``` + +The same one line of code serves an on/off, a per-plan entitlement, or a 25% rollout. + +> The provider is registered once at startup; from then on the application asks the client for a value. The crucial thing to notice is that the application code is the same one line regardless of whether the flag is a simple on/off, a per-plan entitlement, or a 5 percent rollout. All of that lives in the flag's configuration, not the code. The default is what gets returned if flagd is unreachable, so the new path stays dark on a flag-system outage rather than erroring. The targeting key is the user id, which is what makes a percentage rollout sticky. Across every language the shape is identical: register a flagd provider, get a client, build an evaluation context with a targeting key and attributes, and call getBooleanValue with a safe default. + +## [258] APPENDIX N · FEATURE FLAGS · Targeting and rollout live in config (sldId 513) + +p. 230 + +[chip] json · flagd + +```json +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "new-checkout": { + "state": "ENABLED", + "variants": { "on": true, "off": false }, + "defaultVariant": "off", + "targeting": { + "if": [ + { "==": [ {"var": "plan"}, "enterprise" ] }, "on", + { "fractional": [ {"var": "targetingKey"}, + ["on", 25], ["off", 75] ] } + ] + } + } + } +} +``` + +Enterprise plans always on (entitlement); everyone else split 25/75 (sticky rollout) — no code change. + +> Because the rules live in flagd, changing who sees a feature never touches the application. A flag definition is a set of variants, a default, and a targeting expression. Two operators do most of the work: a conditional for segmentation - here, the enterprise plan always gets the new checkout, an entitlement - and the fractional operator for percentage rollouts, here splitting everyone else 25/75. The fractional operator buckets on the targeting key, which is what gives sticky assignment: the same user hashes to the same bucket every time. Ramping the rollout is editing the 25 upward; rolling back is editing it down - both take effect in the running fleet within flagd's sync interval, with no redeploy. A kill switch is the same file: set a flag's state to DISABLED and it stops evaluating entirely. + +## [259] APPENDIX N · FEATURE FLAGS · Percentage rollout via sticky bucketing (sldId 514) + +p. 231 + +consistent per-user assignment; instant ramp and rollback + +> Sticky bucketing matters for two reasons. For a rollout, it prevents flicker - a user who got the new checkout on their first visit keeps getting it, rather than bouncing between variants on refresh. For an experiment, it is a correctness requirement: a user must stay in one arm of the test for the measurement to mean anything. The mechanism is simple - hash a stable key, the user id, into a bucket from 0 to 99, and compare it to the rollout threshold. Because the hash is stable, the same user always lands in the same bucket. Ramping the rollout is moving the threshold up; rolling back is moving it down, and both take effect in the running fleet within the sync interval with no deploy on the path of either. + +## [260] APPENDIX N · FEATURE FLAGS · A kill switch (sldId 515) + +p. 232 + +[chip] java · Spring Boot + +```java +// ops kill-switch - default true (fail open): on until an operator kills it +Recommendations recommend(String user) { + if (!flags.getBooleanValue("recommendations-enabled", true, ctx(user))) + return Recommendations.empty(); // degrade - still serve the page + return recsClient.fetch(user); // pairs with a circuit breaker +} +``` + +Default true = fail open: only an operator flipping the flag disables it. The default flips with the flag type. + +> A kill switch is the operational flag you reach for during an incident: wrap a risky or expensive dependency in a flag so it can be disabled instantly without a deploy. It is the human-driven complement to the circuit breaker from the Failure Modes appendix - the breaker trips automatically on errors; an operator flips the kill switch by hand. The default flips with the flag type: a release flag defaults off so half-shipped code stays dark, while this kill switch defaults true, fail-open, so the feature survives a flag-system outage and only an explicit flip removes it. When it is killed, the page still renders - just without the recommendations - which is graceful degradation, the same idea as the load-shedding discussion in the Failure Modes appendix. + +## [261] APPENDIX N · FEATURE FLAGS · Flags over the patterns you already have (sldId 516) + +p. 233 + +a flag is a runtime control surface + +> Feature flags are not a new subsystem so much as a runtime control plane over patterns already in this book. A canary is a percentage flag deciding which build a request exercises, often paired with the L7 routing from the routing appendix. A strangler-fig cutover is a flag, or the proxy's percentage, steering a widening slice from the monolith to the extracted service. A saga step can be guarded by a flag to switch a new compensation path on cautiously. And an experiment is a sticky-bucketed variant flag with a metric attached. Recognising flags as the control surface for all of these keeps you from building a bespoke mechanism for each - the same decoupling, expressed four ways. + +## [262] APPENDIX N · FEATURE FLAGS · The flag lifecycle (sldId 517) + +p. 234 + +a release flag is born with a removal ticket + +> The failure mode of feature flags is not technical, it is organisational: flags that are never removed. Every flag is a branch in your code and a dimension in your test matrix, and a stale one is dead code, a hidden conditional, and a live risk surface that nobody remembers the meaning of. The discipline that keeps it healthy: give every release flag an owner and an expiry from the moment it is created, and treat removing it as part of finishing the feature, not optional cleanup. Long-lived ops and entitlement flags are exempt - they are permanent by design - which is exactly why distinguishing the four types matters. A short-lived flag that has been at 100 percent for two weeks should be deleted along with the branch it guarded. + +## [263] APPENDIX N · FEATURE FLAGS · Testing, observing, and anti-patterns (sldId 518) + +p. 235 + + - Test both sides of every flag you depend on - a suite that only hits the default path misses the bug behind the flag. + - Put the resolved variant on your telemetry - a span attribute and a metric label - so a latency or error change attributes to a specific flag and cohort. +- Anti-patterns: flags as a config store; release flags that never die; stacks of flags whose combinations no one reasons about; experiments without sticky assignment; business logic buried in flag rules. + +> A flag multiplies the states your system can be in, so test both sides of every flag you depend on; a test suite that only exercises the default path will not catch a bug behind the flag. Keep the number of simultaneously interacting flags small, and default flags off in test environments unless a test sets them explicitly. Observability ties flags back to the Observability section: record the resolved variant as a span attribute and a metric label, so a latency or error change can be attributed to a specific flag and cohort. The recurring anti-patterns: using flags as a general configuration store - they are for release control, not for tuning timeouts; letting release flags become permanent; stacking many flags whose combinations no one reasons about; running experiments without sticky assignment, which makes the data meaningless; and putting real business logic inside flag rules instead of in code. + +## [264] APPENDIX N · FEATURE FLAGS · Take-aways (sldId 519) + +p. 236 + +- Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. +- Distinguish the four flag types - they want different owners, defaults, and lifecycles. +- Evaluate through a vendor-neutral SDK (OpenFeature) against an OSS backend (flagd), always with a safe default. +- Keep targeting and rollout in configuration, not code, and make percentage rollouts sticky. +- Treat release flags as debt with a removal ticket; put the resolved variant on your telemetry. + +References: OpenFeature spec & flagd docs (CNCF) · Hodgson, “Feature Toggles” · Accelerate / DORA · Unleash (OSS alternative). + +> Close on the imperatives. Decouple deploy from release: ship dark, release gradually, roll back by narrowing a flag. Distinguish the four flag types, because they want different owners, defaults, and lifecycles. Evaluate through a vendor-neutral SDK against an open-source backend, always with a safe default so a flag outage cannot take the app down. Keep targeting and rollout rules in configuration, not code, and make percentage rollouts sticky. Treat release flags as debt with a removal ticket attached, and put the resolved variant on your telemetry so you can attribute a regression to a cohort. The canonical references are the OpenFeature specification and the flagd documentation, Pete Hodgson's Feature Toggles article for the taxonomy of flag types, and the Accelerate and DORA research for the deploy-versus-release distinction; Unleash is a capable open-source alternative backend behind the same OpenFeature SDK. + +## [265] API Testing with Newman (sldId 520) + +O + +Postman collections as an automated gate on Docker Engine + +> This appendix turns the Postman collections the team already pokes at by hand into an automated test gate, using Newman, Postman's command-line runner. It is a deep-dive you keep open when you are wiring API tests into CI. The key idea that makes it cheap: Newman tests the API as a black box over HTTP, so one collection is the conformance suite for every implementation in the book, and the only language-specific step is bringing the service under test up. The arc: why Newman, the anatomy of a collection, one collection against six backends, running it and shaping the output, data-driven cases, the CI gate on Docker Engine, and where Newman sits in the test pyramid. + +## [266] APPENDIX O · API TESTING · Why Newman (sldId 521) + +p. 237 + +the headless runner turns a Postman collection into CI-readable results + +> A Postman collection is an executable contract, not just a saved list of requests. Newman runs it without the GUI and emits machine-readable results: a cli reporter for a human watching, a junit reporter that CI turns into a test report, and an html reporter to share. There is no new test framework to learn - the tests are the JavaScript assertions already supported inside Postman, and Newman is the bridge from works-when-I-click-it to verified-on-every-pull-request. The same file serves three audiences: a developer in Postman, a reviewer reading the tests, and a pipeline failing the build when an assertion breaks. + +## [267] APPENDIX O · API TESTING · Anatomy of a collection (sldId 522) + +p. 238 + +folders of requests, each with assertions; the environment supplies the variables + +> A collection nests folders and requests; each request can carry a pre-request script, the HTTP call itself, and a block of tests that run against the response. The tests are small JavaScript blocks using Postman's pm API - asserting the status, the response time, the shape of the body. Environment variables supply the parts that change between runs, like the base URL and an auth token, resolved at run time. Storing a value from one response - a new order id - in a variable lets a later request chain off it, which is how a collection models a whole flow rather than isolated calls. + +## [268] APPENDIX O · API TESTING · Tests live in the request (sldId 523) + +p. 239 + +[chip] json · Postman + +{% raw %} +```json +{ + "name": "Create order", + "request": { + "method": "POST", + "header": [{ "key": "Idempotency-Key", "value": "{{$guid}}" }], + "url": "{{base_url}}/orders", + "body": { "mode": "raw", "raw": "{\"sku\":\"A-1\",\"qty\":2}" } + }, + "event": [{ + "listen": "test", + "script": { "exec": [ + "pm.test('201 Created', () => pm.response.to.have.status(201));", + "pm.test('under 300ms', () => pm.expect(pm.response.responseTime).to.be.below(300));", + "const body = pm.response.json();", + "pm.test('returns an id', () => pm.expect(body).to.have.property('id'));", + "pm.collectionVariables.set('order_id', body.id);" + ] } + }] +} +``` +{% endraw %} + +Assertions run after the response; the saved order_id chains into later requests. + +> Here is a request that creates an order and asserts on the result. The test block asserts a 201 Created, parses the body, asserts it carries an id, and stores that id in a collection variable so a later request - fetch, cancel, or pay for the order - can use it. Beyond status codes, a test can validate the response shape against a JSON Schema, which is how you turn the metadata and error-format work from earlier appendices into an enforced contract: assert that an error body matches the Problem Details schema rather than checking exact strings, so the test stays stable as data changes but still catches a removed field or a wrong type. + +## [269] APPENDIX O · API TESTING · One collection, six backends (sldId 524) + +p. 240 + +Newman tests over HTTP, so one collection validates every implementation + +> Because Newman exercises the API as a black box over HTTP, the collection does not know or care which language implements the service. The same collection is the conformance suite for every implementation in this book - point it at whichever service is running and the assertions are identical. The collection is the API specification made executable. Bringing the service under test up is the only language-specific step; the Newman command that follows is the same everywhere. + +## [270] APPENDIX O · API TESTING · Launch the service under test (sldId 525) + +p. 241 + +[chip] bash · Spring Boot + +```bash +# Spring Boot — start the service under test, then run the same collection +./mvnw spring-boot:run & # http://localhost:8080 +newman run orders.postman_collection.json -e local.postman_environment.json +``` + +Only this step is language-specific; the Newman command is identical for all six. + +> The only per-language step is starting the service. Each implementation exposes the same HTTP API on the same port, so the base URL in the environment file is identical and the Newman command that follows does not change. That is the point of black-box testing: bring up Spring Boot, Quarkus, .NET, Python, C++ or Go, and run the same collection against it. In CI the services start with docker compose up -d --build --wait instead of a foreground command; locally, a background process and a newman run is the fastest loop. + +## [271] APPENDIX O · API TESTING · Running Newman & shaping output (sldId 526) + +p. 242 + +[chip] bash + +{% raw %} +```bash +# install once (Node.js): +# npm install -g --prefix ~/.local newman@6.2.3 newman-reporter-htmlextra@1.23.1 +newman run orders.postman_collection.json \ + --environment local.postman_environment.json \ + --reporters cli,junit,htmlextra \ + --reporter-junit-export results/newman.xml \ + --timeout-request 5000 \ + --bail +# --timeout-request: per-request ceiling (ms); --bail: stop at the first failure + +# data-driven: one iteration per CSV row; the columns become {{variables}} +newman run orders.postman_collection.json -e local.postman_environment.json \ + --iteration-data cases.csv +``` +{% endraw %} + +Reporters target the audience; --bail gates fast; --iteration-data drives edge cases. + +> The runner takes the collection and an environment file, and you choose reporters for the audience: cli for a human, junit for the XML CI turns into a report, htmlextra for a shareable page. --bail stops at the first failed assertion, which is what you usually want in a gate; drop it to see every failure in one run. A single set of requests can be run repeatedly over a table of inputs with --iteration-data, which is how you cover boundary values, bad input, and edge cases without duplicating requests - and the expected status can travel in the data too, so one request becomes a table of positive and negative cases. + +## [272] APPENDIX O · API TESTING · Data-driven tests (sldId 527) + +p. 243 + +one request set, many inputs — boundary values and negative cases from a table + +> Data-driven testing runs the same requests once per row of a CSV. The columns become variables in the requests, and a column can carry the expected status so a single assertion checks status against the row's expectation. This is how a handful of requests becomes broad coverage: the happy path, a zero-quantity order that must be rejected with a 422, an over-stock order that must also be rejected - all without copy-pasting requests. Keeping the cases in data rather than in duplicated requests is what makes the suite maintainable as the API grows. + +## [273] APPENDIX O · API TESTING · Newman in CI (sldId 528) + +p. 244 + +[chip] yaml · GitHub Actions + +```yaml +# .github/workflows/api-tests.yml (any runner with Docker Engine and Node.js) +jobs: + api-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: docker compose up -d --build --wait # order, payment, inventory, ... + - run: ./scripts/wait-for-ready.sh # poll /readyz before testing + - run: npm i -g newman@6.2.3 newman-reporter-htmlextra@1.23.1 + - run: | + newman run orders.postman_collection.json \ + -e ci.postman_environment.json \ + --reporters cli,junit \ + --reporter-junit-export results/newman.xml + - uses: actions/upload-artifact@v7 # keep the report even on failure + if: always() + with: { name: newman-results, path: results/ } +``` + +Bring the stack up with docker compose, wait for readiness, run the collection: a failed assertion fails the build. + +> Newman exits non-zero when any assertion fails, which is all a pipeline needs to gate a merge. The services come up on Docker Engine as they do locally, with no managed cloud, and Newman runs against them. The readiness poll matters: start testing only once the services report ready, using the readiness endpoint from the Observability and Graceful Shutdown material, or the first requests race the boot and fail spuriously. Upload the junit report as an artifact even on failure so the failing assertion is visible in the run. + +## [274] APPENDIX O · API TESTING · The gate on Docker Engine (sldId 529) + +p. 245 + +the same stack you run locally, gating every pull request + +> The full gate: a pull request builds the image, docker compose brings the order, payment, and inventory services up, Newman runs the collection against them, and the result gates the merge: pass merges, fail blocks. The important property is parity: the stack in CI is the stack you run locally, on the same Docker Engine, so a green run in CI means the contract holds against the running services rather than a mock. + +## [275] APPENDIX O · API TESTING · Where Newman fits (sldId 530) + +p. 246 + +the API/contract tier: on top of unit tests, under a thin end-to-end layer + +> Newman does not replace unit tests; it is the layer on top of them. It owns the API/contract tier: real HTTP against a running service, fast enough to run on every pull request. Keep the base of fast unit tests broad, use Newman for the contract and integration behaviour that only shows up over real HTTP, and reserve a thin layer of full end-to-end tests for the few user flows that need the whole system. Pushing everything into Newman makes a slow, brittle suite; ignoring it leaves the contract untested. Test the failure paths as carefully as the happy path: a 401 with WWW-Authenticate, a 403, a 409 or 422 on a business rule, a 429 with RateLimit headers, and idempotency by sending the same key twice. Those cases tie this appendix back to the error-format, security, idempotency, and rate-limiting material. + +## [276] APPENDIX O · API TESTING · Take-aways (sldId 531) + +p. 247 + +- Treat the Postman collection as the executable specification - Newman runs it headlessly to guard every change. +- One collection is the conformance suite for all six implementations: Newman tests over HTTP, as a black box. +- Validate response shape against a schema, not just status codes; drive edge cases from data, not duplicated requests. +- Assert the failure paths - 401, 403, 409/422, 429, idempotency - as carefully as the happy path. +- Gate CI on plain Docker Engine so the build fails on a broken contract; keep Newman in the API/contract tier. + +References: Postman docs & Newman CLI docs (reporters, --iteration-data, --bail) · Postman Learning Center (pm API, JSON-schema validation). + +> Close on the imperatives. Treat the Postman collection as the executable specification of your API, and let Newman run it headlessly so the same assertions guard every change. Because Newman tests over HTTP, one collection is the conformance suite for all six implementations. Validate response shape against a schema, not just status codes; drive edge cases from data rather than duplicated requests; and assert the failure paths as carefully as the happy path. Wire it into CI on plain Docker Engine so the build fails on a broken contract, and keep Newman in its lane: the API/contract tier, on top of unit tests and under a thin end-to-end layer. The references are the Postman documentation and the Newman command-line documentation, and the Postman Learning Center guides on writing tests with the pm API and on JSON-schema validation. + +## [277] Example 05 on minikube (sldId 532) + +R + +The same services and the same verify.sh on a single-node Kubernetes cluster + +> Appendix R takes Example 05, the event-driven fan-out, from docker compose to a single-node Kubernetes cluster on minikube, in all six languages, and records what changed. Compose proves the code; a cluster adds questions compose never asks: where configuration and credentials live, how a service finds a broker that an operator manages, what restarts a process that exits, and how a request from the host reaches a pod. +> +> The arc: the runtime, the topology, the compose-to-Kubernetes mapping, one overlay per language, how images get into the node, how to run it, and the recorded results. + +## [278] APPENDIX R · MINIKUBE · From compose to a cluster (sldId 533) + +p. 248 + +- Compose proves the code; a cluster adds configuration, credentials, discovery, restarts and ingress. +- Scope: Example 05 (event-driven fan-out), three services, all six languages, one node. +- One Kafka broker, one Postgres instance, no service mesh: a local topology, not production. +- The contract carries over: Service names, the Secret, environment variables, the health endpoint. +- The same verify.sh runs against compose and the cluster: ten checks per language. + +Site: Appendix R · Code: examples/05-event-driven/k8s/ + +> What the appendix covers and what it leaves out. The scope is one example on one node: one Kafka broker, one Postgres instance, no service mesh. High availability, network policy and autoscaling are outside it. +> +> What it fixes is the contract between an application and its platform: the Service names, the Secret that holds the database credential, the environment variables each language reads, and the health endpoint. That contract is what carries over to any conformant Kubernetes cluster, and it is why the application code is unchanged. +> +> The test is the one compose uses: verify.sh, ten checks, with its container operations switched to kubectl exec. + +## [279] APPENDIX R · MINIKUBE · The minikube runtime (sldId 534) + +p. 249 + +- Profile cndp-k8s: Docker driver on Docker Engine, containerd, Kubernetes v1.36.5. +- 8 GB memory, 4 CPUs, 40 GB disk for Kafka, Postgres, LGTM and three services. +- NodePorts 30080 (order-service) and 30300 (Grafana), published on 127.0.0.1 at creation. +- No kubectl port-forward, minikube tunnel or minikube service: forwarded connections drop. +- Every command passes --context cndp-k8s; setup-profile.sh restores the prior context. + +minikube v1.39.0 · kubectl v1.36.5 · Helm v4.3.0 · Strimzi 1.2.0 + +> minikube runs the control plane and the workload in one container on Docker Engine, the same engine compose uses, with containerd as the container runtime inside the node. Kubernetes v1.36.5 matches the pinned kubectl, so client and server are the same version. +> +> Host access uses two NodePorts published on the loopback address when the profile is created. Port-forwarding, minikube tunnel and minikube service are not used: forwarded connections drop under load and on idle, and a drop looks like a broken service. setup-profile.sh checks with docker port that both ports bind to 127.0.0.1. +> +> The kubectl context never changes: every command names --context cndp-k8s, minikube start runs with --keep-context, and setup-profile.sh restores the prior context because minikube's restart path can override that flag. + +## [280] APPENDIX R · MINIKUBE · One node, two namespaces (sldId 535) + +p. 250 + +The node container holds two namespaces; the host reaches two loopback NodePorts and uses kubectl exec + +> The topology. The node container cndp-k8s holds two namespaces: strimzi runs the Strimzi operator, and cndp-05 runs order-service, the shipping and notification consumers, a single-node KRaft Kafka cluster, Postgres 18, the LGTM stack and the Postgres Secret. +> +> From the host, curl and verify.sh reach order-service on 127.0.0.1:30080, a browser reaches Grafana on 127.0.0.1:30300, and kubectl exec runs the Kafka and Postgres checks inside their pods. Other minikube profiles stay stopped while this one runs. + +## [281] APPENDIX R · MINIKUBE · Compose to Kubernetes (sldId 536) + +p. 251 + +- kafka → Strimzi Kafka + KafkaNodePool, bootstrap cndp-kafka-bootstrap:9092 (Kafka 4.3.1). +- postgres → StatefulSet cndp-postgres, schema ConfigMap, Service cndp-postgres-rw. +- Credential → Secret cndp-postgres-app, read through secretKeyRef. +- build: → docker build, then minikube image load; imagePullPolicy: Never. +- healthcheck → startup and readiness probes on the order-service /healthz. +- ports: 8080:8080 → NodePort 30080. + +The schema file and the OTLP endpoint http://lgtm:4318 are the same ones compose uses. + +> Each compose element has a Kubernetes counterpart, and each counterpart changes something. Kafka becomes a Kafka resource that the Strimzi operator turns into a broker; the plain listener on port 9092 is in-cluster only. Postgres becomes a StatefulSet that mounts the same schema file compose mounts. +> +> The demo credential moves out of every service's environment into one Secret. Only order-service has an HTTP surface, so it alone has startup and readiness probes, with a 150-second startup window for the JVM and .NET images. The consumers have no HTTP surface: the cluster restarts them if they exit, and they start from the earliest offset, so a restart loses nothing. + +## [282] APPENDIX R · MINIKUBE · One overlay per language (sldId 537) + +p. 252 + +[chip] yaml · kustomize + +```yaml +# k8s/overlays/spring-boot/kustomization.yaml (excerpt) +images: + - name: order-service + newName: localhost/cndp-05/spring-boot-order-service + newTag: v1 + # shipping-consumer and notification-consumer map the same way +# appended to each Deployment's environment by a JSON patch +- name: SPRING_DATASOURCE_URL + value: "jdbc:postgresql://cndp-postgres-rw:5432/appdb" +- name: SPRING_DATASOURCE_USERNAME + value: "$(PG_USER)" +- name: SPRING_DATASOURCE_PASSWORD + value: "$(PG_PASSWORD)" +- name: SPRING_KAFKA_BOOTSTRAP_SERVERS + value: "cndp-kafka-bootstrap:9092" +``` + +base/ is language-neutral; each overlay maps the images and appends the variables its language reads. + +> The Spring Boot overlay. The base Deployments are language-neutral: they run with a restricted security context (non-root, no privilege escalation, all capabilities dropped, RuntimeDefault seccomp, no fixed user ID), read PG_USER and PG_PASSWORD from the Secret, and point OTEL_EXPORTER_OTLP_ENDPOINT at the LGTM Service. +> +> Each overlay maps the three image names to localhost/cndp-05/-:v1 and appends the variables that language already reads in compose, so the application code is untouched. Kubernetes expands $(PG_PASSWORD) only if PG_PASSWORD is defined earlier in the list, which is why the credential variables sit in the base. + +## [283] APPENDIX R · MINIKUBE · Images built on the host (sldId 538) + +p. 253 + +docker build on the host, minikube image load into the node; pods never pull + +> The cluster does not build images. Each service is built on the host with docker build, which keeps the host's BuildKit caches (a cold C++ build takes about half an hour), then copied into the node's containerd store with minikube image load. +> +> The Deployments set imagePullPolicy: Never, so a missing image fails fast with ErrImageNeverPull instead of trying a registry that does not have it. Tags are always v1; nothing uses latest. + +## [284] APPENDIX R · MINIKUBE · Run it (sldId 539) + +p. 254 + +[chip] bash · minikube + +```bash +# from examples/05-event-driven/, with no other profile or container running +k8s/setup-profile.sh # create cndp-k8s; check the published ports +k8s/build-images.sh spring-boot # docker build, then minikube image load +k8s/deploy.sh spring-boot # Strimzi, Kafka, Postgres, LGTM, then the overlay +RUNTIME=kubernetes BASE=http://127.0.0.1:30080 ./verify.sh + +# all six languages, a fresh namespace for each +OUT=/tmp/cndp-k8s-matrix k8s/verify-matrix.sh + +# clean up: the namespace, the Strimzi release and its CRDs; then stop the profile +k8s/teardown.sh --operator +minikube stop -p cndp-k8s +``` + +With RUNTIME=kubernetes, verify.sh runs its container checks through kubectl exec; the ten checks are unchanged. + +> The run, from the example directory. setup-profile.sh creates the profile and refuses to start while another profile or a container is running. build-images.sh builds and loads the three Spring Boot images, and deploy.sh installs Strimzi, then Kafka, Postgres and LGTM, then the overlay. +> +> verify.sh is the file compose uses. Its container operations go through helper functions, and RUNTIME=kubernetes swaps them: the Kafka checks run in pod cndp-dual-role-0 and the Postgres checks in cndp-postgres-0. +> +> What to show: the ten check marks and the Results line, then Grafana on 127.0.0.1:30300. Fallback: the recorded results. teardown.sh never deletes the profile; minikube stop frees the memory and keeps the images. + +## [285] APPENDIX R · MINIKUBE · Recorded results (sldId 540) + +p. 255 + +- 10 passed, 0 failed in all six languages (Fedora 44, Docker Engine 29.8.2, minikube v1.39.0). +- Spring Boot 361 s · Quarkus 135 s · .NET 134 s · Python 116 s · C++ 103 s · Go 104 s. +- Tempo received traces from order-service, shipping-consumer and notification-consumer in every language. +- shipping-consumer scaled to 0: no shipment; back to 1: the group resumes from its committed offset. +- Stop the profile before starting compose or another cluster; the scripts refuse to run alongside them. + +Times cover the image build and load, the deploy, the checks and the namespace teardown. + +> The recorded run, 2026-10-11: every language passed all ten checks with a fresh namespace per language. After each run, verify-matrix.sh asked Tempo through the lgtm pod which services had sent traces, and all three services reported in every language. +> +> The scale-down check was run by hand: with the shipping consumer at zero replicas a new order produces no shipment; scaled back to one, the consumer group resumes from its last committed offset and the shipment appears. That is the event-driven contract from the Event-Driven section, observed on a cluster. diff --git a/lgtm-presentation/text/K8s-Environment-Access-Policies.md b/lgtm-presentation/text/K8s-Environment-Access-Policies.md new file mode 100644 index 00000000..85591af2 --- /dev/null +++ b/lgtm-presentation/text/K8s-Environment-Access-Policies.md @@ -0,0 +1,276 @@ +# K8s-Environment-Access-Policies + +Source: K8s-Environment-Access-Policies.pptx · 18 slides + +## [001] Kubernetes Environment Access Policies (sldId 256) + +Dev · Test · Prod — RBAC, NetworkPolicy, and GitOps guardrails. + +> Welcome the audience and set context. The subject is environment access governance for Kubernetes: the policies, guardrails, and automation that separate a cluster where developers can safely experiment from one where a single misconfigured kubectl command wipes production. We will walk through a layered model: RBAC for identity-based access, NetworkPolicy for traffic segmentation, OPA Gatekeeper for admission control, and GitOps for change management. The goal is a concrete, implementable framework that scales from a single dev namespace to a multi-team production fleet. By the end, the audience should understand not just what each layer does, but how they compose into a defense-in-depth posture across dev, test, and production environments. Ask the audience: how many of you have a documented policy for who can do what in your production Kubernetes cluster? The answer usually reveals the gap this deck fills. + +## [002] THE PROBLEM · Why environment governance matters (sldId 257) + +Every team running Kubernetes eventually faces the same question: who can do what, in which environment? Without clear governance, a developer's experimental kubectl delete namespace in the wrong context wipes production. + +Network policies that were never tested allow lateral movement after a breach. Audit asks "who changed the firewall rules last Tuesday?" and nobody can answer. + +Configuration drift between environments means staging never catches what breaks in prod. The cost of ungoverned access is measured in outages, security incidents, and audit failures. + +The fix is a layered policy model (RBAC, NetworkPolicy, admission control, and GitOps) applied differently per environment. + +The cost is measured in outages, security incidents, and failed audits. + +p. 2 + +> Open with the cost of ungoverned access. Every team running Kubernetes hits the same pain points: a developer runs kubectl delete namespace in the wrong context and wipes production workloads. Network policies that were never tested allow lateral movement after a container is compromised. An auditor asks who changed the firewall rules last Tuesday and nobody can produce an answer. Configuration drift between environments means staging never catches what breaks in production. These are not hypothetical — they are incidents that happen in real organizations every quarter. The cost is measured in outages, security incidents, compliance failures, and engineering hours spent on incident response instead of building features. The fix is not one tool but a layered policy model (RBAC, NetworkPolicy, admission control, and GitOps) applied with increasing strictness from dev through test to production. Frame this as the motivation for everything that follows. + +## [003] THREE ENVIRONMENTS · Risk profiles by environment (sldId 258) + +Dev — freedom to experiment, iterate, break things. Full self-service within assigned namespaces. Deploy on every commit. Permissive networking. + +Test / Staging — validate before promotion. CI-driven deploys, read-only manual access. Network topology mirrors production exactly. + +Production — serve real users. Strictly read-only for humans. All changes via GitOps pipeline (ArgoCD/Flux). Strict micro-segmentation. + +The principle: freedom increases as risk decreases. Dev is the most permissive, prod is the most restrictive, and test mirrors prod's topology so what passes test also passes prod. + +Freedom ←→ Restriction: access decreases as environment risk increases. + +p. 3 + +> Walk through the three environments as a risk gradient. Dev is where developers experiment, iterate, and break things — full self-service within their own namespace, deploy on every commit, permissive networking. The blast radius is one developer's sandbox. Test and staging exist to validate before promotion — CI-driven deploys, read-only manual access, and network topology that mirrors production exactly. If production only allows order-service to reach inventory on port 8080, test enforces the same rule. Production serves real users — strictly read-only for all humans, every change flows through a GitOps pipeline, strict micro-segmentation. The key principle is that freedom increases as risk decreases: dev is the most permissive, prod is the most restrictive, and test mirrors prod so what passes test also passes prod. Point to the diagram showing the three columns and stress that the RBAC verbs and deploy mechanisms differ by environment. This gradient drives every policy decision in the model. + +## [004] RBAC · RBAC: the Kubernetes gate (sldId 259) + +Kubernetes RBAC has four objects: Role (namespace-scoped permissions), ClusterRole (cluster-wide), RoleBinding (grants a Role to a user/group in a namespace), ClusterRoleBinding (grants cluster-wide). + +Key verbs: get, list, watch (read-only) versus create, update, patch, delete (write). The governance question is which verbs each persona gets in each environment. + +Industry best practice: least privilege, no cluster-admin for humans, audit logging of all RBAC decisions. Quarterly review of who has access to what. Remove stale bindings. + +Reference: Kubernetes RBAC Good Practices (kubernetes.io) recommends namespace-scoped roles over cluster roles, and service accounts with minimal permissions. + +kubernetes.io/docs/concepts/security/rbac-good-practices/ + +p. 4 + +> Introduce Kubernetes RBAC as the primary gate. There are four objects: Role (namespace-scoped permissions), ClusterRole (cluster-wide or reusable via RoleBinding), RoleBinding (grants a Role to a user, group, or service account in a namespace), and ClusterRoleBinding (grants cluster-wide). The critical distinction is the verbs: get, list, and watch are read-only; create, update, patch, and delete are write operations. The governance question is which verbs each persona gets in each environment. Industry best practice from the official Kubernetes RBAC Good Practices documentation recommends least privilege, no cluster-admin for human users, namespace-scoped roles over cluster roles, audit logging of all RBAC decisions, and quarterly review of bindings to remove stale access. RBAC is the foundation because it controls who can act, but it is not sufficient alone. You also need admission control to govern what they can create, and NetworkPolicy to govern what traffic can flow. The environment-specific RBAC matrix maps these verbs to each environment. + +## [005] RBAC · MATRIX · Environment-specific RBAC matrix (sldId 260) + +Dev: Pods/Deployments/Services = full CRUD. ConfigMaps/Secrets = full CRUD. NetworkPolicies = full CRUD. Developers own their namespace. + +Test: All resources = get, list, watch only. Deploys happen via CI/CD pipeline. Manual kubectl apply is blocked by admission control. + +Prod: All resources = get, list, watch only. No watch on Secrets. All changes flow through ArgoCD/Flux after automated validation and peer review. + +Use the built-in ClusterRoles: "edit" for dev namespaces, "view" for test and prod. Bind via RoleBinding (namespace-scoped, not ClusterRoleBinding). + +Built-in ClusterRoles: edit (dev), view (test/prod) + +p. 5 + +> Walk the RBAC matrix column by column. In dev, developers get full CRUD on pods, deployments, services, ConfigMaps, Secrets, and NetworkPolicies within their own namespace — use the built-in ClusterRole 'edit' bound via a namespace-scoped RoleBinding. In test and staging, developers can read everything (get, list, watch) but deploys happen only through the CI/CD pipeline — manual kubectl apply is blocked by admission control. In production, everything is read-only for humans — use the built-in ClusterRole 'view'. No watch on Secrets in prod is an extra hardening step. All production changes flow through ArgoCD or Flux after automated validation and peer review. Stress the practical implementation: you do not need to write custom Roles — Kubernetes ships built-in ClusterRoles named 'edit' and 'view' that cover the common case. Bind them via RoleBinding, not ClusterRoleBinding, to keep permissions namespace-scoped. This is both simpler and more secure than crafting custom roles for each team. + +## [006] NETWORKPOLICY · ZERO TRUST · Default-deny NetworkPolicy (sldId 261) + +Zero-trust networking starts with a default-deny rule in every namespace. Without any NetworkPolicy, Kubernetes allows all traffic — any pod can reach any other pod in the cluster. + +A single default-deny policy flips that to "nothing is allowed unless explicitly permitted." podSelector: {} matches every pod. policyTypes: [Ingress, Egress] denies both directions. + +After the baseline, developers write targeted policies to open only necessary connections — port 5432 to the database, port 9092 to Kafka, port 8080 between specific services. + +This is the single highest-impact security measure for a Kubernetes namespace. Without it, a compromised pod can scan and reach every service in the cluster. + +The single highest-impact security measure you can apply to a Kubernetes namespace. + +p. 6 + +> This is the single highest-impact security measure you can apply to a Kubernetes namespace. Without any NetworkPolicy, Kubernetes allows all traffic — any pod can reach any other pod in the entire cluster. A single default-deny policy flips that to nothing is allowed unless explicitly permitted. The YAML is simple: podSelector empty (matches every pod), policyTypes includes both Ingress and Egress. After this baseline, developers write targeted policies to open only the connections their services need — port 5432 to the database, port 9092 to Kafka, port 8080 between specific services. Everything else is blocked. Without default-deny, a compromised pod can scan and reach every service in the cluster — it is the network equivalent of leaving every door unlocked. With it, a compromise is contained to the traffic the service was already authorized to send. This should be applied on day one of every new namespace, in every environment. Ask the audience to check: how many of your production namespaces have default-deny today? The answer drives urgency. + +## [007] NETWORKPOLICY · BY ENVIRONMENT · NetworkPolicy by environment (sldId 262) + +Dev: permissive within the namespace. Allow all intra-namespace traffic. Block cross-namespace to prevent accidental dependencies. + +Test / Staging: mirror production's network topology exactly. If prod only allows order → inventory on port 8080, test enforces the same rule. + +Prod: strict micro-segmentation. Each service-to-service connection is explicitly allowed by label selector, port, and protocol. Egress to external services is allow-listed by CIDR or DNS. + +Same default-deny baseline everywhere; the allow rules differ by environment. + +p. 7 + +> The same default-deny baseline applies everywhere, but the allow rules differ by environment. In dev, allow all intra-namespace traffic so developers can wire up services without filing tickets — but block cross-namespace traffic to prevent accidental dependencies on other teams' environments. In test and staging, mirror production's network topology exactly. If production only allows order-service to reach inventory on port 8080, test should enforce the same rule — this catches misconfigurations before they reach prod. In production, enforce strict micro-segmentation: each service-to-service connection is explicitly allowed by label selector, port, and protocol, and egress to external services is allow-listed by CIDR or DNS. Point to the diagram showing the namespace boundary with RBAC, NetworkPolicy, ResourceQuota, and LimitRange stacked as four controls. The cross-namespace traffic at the bottom is denied by default — that is the blast-radius boundary in action. + +## [008] ADMISSION CONTROL · Policy-as-code with OPA Gatekeeper (sldId 263) + +RBAC controls who can act; admission control controls what they can create. OPA Gatekeeper evaluates every object against policies written as code. + +Common policies: images must come from a trusted registry, no privileged containers, resource limits required, namespace labels required (env, team). + +Same policies run in two places: in CI (conftest) before merge, and at admission on the cluster. Violation in CI = PR comment. Violation at admission = hard reject. + +Alternative: Kyverno — Kubernetes-native policies in YAML instead of Rego. Same dual-enforcement model (CI + admission). + +Policy lives in Git and is enforced at admission; no human runs a checklist. + +p. 8 + +> RBAC controls who can act; admission control controls what they can create. OPA Gatekeeper evaluates every object submitted to the Kubernetes API server against policies written as code in Rego. Common policies include: images must come from a trusted registry (block docker.io/random:latest), no privileged containers, resource limits required on every container, and namespace labels required for policy scoping. The key architectural point is dual enforcement: the same policies run in CI via conftest before merge, and at admission on the cluster via Gatekeeper. A violation caught in CI is a PR comment the developer fixes before merge. The same violation at admission is a hard reject — the object is never created. This means policy is code, lives in Git, is reviewed like code, and is enforced automatically. No human runs a checklist. Mention Kyverno as an alternative that uses Kubernetes-native YAML instead of Rego — same dual-enforcement model, different policy language. Point to the diagram showing the flow from developer through Git/CI to the admission controller. + +## [009] GITOPS · GitOps-gated production changes (sldId 264) + +In production, no human runs kubectl apply. Every change follows a GitOps pipeline: Developer opens PR → CI validates (kubeconform, conftest) → staging deploy + test → peer review → merge triggers ArgoCD/Flux sync. + +Benefits: complete audit trail (Git history), separation of duties (developer proposes, SRE approves), reproducibility (any cluster state rebuilt from Git). + +Drift detection: if someone manually changes the cluster, ArgoCD detects the drift and either alerts or auto-reverts to the Git-declared state. + +The only path to production is a merged PR — no exceptions, no bypass. + +p. 9 + +> In production, no human runs kubectl apply. Every change follows a GitOps pipeline: the developer opens a PR against the environment repo or a kustomize overlay for the prod branch. CI validates: kubeconform v0.8.0 checks the manifests against the Kubernetes and CRD schemas, conftest runs OPA policies, and a staging deployment proves the change does not break. A peer reviewer from the platform or SRE team approves. The merge triggers ArgoCD or Flux to sync the cluster state to match Git. If someone manually changes the cluster, ArgoCD detects the drift and either alerts or auto-reverts to the Git-declared state. This gives you three things compliance teams care about: a complete audit trail in Git history, separation of duties where the developer proposes and the SRE approves, and reproducibility where any cluster state can be rebuilt from Git. Point to the diagram showing the pipeline from developer through Git repo, CI lint, staging, approval, ArgoCD, and finally the production cluster with drift detection looping back. Stress: the only path to production is a merged PR. No exceptions, no bypass. + +## [010] L7 ROUTING · POLICY LAYERS · The four-layer L7 stack (sldId 265) + +Edge / Ingress: TLS termination, host-based routing, DDoS mitigation. Tools: Ingress controller, cloud load balancer. + +API Gateway: authentication, rate limiting, API key validation. Tools: Kong, Ambassador, Envoy Gateway. + +Service Mesh: mTLS, AuthorizationPolicy, circuit breaking, retry budgets. Tools: Istio / Envoy sidecar. + +In-app: business rules, feature flags, tenant isolation. Place policies at the lowest layer that has the information needed to decide. + +Each layer adds capability but also latency (~1-3ms per L7 hop). + +p. 10 + +> Policy decisions in a cloud-native architecture happen at four layers, each with a distinct scope and toolset. At the edge and ingress layer, the ingress controller or cloud load balancer handles TLS termination, host-based routing, and DDoS mitigation. The API gateway layer (Kong, Ambassador, Envoy Gateway) handles authentication, rate limiting, and API key validation. The service mesh layer (Istio with Envoy sidecars) provides mTLS encryption, AuthorizationPolicy for identity-based access control, circuit breaking, and retry budgets. The in-app layer handles business rules, feature flags, and tenant isolation. Each layer adds capability but also latency — roughly one to three milliseconds per L7 hop. The governance principle is to place policies at the lowest layer that has the information needed to decide. TLS belongs at the edge, authentication at the gateway, service identity at the mesh, and business logic in the application. Point to the diagram showing the north-south and east-west request paths. The mesh is the same L7 routing applied to every internal call, not just at the edge. + +## [011] ISTIO · ZERO TRUST · Istio AuthorizationPolicy (sldId 266) + +Inside the mesh, every workload gets a SPIFFE identity: cluster.local/ns//sa/. Istio AuthorizationPolicy controls which identities can call which endpoints. + +Example: only order-service in the orders namespace can POST to /reserve on inventory. Even if a pod is compromised, it can only call the exact endpoints its identity is authorized for. + +Combined with mTLS (enabled by default in the mesh), every service-to-service call is authenticated, encrypted, and authorized. + +This is zero-trust at the service level — the mesh enforces it without application code changes. + +Zero-trust at the service level: even compromised pods are contained. + +p. 11 + +> Inside the service mesh, Istio AuthorizationPolicy provides namespace-scoped, identity-based access control. Every workload gets a SPIFFE identity in the form cluster.local/ns/namespace/sa/service-account, and policies control which identities can call which endpoints. The example on the slide shows that only order-service in the orders namespace can POST to the /reserve endpoint on inventory — no other service, no other method, no other path is allowed. This is zero-trust at the service level: even if a pod is compromised, it can only call the exact endpoints its identity is authorized for. Combined with mTLS, which the mesh enables by default, every service-to-service call is authenticated with cryptographic identity, encrypted in transit, and authorized against a declared policy. The application code does not need to implement any of this — the mesh enforces it transparently via the sidecar proxy. In production this guarantees that services communicate only along defined paths, regardless of what the application code attempts. + +## [012] L7 ROUTING · SAFE ROLLOUTS · Traffic steering for safe rollouts (sldId 267) + +Canary: route 5% of traffic to the new version, monitor error rates, gradually increase. Rollback = set weight to 0. + +Blue/Green: two identical environments; switch traffic atomically by updating the VirtualService. + +Header-based dark launch: route requests with x-canary: true to the new version. Only opted-in developers or QA see it. Production users are unaffected. + +Shadow/mirror: duplicate production traffic to a test deployment without affecting responses. Validate behavior under real load with zero risk. + +L7 routing enables environment-safe deployments without modifying RBAC or network policies. + +p. 12 + +> L7 routing enables environment-safe deployments without modifying RBAC or network policies. Walk through four patterns: Canary deploys route a small percentage of traffic, say five percent, to the new version while monitoring error rates — rollback is just setting the weight to zero. Blue/green maintains two identical environments and switches traffic atomically by updating the VirtualService. Header-based dark launches route only requests with a specific header like x-canary: true to the new version — only opted-in developers or QA see it, production users are unaffected. Shadow or mirror mode duplicates production traffic to a test deployment without affecting responses, letting you validate behavior under real load with zero risk. Point to the diagram showing the six release shapes: canary, blue/green, A/B test, header-based, geo/latency, and shadow. Stress that each pattern is a header or weight rule away — the value is in the deploy and rollback discipline around them, not the routing mechanism itself. + +## [013] NAMESPACE ISOLATION · Namespace isolation architecture (sldId 268) + +A well-governed namespace is a blast-radius boundary with four controls stacked: RBAC (who can act), NetworkPolicy (what traffic flows), ResourceQuota (how much the namespace consumes), LimitRange (per-container defaults). + +Together, these prevent one team's namespace from consuming cluster resources, accessing another team's data, or receiving unauthorized traffic. + +ResourceQuota bounds the namespace: max CPU, memory, pod count, service count. LimitRange sets defaults so no single container can claim unbounded resources. + +Four controls stack to make each namespace a blast-radius boundary. + +p. 13 + +> A well-governed namespace is a blast-radius boundary with four controls stacked. RBAC determines who can act in this namespace via RoleBindings — view-only in production, edit in development. NetworkPolicy determines what traffic can enter and leave — default-deny plus explicit allow rules. ResourceQuota bounds how much CPU, memory, storage, and how many pods and services the namespace can consume — preventing one team from starving the cluster. LimitRange sets default and maximum resource limits for individual containers — so no single container can claim unbounded resources even if the developer forgets to set limits. Together, these four controls prevent one team's namespace from consuming cluster resources, accessing another team's data, or receiving traffic it should not. Point to the diagram showing these four controls at the top of the namespace boundary, with services communicating via allowed paths inside and cross-namespace traffic denied at the bottom. This is the architecture every production namespace should have from day one. + +## [014] CI/CD PIPELINE · CI/CD validation pipeline (sldId 269) + +Every policy change flows through: Schema validation (kubeconform) → Policy lint (conftest/kyverno) → Dry-run apply (kubectl --dry-run=server) → Staging deploy (ArgoCD sync) → Smoke test (curl/hey/newman) → Approval gate (PR review) → Prod deploy. + +The key principle: the CI pipeline is the only path to production. No exceptions, no emergency kubectl apply, no "just this once." If the pipeline is too slow for emergencies, fix the pipeline. + +Every stage produces artifacts: schema validation results, policy compliance reports, staging test results. These feed the audit trail. + +Fail at any stage → PR blocked. No exceptions, no bypass. + +p. 14 + +> Every policy change (NetworkPolicy, RBAC Role, OPA Constraint, ResourceQuota) flows through the same validation pipeline before reaching any cluster. Walk through the stages: schema validation with kubeconform v0.8.0 catches invalid YAML, unknown fields and wrong API versions, including custom resources through the CRDs-catalog schemas. Policy lint with conftest or the Kyverno CLI catches organizational policy violations like untrusted registries or missing resource limits. Dry-run apply with kubectl apply --dry-run=server catches API server rejections like quota exceeded or admission denial. Staging deploy via ArgoCD sync catches runtime failures, misconfigured selectors, and broken connectivity. Smoke tests with curl, hey, or newman verify service health, API contracts, and latency regression. An approval gate requires human sign-off from the platform or SRE team. Finally, prod deploy applies the identical manifests that were proven in staging. Point to the diagram showing the linear pipeline. The key principle: the CI pipeline is the only path to production. No exceptions, no emergency kubectl apply, no just this once. If the pipeline is too slow for emergencies, fix the pipeline — do not bypass it. + +## [015] AUDIT & COMPLIANCE · Audit & compliance (sldId 270) + +API server audit logs: every kubectl command, every API call, with user identity, timestamp, resource, and verb. Configure at Metadata or Request level for sensitive resources. + +OPA decision logs: every admission decision (allow/deny) with the policy that matched and the object that triggered it. + +Git history as audit trail: with GitOps, every production change is a Git commit with author, reviewer, timestamp, and diff. This is the audit trail compliance teams want. + +RBAC review cadence: quarterly review of who has access. Remove stale bindings. Verify no human has cluster-admin. Scan for namespaces missing default-deny. + +Git history + audit logs + OPA decision logs = complete compliance trail. + +p. 15 + +> Governance requires more than access control — it requires proof that the controls are working. Walk through four audit dimensions. API server audit logs capture every kubectl command and API call with user identity, timestamp, resource, and verb — configure at the Metadata or Request level for sensitive resources like Secrets, RBAC objects, and NetworkPolicies. OPA decision logs record every admission decision, allow or deny, with the policy that matched and the object that triggered it. With GitOps, Git history becomes the audit trail: every production change is a Git commit with author, reviewer, timestamp, and diff — this is exactly what compliance teams want when they ask who changed what and when. Finally, establish a quarterly RBAC review cadence: review who has access to what, remove stale bindings, verify no human has cluster-admin, and scan for namespaces missing default-deny using tools like kubectl np-viewer or Cilium's policy visualization. The combination of API server logs, OPA decision logs, and Git history creates a complete compliance trail. + +## [016] ANTI-PATTERNS · Anti-patterns to avoid (sldId 271) + +cluster-admin for developers: one typo deletes a namespace, one compromise owns the cluster. Fix: use edit in dev, view in prod. + +No default-deny: any pod can reach any pod — lateral movement is trivial. Fix: apply default-deny in every namespace on day one. + +Manual kubectl apply in prod: no audit trail, no review, no rollback. Fix: GitOps pipeline with ArgoCD/Flux. + +Shared namespaces across teams: no blast-radius boundary. Fix: one namespace per team per environment. No resource quotas: one runaway deployment consumes all cluster memory. Fix: ResourceQuota + LimitRange everywhere. + +Each anti-pattern has a concrete, deployable fix. + +p. 16 + +> Walk through each anti-pattern and its fix — these are drawn from real incidents, not hypothetical risks. Cluster-admin for developers: one typo deletes a namespace, one compromise owns the entire cluster. Fix: use the built-in edit role in dev and view in prod. No default-deny: any pod can reach any pod, lateral movement after a breach is trivial. Fix: apply default-deny in every namespace on day one. Manual kubectl apply in prod: no audit trail, no review, no rollback path — nobody can answer who changed this. Fix: GitOps pipeline with ArgoCD or Flux. Shared namespaces across teams: no blast-radius boundary, one team's quota spike starves another. Fix: one namespace per team per environment. Test that does not mirror prod: NetworkPolicies that pass in test fail in prod because the topology differs. Fix: same policies, same selectors, same deny baseline. No resource quotas: one runaway deployment consumes all cluster memory. Fix: ResourceQuota plus LimitRange in every namespace. Ask the audience which of these they recognize in their own clusters. + +## [017] SUMMARY · Recommendations (sldId 272) + +1. Default-deny NetworkPolicy in every namespace — the single highest-impact measure. +2. Environment-tiered RBAC — edit in dev, view in test and prod, CI/CD as the only deploy path. +3. Policy-as-code — OPA Gatekeeper or Kyverno at admission, conftest in CI, same policies in both. +4. GitOps for production — ArgoCD or Flux, no manual kubectl apply, drift detection enabled. +5. Namespace isolation — RBAC + NetworkPolicy + ResourceQuota + LimitRange per namespace. +6. Audit everything — API server audit logs, OPA decision logs, Git history as the source of truth. + +p. 17 + +> Close with the priority-ordered checklist. First, apply default-deny NetworkPolicy in every namespace — it is the single highest-impact security measure and costs nothing to implement. Second, implement environment-tiered RBAC: edit in dev, view in test and prod, with CI/CD as the only deploy path. Third, adopt policy-as-code with OPA Gatekeeper or Kyverno at admission and conftest in CI, running the same policies in both places. Fourth, enforce GitOps for production — ArgoCD or Flux, no manual kubectl apply, drift detection enabled. Fifth, implement namespace isolation with RBAC plus NetworkPolicy plus ResourceQuota plus LimitRange per namespace per team. Sixth, audit everything — API server audit logs, OPA decision logs, and Git history as the source of truth. The order matters: default-deny and RBAC tiering give you the most security for the least effort, while GitOps and policy-as-code take more infrastructure investment but close the remaining gaps. Start with the first two this week. + +## [018] REFERENCES · References (sldId 273) + +Kubernetes RBAC Good Practices — kubernetes.io/docs/concepts/security/rbac-good-practices/ + +Kubernetes Network Policies — kubernetes.io/docs/concepts/services-networking/network-policies/ + +OPA Gatekeeper — open-policy-agent.github.io/gatekeeper/ + +Kyverno — kyverno.io + +Istio AuthorizationPolicy — istio.io/latest/docs/reference/config/security/authorization-policy/ + +ArgoCD: argo-cd.readthedocs.io · Flux: fluxcd.io + +CNCF Cloud Native Security Whitepaper — github.com/cncf/tag-security + +Pod Security Standards — kubernetes.io/docs/concepts/security/pod-security-standards/ + +p. 18 + +> Point the audience to the authoritative sources for deeper reading. The Kubernetes RBAC Good Practices page is the official guidance on least privilege, namespace-scoped roles, and audit logging. The NetworkPolicy documentation covers the spec and includes worked examples. OPA Gatekeeper and Kyverno are the two leading admission controllers — both open source, both CNCF. The Istio AuthorizationPolicy reference is the specification for service-level zero-trust. ArgoCD and Flux are the two leading GitOps operators. The CNCF Cloud Native Security Whitepaper is the industry-consensus framework for cloud-native security posture, covering supply chain, runtime, and governance. Pod Security Standards define three levels (privileged, baseline, restricted) and replace PodSecurityPolicy, which was removed in Kubernetes 1.25. SPIFFE and SPIRE provide the workload identity framework that underpins mesh-based AuthorizationPolicy. Encourage the audience to start with the RBAC Good Practices and the CNCF whitepaper — together they cover most of what this deck presented. diff --git a/lgtm-presentation/text/Network-Policy-Process-Advisory.md b/lgtm-presentation/text/Network-Policy-Process-Advisory.md new file mode 100644 index 00000000..e1718113 --- /dev/null +++ b/lgtm-presentation/text/Network-Policy-Process-Advisory.md @@ -0,0 +1,327 @@ +# Network-Policy-Process-Advisory + +Source: Network-Policy-Process-Advisory.pptx · 20 slides + +## [001] Network Policy Process Advisory — Shifting from perimeter to in-cluster segmentation. (sldId 256) + +> Welcome the audience and set context. This deck presents a recommended approach for operationalizing in-cluster network segmentation on Kubernetes, with upstream APIs and open-source tools. The starting point is common: perimeter and east-west controls run through the firewall team on a traditional appliance, there is no in-cluster network policy, and developers have never written a firewall rule. The goal is not to pick tools or assign owners; it is to describe a process and a maturity path that the organization can adopt at its own pace. We will walk through where organizations typically start, how to handle the developer skill gap, the CI/CD flow for policy changes, repo structure, day-two operations, the firewall team's evolving role, and what maturity looks like. Ask the audience: do you have a documented process for who authors, reviews, and approves network policies in your clusters today? The answer usually reveals the gap this deck fills. + +## [002] STARTING POINT · Where you are today (sldId 257) + +Perimeter and east-west controls run through the firewall team on a traditional appliance today. + +The organization wants to shift network segmentation left into the cluster (NetworkPolicy, ClusterNetworkPolicy, egress rules) but has not defined the workflow. + +Developers are not security specialists. Most have never written a firewall rule and don't inherently know what traffic their applications require. + +The goal: a recommended process for operationalizing in-cluster network segmentation, not a tool selection exercise or an ownership assignment. + +A recommended approach, built on upstream Kubernetes and open-source tools. + +p. 2 + +> Frame the current state without judgment. The perimeter firewall handles everything: north-south and east-west traffic controls run through the firewall team on the perimeter appliance. There is no Kubernetes NetworkPolicy in the cluster. Developers are not security specialists; most have never written a firewall rule and do not inherently know what traffic their applications require. This is not unusual. Most organizations start in exactly this position. The shift to in-cluster segmentation is not about replacing the firewall team or the perimeter; it is about extending the organization's security posture into a layer the perimeter cannot see. The perimeter firewall has no visibility into pod-to-pod traffic inside the cluster. A compromised pod behind the perimeter can reach every other pod unless the cluster itself enforces segmentation. That is the gap this process fills. + +## [003] MATURITY PATH · The four-phase maturity path (sldId 258) + +Phase 1 — Observe: understand what traffic flows today from the CNI's flow logs (Hubble or Calico). No enforcement yet. + +Phase 2 — Baseline: apply default-deny in every namespace, generate policies from the observed flows, clean them up. + +Phase 3 — Guardrails: introduce cluster-scoped ClusterNetworkPolicy (Admin and Baseline tiers) owned by the platform team. + +Phase 4 — Developer ownership: app teams author and own their namespace-scoped NetworkPolicies via PR. + +The timeline for each phase depends on the organization's complexity, team capacity, and risk tolerance. Each phase builds on the previous. + +Most common failure: skipping Phase 1 and jumping to enforcement. + +p. 3 + +> Walk through the four phases. The most important point is that these are sequential and the timeline depends on the organization's complexity and risk tolerance. Phase 1 is observation: reading the CNI's flow logs to discover what traffic flows before writing any policy. Phase 2 is baseline: applying default-deny and cleaning up policies generated from the observed flows. Phase 3 is guardrails: the platform team introduces cluster-scoped ClusterNetworkPolicy rules in the Admin and Baseline tiers that developers cannot override. Phase 4 is developer ownership: app teams author their own namespace-scoped NetworkPolicies via pull request. The most common failure is skipping Phase 1 and jumping straight to enforcement; teams that did this caused outages from denied traffic they did not know existed. The observation phase pays for itself by revealing undocumented dependencies and unauthorized connections. Stress that each phase builds on the previous: you cannot do Phase 3 well without Phase 2's baseline, and Phase 2 is reckless without Phase 1's visibility. + +## [004] PHASE 1 · Observe the flows (sldId 259) + +The CNI records flows between pods, namespaces, and external destinations: Hubble with Cilium, flow logs (Goldmane, Whisker) with Calico. + +Inspektor Gadget's advise_networkpolicy gadget records the TCP and UDP traffic of chosen namespaces. Together they give the network baseline, the source of truth for what traffic is expected. + +The firewall team and platform team review the flows together and identify which services talk to each other, which make outbound calls, and which cross namespace boundaries. + +What field teams typically see: organizations are surprised by the volume of east-west traffic that was invisible behind the perimeter. + +Nothing is denied in Phase 1. The goal is visibility. + +p. 4 + +> Explain what observation means without a commercial console. The CNI already sees every flow. With Cilium, Hubble shows them (hubble observe, or the Hubble UI); with Calico 3.30 and later, Felix sends flow logs to Goldmane and the Whisker console shows them. Inspektor Gadget, a CNCF Sandbox project, adds the advise_networkpolicy gadget: it records the TCP and UDP traffic of the namespaces you choose and later turns it into NetworkPolicy YAML. At this stage, nothing is denied. The goal is pure visibility. The firewall team and platform team review the observed flows together. They identify which services talk to each other and which should not, which services make outbound calls to external endpoints, which connections cross namespace boundaries, and which traffic patterns were previously invisible behind the perimeter. What field teams typically see: organizations are surprised by the volume of east-west traffic they did not know existed. A frontend directly querying a database it should go through an API for. A monitoring agent making outbound calls to a SaaS endpoint. Discovery is the highest-value step. + +## [005] PHASE 2 · Baseline: default-deny + generated policies (sldId 260) + +Apply default-deny NetworkPolicy in every namespace — the single highest-impact security measure. + +Generate NetworkPolicy YAML from the observed flows: advise_networkpolicy writes one policy per workload, with rules from the observed peers. + +Generated policies capture what traffic did flow, not what should flow. The platform team reviews, removes unauthorized connections, and applies cleaned-up policies alongside default-deny. + +The first default-deny rollout reveals undocumented dependencies — monitoring agents, legacy batch jobs, SaaS sidecars. Discovering these in dev/test is the point. + +Generated policies are a starting point, not the final state. + +p. 5 + +> Walk through the baseline phase. The first action is applying default-deny NetworkPolicy in every namespace: podSelector empty, policyTypes Ingress and Egress. This is the single highest-impact security measure. Simultaneously, generate NetworkPolicy YAML from the observed flows. Inspektor Gadget's advise_networkpolicy writes one policy per workload, with ingress and egress rules based on the peers it saw; it can also write CiliumNetworkPolicy. On Calico, a StagedNetworkPolicy shows in the flow logs what a policy would deny before it is enforced. These generated policies capture what traffic did flow, not what traffic should flow. The platform team reviews them, removes connections that should not have existed, and applies the cleaned-up policies alongside the default-deny. This audit step finds the connections nobody documented. The first default-deny rollout in a non-prod namespace immediately reveals undocumented dependencies: a monitoring agent scraping every pod, a legacy batch job connecting to an external database, a sidecar phoning home to a SaaS endpoint. Discovering these in dev or test is the point. + +## [006] PHASES 3-4 · Guardrails and developer ownership (sldId 261) + +Phase 3: the platform team introduces cluster-scoped guardrails with ClusterNetworkPolicy, in its Admin and Baseline tiers. These are the policies developers cannot override. + +Phase 4: app teams begin authoring their own namespace-scoped NetworkPolicies via pull request, with CI validation and platform team review. + +The transition from Phase 3 to Phase 4 is gradual — developers start by reviewing generated baselines, then modify them, then eventually author from scratch. + +The platform team controls the guardrails. Developers control their application connectivity. Even if a developer writes no policies at all, the Baseline tier ensures default-deny. + +The Baseline tier addresses the developer skill gap directly: safety by default. + +p. 6 + +> Explain the transition from platform-owned guardrails to developer ownership. In Phase 3, the platform team introduces ClusterNetworkPolicy rules in the Admin and Baseline tiers: cluster-scoped rules that developers cannot override. They enforce non-negotiable boundaries: deny cross-environment traffic, accept monitoring, restrict control-plane access. In Phase 4, app teams begin authoring their own namespace-scoped NetworkPolicies. The transition is gradual: developers start by reviewing generated baselines, then modify them, then eventually author from scratch as they build familiarity. The key architectural point: even if a developer writes no NetworkPolicies at all, the Baseline-tier default-deny keeps the namespace closed. This directly addresses the developer skill gap: the platform provides safety by default, and developers add precision as they learn. This is a fundamentally different model from asking every developer to be a firewall engineer. + +## [007] THREE-TIER MODEL · The policy evaluation model (sldId 262) + +Tier 1 — ClusterNetworkPolicy, Admin tier: cluster-scoped, priority 0-1000, Accept/Deny/Pass. Cannot be overridden by tenants. + +Tier 2 — NetworkPolicy (NP): namespace-scoped, owned by app teams. Evaluated unless an Admin rule accepted or denied it. + +Tier 3 — ClusterNetworkPolicy, Baseline tier: cluster-scoped safety net. Overridable by NetworkPolicy. + +Admin Accept/Deny are final — NP and the Baseline tier are skipped. Admin Pass hands evaluation to the next tier. + +Platform team controls tiers 1 and 3. App teams control tier 2. + +p. 7 + +> This is the most important conceptual slide. A CNI that implements the upstream ClusterNetworkPolicy API evaluates traffic against three tiers of policy. Tier 1 is the Admin tier: cluster-scoped, priority zero to one thousand, with Accept, Deny, or Pass actions. If the traffic matches an Admin Accept or Deny rule, tiers 2 and 3 are skipped entirely. If it matches a Pass rule, evaluation hands to tier 2. Tier 2 is standard Kubernetes NetworkPolicy: namespace-scoped, owned by app teams. Tier 3 is the Baseline tier, the safety net: it decides traffic that no Admin rule accepted or denied and that no NetworkPolicy covers. Point to the diagram showing the evaluation flow. The value proposition: the platform team controls tiers 1 and 3, the guardrails. Developers control tier 2, their application's policies. This separation of concerns is what makes the model scalable. + +## [008] ADMIN · BASELINE · ClusterNetworkPolicy: Admin and Baseline tiers (sldId 263) + +The Admin tier enforces non-negotiable rules: deny traffic to the control plane, accept monitoring agents scraping all pods, deny cross-environment traffic (dev → prod). + +A Pass rule explicitly delegates to the NetworkPolicy tier — the platform team says 'app teams, this one is yours to decide.' + +The Baseline tier is the safety net. Recommended: a default-deny Baseline policy. If no Admin rule and no NP allows the traffic, the Baseline tier denies it. + +Critical: an Admin-tier policy with an empty namespace selector ({}) matches all namespaces including kube-system and the CNI's own. Use workload-specific label selectors. + +An overly broad Admin-tier policy can lock the cluster. Use workload-specific labels. + +p. 8 + +> Dive deeper into the two cluster tiers. The Admin tier enforces non-negotiable rules: deny traffic to the control plane from application namespaces, accept monitoring agents scraping all pods, deny cross-environment traffic like dev to prod. A Pass rule explicitly delegates the decision to the NetworkPolicy tier: this is how the platform team says app teams, this one is yours to decide. The Baseline tier is the safety net, configured as default-deny. If no Admin rule and no NetworkPolicy allows the traffic, the Baseline tier denies it, so a namespace with no NetworkPolicies still has a default-deny posture without the app team creating it. Critical caution: an Admin-tier policy with an empty namespace selector matches all namespaces, including kube-system and the CNI's own namespaces. This can lock the cluster. Label application namespaces and use that label as the namespace selector. +> +> Status, as of network-policy-api v0.2.0 (April 2026): ClusterNetworkPolicy is policy.networking.k8s.io/v1alpha2, an alpha API. It merged the earlier AdminNetworkPolicy and BaselineAdminNetworkPolicy (v1alpha1), which upstream now lists as previous APIs, and renamed Allow to Accept. Calico enforces it from 3.32 and no longer enforces the old pair; Cilium 1.20 enforces it behind the k8sClusterNetworkPolicy.enabled Helm value. Check the release notes before each upgrade. + +## [009] DEVELOPER SKILL GAP · Bridging the gap (sldId 264) + +Generated baselines: developers review generated policies, not author from scratch. Reviewing a concrete policy is dramatically easier than writing one from zero. + +Platform-authored starter templates: web-facing service, internal API, batch job, database archetypes. Developers pick the template, customize selectors, submit a PR. + +The Baseline tier as safety net: a developer who deploys without any NetworkPolicy gets default-deny automatically. Missing policies become connectivity issues, not security gaps. + +Golden-path provisioning: new namespaces come with default-deny (a Kyverno generate rule), egress rules, starter templates, and RBAC bindings pre-installed. + +Invert the skill requirement: review, don't author. + +p. 9 + +> Walk through the four approaches for bridging the developer skill gap. First, generated baselines invert the skill requirement: developers review a policy generated from observed flows, they do not author one from scratch. Reviewing a concrete policy (does this look right, should this connection exist) is dramatically easier than writing one from zero. Second, platform-authored starter templates for common workload archetypes: web-facing, internal API, batch job, database. Developers pick the template, customize the namespace and label selectors, and submit a PR. Third, the Baseline tier as a safety net converts missing policies from a security gap into a connectivity issue that surfaces immediately in testing. Fourth, golden-path namespace provisioning includes default-deny, egress rules, starter templates, and RBAC bindings automatically; Kyverno's add-network-policy sample is a generate rule that creates the default-deny NetworkPolicy in every new namespace. The developer starts with a secured namespace and never sees the everything-is-open starting point that leads to procrastination. + +## [010] TEMPLATES · Starter policy templates (sldId 265) + +Web-facing service — accepts ingress from ingress controller on 8080/8443, allows egress to backend services and database. + +Internal API — accepts ingress from specific consumer services, allows egress to database and Kafka. + +Batch job — no ingress, allows egress to database and specific external endpoints. + +Database — accepts ingress from application services on port 5432, no egress. + +Start with 3-5 archetypes, not twenty. Refine based on actual PR volume and developer feedback. + +Over-engineering templates is a common early mistake. + +p. 10 + +> Describe the starter template library. The platform team maintains a small set of NetworkPolicy templates for common workload archetypes. A web-facing service template accepts ingress from the ingress controller on ports 8080 and 8443 and allows egress to backend services and the database. An internal API template accepts ingress from specific consumer services and allows egress to the database and Kafka. A batch job template has no ingress and allows egress to the database and specific external endpoints. A database template accepts ingress from application services on port 5432 and has no egress. Developers pick the template that matches their workload, customize the namespace and label selectors, and submit a PR. The template handles the hard parts — the deny-all baseline, correct policyTypes, CIDR ranges for external services. Start with three to five archetypes, not twenty. Over-engineering the first set of templates is a common mistake. Refine based on actual PR volume and developer feedback. + +## [011] CI/CD PIPELINE · Policy validation end-to-end (sldId 266) + +Schema check: kubeconform and kubectl apply --dry-run=client validate YAML and API version. + +Policy lint: conftest / kyverno test / gator verify enforce organizational rules (no allow-all, required labels); trivy config scans the manifests. + +Baseline diff: compare the change with the policy generated from observed flows; flag traffic outside the baseline. + +Review: CODEOWNERS enforces — app teams author NP (platform reviews), platform authors ClusterNetworkPolicy (firewall reviews). + +Fail at any stage → PR blocked. No exceptions, no bypass. + +p. 11 + +> Walk through the policy CI/CD pipeline. Every policy change (NetworkPolicy, ClusterNetworkPolicy, egress rules) flows through the same pipeline. Schema check with kubeconform and kubectl apply --dry-run=client catches invalid YAML and wrong API versions. Policy lint with conftest, kyverno test or Gatekeeper's gator verify catches organizational policy violations like allow-all rules or missing labels, and trivy config scans the manifests (its KSV-0038 check flags network policies without pod or namespace selectors). The baseline diff compares the change with the policy generated from observed flows and flags rules that would allow traffic outside the baseline. Platform or security team reviews the PR; CODEOWNERS enforces who reviews based on the file path. Merge triggers an Argo CD sync to staging, smoke tests validate connectivity, then sync to production. Post-deploy validation reads the Hubble or Calico flow logs, and Falco alerts on unexpected egress at runtime. Point to the diagram. The key principle: the CI pipeline is the only path to production. No exceptions, no emergency kubectl apply, no just this once. + +## [012] REPO STRUCTURE · Three-layer policy repo (sldId 267) + +platform-baselines/ — ClusterNetworkPolicy (Admin and Baseline tiers) and egress rules. Platform team authors, firewall/security team reviews. Cluster-wide scope. + +onboarding/ — starter NetworkPolicy templates per workload archetype. Platform team maintains, app teams consume. + +app-policies/ — per-namespace NetworkPolicy. App teams author, platform team reviews. Kustomize overlays for dev/test/prod. + +CODEOWNERS enforces review requirements by path. The same PR flow applies regardless of target environment. + +Separate ownership boundaries with clear review rules. + +p. 12 + +> Describe the three-layer repository model. The platform-baselines directory contains the ClusterNetworkPolicy resources of the Admin and Baseline tiers and the egress rules: cluster-scoped policies authored by the platform team and reviewed by the firewall and security team. The onboarding directory contains starter NetworkPolicy templates per workload archetype, maintained by the platform team and consumed by app teams as starting points. The app-policies directory contains per-namespace NetworkPolicies authored by app teams, with base and overlay directories for Kustomize-based environment promotion. CODEOWNERS enforces review requirements by path: platform-baselines requires firewall team review, app-policies requires platform team review. This separation provides clear ownership boundaries. The same PR that modifies a policy flows through the same CI pipeline regardless of whether it targets dev, test, or production; only the Kustomize overlay differs. + +## [013] OWNERSHIP · Who owns what (sldId 268) + +Firewall team: authors egress rules, reviews the Admin and Baseline tiers, owns the perimeter firewall (north-south and compliance). + +Platform team: authors ClusterNetworkPolicy guardrails and starter templates, reviews app NetworkPolicies, owns cluster security posture. + +App teams: author namespace-scoped NetworkPolicy, consume templates and generated baselines, own application connectivity. + +The firewall team moves from 'approve every rule' to 'set the boundaries and review the exceptions.' + +Faster for developers, safer for the organization. + +p. 13 + +> Describe the three-team ownership model and point to the diagram. The firewall team authors the egress rules for external destinations, reviews all cluster-scoped policies (the Admin and Baseline tiers) because they have cluster-wide blast radius, and continues to own the perimeter for north-south traffic, TLS inspection, and compliance logging. The platform team authors the ClusterNetworkPolicy guardrails, maintains starter templates, reviews app team NetworkPolicies, and owns the overall cluster security posture. App teams author namespace-scoped NetworkPolicy for their services, consume starter templates and generated baselines, and own their application's connectivity. The critical framing: the firewall team moves from approve every rule to set the boundaries and review the exceptions. This is faster for developers, safer for the organization, and a better use of the firewall team's expertise. + +## [014] DAY-2 OPS · Denied-flow triage (sldId 269) + +Step 1: compare with the observed baseline — was this connection ever in the generated policies in Git? + +Step 2: read the flow logs (hubble observe --verdict DROPPED, or Calico's flow logs) and match the dropped flow to the policies that select the pod: which tier (Admin, NP, or Baseline) denied it tells you who updates the policy. + +Step 3: cilium sysdump or calicoctl cluster diags collects policy state, CNI configuration, and logs for offline analysis or escalation. + +Target: denied-flow triage should take under 30 minutes. If it takes longer, the observability tooling is not configured correctly. + +The flow logs and the tier that matched say who owns the fix. + +p. 14 + +> Walk through the denied-flow triage process. When a developer reports my service cannot reach X, the first step is comparing with the observed baseline: was this connection ever in the generated policies kept in Git? If not, it was likely never policy-allowed, and the decision is whether to add a policy or investigate why the new connection exists. The second step is the flow logs: hubble observe with --verdict DROPPED on Cilium, or the Calico flow logs in Whisker, show the dropped flow with source pod, destination and port. Matching it to the policies that select the pod tells you which tier denied the traffic: Admin, NetworkPolicy, or Baseline. If the Admin tier denied it, the platform team needs to act. If a NetworkPolicy denied it, the app team updates their policy. If the Baseline tier denied it, no one wrote a policy for this connection at all. The third step is a diagnostics bundle, cilium sysdump or calicoctl cluster diags, for offline analysis, an upstream issue, or escalation to your support provider. The target is under 30 minutes for triage. If it regularly takes longer, the observability tooling is not configured correctly. + +## [015] DAY-2 OPS · Break-glass and drift detection (sldId 270) + +Break-glass: platform team creates a temporary Admin-tier Pass rule at high priority, time-boxed with an expiration annotation, logged via incident ticket. Proper policy change follows within 24 hours via normal PR. + +Argo CD drift detection: if someone manually applies a policy via kubectl apply instead of the PR pipeline, Argo CD detects the drift and alerts or reverts it (self-heal). + +Baseline drift: new flows outside the observed baseline show up in the flow logs and Falco alerts — catches both policy drift and application drift. + +All break-glass events are reviewed in the quarterly policy review. + +Define the break-glass process before you enforce anything in production. + +p. 15 + +> Explain the break-glass process and drift detection. When a denied flow is blocking a production incident and the normal PR process is too slow, a platform team member creates a temporary Admin-tier ClusterNetworkPolicy with a Pass rule at high priority. This is time-boxed with an expiration annotation and logged via an incident ticket. If the Baseline tier is default-deny and no NetworkPolicy covers the target, a temporary NetworkPolicy is needed as well. Within 24 hours, the proper policy change goes through the normal PR and CI pipeline, and the temporary rule is removed. All break-glass events are reviewed in the quarterly policy review. For drift detection: Argo CD reports sync status for every policy manifest; if someone manually applies a policy, Argo CD detects the drift and alerts, or reverts it when self-heal is on. Baseline drift catches new traffic flows outside the observed baseline, whether from policy changes or application changes. The critical lesson: define the break-glass process before you enforce anything in production. The first denied-flow incident without a break-glass process causes panic and a rollback of all policies. + +## [016] FIREWALL TEAM · The evolving role (sldId 271) + +What stays: north-south edge controls (the perimeter firewall for TLS inspection, DDoS, compliance logging), egress rule authorship (approved CIDRs, DNS rules), Admin and Baseline tier review (cluster-wide blast radius). + +What shifts: east-west traffic segmentation moves to NetworkPolicy authored by app teams. The firewall team does not approve every pod-to-pod rule — that does not scale. + +The new model: the firewall team's expertise is applied at a new layer. Position this as elevation, not replacement. + +Teams that presented in-cluster policy as a replacement met resistance. Teams that positioned it as the firewall team's expertise at a new layer got buy-in. + +Gatekeeper → architect. Faster for developers, safer for the org. + +p. 16 + +> Frame the firewall team's evolving role as elevation, not replacement. What stays with the firewall team: north-south edge controls on the perimeter firewall for TLS inspection, DDoS mitigation, and compliance logging. Egress rule authorship: the firewall team catalogs approved external destinations and authors the egress rules, which is their domain expertise. Admin and Baseline tier review: the firewall team reviews all cluster-scoped policies because they have cluster-wide blast radius. What shifts to the platform and app teams: east-west traffic segmentation is handled by NetworkPolicy authored by app teams. The firewall team does not need to approve every pod-to-pod rule; that does not scale. The key framing: teams that presented in-cluster policy as a replacement for the firewall team met resistance. Teams that positioned it as the firewall team's expertise applied at a new layer got buy-in. + +## [017] EGRESS · Controlling outbound traffic (sldId 272) + +Admin-tier ClusterNetworkPolicy egress rules with networks peers (CIDR, standard channel) restrict outbound traffic cluster-wide. + +DNS-based rules: CNP domainNames peers are experimental and Accept-only; Cilium's toFQDNs (CiliumNetworkPolicy) gives DNS-based egress today. + +The firewall team's domain: they catalog approved external endpoints and author the egress rules per environment. + +Combine with NetworkPolicy: NetworkPolicy for intra-cluster segmentation, cluster egress rules for external traffic control. They complement each other. + +Cataloging external endpoints is the most time-consuming part of the shift. + +p. 17 + +> Explain egress control with upstream APIs. A ClusterNetworkPolicy in the Admin tier can carry egress rules whose peers are networks, CIDR ranges, which is part of the standard channel; deny everything outside the approved ranges and accept the approved ones. DNS names are harder: the domainNames peer of ClusterNetworkPolicy is in the experimental channel and only valid in Accept rules, and Calico's open-source edition has no DNS-based policy. On Cilium, a CiliumNetworkPolicy with toFQDNs gives DNS-based egress today, through Cilium's DNS proxy, which matters for external services that change IP addresses without notice. The firewall team's domain: they catalog approved external endpoints and author the egress rules. This extends what they already do at the perimeter, controlling which destinations workloads can reach, to the cluster level, where the perimeter has no visibility. Combine the two: NetworkPolicy for intra-cluster segmentation, cluster egress rules for external traffic control. Caveat: DNS-based rules depend on the DNS answers the cluster sees, so test them against the real resolvers. + +## [018] MATURITY · What 'good' looks like (sldId 273) + +Default-deny in every namespace — no namespace without coverage. + +Baselines reviewed — new flows outside them raise alerts. + +Admin-tier guardrails enforced — cross-environment traffic blocked, monitoring allowed. + +Baseline-tier default-deny — the cluster-wide safety net catches unmatched traffic. + +App teams authoring policies via PR — not kubectl apply. + +CI validates before merge — schema, lint, baseline diff, dry-run on every PR. + +Egress rules per environment — outbound traffic restricted to approved destinations. + +Denied-flow triage under 30 minutes. Quarterly policy review cadence. + +The maturity timeline is agreed upon by the organization. + +p. 18 + +> Walk through the maturity checklist. Default-deny in every namespace: no namespace without coverage. Baselines reviewed: every production namespace has a reviewed baseline in Git, and new flows outside it raise alerts. Admin-tier guardrails enforced: cross-environment traffic blocked, monitoring allowed, control-plane access restricted. Baseline-tier default-deny: the cluster-wide safety net catches any traffic not explicitly allowed. App teams authoring policies via PR, not kubectl apply. CI validates before merge: schema checks, policy linting, the baseline diff, and dry-run apply on every PR. Egress rules restrict outbound traffic to approved destinations. Firewall team reviews, does not block: turnaround on Admin and Baseline tier reviews is prompt. Denied-flow triage under 30 minutes. Quarterly policy review cadence covering break-glass events, stale policies, baseline refreshes, and RBAC audits. The maturity timeline is agreed upon by the organization; there is no universal pace. + +## [019] LESSONS LEARNED · What teams wish they'd done (sldId 274) + +1. 'We should have started with observation, not enforcement.' Jumping to default-deny without observing the flows caused outages from denied traffic they didn't know existed. +2. 'We over-engineered the templates.' Start with 3-5 archetypes, not twenty. Refine based on actual usage. +3. 'We underestimated the egress work.' Cataloging every external endpoint is the most time-consuming part. Start this inventory in Phase 1. +4. 'We should have involved the firewall team earlier.' Position as elevation, not replacement. +5. 'We needed a break-glass process on day one.' The first denied-flow incident without break-glass caused panic and a rollback of all policies. + +These lessons come from real engagements. + +p. 19 + +> Walk through the top five lessons from real engagements. First, teams that jumped to default-deny without observing the flows caused outages from denied traffic they did not know existed. The observation phase pays for itself. Second, the first set of starter templates should be simple: three to five archetypes, not twenty. Start broad and refine based on actual PR volume. Third, cataloging every external endpoint every service needs to reach is the most time-consuming part of the shift. Start this inventory in Phase 1 alongside the flow observation. Fourth, teams that presented in-cluster policy as a replacement for the firewall team met resistance; teams that positioned it as the firewall team's expertise applied at a new layer got buy-in. Fifth, the first denied-flow incident in production without a break-glass process caused panic and a rollback of all policies. Define break-glass before you enforce. These lessons are from real engagements: they are patterns, not hypotheticals. + +## [020] REFERENCES · References (sldId 275) + +Network Policy API (ClusterNetworkPolicy) — network-policy-api.sigs.k8s.io + +network-policy-api v0.2.0 release — github.com/kubernetes-sigs/network-policy-api/releases/tag/v0.2.0 + +Calico ClusterNetworkPolicy (v3.32 release notes) — github.com/projectcalico/calico, release-notes/v3.32.0-release-notes.md + +Cilium network policy — docs.cilium.io/en/stable/network/kubernetes/policy/ + +Inspektor Gadget advise_networkpolicy — inspektor-gadget.io/docs/latest/gadgets/advise_networkpolicy + +Kubernetes Network Policies — kubernetes.io/docs/concepts/services-networking/network-policies/ + +Companion: Kubernetes Environment Access Policies (k8s-environment-policies.md) + +p. 20 + +> Point the audience to authoritative sources. The Network Policy API site covers ClusterNetworkPolicy, its Admin and Baseline tiers and the list of implementations. The v0.2.0 release notes record the move from AdminNetworkPolicy and BaselineAdminNetworkPolicy to ClusterNetworkPolicy and the Allow-to-Accept rename. The Calico 3.32 release notes and the Cilium network policy page say how each CNI enforces it. The Inspektor Gadget page covers generating NetworkPolicy from observed traffic. The Kubernetes Network Policies documentation covers the standard NetworkPolicy API that app teams will author. The companion document, Kubernetes Environment Access Policies, covers the broader governance framework including RBAC, admission control, GitOps, and audit that surrounds the network policy process described in this deck. diff --git a/lgtm-presentation/text/k8s-environment-policies.md b/lgtm-presentation/text/k8s-environment-policies.md new file mode 100644 index 00000000..4d4d6fe1 --- /dev/null +++ b/lgtm-presentation/text/k8s-environment-policies.md @@ -0,0 +1,511 @@ +# k8s-environment-policies + +Source: k8s-environment-policies.docx + +# Kubernetes Environment Access Policies — Dev · Test · Prod + +Governance, RBAC, NetworkPolicy, and GitOps guardrails for multi-environment + +Kubernetes clusters. + +## 1. The problem + +Every team running Kubernetes eventually faces the same question: **who can do what, + +in which environment?** Without clear governance: + +- A developer's experimental `kubectl delete namespace` in the wrong context wipes +production. + +- Network policies that were never tested allow lateral movement after a breach. +- Audit asks "who changed the firewall rules last Tuesday?" and nobody can answer. +- Configuration drift between environments means staging never catches what breaks +in prod. + +The cost of ungoverned access is measured in outages, security incidents, and audit + +failures. The fix is a layered policy model — RBAC, NetworkPolicy, admission control, + +and GitOps — applied differently per environment. + +## 2. Risk profiles by environment + +Each environment serves a different purpose and demands a different level of control: + +Dimension | Dev | Test / Staging | Production +Purpose | Experiment, iterate, break things | Validate before promotion | Serve real users +Change velocity | High — deploy on every commit | Moderate — deploy on PR merge | Low — deploy on release +Access model | Self-service within namespace | CI-driven deploy, read-only manual | Read-only, all changes via GitOps +Blast radius | One developer's sandbox | Shared validation environment | Revenue, reputation, compliance +Network policy | Permissive within namespace | Match production topology | Strict micro-segmentation +Who applies changes | Developer directly | CI/CD pipeline | Platform / SRE team via ArgoCD/Flux + +The principle: freedom increases as risk decreases. Dev is the most permissive, + +prod is the most restrictive, and test mirrors prod's topology so that what passes + +test also passes prod. + +## 3. RBAC: the Kubernetes gate + +Kubernetes Role-Based Access Control (RBAC) is the primary mechanism for controlling + +who can perform which actions on which resources. It has four objects: + +- Role — a set of permissions (verbs on resources) scoped to a single namespace. +- ClusterRole — the same, but cluster-wide (or reusable across namespaces via +RoleBindings). + +- RoleBinding — grants a Role to a user, group, or service account in a namespace. +- ClusterRoleBinding — grants a ClusterRole cluster-wide. +The key verbs: `get`, `list`, `watch` (read-only) versus `create`, `update`, `patch`, + +`delete` (write). The governance question is which verbs each persona gets in each + +environment. + +Industry reference: Kubernetes RBAC Good Practices + +recommends least privilege, no cluster-admin for humans, namespace-scoped roles over + +cluster roles, service accounts with minimal permissions, audit logging of all RBAC + +decisions, and quarterly review of bindings to remove stale access. + +## 4. Environment-specific RBAC matrix + +Resource | Dev | Test / Staging | Production +Pods, Deployments, Services | `*` (full CRUD) | `get, list, watch` | `get, list, watch` +ConfigMaps, Secrets | `*` | `get, list, watch` | `get, list` (no watch on Secrets) +NetworkPolicies | `*` | `get, list, watch` | `get, list, watch` +Namespaces | `get, list` | `get, list` | `get, list` +RBAC (Roles, Bindings) | `get, list` | `get, list` | `get, list` +CRDs (VirtualService, etc.) | `*` | `get, list, watch` | `get, list, watch` + +Dev — full self-service within assigned namespaces. Developers can create, modify, + +and delete any workload or network policy. They learn by doing. + +Test / Staging — developers can read everything but deploy only through the CI/CD + +pipeline. Manual `kubectl apply` is blocked by admission control. + +Production — strictly read-only for all humans. Every change flows through a + +GitOps pipeline (ArgoCD or Flux) after passing automated validation. + +Practical tip: you do not need to write custom Roles. Kubernetes ships built-in + +ClusterRoles named `edit` (create, update, delete on most resources) and `view` + +(get, list, watch only). Bind them via RoleBinding (namespace-scoped), not + +ClusterRoleBinding, to keep permissions scoped to the team's namespace. + +``` +# Dev namespace — developer gets full access +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: dev-full-access + namespace: order-dev +subjects: + - kind: Group + name: order-team-devs + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: edit # built-in: create, update, delete on most resources + apiGroup: rbac.authorization.k8s.io +--- +# Prod namespace — developer gets read-only +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: prod-read-only + namespace: order-prod +subjects: + - kind: Group + name: order-team-devs + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: view # built-in: get, list, watch only + apiGroup: rbac.authorization.k8s.io +``` + +## 5. Default-deny NetworkPolicy + +Zero-trust networking starts with a default-deny rule in every namespace. With no + +NetworkPolicy, Kubernetes allows all traffic — any pod can reach any other pod in the + +cluster. A single default-deny policy flips that to "nothing is allowed unless + +explicitly permitted." + +``` +# Default deny all ingress and egress +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: order-prod +spec: + podSelector: {} # matches every pod in the namespace + policyTypes: + - Ingress + - Egress +``` + +After this baseline, developers write targeted policies to open only the connections + +their services need — port 5432 to the database, port 9092 to Kafka, port 8080 + +between specific services. Everything else is blocked. + +This is the single highest-impact security measure you can apply to a Kubernetes + +namespace. Without it, a compromised pod can scan and reach every service in the + +cluster. With it, a compromise is contained to the traffic the service was already + +authorized to send. Apply default-deny on day one of every new namespace, in every + +environment. + +## 6. NetworkPolicy by environment + +The same default-deny baseline applies everywhere, but the allow rules differ by + +environment: + +Dev — permissive within the namespace. Allow all intra-namespace traffic so + +developers can wire up services without filing tickets. Block cross-namespace traffic + +to prevent accidental dependencies on other teams' dev environments. + +Test / Staging — mirror production's network topology exactly. If production only + +allows order → inventory on port 8080, test should enforce the same rule. This catches + +misconfigurations before they reach prod. + +Production — strict micro-segmentation. Each service-to-service connection is + +explicitly allowed by label selector, port, and protocol. Egress to external services + +is allow-listed by CIDR or DNS. Everything else is denied. + +``` +# Prod: only order-service can reach inventory on port 8080 +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-order-to-inventory + namespace: order-prod +spec: + podSelector: + matchLabels: { app: inventory } + policyTypes: [Ingress] + ingress: + - from: + - podSelector: + matchLabels: { app: order-service } + ports: + - protocol: TCP + port: 8080 +``` + +## 7. Policy-as-code with OPA Gatekeeper + +RBAC controls who can act; admission control controls what they can create. + +OPA Gatekeeper (or Kyverno) evaluates every object submitted to the API server + +against a set of policies written as code: + +- Images must come from a trusted registry — block `docker.io/random:latest`. +- No privileged containers — reject `securityContext.privileged: true`. +- Resource limits required — every container must declare CPU and memory limits. +- Namespace labels required — every namespace must carry `env: dev|test|prod` +and `team: ` labels for policy scoping. + +The same policies run in two places: in CI (via `conftest`) before merge, and at + +admission on the cluster. A violation caught in CI is a PR comment; the same + +violation at admission is a hard reject. + +``` +# OPA Gatekeeper ConstraintTemplate — require trusted registry +apiVersion: templates.gatekeeper.sh/v1 +kind: ConstraintTemplate +metadata: { name: k8srequiretrustedregistry } +spec: + crd: + spec: + names: { kind: K8sRequireTrustedRegistry } + targets: + - target: admission.k8s.gatekeeper.sh + rego: | + package k8srequiretrustedregistry + violation[{"msg": msg}] { + container := input.review.object.spec.containers[_] + not startswith(container.image, "registry.access.redhat.com/") + not startswith(container.image, "registry.internal/") + msg := sprintf("image %v is not from a trusted registry", [container.image]) + } +``` + +## 8. GitOps-gated production changes + +In production, no human runs `kubectl apply`. Every change follows a GitOps pipeline: + +1. Developer opens a PR against the environment repo (or a `kustomize` overlay +for the prod branch). + +1. CI validates — `kubeconform` checks schema, `conftest` runs OPA policies, a +staging deployment proves the change doesn't break. + +1. Peer review + approval — at least one reviewer from the platform or SRE team. +2. Merge triggers deployment — ArgoCD or Flux detects the merge, syncs the +cluster state to match Git. + +1. Drift detection — if someone manually changes the cluster, ArgoCD detects the +drift and either alerts or auto-reverts. + +This gives you a complete audit trail (Git history), separation of duties (developer + +proposes, SRE approves), and reproducibility (any cluster state can be rebuilt from + +Git). + +## 9. The four-layer L7 stack + +Policy decisions happen at four layers, each with a distinct scope: + +Layer | Tool | Policy scope +Edge / Ingress | Ingress controller, cloud LB | TLS termination, host-based routing, DDoS mitigation +API Gateway | Kong, Ambassador, Envoy Gateway | Authentication, rate limiting, API key validation +Service Mesh | Istio / Envoy sidecar | mTLS, AuthorizationPolicy, circuit breaking, retry budgets +In-app | Application code, rule engines | Business rules, feature flags, tenant isolation + +Each layer adds capability but also latency (~1-3ms per L7 hop). Place policies at + +the lowest layer that has the information needed to decide. TLS at the edge, + +auth at the gateway, service identity at the mesh, business logic in the app. + +Diagram: 22-l7-layers — the four-layer L7 stack + +## 10. Istio AuthorizationPolicy + +Inside the mesh, Istio's `AuthorizationPolicy` provides **namespace-scoped, + +identity-based access control**. Every workload gets a SPIFFE identity + +(`cluster.local/ns//sa/`), and policies control which + +identities can call which endpoints: + +``` +# Only order-service in the orders namespace can POST to /reserve +apiVersion: security.istio.io/v1 +kind: AuthorizationPolicy +metadata: + name: inventory-allow-order + namespace: inventory +spec: + selector: + matchLabels: { app: inventory } + action: ALLOW + rules: + - from: + - source: + principals: ["cluster.local/ns/orders/sa/order-service"] + to: + - operation: + methods: ["POST"] + paths: ["/reserve"] +``` + +This is zero-trust at the service level: even if a pod is compromised, it can + +only call the exact endpoints its identity is authorized for. Combined with mTLS + +(which the mesh enables by default), every service-to-service call is authenticated + +with cryptographic identity, encrypted in transit, and authorized against a declared + +policy. The application code does not need to implement any of this — the mesh + +enforces it transparently via the sidecar proxy. + +## 11. Traffic steering for safe rollouts + +L7 routing enables environment-safe deployments without modifying RBAC or network + +policies: + +- Canary — route 5% of traffic to the new version, monitor error rates, gradually +increase. Rollback = set weight to 0. + +- Blue/Green — two identical environments; switch traffic atomically by updating +the VirtualService. + +- Header-based dark launch — route requests with `x-canary: true` to the new +version. Only opted-in developers or QA see it. Production users are unaffected. + +- Shadow / mirror — duplicate production traffic to a test deployment without +affecting responses. Validate behavior under real load with zero risk. + +``` +# Istio VirtualService — canary with 5% traffic split +apiVersion: networking.istio.io/v1 +kind: VirtualService +metadata: { name: order-service, namespace: orders } +spec: + hosts: [order-service] + http: + - route: + - destination: + host: order-service + subset: stable + weight: 95 + - destination: + host: order-service + subset: canary + weight: 5 +``` + +Diagram: 22-traffic-steering — six release shapes + +## 12. Namespace isolation architecture + +A well-governed namespace is a blast-radius boundary with four controls stacked: + +- RBAC — who can act in this namespace (RoleBindings). +- NetworkPolicy — what traffic can enter and leave (default-deny + allow rules). +- ResourceQuota — how much CPU, memory, and storage the namespace can consume. +- LimitRange — default and maximum resource limits for individual containers. +Together, these prevent one team's namespace from consuming cluster resources, + +accessing another team's data, or receiving traffic it shouldn't. + +``` +# ResourceQuota — bound the namespace +apiVersion: v1 +kind: ResourceQuota +metadata: + name: team-quota + namespace: order-prod +spec: + hard: + requests.cpu: "8" + requests.memory: 16Gi + limits.cpu: "16" + limits.memory: 32Gi + pods: "50" + services: "20" +``` + +## 13. CI/CD validation pipeline + +Every policy change — NetworkPolicy, RBAC Role, OPA Constraint — flows through the + +same validation pipeline before reaching any cluster: + +Stage | Tool | What it catches +Schema validation | kubeconform v0.8.0 | Invalid YAML, unknown fields, wrong API versions +Policy lint | conftest, kyverno CLI | Violations of organizational policies (no privileged, trusted registry, etc.) +Dry-run apply | `kubectl apply --dry-run=server` | API server rejects (quota exceeded, admission denial) +Staging deploy | ArgoCD sync to staging | Runtime failures, misconfigured selectors, broken connectivity +Smoke test | curl, hey, newman | Service health, API contract, latency regression +Approval gate | GitHub PR review | Human sign-off from platform/SRE team +Prod deploy | ArgoCD sync to prod | Identical manifests, proven in staging + +The key principle: the CI pipeline is the only path to production. No exceptions, + +no emergency `kubectl apply`, no "just this once." If the pipeline is too slow for + +emergencies, fix the pipeline — don't bypass it. Every stage produces artifacts — + +schema validation results, policy compliance reports, staging test results — that + +feed the audit trail. + +## 14. Audit & compliance + +Kubernetes provides native audit logging, but governance requires more: + +- API server audit logs — every `kubectl` command, every API call, with user +identity, timestamp, resource, and verb. Configure at the `Metadata` or `Request` + +level for sensitive resources (Secrets, RBAC, NetworkPolicies). + +- OPA decision logs — every admission decision (allow/deny) with the policy that +matched and the object that triggered it. + +- Git history as audit trail — with GitOps, every production change is a Git +commit with author, reviewer, timestamp, and diff. This is the audit trail + +compliance teams want. + +- RBAC review cadence — quarterly review of who has access to what. Remove stale +bindings. Verify no human has cluster-admin. + +- Network policy coverage — regularly scan for namespaces missing default-deny. +Tools like `kubectl np-viewer` or Cilium's policy visualization help. + +## 15. Anti-patterns to avoid + +Anti-pattern | Why it's dangerous | Fix +cluster-admin for developers | One typo deletes a namespace, one compromise owns the cluster | Use `edit` in dev, `view` in prod +No default-deny | Any pod can reach any pod — lateral movement is trivial | Apply default-deny in every namespace on day one +Manual `kubectl apply` in prod | No audit trail, no review, no rollback — "who changed this?" | GitOps pipeline with ArgoCD/Flux +Shared namespaces across teams | No blast-radius boundary — one team's quota spike starves another | One namespace per team per environment +Test doesn't mirror prod | NetworkPolicies that work in test fail in prod because the topology differs | Same policies, same selectors, same deny baseline +No resource quotas | One runaway deployment consumes all cluster memory | ResourceQuota + LimitRange in every namespace + +## 16. Summary & recommendations + +Checklist for environment governance: + +1. Default-deny NetworkPolicy in every namespace — the single highest-impact +measure. + +1. Environment-tiered RBAC — `edit` in dev, `view` in test and prod, with +CI/CD as the only deploy path. + +1. Policy-as-code — OPA Gatekeeper or Kyverno at admission, `conftest` in CI, +same policies in both. + +1. GitOps for production — ArgoCD or Flux, no manual `kubectl apply`, drift +detection enabled. + +1. Namespace isolation — RBAC + NetworkPolicy + ResourceQuota + LimitRange per +namespace per team. + +1. Audit everything — API server audit logs, OPA decision logs, Git history as +the source of truth. + +1. Test mirrors prod — same network topology, same policies, same deny baseline. +The order matters: default-deny and RBAC tiering give you the most security for the + +least effort, while GitOps and policy-as-code take more infrastructure investment but + +close the remaining gaps. Start with the first two this week. + +## 17. References + +- Kubernetes RBAC Good Practices +- Kubernetes Network Policies +- OPA Gatekeeper +- Kyverno +- Istio AuthorizationPolicy +- ArgoCD / Flux +- CNCF Cloud Native Security Whitepaper +- Pod Security Standards +- SPIFFE / SPIRE diff --git a/lgtm-presentation/text/network-policy-process-advisory.md b/lgtm-presentation/text/network-policy-process-advisory.md new file mode 100644 index 00000000..879c50a0 --- /dev/null +++ b/lgtm-presentation/text/network-policy-process-advisory.md @@ -0,0 +1,731 @@ +# network-policy-process-advisory + +Source: network-policy-process-advisory.docx + +# Network Policy Process — Advisory + +A recommended approach for operationalizing in-cluster network segmentation + +on Kubernetes, from perimeter-only to developer-owned NetworkPolicies. + +## What this document is + +This is a recommended approach to building a network policy process + +for an organization that: + +- Runs perimeter and east-west controls through a firewall team on a +traditional perimeter firewall appliance today. + +- Wants to shift network segmentation left into the Kubernetes cluster using +NetworkPolicy, ClusterNetworkPolicy, and egress rules. + +- Has developers who are not security specialists — most have never written +a firewall rule and do not inherently know what traffic their applications + +require. + +What this document is not: a tool selection exercise or an ownership + +assignment. It describes a process and a maturity path. Organizations apply + +it with the teams and tools they already have. The tools named are upstream + +open-source options: a CNI that enforces NetworkPolicy and ClusterNetworkPolicy + +(Calico or Cilium), Kyverno or OPA Gatekeeper for admission policy, Trivy for + +manifest scanning, Falco for runtime detection, and Argo CD for GitOps. + +## 1. Where organizations typically start + +Most organizations move through a four-phase maturity path. Trying to skip + +phases — jumping straight to developer-authored micro-segmentation — is the + +most common cause of stalled adoption. + +The timeline for each phase depends on the organization's complexity, team + +capacity, and risk tolerance. The four phases are sequential, each building + +on the previous, but the pace is agreed upon by the organization. + +### Phase 1 — Observe + +Before writing any policy, understand what traffic flows today. + +The CNI records the network flows between pods, namespaces, and external + +destinations: Hubble with Cilium (`hubble observe`), or Calico's flow logs + +(Goldmane, viewed in the Whisker console). Inspektor Gadget's + +`advise_networkpolicy` gadget records the TCP and UDP traffic of chosen + +namespaces. Together they give a network baseline, a map of every + +connection the cluster has seen, which becomes the source of truth for what + +traffic is expected. + +At this stage, nothing is denied. The goal is visibility. The firewall team + +and platform team review the observed flows together and identify: + +- Which services talk to each other (and which shouldn't). +- Which services make outbound calls to external endpoints. +- Which connections cross namespace boundaries. +- Which traffic patterns were previously invisible behind the perimeter. +What field teams typically see: organizations are surprised by the volume of + +east-west traffic they did not know existed — service-to-service connections + +that were never documented because the perimeter firewall was the only + +control plane. + +### Phase 2 — Baseline + +Apply default-deny NetworkPolicy in every namespace. This is the single + +highest-impact security measure — without it, any pod can reach any other + +pod in the cluster. + +``` +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: default-deny-all + namespace: +spec: + podSelector: {} + policyTypes: + - Ingress + - Egress +``` + +Simultaneously, generate NetworkPolicy YAML from the observed baseline. + +Inspektor Gadget's `advise_networkpolicy` writes one policy per workload, with + +ingress and egress rules based on the observed peers (Kubernetes NetworkPolicy, + +or CiliumNetworkPolicy on Cilium). These generated policies are a starting + +point — they capture what traffic did flow, not what traffic should flow. + +On Calico, a StagedNetworkPolicy shows in the flow logs what a policy would + +deny before it is enforced. + +The platform team reviews the generated policies, removes any connections + +that should not have existed (the audit value of this step is significant), + +and applies the cleaned-up policies alongside the default-deny. + +What field teams typically see: the first default-deny rollout in a non-prod + +namespace immediately reveals undocumented dependencies — a monitoring agent + +that scrapes every pod, a legacy batch job that connects to an external + +database, a sidecar that phones home to a SaaS endpoint. Discovering these + +in dev or test is the point. + +### Phase 3 — Guardrails + +With baseline policies in place, the platform team introduces cluster-scoped + +guardrails using the upstream ClusterNetworkPolicy (CNP) API, in its + +Admin and Baseline tiers. + +These are the policies the platform team owns — they cannot be overridden by + +developers, and they establish the security boundaries that all applications + +inherit. + +### Phase 4 — Developer ownership (ongoing) + +App teams begin authoring and owning their own namespace-scoped + +NetworkPolicies. They do this via pull request, with CI validation and + +platform team review. The CI/CD flow section describes + +this workflow in detail. + +## 2. The three-tier policy model + +A CNI that implements ClusterNetworkPolicy evaluates traffic against three + +tiers of policy, in order. Understanding this model is critical — it + +determines who controls what and where overrides are possible. + +### Tier 1 — ClusterNetworkPolicy, Admin tier + +- Scope: cluster-wide (not namespace-scoped) +- Controlled by: platform / infrastructure team +- Priority: 0 (highest) to 1000 (lowest) +- Actions: Accept, Deny, or Pass (hand evaluation to the next tier) +- Override: cannot be overridden by NetworkPolicy or the Baseline tier +- Status: `policy.networking.k8s.io/v1alpha2` (alpha, network-policy-api v0.2.0); enforced by Calico 3.32 and later, and by Cilium 1.20 when enabled +The Admin tier is where the platform team enforces non-negotiable rules: deny + +traffic to the control plane from application namespaces, accept monitoring + +agents scraping all pods, deny cross-environment traffic (dev → prod). A Pass + +rule explicitly delegates the decision to the next tier (NetworkPolicy), + +which is how the platform team says "app teams, this one is yours to decide." + +Critical caution: an Admin-tier policy with an empty namespace selector + +(`{}`) matches all namespaces, including `kube-system` and the CNI's own + +namespaces. Use workload-specific label selectors. An overly broad Admin-tier + +policy can lock the cluster. + +### Tier 2 — NetworkPolicy (NP) + +- Scope: namespace-scoped +- Controlled by: app teams (developers) +- Override: can be overridden by the Admin tier (which takes precedence) +This is the standard Kubernetes NetworkPolicy API. App teams use it to + +define which pods in their namespace accept traffic from which sources, on + +which ports. Traffic that matches an Admin-tier Accept or Deny rule never reaches + +this tier; it is evaluated unless an Admin-tier rule accepted or denied the traffic. + +### Tier 3 — ClusterNetworkPolicy, Baseline tier + +- Scope: cluster-wide (one default-deny policy is the recommended shape) +- Controlled by: platform / infrastructure team +- Actions: Deny for the safety net, Accept for cluster-wide defaults such as DNS +- Override: can be overridden by NetworkPolicy +The Baseline tier is the safety net. It decides traffic that no Admin-tier rule + +accepted or denied and no NetworkPolicy covers. The recommended use is **default-deny at + +the Baseline tier**: if no Admin-tier rule and no NetworkPolicy allows it, the + +Baseline tier denies it. This means that a namespace with no NetworkPolicies at all still has a + +default-deny posture, without requiring the app team to create the + +default-deny policy themselves. + +The earlier releases of the API split these two tiers into + +AdminNetworkPolicy and BaselineAdminNetworkPolicy (`v1alpha1`). Upstream now + +lists them as previous APIs, and Calico 3.32 no longer enforces them, so new + +policies use ClusterNetworkPolicy. + +### How the tiers compose + +Diagram: advisory-tier-evaluation — ClusterNetworkPolicy three-tier evaluation + +Three-tier policy evaluation + +The value proposition for the organization: the platform team controls tiers + +1 and 3 (the guardrails). Developers control tier 2 (their application's + +policies). Even if a developer writes no NetworkPolicies at all, the Baseline + +default-deny ensures the namespace is not wide open. This directly addresses + +the developer skill gap — the platform provides safety by default. + +## 3. Handling the developer skill gap + +Developers are not firewall engineers. Expecting them to author network + +policies from scratch — knowing which ports their services listen on, which + +upstream services call them, and which external endpoints they need — is + +unrealistic at the start. Four complementary approaches close the gap: + +### a) Generated baselines — developers review, not author + +Inspektor Gadget's `advise_networkpolicy` observes traffic for a chosen window, + +then generates + +NetworkPolicy YAML that matches the observed flows. The developer's job is + +to review the generated policy — "does this look right? should this + +connection exist?" — not to author it from scratch. This inverts the skill + +requirement: reviewing a concrete policy is dramatically easier than writing + +one from zero. + +Over time, as developers become familiar with the policy format, they begin + +modifying and authoring policies directly. But the starting point is always + +a generated baseline, not a blank file. + +### b) Platform-authored starter templates + +The platform team maintains a library of NetworkPolicy templates for common + +workload archetypes: + +- Web-facing service — accepts ingress from the ingress controller on +port 8080/8443, allows egress to backend services and the database. + +- Internal API — accepts ingress from specific consumer services, allows +egress to the database and Kafka. + +- Batch job — no ingress, allows egress to the database and specific +external endpoints. + +- Database — accepts ingress from application services on port 5432, +no egress. + +Developers pick the template that matches their workload, customize the + +namespace and label selectors, and submit a PR. The template handles the + +hard parts (deny-all baseline, correct policyTypes, CIDR ranges for external + +services). + +### c) The Baseline tier as a safety net + +With a Baseline-tier policy that denies traffic no other policy allows, a + +developer who deploys a new service without any NetworkPolicy gets + +default-deny behavior automatically. Their service cannot send or receive + +traffic until they (or the platform) create a policy. This is strict but + +safe — it converts "I forgot to write a policy" from a security gap into a + +connectivity issue that surfaces immediately in testing. + +### d) Golden-path namespace provisioning + +When a new namespace is provisioned (via GitOps, a self-service portal, or + +an operator), the provisioning process automatically includes: + +- A default-deny NetworkPolicy (a Kyverno generate rule can create it for every new namespace) +- Egress rules allowing only approved external destinations +- Starter NetworkPolicy templates in a `policies/` directory +- RBAC bindings giving the app team edit access to NetworkPolicy resources +The developer starts with a working, secured namespace. They never see the + +"everything is open, now lock it down" starting point that leads to + +procrastination. + +## 4. CI/CD flow end-to-end + +Network policy changes follow the same CI/CD pipeline as application code. + +The principle is the same: the CI pipeline is the only path to production. + +### The flow + +Diagram: advisory-ci-pipeline — network policy CI/CD pipeline + +Policy CI/CD pipeline + +The pipeline stages: + +1. Developer authors or modifies NetworkPolicy YAML and opens a PR +against the policy repo. + +1. CI validates automatically: +- Schema check — `kubeconform` and `kubectl apply --dry-run=client` (valid YAML, correct API version) +- Policy lint — conftest / `kyverno test` / `gator verify` (organizational rules: no allow-all, +required labels, port range limits); `trivy config` scans the manifests + +- Baseline diff — compare the change with the policy generated from observed flows +and flag rules that would allow traffic outside the baseline + +- Dry-run apply — `kubectl apply --dry-run=server` against a staging cluster +(catches admission denials, quota issues) + +1. Platform / security team reviews the PR — CODEOWNERS enforces: app +team authors NetworkPolicies (platform reviews), platform authors + +ClusterNetworkPolicies and egress rules (firewall/security reviews). + +1. Merge triggers GitOps sync — Argo CD syncs to the +staging cluster, smoke tests validate connectivity, then syncs to + +production. + +1. Post-deploy validation — Hubble or Calico flow logs confirm no unexpected +flows and the expected deny counts; Falco alerts on unexpected egress at runtime. + +### What generated baselines add + +Inspektor Gadget's `advise_networkpolicy` gadget records the traffic of the + +selected namespaces and writes the NetworkPolicy that matches it. This closes the loop between + +"what traffic does my app need" (observed by the CNI) and "what policy should I + +write" (generated, then validated in CI). The developer never needs to author + +a policy from scratch — they generate one from the baseline, review it, and + +submit the PR. + +## 5. Repo structure + +A three-layer repository model separates platform- + +owned policies from application-owned policies, with clear ownership and + +review boundaries. + +cluster-policies/ +│ +├── platform-baselines/ # Platform team owns +│ ├── admin-tier/ +│ │ ├── deny-cross-env.yaml # Block dev ↔ prod traffic +│ │ ├── accept-monitoring.yaml # Prometheus/OTel can scrape all pods +│ │ ├── deny-control-plane.yaml # Apps cannot reach API server directly +│ │ └── pass-app-traffic.yaml # Delegate app-to-app decisions to NP tier +│ ├── baseline-tier/ +│ │ └── default-deny-baseline.yaml # Safety net: deny anything not explicitly allowed +│ ├── egress/ +│ │ ├── prod-egress.yaml # Production: allow only approved external CIDRs/DNS +│ │ ├── test-egress.yaml # Test: same as prod (mirrors topology) +│ │ └── dev-egress.yaml # Dev: broader access, still no direct internet +│ └── kustomization.yaml +│ +├── onboarding/ # Platform team maintains, app teams consume +│ ├── web-service/ +│ │ ├── default-deny.yaml +│ │ ├── allow-ingress-controller.yaml +│ │ ├── allow-egress-db.yaml +│ │ └── kustomization.yaml +│ ├── internal-api/ +│ │ ├── default-deny.yaml +│ │ ├── allow-from-consumers.yaml +│ │ ├── allow-egress-kafka.yaml +│ │ └── kustomization.yaml +│ └── batch-job/ +│ ├── default-deny.yaml +│ ├── allow-egress-db.yaml +│ └── kustomization.yaml +│ +├── app-policies/ # App teams own, platform reviews +│ ├── orders/ +│ │ ├── base/ +│ │ │ ├── order-service-np.yaml +│ │ │ ├── inventory-service-np.yaml +│ │ │ └── kustomization.yaml +│ │ └── overlays/ +│ │ ├── dev/ +│ │ ├── test/ +│ │ └── prod/ +│ ├── payments/ +│ │ └── ... +│ └── notifications/ +│ └── ... +│ +├── CODEOWNERS # Enforces review rules +│ # /platform-baselines/ @platform-team @firewall-team +│ # /onboarding/ @platform-team +│ # /app-policies/orders/ @orders-team @platform-team +│ +└── .tekton/ or .github/ # CI pipeline definitions +└── policy-validation.yaml + +### Ownership model + +Layer | Authors | Reviewers | Scope +`platform-baselines/` (Admin tier, Baseline tier, egress rules) | Platform team | Firewall / security team | Cluster-wide +`onboarding/` (templates) | Platform team | Platform team | Reusable templates +`app-policies/` (NetworkPolicy) | App teams | Platform team | Per-namespace + +### Environment promotion + +Kustomize overlays handle environment differences. The `base/` directory + +contains the policy shape; overlays adjust namespace names, label selectors, + +and external CIDR ranges per environment. The same PR that modifies a + +policy flows through the same CI pipeline regardless of target environment. + +## 6. Day-2 operations + +### Denied-flow triage + +When a developer reports "my service can't reach X," the triage path is: + +1. Compare with the baseline — check whether the connection is in the +observed baseline (the generated policies in Git). If it is not, it was + +likely never policy-allowed. Decide whether to add + +a policy or investigate why the new connection exists. + +1. Flow logs — `hubble observe --verdict DROPPED` (Cilium) or the Calico +flow logs show the dropped flow with source pod, destination and port. + +Match it with the policies that select the pod to find which tier + +denied the traffic (Admin, NP, or Baseline), which + +tells you who needs to update the policy. + +1. Diagnostics bundle — `cilium sysdump` or `calicoctl cluster diags` +collects network policy state, CNI configuration, and logs for offline + +analysis. Use it for an upstream issue or for escalation to your support + +provider. + +Target: denied-flow triage should take under 30 minutes. If it + +regularly takes longer, the observability tooling (flow logs, baselines) is not + +configured correctly. + +### Break-glass process + +When a denied flow is blocking a production incident and the normal PR + +process is too slow: + +1. A platform team member creates a temporary Admin-tier policy with a Pass rule at a +high priority (low number), handing the traffic decision to the + +NetworkPolicy tier. If no NetworkPolicy exists for the target, the Baseline tier + +(if configured as default-deny) will still deny, so the break-glass + +may need a temporary NetworkPolicy as well. + +1. The temporary rule is time-boxed (label it with an expiration date +annotation) and logged (create an incident ticket referencing the + +change). + +1. Within 24 hours, the proper policy change goes through the normal PR +and CI pipeline. The temporary rule is removed. + +1. All break-glass events are reviewed in the quarterly policy review. +### Drift detection + +- Argo CD reports sync status for every policy +manifest. If someone manually applies a policy via `kubectl apply` instead + +of the PR pipeline, Argo CD detects the drift and either alerts or + +auto-reverts (self-heal, depending on configuration). + +- Baseline drift — if new traffic flows appear that are not in +the observed baseline, the flow logs and Falco flag them. This catches + +both policy drift (someone added a rule manually) and application + +drift (a new service started making unexpected connections). + +### Policy coverage reporting + +Maintain a dashboard or periodic report showing: + +- Namespaces with default-deny in place vs. those without. +- Namespaces with reviewed baselines vs. those still in observation. +- Admin-tier and Baseline-tier rules in effect and their last-modified dates. +- Break-glass events in the current quarter. +- Denied-flow triage times (mean, p95). +## 7. The firewall team's evolving role + +The shift to in-cluster network policy does not eliminate the firewall team. + +It changes their scope and moves their role from gatekeeper to architect. + +### What stays with the firewall team + +- North-south edge controls — the perimeter firewall +continues to manage traffic entering and leaving the data center or + +cloud VPC. This is where TLS inspection, DDoS mitigation, and + +compliance logging (PCI, HIPAA) remain. + +- Egress rule authorship — the firewall team authors and reviews +the egress rules that control which external destinations cluster + +workloads can reach. This is their domain expertise (approved CIDRs, + +DNS-based rules, compliance-required blocks). + +- Admin and Baseline tier review — the firewall team reviews all cluster-scoped +network policies (the Admin and Baseline tiers) because these have cluster-wide blast + +radius. The platform team authors them; the firewall team approves. + +### What shifts to the platform and app teams + +- East-west traffic segmentation — service-to-service traffic within +the cluster is segmented by NetworkPolicy, authored by app teams and + +reviewed by the platform team. The firewall team does not need to + +approve every pod-to-pod rule — that does not scale. + +- NetworkPolicy authorship — app teams own their namespace-scoped +policies. They know which services call which endpoints. The platform + +team reviews for correctness and compliance; the firewall team is + +consulted for cross-namespace or unusual egress patterns. + +### The new relationship + +Diagram: advisory-team-ownership — policy ownership model + +Policy ownership model + +The firewall team moves from "approve every rule" to "set the boundaries + +and review the exceptions." This is faster for developers, safer for the + +organization, and a better use of the firewall team's expertise. + +## 8. What "good" looks like at maturity + +### Maturity checklist + +After sustained effort over a maturity period agreed upon by the + +organization, a mature network policy practice looks like this: + +- Default-deny in every namespace — no namespace exists without a +default-deny NetworkPolicy or Baseline-tier coverage. + +- Baselines reviewed — every production namespace has a reviewed +baseline in Git. New flows outside it raise alerts. + +- Admin-tier guardrails enforced — cross-environment traffic blocked, +monitoring access allowed, control-plane access restricted. + +- Baseline-tier default-deny — the cluster-wide safety net catches any +traffic not explicitly allowed by the Admin tier or NetworkPolicy. + +- App teams authoring policies via PR — developers submit +NetworkPolicy changes through the CI/CD pipeline, not `kubectl apply`. + +- CI validates before merge — schema checks, policy linting, baseline +diff, and dry-run apply run on every PR. + +- Egress rules per namespace — outbound traffic is restricted to +approved external destinations in every namespace. + +- Firewall team reviews, does not block — turnaround on Admin and Baseline tier +reviews is under 48 hours. + +- Denied-flow triage under 30 minutes — flow logs and Falco alerts +provide immediate root-cause. + +- Quarterly policy review — all break-glass events reviewed, +stale policies removed, baselines refreshed, RBAC bindings + +audited. + +### What organizations wish they'd done differently + +These are the five lessons heard most often from organizations that have + +completed this transition: + +1. "We should have started with observation, not enforcement." Teams +that jumped to default-deny without first observing the flows + +caused outages from denied traffic they did not know existed. The observe + +phase pays for itself. + +1. "We over-engineered the templates." The first set of starter templates +should be simple — three to five archetypes, not twenty. Start broad + +and refine based on actual PR volume and developer feedback. + +1. "We underestimated the egress work." Cataloging every +external endpoint every service needs to reach is the most + +time-consuming part of the shift. Start this inventory in Phase 1 + +alongside the flow observation. + +1. "We should have involved the firewall team earlier." Teams that +presented in-cluster policy as a replacement for the firewall team met + +resistance. Teams that positioned it as "the firewall team's + +expertise applied at a new layer" got buy-in. + +1. "We needed a break-glass process on day one." The first denied-flow +incident in production without a break-glass process caused panic and + +a rollback of all policies. Define the break-glass process before you + +enforce anything in production. + +## 9. Alignment with environment governance + +This network policy process fits within a broader environment governance + +framework covering RBAC, admission control, GitOps, and audit. The + +companion document — Kubernetes Environment Access Policies — covers the + +full stack: + +Governance layer | This document | Companion document +Network segmentation | NetworkPolicy, ClusterNetworkPolicy (Admin and Baseline tiers), egress rules: the focus here | Default-deny baseline, environment-specific allow rules +RBAC | Who can create/modify network policies | Full RBAC matrix by environment (dev/test/prod) +Admission control | Policy linting in CI (conftest, kyverno) | OPA Gatekeeper ConstraintTemplates +GitOps | Argo CD sync and drift detection for policies | Full GitOps-gated production change pipeline +Audit | Flow logs, Falco alerts, break-glass tracking | API server audit logs, OPA decision logs, Git history +Namespace isolation | NetworkPolicy as one of four controls | RBAC + NetworkPolicy + ResourceQuota + LimitRange + +The network policy process described here is one vertical slice of that + +broader model. Organizations should implement both together — network + +segmentation without RBAC governance is incomplete, and RBAC without + +network segmentation leaves east-west traffic uncontrolled. + +## References + +- Kubernetes Network Policy API: ClusterNetworkPolicy +- network-policy-api v0.2.0 release notes +- Network Policy API implementations +- Calico v3.32 release notes: ClusterNetworkPolicy +- Cilium: Kubernetes network policy +- Inspektor Gadget: advise_networkpolicy +- Kyverno policy: add-network-policy +- Kubernetes Network Policies +- CNCF Cloud Native Security Whitepaper +- Kubernetes RBAC Good Practices +- Companion: Kubernetes Environment Access Policies diff --git a/scripts/deck-denylist.json b/scripts/deck-denylist.json new file mode 100644 index 00000000..b6c8dbff --- /dev/null +++ b/scripts/deck-denylist.json @@ -0,0 +1,236 @@ +[ + { + "id": "old-engine", + "regex": "p[o]dman", + "flags": "i", + "why": "Docker Engine / docker compose is the runtime (S1)" + }, + { + "id": "poetry", + "regex": "\\bPoetry\\b|\\bpoetry (install|add|lock|run)\\b", + "why": "Python examples use pip with a full == requirements.txt freeze (S5)" + }, + { + "id": "fastapi-on-event", + "regex": "@app\\.on_event\\b|\\.on_event\\(", + "why": "FastAPI on_event replaced by lifespan (S5)" + }, + { + "id": "faust", + "regex": "\\bfaust\\b(?![-.])", + "flags": "i", + "exempt": "faust-streaming", + "why": "stream processing is faust-streaming 0.15.3 (S5); its Python module is `faust` (faust.App), which stays" + }, + { + "id": "modern-cpp-kafka", + "regex": "modern-cpp-kafka|kafka::clients", + "flags": "i", + "exempt": "archived", + "why": "C++ Kafka is librdkafka's C++ API rdkafka++ (S7)" + }, + { + "id": "masstransit", + "regex": "MassTransit", + "flags": "i", + "exempt": "dropped|commercial licen[cs]e", + "why": ".NET Kafka is Confluent.Kafka; MassTransit dropped (S4)" + }, + { + "id": "kafka-ui", + "regex": "kafka-ui", + "flags": "i", + "why": "kafka-ui removed; kcat or kafka-console-consumer.sh (S8)" + }, + { + "id": "kubeval", + "regex": "\\bkubeval\\b", + "flags": "i", + "exempt": "kubeconform", + "why": "kubeval is unmaintained; kubeconform v0.8.0" + }, + { + "id": "libpqxx", + "regex": "\\b(lib)?pqxx\\b", + "exempt": "\\bnot (lib)?pqxx\\b", + "why": "C++ uses libpq directly (S7)" + }, + { + "id": "cxx-old-standard", + "regex": "[Cc]\\+\\+ ?(17|20)\\b|c\\+\\+2a|-std=c\\+\\+(17|20)|CMAKE_CXX_STANDARD (17|20)\\b", + "exempt": "\\b(since|in|from) [Cc]\\+\\+ ?(17|20)\\b", + "why": "C++23 (S7); history ('since C++20') stays" + }, + { + "id": "gcc-old", + "regex": "\\bGCC 1[0-4]\\b|gcc-toolset-1[0-4]\\b", + "why": "GCC 15 via gcc-toolset-15 (S7)" + }, + { + "id": "cmake-old", + "regex": "\\bCMake 3\\.\\d+", + "exempt": "UBI ships", + "why": "CMake 4.4.4 (S7)" + }, + { + "id": "quarkus-old", + "regex": "\\bQuarkus 3\\.(?!40\\b)\\d+", + "why": "Quarkus 3.40.1 LTS (S2)" + }, + { + "id": "spring-boot-old", + "regex": "\\bSpring Boot (3\\.\\d+|3\\.x|4\\.0)\\b", + "exempt": "migrat|upgrad|from Spring Boot 3", + "why": "Spring Boot 4.1.1 (S3)" + }, + { + "id": "go-old", + "regex": "\\bGo 1\\.(1\\d|2[0-6])\\b|golang:1\\.(1\\d|2[0-6])\\b", + "exempt": "go-toolset|Go 1\\.22\\b|since Go 1\\.\\d+", + "why": "Go 1.27.2 (S6); Go 1.22 (method-aware ServeMux patterns) is the version a feature arrived in and stays" + }, + { + "id": "python-old", + "regex": "\\bPython 3\\.([0-9]|1[0-3])\\b|python:3\\.([0-9]|1[0-3])\\b|python-3(9|1[0-3])\\b", + "why": "Python 3.14 (S5)" + }, + { + "id": "dotnet-old-tfm", + "regex": "\\bnet[5-9]\\.0\\b|dotnet-[5-9]0\\b|dotnet/(sdk|aspnet):[5-9]\\.", + "why": ".NET 10 (net10.0, ubi9/dotnet-100) (S4)" + }, + { + "id": "ubi8", + "regex": "\\bubi8\\b", + "flags": "i", + "why": "UBI 10 (UBI 9 only for .NET)" + }, + { + "id": "confluent-kafka-version", + "regex": "Confluent\\.Kafka[\\\" ]*(Version=\\\")?[ @]?2\\.(?!16\\.0)\\d+\\.\\d+", + "why": "Confluent.Kafka 2.16.0 (S4)" + }, + { + "id": "streamiz-version", + "regex": "Streamiz(\\.Kafka\\.Net)?[\\\" ]*(Version=\\\")?[ @]?1\\.(?!8\\.2)\\d+\\.\\d+", + "why": "Streamiz.Kafka.Net 1.8.2 (S4)" + }, + { + "id": "jackson2", + "regex": "com\\.fasterxml\\.jackson\\.(databind|core)\\b|\\bJackson 2\\b", + "why": "Jackson 3 (tools.jackson) with Spring Boot 4 (S3)" + }, + { + "id": "spring-kafka-json-serde", + "regex": "support\\.serializer\\.Json(Serializer|Deserializer)\\b", + "why": "Spring Kafka 4 uses the Jackson 3 JacksonJson serializers (S3)" + }, + { + "id": "topic-order-placed", + "regex": "\\border-placed\\b", + "exempt": "artifact|@(Channel|Incoming|Outgoing)\\(|mp\\.messaging\\.|channel|\\.avsc", + "why": "the Kafka topic is order.placed; the Quarkus channel name order-placed (bound to topic order.placed, as in every Quarkus example) and the Apicurio artifact id order-placed (Example 09) stay" + }, + { + "id": "graphql-go", + "regex": "\\bgraphql-go\\b|github\\.com/graphql-go/", + "why": "Go GraphQL is gqlgen (S6)" + }, + { + "id": "go-deck-python-leftovers", + "regex": "pyproject|requirements\\.txt|\\bpip install\\b|uvicorn|FastAPI", + "files": [ + "Designing-Cloud-Native-APIs-Go" + ], + "why": "the Go deck was retargeted from the Python deck; no Python tooling belongs in it" + }, + { + "id": "ocp-docs-old", + "regex": "openshift_container_platform/4\\.(1\\d|2[01])/", + "why": "OpenShift 4.22 (CRC 2.64.0 / OCP 4.22.14)" + }, + { + "id": "kafka-image", + "regex": "apache/kafka:(?!4\\.3\\.1\\b)\\d+\\.\\d+", + "why": "apache/kafka:4.3.1 (S8)" + }, + { + "id": "postgres-image", + "regex": "\\bpostgres:(?!18\\.6-alpine\\b)\\d+\\.\\d+", + "why": "postgres:18.6-alpine (S8)" + }, + { + "id": "redis-image", + "regex": "\\bredis:(?!8\\.10\\.2-alpine\\b)\\d+\\.\\d+", + "why": "redis:8.10.2-alpine (S8)" + }, + { + "id": "otel-lgtm-image", + "regex": "otel-lgtm:(?!0\\.36\\.0\\b)\\d", + "why": "grafana/otel-lgtm:0.36.0 (S8)" + }, + { + "id": "envoy-image", + "regex": "envoyproxy/envoy:v(?!1\\.39\\.3\\b)\\d", + "why": "envoyproxy/envoy:v1.39.3 (S8)" + }, + { + "id": "apicurio-image", + "regex": "apicurio-registry:(?!3\\.3\\.3\\b)\\d", + "why": "apicurio-registry:3.3.3 (S8)" + }, + { + "id": "flagd-image", + "regex": "flagd:v(?!0\\.17\\.0\\b)\\d", + "why": "ghcr.io/open-feature/flagd:v0.17.0 (S8)" + }, + { + "id": "debezium-image", + "regex": "debezium/connect:(?!3\\.7\\.0\\.Final\\b)\\d", + "why": "quay.io/debezium/connect:3.7.0.Final (S8)" + }, + { + "id": "five-implementations", + "regex": "\\b(five|5) (implementations|backends)\\b", + "flags": "i", + "why": "six language implementations (Go added)" + }, + { + "id": "latest-tag", + "regex": "[a-z0-9]:latest\\b", + "exempt": "never|no |not |nothing uses|sketchy|\\bblock\\b", + "why": "every image is pinned; :latest appears only as the thing not to do" + }, + { + "id": "cloud-sdk", + "regex": "aws-sdk|\\bboto3\\b|AWSSDK|IAmazonS3|software\\.amazon|@aws-sdk|AWS SDK|azure-sdk|Azure\\.Storage|google-cloud-|cloud\\.google\\.com/go", + "flags": "i", + "why": "decision 7: no vendor cloud SDKs; valet key and claim check follow examples/12-security (a ticket the service signs itself)" + }, + { + "id": "vendor-cloud", + "regex": "\\bAWS\\b|\\bAmazon\\b|\\bS3\\b|\\bSTS\\b|Azure|\\bGCS\\b|Google Cloud|Google IAP|\\bEKS\\b|\\bGKE\\b|\\bAKS\\b|CloudTrail|AWS Lambda|Cloudflare Access|Apigee|Confluent Cloud", + "why": "decision 7: no AWS, Azure, Google Cloud or other managed-service framing in examples or recommendations (object storage is described generically)" + }, + { + "id": "minio", + "regex": "minio-go|\\bminio\\b", + "flags": "i", + "why": "decision 7: minio-go removed (Go valet key is a golang-jwt ticket)" + }, + { + "id": "acs", + "regex": "\\bACS\\b|[Aa]dvanced [Cc]luster [Ss]ecurity|StackRox|roxctl", + "why": "decision 7: nothing relies on ACS; the network advisory uses CNI flow logs, Inspektor Gadget, Kyverno/Gatekeeper, Trivy and Falco" + }, + { + "id": "openshift", + "regex": "OpenShift|\\bOCP\\b|EgressFirewall|OperatorHub|\\bSCCs?\\b|OVN-K|\\boc (apply|adm|get|create|new-project)\\b", + "why": "decision 7: decks carry no Appendix S and no OpenShift-only feature; generic Kubernetes (Ingress/Gateway API, Pod Security Standards, Helm/upstream operators)" + }, + { + "id": "vendor-framework", + "regex": "Cloud Adoption Framework|Well-Architected Framework|Azure Architecture Center|AWS Prescriptive Guidance", + "why": "decision 7 (fix round A): cite vendor-neutral references (CNCF, NIST, OWASP, the books) instead of a cloud vendor's framework" + } +] diff --git a/scripts/deck_edit.py b/scripts/deck_edit.py new file mode 100755 index 00000000..073c39bc --- /dev/null +++ b/scripts/deck_edit.py @@ -0,0 +1,1293 @@ +#!/usr/bin/env python3 +"""Apply scripted edits to a .pptx in place (python-pptx + lxml; run with the S12 venv python). + + deck_edit.py [ ...] [--dry-run] [--out PATH] + +Edit files are applied in order; a failing operation aborts the run and nothing is written. +Without --out the deck is rewritten in place (atomic replace) after a backup copy is saved to +$DECK_BACKUP_DIR (default ~/.cache/deck_edit/backup). --dry-run prints the text diff (using +deck_text's extractor) and writes nothing. On success a JSON summary is printed as the last line +({"ok": true, "out": ..., "created": {...}, "ops": N}). + +Slides are addressed by sldId (the stable id in presentation.xml, shown as "sldId N" in the text +copy). A shape selector is a shape name (exact) or a unique substring of the shape's text; an op may +also give "shape_id": N. A sldId of the form "$name" refers to a slide created earlier by a +clone_slide op with "as": "name". + +Edits JSON: a list of operations, or {"ops": [...]}. + + [ + {"op": "replace", "scope": "both", "find": "Poetry", "repl": "uv", "expect": 4, + "sldIds": [259, 260]}, # sldIds optional; scope slide|notes|both + {"op": "set_notes", "sldId": 259, "paragraphs": ["First paragraph.", "Second one."]}, + {"op": "set_notes", "sldId": 259, "text": "One paragraph per line\\nof this string"}, + {"op": "set_code", "sldId": 269, "shape": "pyproject.toml", # shape optional: sole code box + "lines": ["# comment", "from fastapi import FastAPI", "", "app = FastAPI()"], + "max_lines": 22, "max_cols": 95}, + {"op": "set_text", "sldId": 258, "shape": "Foundations", + "paragraphs": ["New first paragraph", "New second paragraph"]}, + {"op": "clone_slide", "template": 269, "after": 275, "as": "r1"}, # after optional: append + {"op": "set_text", "sldId": "$r1", "shape": "CLOUD-NATIVE", "paragraphs": ["APPENDIX R"]}, + {"op": "replace_picture", "sldId": 260, "shape": "Picture 6", "png": "/path/fig.png"}, + {"op": "set_page_number", "sldId": "$r1", "text": "273"}, + {"op": "fit_font", "sldId": 431, "shape_id": 1938, "pt": 11}, + {"op": "resize_shape", "sldId": 343, "shape_id": 979, "cy": 4572000}, + {"op": "resize_shape", "sldId": 300, "shape_id": 508, "cx": 2400000, "keep": "right"}, + {"op": "cut_code", "sldId": 474, "shape_id": 2424, "pt": 9, "cuts": [ + {"from": 13, "to": 20, "first": "# Optional defensive variant: ...", "ellipsis": "# ..."}]}, + {"op": "recolour_code", "sldId": 521, "shape_id": 8, "code_rgb": "E6E6E6", "comment_rgb": "8FB98F"} + ] + +Operations + replace exact text replace; "expect" (required) is the total match count over the + selected slides/notes and must equal the real count, otherwise the run fails. + Matches inside one run keep its formatting; a match spanning runs is merged + into the first run's formatting and logged. + set_notes replace the speaker notes body (creates a notes slide when missing); keeps + the formatting of the first existing notes paragraph. + set_code rewrite a code box line by line, one paragraph per line, copying the box's own + paragraph/run formatting; whole-line comments (# // -- /* *) take the format of + an existing comment line of that box. Fails over max_lines (22, or the box's current line + count if larger) or max_cols + (95) or the box capacity (height / font size / width). Optional "pt" and + "line_spacing" resize the box's text first, as fit_font does. A line's + template run is the run that put non-blank text on it. The result is then + coloured by line type (recolour_code): "code_rgb" (default: the box's code + colour, else E6E6E6) and "comment_rgb" (default: the box's comment colour, + else 8FB98F); "colour": false keeps the template colours. + recolour_code colour one code box by line type: whole-line comments "comment_rgb" + (8FB98F), other non-blank lines "code_rgb" (E6E6E6), a trailing-comment run + after code stays comment-coloured; runs of a third colour are kept unless + "force"; fails when nothing changes unless "allow_noop". Multi-line runs are + split per line first (same text). + set_text rewrite a text shape's paragraphs keeping per-paragraph formatting (paragraph i + reuses existing paragraph i, extras reuse the last one). + clone_slide deep copy a slide (shapes, relationships incl. images, own notes slide) and + insert it after another slide or at the end; the new sldId is printed and + returned in "created". + replace_picture point a picture shape at a PNG (fit inside the old frame, aspect kept). + set_page_number set the static page-number text box (a digits-only text at 11 pt or less). + fit_font set the size of every run of one shape to "pt" (a multiple of 0.5; also the + paragraph-end, default and bullet sizes), or multiply every size by "scale" + (0.5-1.0, rounded to 0.5 pt; keeps a shape's size hierarchy); optional + "line_spacing" (0.8-1.5, with pt); optional "space_before" (pt) resets every + existing paragraph space-before + sets every paragraph's spcPct. Floors: 9 pt for code boxes, 12 pt for other + text; a code box must then fit its capacity (lines and columns) or the op fails. + cut_code excerpt a code box in place: "cuts" is a list of {"from": a, "to": b, "first": + "", "last": "" (optional), "ellipsis": "# ..." + (optional comment line that replaces the range, coloured "comment_rgb", + default 8FB98F)}; 1-based line numbers of + the box as it is when the op runs. Every other line keeps its runs and colours. + Optional "pt" / "line_spacing" as in fit_font; the result must fit the box. + resize_shape move or resize one text shape: any of "x", "y", "cx", "cy" in EMU; "keep": + "right" or "bottom" (or both, as a list) holds that edge. Fails when the frame + would leave the slide or overlap the page-number box or the logo picture. + set_geometry move or resize one picture (or any shape) by "shape" name or "shape_id": any + of "x", "y", "w", "h" in inches; the frame must stay on the slide and clear of + the page-number box and the logo. + +Box capacity (set_code, cut_code, fit_font): lines = 1 + (height - top inset - 1.32 x size) / +(1.20 x size x spcPct), columns = (width - insets) / (0.6 x size), the LibreOffice metrics of Red +Hat Mono measured in renders (the last line may reach into the bottom inset, not past the frame). +Run scripts/deck_fit.py on the edited deck: it checks the real render. +""" +from __future__ import annotations + +import argparse +import copy +import difflib +import json +import math +import os +import re +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lxml import etree # noqa: E402 +from pptx import Presentation # noqa: E402 +from pptx.opc.constants import RELATIONSHIP_TYPE as RT # noqa: E402 + +import deck_text # noqa: E402 + +A = "http://schemas.openxmlformats.org/drawingml/2006/main" +P = "http://schemas.openxmlformats.org/presentationml/2006/main" +R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +NS = {"a": A, "p": P, "r": R} +MONO_RE = deck_text.MONO_RE + + +def q(ns, tag): + return f"{{{ns}}}{tag}" + + +class EditError(Exception): + pass + + +# ----------------------------------------------------------------- helpers + +def ptext(p): + return "".join(t.text or "" for t in p.iter(q(A, "t"))) + + +def sp_text(sp): + return "\n".join(ptext(p) for p in sp.iter(q(A, "p"))) + + +def all_sps(root): + """Every p:sp in document order (groups recursed).""" + return [e for e in root.iter(q(P, "sp"))] + + +def sp_name(sp): + c = sp.find(f"{q(P, 'nvSpPr')}/{q(P, 'cNvPr')}") + return c.get("name") if c is not None else "" + + +def sp_id(sp): + c = sp.find(f"{q(P, 'nvSpPr')}/{q(P, 'cNvPr')}") + return int(c.get("id")) if c is not None else -1 + + +def run_font(r): + rpr = r.find(q(A, "rPr")) + if rpr is None: + return None + lat = rpr.find(q(A, "latin")) + return lat.get("typeface") if lat is not None else None + + +def run_size(r): + rpr = r.find(q(A, "rPr")) + return int(rpr.get("sz")) if rpr is not None and rpr.get("sz") else 0 + + +def is_code_sp(sp): + runs = [r for r in sp.iter(q(A, "r")) if (r.findtext(q(A, "t")) or "").strip()] + if not runs or not all(MONO_RE.search(run_font(r) or "") for r in runs): + return False + paras = [p for p in sp.iter(q(A, "p")) if ptext(p).strip()] + one = len(paras) == 1 and len(ptext(paras[0])) <= 60 and max(run_size(r) for r in runs) <= 1100 + return not one # a single short small mono line is a chip, not code + + +def select_sp(root, op, kind=None): + sps = [s for s in all_sps(root) if s.find(q(P, "txBody")) is not None] + if op.get("shape_id") is not None: + hit = [s for s in sps if sp_id(s) == int(op["shape_id"])] + else: + sel = op.get("shape") + if sel is None: + if kind == "code": + hit = [s for s in sps if is_code_sp(s)] + else: + raise EditError(f"{op['op']}: a shape selector is required") + else: + hit = [s for s in sps if sp_name(s) == sel] + if not hit: + hit = [s for s in sps if sel in sp_text(s)] + if len(hit) != 1: + desc = op.get("shape", op.get("shape_id", "(sole code box)")) + names = [f"{sp_name(s)}:{sp_text(s)[:40]!r}" for s in hit] + raise EditError(f"{op['op']}: selector {desc!r} matched {len(hit)} shapes {names}") + return hit[0] + + +def set_par_text(template, text): + """Deep-copy a paragraph, keep the first run's formatting, put `text` in it.""" + p = copy.deepcopy(template) + runs = [c for c in p if c.tag == q(A, "r")] + for c in list(p): + if c.tag in (q(A, "br"), q(A, "fld")): + p.remove(c) + if runs: + first = runs[0] + for extra in runs[1:]: + p.remove(extra) + else: + first = etree.SubElement(p, q(A, "r")) + etree.SubElement(first, q(A, "t")) + end = p.find(q(A, "endParaRPr")) + if end is not None: + p.remove(first) + end.addprevious(first) + t = first.find(q(A, "t")) + if t is None: + t = etree.SubElement(first, q(A, "t")) + t.text = text + return p + + +def fresh_par(): + p = etree.Element(q(A, "p")) + r = etree.SubElement(p, q(A, "r")) + rpr = etree.SubElement(r, q(A, "rPr")) + rpr.set("lang", "en-US") + etree.SubElement(r, q(A, "t")) + return p + + +def rewrite_paragraphs(txbody, texts, templates=None): + old = txbody.findall(q(A, "p")) + base = templates or old + if not base: + base = [fresh_par()] + anchor = old[0] if old else None + new = [] + for i, text in enumerate(texts): + new.append(set_par_text(base[min(i, len(base) - 1)], text)) + for o in old: + txbody.remove(o) + for p in new: + txbody.append(p) + return len(texts) + + +class Ctx: + def __init__(self, prs): + self.prs = prs + self.created: dict[str, int] = {} + self.log: list[str] = [] + + def slide(self, ref): + if isinstance(ref, str) and ref.startswith("$"): + if ref[1:] not in self.created: + raise EditError(f"unknown slide reference {ref}") + ref = self.created[ref[1:]] + for s in self.prs.slides: + if s.slide_id == int(ref): + return s + raise EditError(f"no slide with sldId {ref}") + + def sld_id_list(self): + return self.prs.slides._sldIdLst + + +# ----------------------------------------------------------------- replace + +def replace_in_paragraph(p, find, repl, label, log): + """Replace in one paragraph; returns match count.""" + count = 0 + # segments of consecutive a:r separated by a:br + segs, cur = [], [] + for c in p: + if c.tag == q(A, "r"): + cur.append(c) + elif c.tag == q(A, "br"): + segs.append(cur) + cur = [] + segs.append(cur) + for runs in segs: + if not runs: + continue + texts = [(r.findtext(q(A, "t")) or "") for r in runs] + full = "".join(texts) + spans, pos = [], 0 + while True: + i = full.find(find, pos) + if i < 0: + break + spans.append((i, i + len(find))) + pos = i + len(find) + if not spans: + continue + count += len(spans) + bounds, acc = [], 0 + for t in texts: + bounds.append((acc, acc + len(t))) + acc += len(t) + + def run_at(off, end=False): + for k, (s, e) in enumerate(bounds): + if (s < off <= e) if end else (s <= off < e): + return k + return len(bounds) - 1 + + for s, e in reversed(spans): + i, j = run_at(s), run_at(e, end=True) + if i == j: + bs = bounds[i][0] + texts[i] = texts[i][: s - bs] + repl + texts[i][e - bs:] + else: + log.append(f"cross-run match in {label}: {find!r} spans runs {i}..{j}; merged into run {i} formatting") + bs_i, bs_j = bounds[i][0], bounds[j][0] + texts[i] = texts[i][: s - bs_i] + repl + for k in range(i + 1, j): + texts[k] = "" + texts[j] = texts[j][e - bs_j:] + for r, t in zip(runs, texts): + te = r.find(q(A, "t")) + if te is None: + te = etree.SubElement(r, q(A, "t")) + te.text = t + # drop runs emptied by a cross-run merge (keep the first run of the segment) + for k, r in enumerate(runs): + if k > 0 and (r.findtext(q(A, "t")) or "") == "" and bounds[k][1] > bounds[k][0]: + p.remove(r) + return count + + +def notes_roots(slide): + if not slide.has_notes_slide: + return [] + return [slide.notes_slide._element] + + +def op_replace(ctx, op): + for k in ("find", "repl", "expect"): + if k not in op: + raise EditError(f"replace: missing {k!r}") + find, repl, expect = op["find"], op["repl"], int(op["expect"]) + if not find: + raise EditError("replace: empty find") + scope = op.get("scope", "both") + if scope not in ("slide", "notes", "both"): + raise EditError(f"replace: bad scope {scope!r}") + ids = op.get("sldIds") + slides = [ctx.slide(i) for i in ids] if ids is not None else list(ctx.prs.slides) + total = 0 + for s in slides: + roots = [] + if scope in ("slide", "both"): + roots.append(("slide", s._element)) + if scope in ("notes", "both"): + for r in notes_roots(s): + # only the notes body placeholder text, never the slide-number or image placeholders + for sp in all_sps(r): + ph = sp.find(f"{q(P, 'nvSpPr')}/{q(P, 'nvPr')}/{q(P, 'ph')}") + if ph is not None and ph.get("type") == "body": + roots.append(("notes", sp)) + for kind, root in roots: + for p in root.iter(q(A, "p")): + total += replace_in_paragraph(p, find, repl, f"sldId {s.slide_id} {kind}", ctx.log) + if total != expect: + raise EditError(f"replace {find!r}: expected {expect} match(es), found {total}") + ctx.log.append(f"replace {find!r} -> {repl!r}: {total} match(es)") + + +# ----------------------------------------------------------------- notes + +def notes_body(slide, create=True): + if not slide.has_notes_slide and not create: + return None + ns = slide.notes_slide + for sp in all_sps(ns._element): + ph = sp.find(f"{q(P, 'nvSpPr')}/{q(P, 'nvPr')}/{q(P, 'ph')}") + if ph is not None and ph.get("type") == "body": + return sp + raise EditError(f"sldId {slide.slide_id}: notes slide has no body placeholder") + + +def op_set_notes(ctx, op): + s = ctx.slide(op["sldId"]) + if "paragraphs" in op: + texts = list(op["paragraphs"]) + elif "text" in op: + texts = op["text"].split("\n") + else: + raise EditError("set_notes: needs text or paragraphs") + texts = [t for t in texts if t.strip() != ""] or [""] + existed = s.has_notes_slide + sp = notes_body(s) + tx = sp.find(q(P, "txBody")) + if tx is None: + raise EditError(f"sldId {s.slide_id}: notes body has no txBody") + old = [p for p in tx.findall(q(A, "p")) if ptext(p).strip()] or tx.findall(q(A, "p")) + rewrite_paragraphs(tx, texts, templates=old[:1] or None) + ctx.log.append(f"set_notes sldId {s.slide_id}: {len(texts)} paragraph(s)" + ("" if existed else " (created notes slide)")) + + +# ----------------------------------------------------------------- code + +COMMENT_RE = re.compile(r"^\s*(#(?!\s*(include|define|pragma|if|ifdef|ifndef|else|elif|endif|undef|error)\b)|#!|//|--\s|/\*|\*\s|\*/)") + + +def is_comment(text): + return bool(COMMENT_RE.match(text)) and not text.lstrip().startswith("#!") + + +# LibreOffice line pitch for Red Hat Mono and Red Hat Text: 1.20 x the font size x the +# paragraph's spcPct (median of 248 multi-line text boxes measured in the soffice PDFs of the +# Python and .NET decks with pdftotext -bbox-layout, 2026-10-12; range 1.1985-1.2020). +# Red Hat Mono advances 0.5985 x the size per character; a rendered line box is 1.32 x the size. +LINE_HEIGHT = 1.20 +MONO_ADVANCE = 0.6 +GLYPH_BOX = 1.32 # ascent + descent of a rendered line box (pdftotext -bbox-layout), x the size + + +def box_capacity(sp, tmpl, run=None): + xfrm = sp.find(f"{q(P, 'spPr')}/{q(A, 'xfrm')}") + ext = xfrm.find(q(A, "ext")) if xfrm is not None else None + if ext is None: + return None, None + body = sp.find(f"{q(P, 'txBody')}/{q(A, 'bodyPr')}") + ti = int(body.get("tIns", 45720)) if body is not None else 45720 + bi = int(body.get("bIns", 45720)) if body is not None else 45720 + li = int(body.get("lIns", 91440)) if body is not None else 91440 + ri = int(body.get("rIns", 91440)) if body is not None else 91440 + run = run if run is not None else tmpl.find(q(A, "r")) + sz = (run_size(run) if run is not None else 0) or 1300 + sp_pct = tmpl.find(f"{q(A, 'pPr')}/{q(A, 'lnSpc')}/{q(A, 'spcPct')}") + spc = int(sp_pct.get("val")) / 100000 if sp_pct is not None else 1.0 + line_pt = sz / 100 * LINE_HEIGHT * spc + # n lines end at top inset + (n - 1) pitches + the last line's glyph box (GLYPH_BOX x size); + # that box may reach into the bottom inset, as long as it stays inside the frame + lines = math.floor(((int(ext.get("cy")) - ti) / 12700 - sz / 100 * GLYPH_BOX) / line_pt + 1 + 0.02) + cols = math.floor((int(ext.get("cx")) - li - ri) / 12700 / (sz / 100 * MONO_ADVANCE)) + return lines, cols + + +def code_lines(tx): + """Existing lines of a code box as dicts {p, run, br, text}; handles one paragraph per line, + runs separated by a:br inside paragraphs, and newlines embedded in a:t.""" + out = [] + for p in tx.findall(q(A, "p")): + cur = {"p": p, "run": None, "br": None, "text": "", "nonblank": False} + for c in list(p): + if c.tag == q(A, "r"): + t = c.findtext(q(A, "t")) or "" + parts = t.split("\n") + for k, part in enumerate(parts): + if k > 0: + out.append(cur) + cur = {"p": p, "run": None, "br": None, "text": "", "nonblank": False} + cur["text"] += part + # the line's template run is the first run that put non-blank text on this + # line; a run that only ends here (e.g. "# comment\n" before a code run) + # is a fallback for a blank line, never the template of a code line + if part.strip(): + if not cur["nonblank"]: + cur["run"] = c + cur["nonblank"] = True + elif cur["run"] is None: + cur["run"] = c + elif c.tag == q(A, "br"): + cur["br"] = c + out.append(cur) + cur = {"p": p, "run": None, "br": None, "text": "", "nonblank": False} + out.append(cur) + return out + + +# House code palette: code lines E6E6E6, whole-line comments 8FB98F (the 8A8A8A grey is the chip +# label colour and never appears in a code box). +CODE_RGB = "E6E6E6" +COMMENT_RGB = "8FB98F" +RGB_RE = re.compile(r"^[0-9A-Fa-f]{6}$") + + +def run_rgb(r): + rpr = r.find(q(A, "rPr")) + sf = rpr.find(q(A, "solidFill")) if rpr is not None else None + clr = sf.find(q(A, "srgbClr")) if sf is not None else None + return clr.get("val").upper() if clr is not None and clr.get("val") else None + + +def set_run_rgb(r, rgb): + rpr = r.find(q(A, "rPr")) + if rpr is None: + rpr = etree.Element(q(A, "rPr")) + r.insert(0, rpr) + for tag in ("noFill", "gradFill", "blipFill", "pattFill", "grpFill"): + for e in rpr.findall(q(A, tag)): + rpr.remove(e) + sf = rpr.find(q(A, "solidFill")) + if sf is None: + sf = etree.Element(q(A, "solidFill")) + ln = rpr.find(q(A, "ln")) + if ln is not None: + ln.addnext(sf) + else: + rpr.insert(0, sf) + for c in list(sf): + sf.remove(c) + etree.SubElement(sf, q(A, "srgbClr")).set("val", rgb) + + +def check_rgb(v, label): + if v is not None and not RGB_RE.match(str(v)): + raise EditError(f"{label}: {v!r} is not an RRGGBB colour") + return v.upper() if v is not None else None + + +def _split_multiline_runs(tx): + """Split every run whose text holds newlines into one run per line (each piece but the last + keeps its trailing newline), so a line's colour can be set on its own; the text is unchanged.""" + n = 0 + for r in list(tx.iter(q(A, "r"))): + t = r.find(q(A, "t")) + text = t.text if t is not None and t.text else "" + if "\n" not in text.rstrip("\n") and text.count("\n") <= 1: + continue + pieces = [x + "\n" for x in text.split("\n")[:-1]] + [text.split("\n")[-1]] + pieces = [x for x in pieces if x] + if len(pieces) <= 1: + continue + t.text = pieces[0] + prev = r + for piece in pieces[1:]: + nr = copy.deepcopy(r) + nr.find(q(A, "t")).text = piece + prev.addnext(nr) + prev = nr + n += 1 + return n + + +def recolour_box(tx, code_rgb=CODE_RGB, comment_rgb=COMMENT_RGB, force=False): + """Colour a code box by line type: whole-line comments comment_rgb, every other non-blank + line code_rgb; a run that starts a trailing comment after code on the same line keeps + comment_rgb. Runs of any third colour are left alone unless force. Returns the number of + runs whose colour changed.""" + _split_multiline_runs(tx) + lines, cur = [], [] + for p in tx.findall(q(A, "p")): + cur = [] + for c in p: + if c.tag == q(A, "r"): + text = c.findtext(q(A, "t")) or "" + cur.append((c, text.rstrip("\n"))) + if text.endswith("\n"): + lines.append(cur) + cur = [] + elif c.tag == q(A, "br"): + lines.append(cur) + cur = [] + lines.append(cur) + ours = {code_rgb, comment_rgb, CODE_RGB, COMMENT_RGB, None} + changed = 0 + for segs in lines: + text = "".join(t for _, t in segs) + if not text.strip(): + continue + whole = is_comment(text) + seen_code, in_comment = False, False + for r, t in segs: + if not t.strip(): + continue + if whole: + want = comment_rgb + else: + if seen_code and not in_comment and is_comment(t): + in_comment = True + want = comment_rgb if in_comment else code_rgb + if not is_comment(t): + seen_code = True + have = run_rgb(r) + if have == want or (have not in ours and not force): + continue + set_run_rgb(r, want) + changed += 1 + return changed + + +def op_recolour_code(ctx, op): + """Recolour one code box by line type (see recolour_box).""" + s = ctx.slide(op["sldId"]) + sp = select_sp(s._element, op, kind="code") + label = f"recolour_code sldId {s.slide_id}" + code_rgb = check_rgb(op.get("code_rgb", CODE_RGB), label) + comment_rgb = check_rgb(op.get("comment_rgb", COMMENT_RGB), label) + n = recolour_box(sp.find(q(P, "txBody")), code_rgb, comment_rgb, bool(op.get("force"))) + if n == 0 and not op.get("allow_noop"): + raise EditError(f"{label}: no run changed colour (wrong shape, or already coloured)") + ctx.log.append(f"{label} {sp_name(sp)!r}: {n} run(s) recoloured (code {code_rgb}, comment {comment_rgb})") + + +def op_set_code(ctx, op): + s = ctx.slide(op["sldId"]) + sp = select_sp(s._element, op, kind="code") + tx = sp.find(q(P, "txBody")) + if "lines" in op: + lines = list(op["lines"]) + elif "code" in op: + lines = op["code"].rstrip("\n").split("\n") + else: + raise EditError("set_code: needs lines or code") + lines = [ln.replace("\t", " ").rstrip() for ln in lines] + if op.get("pt") is not None or op.get("line_spacing") is not None: + if op.get("pt") is None: + raise EditError(f"set_code sldId {s.slide_id}: line_spacing needs pt") + size_shape(sp, op["pt"], op.get("line_spacing"), f"set_code sldId {s.slide_id}", code=True) + old_lines = code_lines(tx) + has_br = any(l["br"] is not None for l in old_lines) + embedded = any("\n" in (t.text or "") for t in tx.iter(q(A, "t"))) + br_model = has_br or (len(tx.findall(q(A, "p"))) == 1 and embedded) + code_l = comment_l = blank_l = None + for l in old_lines: + if l["run"] is None and not l["text"].strip(): + blank_l = blank_l or l + elif not l["text"].strip(): + blank_l = blank_l or l + elif is_comment(l["text"]): + comment_l = comment_l or l + else: + code_l = code_l or l + if code_l is None: + code_l = comment_l + if code_l is None or code_l["run"] is None: + raise EditError(f"set_code sldId {s.slide_id}: the box has no text line to copy formatting from") + if comment_l is None: + comment_l = code_l + ctx.log.append(f"set_code sldId {s.slide_id}: no comment line in the box; comments use the code format") + # Default limit: 22 lines, or the box's current line count when it already holds more + # (the existing font size was chosen for that many lines). + max_lines = int(op.get("max_lines", max(22, len(old_lines)))) + max_cols = int(op.get("max_cols", 95)) + cap_l, cap_c = box_capacity(sp, code_l["p"], code_l["run"]) + if len(lines) > max_lines: + raise EditError(f"set_code sldId {s.slide_id}: {len(lines)} lines exceeds max_lines {max_lines}") + if cap_l is not None and len(lines) > cap_l: + raise EditError(f"set_code sldId {s.slide_id}: {len(lines)} lines exceeds the box capacity {cap_l}") + wide = max((len(ln) for ln in lines), default=0) + if wide > max_cols: + raise EditError(f"set_code sldId {s.slide_id}: a line is {wide} columns, max_cols {max_cols}") + if cap_c is not None and wide > cap_c: + raise EditError(f"set_code sldId {s.slide_id}: a line is {wide} columns, box capacity {cap_c}") + if not br_model: + old = tx.findall(q(A, "p")) + new = [] + for ln in lines: + if not ln.strip(): + base = blank_l["p"] if blank_l is not None else code_l["p"] + new.append(set_par_text(base, "")) + else: + base = (comment_l if is_comment(ln) else code_l)["p"] + new.append(set_par_text(base, ln)) + for o in old: + tx.remove(o) + for p in new: + tx.append(p) + else: + p0 = copy.deepcopy(old_lines[0]["p"]) + end = p0.find(q(A, "endParaRPr")) + for c in list(p0): + if c.tag in (q(A, "r"), q(A, "br"), q(A, "fld")): + p0.remove(c) + + def add(el): + if end is not None: + end.addprevious(el) + else: + p0.append(el) + + def br_for(tmpl_line): + if tmpl_line["br"] is not None: + return copy.deepcopy(tmpl_line["br"]) + b = etree.Element(q(A, "br")) + rpr = tmpl_line["run"].find(q(A, "rPr")) + if rpr is not None: + b.append(copy.deepcopy(rpr)) + return b + + for i, ln in enumerate(lines): + tmpl = comment_l if is_comment(ln) else code_l + if ln.strip(): + r = copy.deepcopy(tmpl["run"]) + for extra in r.findall(q(A, "t"))[1:]: + r.remove(extra) + te = r.find(q(A, "t")) + if te is None: + te = etree.SubElement(r, q(A, "t")) + te.text = ln + add(r) + if i < len(lines) - 1: + add(br_for(tmpl if ln.strip() else code_l)) + for o in tx.findall(q(A, "p")): + tx.remove(o) + tx.append(p0) + recoloured = 0 + if op.get("colour", True): + label = f"set_code sldId {s.slide_id}" + have_code = run_rgb(code_l["run"]) + have_comment = run_rgb(comment_l["run"]) if comment_l is not code_l else None + code_rgb = check_rgb(op.get("code_rgb"), label) or ( + have_code if have_code and have_code != COMMENT_RGB else CODE_RGB) + comment_rgb = check_rgb(op.get("comment_rgb"), label) or ( + have_comment if have_comment and have_comment != code_rgb else COMMENT_RGB) + recoloured = recolour_box(tx, code_rgb, comment_rgb) + ctx.log.append(f"set_code sldId {s.slide_id}: {len(lines)} line(s), widest {wide} col(s) " + f"(box {cap_l} lines x {cap_c} cols, {'br' if br_model else 'paragraph'} model)" + + (f", {recoloured} run(s) recoloured" if recoloured else "")) + + +def op_set_text(ctx, op): + s = ctx.slide(op["sldId"]) + sp = select_sp(s._element, op) + tx = sp.find(q(P, "txBody")) + texts = list(op["paragraphs"]) + templates = [p for p in tx.findall(q(A, "p")) if ptext(p).strip()] or tx.findall(q(A, "p")) + rewrite_paragraphs(tx, texts, templates=templates) + ctx.log.append(f"set_text sldId {s.slide_id} {sp_name(sp)!r}: {len(texts)} paragraph(s)") + + +def op_set_page_number(ctx, op): + s = ctx.slide(op["sldId"]) + hits = [] + for sp in all_sps(s._element): + if sp.find(q(P, "txBody")) is None: + continue + t = sp_text(sp).strip() + runs = [r for r in sp.iter(q(A, "r"))] + size = max([run_size(r) for r in runs] or [0]) + if re.fullmatch(r"\d{1,3}", t) and 0 < size <= 1100: + hits.append(sp) + if len(hits) != 1: + raise EditError(f"set_page_number sldId {s.slide_id}: found {len(hits)} page-number boxes") + r = next(hits[0].iter(q(A, "r"))) + r.find(q(A, "t")).text = str(op["text"]) + ctx.log.append(f"set_page_number sldId {s.slide_id}: {op['text']}") + + +# ----------------------------------------------------------------- picture + +def op_replace_picture(ctx, op): + s = ctx.slide(op["sldId"]) + pics = list(s._element.iter(q(P, "pic"))) + sel = op.get("shape") + if op.get("shape_id") is not None: + hit = [p for p in pics if p.find(f"{q(P, 'nvPicPr')}/{q(P, 'cNvPr')}").get("id") == str(op["shape_id"])] + elif sel is None and len(pics) == 1: + hit = pics + else: + hit = [p for p in pics if p.find(f"{q(P, 'nvPicPr')}/{q(P, 'cNvPr')}").get("name") == sel] + if not hit: + hit = [p for p in pics if sel in (p.find(f"{q(P, 'nvPicPr')}/{q(P, 'cNvPr')}").get("descr") or "")] + if len(hit) != 1: + raise EditError(f"replace_picture sldId {s.slide_id}: selector matched {len(hit)} pictures of {len(pics)}") + pic = hit[0] + png = Path(op["png"]) + if not png.is_file(): + raise EditError(f"replace_picture: {png} not found") + blip = pic.find(f".//{q(A, 'blip')}") + old_rid = blip.get(q(R, "embed")) + img_part, new_rid = s.part.get_or_add_image_part(str(png)) + blip.set(q(R, "embed"), new_rid) + if old_rid != new_rid: + # drop the old relationship when nothing else on the slide uses it + still = any(v == old_rid for e in s._element.iter() for k, v in e.attrib.items() if k.startswith("{" + R)) + if not still: + s.part.drop_rel(old_rid) + if op.get("fit", True): + iw, ih = img_part.image.size + xfrm = pic.find(f"{q(P, 'spPr')}/{q(A, 'xfrm')}") + off, ext = xfrm.find(q(A, "off")), xfrm.find(q(A, "ext")) + bx, by, bw, bh = int(off.get("x")), int(off.get("y")), int(ext.get("cx")), int(ext.get("cy")) + scale = min(bw / iw, bh / ih) + nw, nh = int(iw * scale), int(ih * scale) + off.set("x", str(bx + (bw - nw) // 2)) + off.set("y", str(by + (bh - nh) // 2)) + ext.set("cx", str(nw)) + ext.set("cy", str(nh)) + sr = pic.find(f"{q(P, 'blipFill')}/{q(A, 'srcRect')}") + if sr is not None: + sr.getparent().remove(sr) + ctx.log.append(f"replace_picture sldId {s.slide_id}: {png.name}") + + +# ----------------------------------------------------------------- clone + +def op_clone_slide(ctx, op): + prs = ctx.prs + tpl = ctx.slide(op["template"]) + after = ctx.slide(op["after"]) if op.get("after") is not None else None + new = prs.slides.add_slide(tpl.slide_layout) + # a free partname (python-pptx numbers by slide count, which collides after deletions) + pkg_names = {str(p.partname) for p in prs.part.package.iter_parts()} + if sum(1 for n in pkg_names if n == str(new.part.partname)) > 1: + n = 1 + while f"/ppt/slides/slide{n}.xml" in pkg_names: + n += 1 + new.part.partname = type(new.part.partname)(f"/ppt/slides/slide{n}.xml") + # relationships: same targets, mapped rIds + rid_map = {} + for rid, rel in tpl.part.rels.items(): + if rel.reltype in (RT.SLIDE_LAYOUT, RT.NOTES_SLIDE): + continue + if rel.is_external: + rid_map[rid] = new.part.relate_to(rel.target_ref, rel.reltype, is_external=True) + else: + rid_map[rid] = new.part.relate_to(rel.target_part, rel.reltype) + # replace the XML children wholesale with copies of the template's + for c in list(new._element): + new._element.remove(c) + for c in tpl._element: + new._element.append(copy.deepcopy(c)) + for e in new._element.iter(): + for k, v in list(e.attrib.items()): + if k.startswith("{" + R) and v in rid_map: + e.set(k, rid_map[v]) + elif k.startswith("{" + R): + raise EditError(f"clone_slide: unmapped relationship {v} on {e.tag}") + # notes: own notes slide, content copied from the template's + if tpl.has_notes_slide: + nn = new.notes_slide + src = tpl.notes_slide._element + dst = nn._element + dst_csld, src_csld = dst.find(q(P, "cSld")), src.find(q(P, "cSld")) + dst.replace(dst_csld, copy.deepcopy(src_csld)) + for e in dst.iter(): + for k in e.attrib: + if k.startswith("{" + R): + raise EditError("clone_slide: notes slide holds a relationship reference; not supported") + # position + lst = ctx.sld_id_list() + new_el = next(e for e in lst if int(e.get("id")) == new.slide_id) + lst.remove(new_el) + if after is None: + lst.append(new_el) + else: + tgt = next(e for e in lst if int(e.get("id")) == after.slide_id) + tgt.addnext(new_el) + name = op.get("as") + if name: + ctx.created[name] = new.slide_id + ctx.log.append(f"clone_slide {tpl.slide_id} -> new sldId {new.slide_id}" + + (f" after {after.slide_id}" if after else " at end") + (f" as ${name}" if name else "")) + + +# ----------------------------------------------------------------- fit + +MIN_CODE_PT = 9 +MIN_TEXT_PT = 12 + + +def size_shape(sp, pt, line_spacing, label, code=None): + """Set every run, paragraph-end, default and bullet size of a shape to pt (and optionally + every paragraph's line spacing); enforces the floors (code: set_code and cut_code boxes, or a + shape whose runs are all monospace).""" + code = is_code_sp(sp) if code is None else code + pt = float(pt) + floor = MIN_CODE_PT if code else MIN_TEXT_PT + if pt < floor: + raise EditError(f"{label}: {pt} pt is under the {floor} pt floor for {'code' if code else 'text'}") + if pt * 2 != int(pt * 2): + raise EditError(f"{label}: {pt} pt is not a multiple of 0.5") + tx = sp.find(q(P, "txBody")) + val = str(int(round(pt * 100))) + n = 0 + # a paragraph without runs takes its height from endParaRPr (or the master default when + # that has no size), so every paragraph gets one with the new size + for p in tx.findall(q(A, "p")): + if p.find(q(A, "endParaRPr")) is None: + etree.SubElement(p, q(A, "endParaRPr")).set("lang", "en-US") + for tag in ("rPr", "endParaRPr", "defRPr"): + for e in tx.iter(q(A, tag)): + e.set("sz", val) + n += 1 + for e in tx.iter(q(A, "buSzPts")): + e.set("val", val) + if n == 0: + raise EditError(f"{label}: the shape has no run properties to size") + if line_spacing is not None: + pct = int(round(float(line_spacing) * 100000)) + if not 80000 <= pct <= 150000: + raise EditError(f"{label}: line_spacing {line_spacing} outside 0.8-1.5") + for p in tx.findall(q(A, "p")): + ppr = p.find(q(A, "pPr")) + if ppr is None: + ppr = etree.Element(q(A, "pPr")) + p.insert(0, ppr) + ln = ppr.find(q(A, "lnSpc")) + if ln is not None: + ppr.remove(ln) + ln = etree.Element(q(A, "lnSpc")) + etree.SubElement(ln, q(A, "spcPct")).set("val", str(pct)) + ppr.insert(0, ln) + return code + + +def scale_shape(sp, factor, label): + """Multiply every size in a shape by factor (rounded to 0.5 pt); keeps the ratio between a + shape's sizes. The smallest resulting text size must respect the floor.""" + code = is_code_sp(sp) + floor = MIN_CODE_PT if code else MIN_TEXT_PT + if not 0.5 <= factor < 1.0: + raise EditError(f"{label}: scale {factor} outside 0.5-1.0") + tx = sp.find(q(P, "txBody")) + run_sizes = [run_size(r) for r in tx.iter(q(A, "r")) if run_size(r)] + if not run_sizes: + raise EditError(f"{label}: the shape has no sized runs") + new = lambda v: int(round(int(v) * factor / 50.0)) * 50 + if new(min(run_sizes)) < floor * 100: + raise EditError(f"{label}: scale {factor} takes {min(run_sizes) / 100} pt under the {floor} pt floor") + for tag in ("rPr", "endParaRPr", "defRPr"): + for e in tx.iter(q(A, tag)): + if e.get("sz"): + e.set("sz", str(new(e.get("sz")))) + for e in tx.iter(q(A, "buSzPts")): + e.set("val", str(new(e.get("val")))) + return code + + +def op_fit_font(ctx, op): + """Set the font size of every run (and the paragraph-end and bullet sizes) of one shape, or + scale all of them by a factor.""" + s = ctx.slide(op["sldId"]) + sp = select_sp(s._element, op) + if ("pt" in op) == ("scale" in op): + raise EditError("fit_font: give exactly one of pt or scale") + if "scale" in op: + code = scale_shape(sp, float(op["scale"]), f"fit_font sldId {s.slide_id}") + pt = f"x{op['scale']}" + else: + pt = float(op["pt"]) + code = size_shape(sp, pt, op.get("line_spacing"), f"fit_font sldId {s.slide_id}") + tx = sp.find(q(P, "txBody")) + if op.get("space_before") is not None: + sb = float(op["space_before"]) + if not 0 <= sb <= 24: + raise EditError(f"fit_font sldId {s.slide_id}: space_before {sb} outside 0-24 pt") + hits = 0 + for e in tx.iter(q(A, "spcBef")): + for c in list(e): + e.remove(c) + etree.SubElement(e, q(A, "spcPts")).set("val", str(int(round(sb * 100)))) + hits += 1 + if not hits: + raise EditError(f"fit_font sldId {s.slide_id}: no paragraph spacing to change") + note = "" + if code: + lines = code_lines(tx) + tmpl = next((l for l in lines if l["run"] is not None and l["text"].strip()), None) + if tmpl is not None: + cap_l, cap_c = box_capacity(sp, tmpl["p"], tmpl["run"]) + wide = max(len(l["text"].replace("\t", " ").rstrip()) for l in lines) + if cap_l is not None and len(lines) > cap_l: + raise EditError(f"fit_font sldId {s.slide_id}: {len(lines)} lines still exceed the box " + f"capacity {cap_l} at {pt} pt") + if cap_c is not None and wide > cap_c: + raise EditError(f"fit_font sldId {s.slide_id}: a {wide}-column line still exceeds the " + f"box capacity {cap_c} at {pt} pt") + note = f" (box {cap_l} lines x {cap_c} cols, {len(lines)} lines, widest {wide})" + ctx.log.append(f"fit_font sldId {s.slide_id} {sp_name(sp)!r}: {pt} pt{note}") + + +def _line_records(tx): + """Lines of a code box in order: {"p", "elems", "br", "text"}; a line ends at an a:br or at + the end of its paragraph. Newlines embedded in a:t are not supported here.""" + recs = [] + for p in tx.findall(q(A, "p")): + cur = {"p": p, "elems": [], "br": None, "text": ""} + for c in p: + if c.tag in (q(A, "r"), q(A, "fld")): + t = "".join(x.text or "" for x in c.iter(q(A, "t"))) + if "\n" in t: + raise EditError("cut_code: newlines inside a text run are not supported") + cur["elems"].append(c) + cur["text"] += t + elif c.tag == q(A, "br"): + cur["br"] = c + recs.append(cur) + cur = {"p": p, "elems": [], "br": None, "text": ""} + recs.append(cur) + return recs + + +def op_cut_code(ctx, op): + """Excerpt a code box in place: remove line ranges, optionally replacing each with one comment + line (the ellipsis), keeping the formatting of every remaining line.""" + s = ctx.slide(op["sldId"]) + sp = select_sp(s._element, op, kind="code") + tx = sp.find(q(P, "txBody")) + label = f"cut_code sldId {s.slide_id}" + if op.get("pt") is not None: + size_shape(sp, op["pt"], op.get("line_spacing"), label, code=True) + recs = _line_records(tx) + cuts = sorted(op.get("cuts") or [], key=lambda c: int(c["from"])) + if not cuts: + raise EditError(f"{label}: no cuts") + removed, ellipsis_at = set(), {} + last_end = 0 + for c in cuts: + a, b = int(c["from"]), int(c.get("to", c["from"])) + if a <= last_end or b < a or b > len(recs): + raise EditError(f"{label}: bad or overlapping range {a}-{b} (box has {len(recs)} lines)") + last_end = b + if recs[a - 1]["text"].rstrip() != c["first"].rstrip(): + raise EditError(f"{label}: line {a} is {recs[a - 1]['text']!r}, expected {c['first']!r}") + if "last" in c and recs[b - 1]["text"].rstrip() != c["last"].rstrip(): + raise EditError(f"{label}: line {b} is {recs[b - 1]['text']!r}, expected {c['last']!r}") + removed.update(range(a - 1, b)) + if c.get("ellipsis"): + if not is_comment(c["ellipsis"]): + raise EditError(f"{label}: ellipsis {c['ellipsis']!r} is not a comment line") + ellipsis_at[a - 1] = c["ellipsis"] + comment = next((r for r in recs if r["text"].strip() and is_comment(r["text"]) and r["elems"]), None) + code = next((r for r in recs if r["text"].strip() and not is_comment(r["text"]) and r["elems"]), None) + tmpl = comment or code + if ellipsis_at and tmpl is None: + raise EditError(f"{label}: no line to copy the ellipsis format from") + if ellipsis_at and comment is None: + ctx.log.append(f"{label}: no comment line in the box; the ellipsis uses the code format") + br_tmpl = next((r["br"] for r in recs if r["br"] is not None), None) + + def ellipsis_run(text): + r = copy.deepcopy(next(e for e in tmpl["elems"] if e.tag == q(A, "r") and (e.findtext(q(A, "t")) or "").strip())) + for extra in r.findall(q(A, "t"))[1:]: + r.remove(extra) + r.find(q(A, "t")).text = text + # an ellipsis is a comment line: comment colour even when the template is a code line + set_run_rgb(r, ell_rgb) + return r + + ell_rgb = check_rgb(op.get("comment_rgb"), label) or COMMENT_RGB + paras = tx.findall(q(A, "p")) + by_p = {id(p): [i for i, r in enumerate(recs) if r["p"] is p] for p in paras} + kept_total = 0 + for p in paras: + idx = by_p[id(p)] + new = [] + for i in idx: + if i in ellipsis_at: + new.append(("ell", ellipsis_at[i])) + if i not in removed: + new.append(("rec", recs[i])) + kept_total += len(new) + if new == [("rec", recs[idx[0]])] and len(idx) == 1: + continue # untouched single-line paragraph + if len(idx) == 1 and not new: + tx.remove(p) + continue + if len(idx) == 1 and len(new) == 1 and new[0][0] == "ell": + np_ = set_par_text(tmpl["p"] if len(by_p[id(tmpl["p"])]) == 1 else p, new[0][1]) + if len(by_p[id(tmpl["p"])]) != 1: + # template line sits in a multi-line paragraph: rebuild this one from the run + for c in list(np_): + if c.tag in (q(A, "r"), q(A, "fld"), q(A, "br")): + np_.remove(c) + end = np_.find(q(A, "endParaRPr")) + (end.addprevious if end is not None else np_.append)(ellipsis_run(new[0][1])) + for r in np_.findall(q(A, "r")): + set_run_rgb(r, ell_rgb) + p.addprevious(np_) + tx.remove(p) + continue + # multi-line paragraph (a:br model): rebuild its runs and breaks + if not new: + tx.remove(p) + continue + end = p.find(q(A, "endParaRPr")) + for c in list(p): + if c.tag in (q(A, "r"), q(A, "fld"), q(A, "br")): + p.remove(c) + add = end.addprevious if end is not None else p.append + for k, (kind, v) in enumerate(new): + if kind == "ell": + add(ellipsis_run(v)) + else: + for e in v["elems"]: + add(e) + if k < len(new) - 1: + b = v["br"] if kind == "rec" and v["br"] is not None else None + if b is None: + if br_tmpl is None: + b = etree.Element(q(A, "br")) + else: + b = copy.deepcopy(br_tmpl) + add(b) + if not tx.findall(q(A, "p")): + raise EditError(f"{label}: every line was cut") + lines = code_lines(tx) + first = next((l for l in lines if l["run"] is not None and l["text"].strip()), None) + cap_l, cap_c = box_capacity(sp, first["p"], first["run"]) if first is not None else (None, None) + wide = max(len(l["text"].replace("\t", " ").rstrip()) for l in lines) + if cap_l is not None and len(lines) > cap_l: + raise EditError(f"{label}: {len(lines)} lines after the cuts exceed the box capacity {cap_l}") + if cap_c is not None and wide > cap_c: + raise EditError(f"{label}: a {wide}-column line exceeds the box capacity {cap_c}") + ctx.log.append(f"{label}: {len(recs)} -> {len(lines)} line(s), {len(ellipsis_at)} ellipsis line(s)" + f" (box {cap_l} lines x {cap_c} cols)") + + +def _furniture(slide_el, sw, sh): + """Frames (x0, y0, x1, y1, what, element) of the page-number box and the logo picture.""" + out = [] + for e in slide_el.iter(q(P, "sp"), q(P, "pic")): + xf = e.find(f"{q(P, 'spPr')}/{q(A, 'xfrm')}") + if xf is None or xf.find(q(A, "off")) is None or xf.find(q(A, "ext")) is None: + continue + x, y = int(xf.find(q(A, "off")).get("x")), int(xf.find(q(A, "off")).get("y")) + cx, cy = int(xf.find(q(A, "ext")).get("cx")), int(xf.find(q(A, "ext")).get("cy")) + if e.tag == q(P, "pic"): + if y >= sh * 0.85 and cx < sw * 0.25: + out.append((x, y, x + cx, y + cy, "logo", e)) + elif e.find(q(P, "txBody")) is not None: + t = sp_text(e).strip() + size = max([run_size(r) for r in e.iter(q(A, "r"))] or [0]) + if re.fullmatch(r"\d{1,3}", t) and 0 < size <= 1100: + out.append((x, y, x + cx, y + cy, "page number", e)) + return out + + +def op_resize_shape(ctx, op): + """Move or resize one text shape: any of x, y, cx, cy (EMU); "keep": "right" / "bottom" + holds that edge when cx / cy change. The result must stay on the slide and clear of the + page-number box and the logo.""" + s = ctx.slide(op["sldId"]) + sp = select_sp(s._element, op) + xf = sp.find(f"{q(P, 'spPr')}/{q(A, 'xfrm')}") + if xf is None or xf.find(q(A, "off")) is None or xf.find(q(A, "ext")) is None: + raise EditError(f"resize_shape sldId {s.slide_id}: the shape has no own frame (inherited placeholder)") + off, ext = xf.find(q(A, "off")), xf.find(q(A, "ext")) + x, y, cx, cy = int(off.get("x")), int(off.get("y")), int(ext.get("cx")), int(ext.get("cy")) + if not any(k in op for k in ("x", "y", "cx", "cy")): + raise EditError("resize_shape: needs at least one of x, y, cx, cy") + keep = op.get("keep", []) + keep = [keep] if isinstance(keep, str) else list(keep) + for k in keep: + if k not in ("right", "bottom"): + raise EditError(f"resize_shape: bad keep {k!r}") + ncx, ncy = int(op.get("cx", cx)), int(op.get("cy", cy)) + nx = int(op.get("x", x + cx - ncx if "right" in keep else x)) + ny = int(op.get("y", y + cy - ncy if "bottom" in keep else y)) + sw, sh = ctx.prs.slide_width, ctx.prs.slide_height + if ncx <= 0 or ncy <= 0 or nx < 0 or ny < 0 or nx + ncx > sw or ny + ncy > sh: + raise EditError(f"resize_shape sldId {s.slide_id}: frame ({nx}, {ny}, {ncx}, {ncy}) leaves the slide") + for fx0, fy0, fx1, fy1, what, e in _furniture(s._element, sw, sh): + if e is sp: + continue + if min(nx + ncx, fx1) > max(nx, fx0) and min(ny + ncy, fy1) > max(ny, fy0): + raise EditError(f"resize_shape sldId {s.slide_id}: the new frame overlaps the {what}") + off.set("x", str(nx)) + off.set("y", str(ny)) + ext.set("cx", str(ncx)) + ext.set("cy", str(ncy)) + ctx.log.append(f"resize_shape sldId {s.slide_id} {sp_name(sp)!r}: ({x}, {y}, {cx}, {cy}) -> ({nx}, {ny}, {ncx}, {ncy})") + + +def op_set_geometry(ctx, op): + """Move or resize one picture or shape, selected by "shape" (name) or "shape_id"; any of + x, y, w, h in inches. The frame must stay on the slide and clear of the page-number box and + the logo. Works on the XML (not python-pptx's shape cache), so it also works on a slide that + clone_slide created earlier in the same run.""" + s = ctx.slide(op["sldId"]) + hit = [] + for e in s._element.iter(q(P, "sp"), q(P, "pic"), q(P, "graphicFrame"), q(P, "grpSp"), q(P, "cxnSp")): + c = next((x for x in e.iter(q(P, "cNvPr"))), None) + if c is None: + continue + if (op.get("shape_id") is not None and c.get("id") == str(op["shape_id"])) or \ + (op.get("shape") is not None and c.get("name") == op["shape"]): + hit.append(e) + if len(hit) != 1: + raise EditError(f"set_geometry sldId {s.slide_id}: selector matched {len(hit)} shapes") + el = hit[0] + xf = el.find(f"{q(P, 'spPr')}/{q(A, 'xfrm')}") + if xf is None: + xf = el.find(q(P, "xfrm")) if el.tag == q(P, "graphicFrame") else el.find(f"{q(P, 'grpSpPr')}/{q(A, 'xfrm')}") + if xf is None or xf.find(q(A, "off")) is None or xf.find(q(A, "ext")) is None: + raise EditError(f"set_geometry sldId {s.slide_id}: the shape has no own frame") + off, ext = xf.find(q(A, "off")), xf.find(q(A, "ext")) + if not any(k in op for k in ("x", "y", "w", "h")): + raise EditError("set_geometry: needs at least one of x, y, w, h") + cur = {"x": int(off.get("x")), "y": int(off.get("y")), "w": int(ext.get("cx")), "h": int(ext.get("cy"))} + nx, ny, ncx, ncy = (int(round(op[k] * 914400)) if k in op else cur[k] for k in ("x", "y", "w", "h")) + sw, shh = ctx.prs.slide_width, ctx.prs.slide_height + if ncx <= 0 or ncy <= 0 or nx < 0 or ny < 0 or nx + ncx > sw or ny + ncy > shh: + raise EditError(f"set_geometry sldId {s.slide_id}: frame ({nx}, {ny}, {ncx}, {ncy}) leaves the slide") + for fx0, fy0, fx1, fy1, what, e in _furniture(s._element, sw, shh): + if e is el: + continue + if min(nx + ncx, fx1) > max(nx, fx0) and min(ny + ncy, fy1) > max(ny, fy0): + raise EditError(f"set_geometry sldId {s.slide_id}: the new frame overlaps the {what}") + off.set("x", str(nx)) + off.set("y", str(ny)) + ext.set("cx", str(ncx)) + ext.set("cy", str(ncy)) + name = next(el.iter(q(P, "cNvPr"))).get("name") + ctx.log.append(f"set_geometry sldId {s.slide_id} {name!r} -> ({nx}, {ny}, {ncx}, {ncy})") + + +OPS = { + "replace": op_replace, + "set_notes": op_set_notes, + "set_code": op_set_code, + "set_text": op_set_text, + "clone_slide": op_clone_slide, + "replace_picture": op_replace_picture, + "set_page_number": op_set_page_number, + "fit_font": op_fit_font, + "resize_shape": op_resize_shape, + "cut_code": op_cut_code, + "set_geometry": op_set_geometry, + "recolour_code": op_recolour_code, +} + + +def load_ops(paths): + ops = [] + for p in paths: + data = json.loads(Path(p).read_text(encoding="utf-8")) + if isinstance(data, dict): + data = data.get("ops", []) + ops.extend(data) + return ops + + +def backup_dir(): + return Path(os.environ.get("DECK_BACKUP_DIR") or Path.home() / ".cache" / "deck_edit" / "backup") + + +def main(argv): + ap = argparse.ArgumentParser(description="Apply scripted edits to a .pptx", + usage="deck_edit.py [ ...] [--dry-run] [--out PATH]") + ap.add_argument("deck", type=Path) + ap.add_argument("edits", type=Path, nargs="+") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--out", type=Path) + args = ap.parse_args(argv) + try: + ops = load_ops(args.edits) + prs = Presentation(str(args.deck)) + ctx = Ctx(prs) + for i, op in enumerate(ops, 1): + fn = OPS.get(op.get("op")) + if fn is None: + raise EditError(f"op #{i}: unknown operation {op.get('op')!r}") + try: + fn(ctx, op) + except EditError as e: + raise EditError(f"op #{i} ({op.get('op')}): {e}") + except KeyError as e: + raise EditError(f"op #{i} ({op.get('op')}): missing key {e}") + except EditError as e: + print(f"deck_edit: FAILED, nothing written: {e}", file=sys.stderr) + return 1 + for line in ctx.log: + print(line) + target = args.out or args.deck + tmpdir = Path(tempfile.mkdtemp(prefix="deck_edit_")) + tmp = tmpdir / target.name + try: + prs.save(str(tmp)) + Presentation(str(tmp)) # reload check + if args.dry_run: + before, _ = deck_text.pptx_to_md(args.deck) + after, _ = deck_text.pptx_to_md(tmp) + diff = list(difflib.unified_diff(before.splitlines(), after.splitlines(), "before", "after", lineterm="", n=1)) + print("\n".join(diff) if diff else "(no text changes)") + print(json.dumps({"ok": True, "dry_run": True, "created": ctx.created, "ops": len(ops)})) + return 0 + if args.out is None: + bdir = backup_dir() + bdir.mkdir(parents=True, exist_ok=True) + b = bdir / f"{args.deck.stem}.{time.strftime('%Y%m%d-%H%M%S')}{args.deck.suffix}" + shutil.copy2(args.deck, b) + print(f"backup: {b}") + target.parent.mkdir(parents=True, exist_ok=True) + os.replace(tmp, target) if tmp.stat().st_dev == (target.parent.stat().st_dev) else shutil.move(str(tmp), str(target)) + print(json.dumps({"ok": True, "out": str(target), "created": ctx.created, "ops": len(ops)})) + return 0 + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/deck_fit.py b/scripts/deck_fit.py new file mode 100644 index 00000000..d8420e07 --- /dev/null +++ b/scripts/deck_fit.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +"""Detect text that does not fit its shape in a rendered deck (python-pptx; run with the S12 venv). + + deck_fit.py [--pdf PATH] [--keep-pdf DIR] [--slides 45,88] [--tol PT] [--json] + +LibreOffice (soffice, headless) converts the deck to PDF unless --pdf gives an existing render of +the same deck; `pdftotext -bbox-layout` then gives the rendered line and word boxes. Each rendered +line is matched to the slide shape it belongs to (its text must occur in the shape's text; ties and +short fragments are resolved by geometry: the frame that contains it, otherwise the nearest frame +above it in the same column), and the slide geometry (EMU, 12700 per PDF point) is compared with +the rendered boxes. A flag is raised for + + overflow-bottom / overflow-right / overflow-top / overflow-left + text outside its own shape's frame by more than --tol points (default 1.5); + the frame is the shape's box, insets included, so text in the inset band is fine + furniture text of another shape overlapping the page-number box (a 1-3 digit text box at + 11 pt or less) or the logo picture (a picture inside the bottom 15 % of the + slide, narrower than a quarter of it) + off-slide text outside the slide + collision-with-N + text of this shape overlapping rendered text of shape N (boxes overlap by more than a + quarter of the smaller line height, and 1 pt across) + +Shapes whose body autofits by growing (spAutoFit) are not checked against their own bottom edge +(LibreOffice grows the frame); they still get the furniture and off-slide checks. Rotated shapes +are skipped. Tables (graphicFrame) are checked like shapes against their frame. Hidden slides are +not exported by LibreOffice and are skipped. Brand fonts must be installed (a render with +substituted fonts hides or invents overflow): + sudo dnf install -y redhat-text-fonts redhat-display-fonts overpass-fonts + +Output: one line per flag ("slide 45 (sldId 300) shape 509 'Google Shape;509;p47': overflow-bottom +by 9.8 pt: '...'"), then a summary; --json prints a JSON list instead. Exit 0 when nothing is +flagged, 1 when something is, 2 on a usage or render error. +Environment: LO_PROFILE sets the LibreOffice profile directory (as in deck_render.sh). +""" +from __future__ import annotations + +import argparse +import html +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unicodedata +from pathlib import Path + +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE_TYPE + +EMU_PER_PT = 12700 +A = "http://schemas.openxmlformats.org/drawingml/2006/main" +P = "http://schemas.openxmlformats.org/presentationml/2006/main" + + +def q(ns, tag): + return f"{{{ns}}}{tag}" + + +def norm(s): + s = unicodedata.normalize("NFKC", s) + return re.sub(r"\s+", "", s) + + +class Box: + __slots__ = ("x0", "y0", "x1", "y1") + + def __init__(self, x0, y0, x1, y1): + self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1 + + def contains(self, x, y, pad=0.0): + return self.x0 - pad <= x <= self.x1 + pad and self.y0 - pad <= y <= self.y1 + pad + + def intersects(self, o, pad=0.0, pad_y=None): + return (min(self.x1, o.x1) - max(self.x0, o.x0) > pad + and min(self.y1, o.y1) - max(self.y0, o.y0) > (pad if pad_y is None else pad_y)) + + +class TShape: + def __init__(self, sid, name, box, text, autogrow, page_number=False): + self.sid, self.name, self.box = sid, name, box + self.text = text + self.ntext = norm(text) + self.autogrow = autogrow + self.page_number = page_number + + +# ----------------------------------------------------------------- slide geometry + +def _xfrm_map(grp_el): + """Child-to-parent mapping of a group: (sx, sy, dx, dy) in EMU.""" + x = grp_el.find(f"{q(P, 'grpSpPr')}/{q(A, 'xfrm')}") + if x is None: + return 1.0, 1.0, 0.0, 0.0 + off, ext = x.find(q(A, "off")), x.find(q(A, "ext")) + choff, chext = x.find(q(A, "chOff")), x.find(q(A, "chExt")) + if off is None or ext is None or choff is None or chext is None: + return 1.0, 1.0, 0.0, 0.0 + cw, ch = int(chext.get("cx")) or 1, int(chext.get("cy")) or 1 + sx, sy = int(ext.get("cx")) / cw, int(ext.get("cy")) / ch + return sx, sy, int(off.get("x")) - int(choff.get("x")) * sx, int(off.get("y")) - int(choff.get("y")) * sy + + +def _rotated(sh): + x = sh._element.find(f".//{q(A, 'xfrm')}") + return x is not None and int(x.get("rot", "0") or 0) % 21600000 != 0 + + +def _run_sizes(el): + return [int(r.get("sz")) for r in el.iter(q(A, "rPr")) if r.get("sz")] + + +def collect(shapes, tf=(1.0, 1.0, 0.0, 0.0), out=None, skipped=None): + """Text shapes, tables and pictures of a shape tree, with frames in PDF points.""" + out = out if out is not None else {"text": [], "pics": []} + skipped = skipped if skipped is not None else [] + sx, sy, dx, dy = tf + for sh in shapes: + if sh.shape_type == MSO_SHAPE_TYPE.GROUP: + gsx, gsy, gdx, gdy = _xfrm_map(sh._element) + collect(sh.shapes, (sx * gsx, sy * gsy, sx * gdx + dx, sy * gdy + dy), out, skipped) + continue + if sh.left is None or sh.top is None or sh.width is None or sh.height is None: + continue + x0 = (sh.left * sx + dx) / EMU_PER_PT + y0 = (sh.top * sy + dy) / EMU_PER_PT + box = Box(x0, y0, x0 + sh.width * sx / EMU_PER_PT, y0 + sh.height * sy / EMU_PER_PT) + if sh.shape_type == MSO_SHAPE_TYPE.PICTURE: + out["pics"].append((sh.shape_id, sh.name, box)) + continue + el = sh._element + if getattr(sh, "has_table", False) and sh.has_table: + text = "\n".join(t.text or "" for t in el.iter(q(A, "t"))) + if text.strip(): + out["text"].append(TShape(sh.shape_id, sh.name, box, text, autogrow=True)) + continue + if not getattr(sh, "has_text_frame", False) or not sh.has_text_frame: + continue + text = "\n".join("".join(t.text or "" for t in p.iter(q(A, "t"))) for p in el.iter(q(A, "p"))) + if not text.strip(): + continue + if _rotated(sh): + skipped.append(sh.shape_id) + continue + body = el.find(f"{q(P, 'txBody')}/{q(A, 'bodyPr')}") + autogrow = body is not None and body.find(q(A, "spAutoFit")) is not None + sizes = _run_sizes(el) + pnum = bool(re.fullmatch(r"\d{1,3}", text.strip())) and 0 < max(sizes or [0]) <= 1100 + out["text"].append(TShape(sh.shape_id, sh.name, box, text, autogrow, pnum)) + return out, skipped + + +# ----------------------------------------------------------------- render + +WORD_RE = re.compile(r'(.*?)') +LINE_RE = re.compile(r'') +PAGE_RE = re.compile(r'') + + +def parse_bbox(xml_text): + """Pages of lines; a line is (Box, [(Box, word), ...]).""" + pages = [] + line = None + for tok in re.finditer(r"]*>|]*>|]*>.*?|", xml_text): + t = tok.group(0) + if t.startswith(" 0] + above = [s for s in column if s.box.y0 <= seg_box.y0] + if above: + return min(above, key=lambda s: seg_box.y0 - s.box.y1) + below = [s for s in column if s.box.y1 >= seg_box.y1] + if below: + return min(below, key=lambda s: s.box.y0 - seg_box.y1) + return min(cands, key=lambda s: abs(cx - (s.box.x0 + s.box.x1) / 2) + abs(cy - (s.box.y0 + s.box.y1) / 2)) + + +def segments(line, shapes): + """Split a rendered line into (shape, box, text) segments: the whole line when its text occurs + in one shape, otherwise word by word (a leading bullet glyph is dropped first).""" + lbox, words = line + if not words: + return [] + for start in (0, 1): + ws = words[start:] + if not ws: + break + text = "".join(w for _, w in ws) + box = Box(min(b.x0 for b, _ in ws), min(b.y0 for b, _ in ws), + max(b.x1 for b, _ in ws), max(b.y1 for b, _ in ws)) + sh = owner(text, box, shapes) + if sh is not None: + return [(sh, box, " ".join(w for _, w in ws))] + return [(owner(w, b, shapes), b, w) for b, w in words] + + +def check_slide(page, shapes, pics, slide_w, slide_h, tol): + flags = [] + furniture = [("page-number", s.box) for s in shapes if s.page_number] + for pid, pname, b in pics: + if b.y0 >= slide_h * 0.85 and (b.x1 - b.x0) < slide_w * 0.25: + furniture.append(("logo", b)) + seen = set() + + def add(sh, kind, amount, text): + key = (sh.sid if sh else None, kind) + if key in seen: + # one flag per shape and kind; keep the worst amount + for f in flags: + if (f["shape_id"], f["kind"]) == key and amount > f["by_pt"]: + f["by_pt"], f["text"] = round(amount, 1), text[:70] + return + seen.add(key) + flags.append({"shape_id": sh.sid if sh else None, "shape": sh.name if sh else None, + "kind": kind, "by_pt": round(amount, 1), "text": text[:70]}) + + segs = [seg for line in page["lines"] for seg in segments(line, shapes)] + for i, (sh, b, text) in enumerate(segs): + if sh is None: + continue + for sh2, b2, text2 in segs[i + 1:]: + # word boxes carry the font's ascent and descent padding; glyphs touch only when + # the boxes overlap by more than about a quarter of the smaller box's height + pad_y = max(2.0, 0.25 * min(b.y1 - b.y0, b2.y1 - b2.y0)) + if sh2 is not None and sh2 is not sh and b.intersects(b2, pad=1.0, pad_y=pad_y): + a_, z_ = (sh, sh2) if sh.sid < sh2.sid else (sh2, sh) + add(z_, f"collision-with-{a_.sid}", min(b.y1, b2.y1) - max(b.y0, b2.y0), + text if z_ is sh else text2) + for sh, b, text in segs: + if b.x0 < -tol or b.y0 < -tol or b.x1 > slide_w + tol or b.y1 > slide_h + tol: + add(sh, "off-slide", max(-b.x0, -b.y0, b.x1 - slide_w, b.y1 - slide_h), text) + if sh is None: + continue + f = sh.box + # a word box spans the font's ascent and descent, about 1.3 x the size, while the line + # pitch LibreOffice lays out is 1.2 x the size: allow that difference on top of --tol + t_v = max(tol, 0.12 * (b.y1 - b.y0)) + if b.y1 > f.y1 + t_v and not sh.autogrow: + add(sh, "overflow-bottom", b.y1 - f.y1, text) + if b.x1 > f.x1 + tol: + add(sh, "overflow-right", b.x1 - f.x1, text) + if b.y0 < f.y0 - t_v and not sh.autogrow: + add(sh, "overflow-top", f.y0 - b.y0, text) + if b.x0 < f.x0 - tol: + add(sh, "overflow-left", f.x0 - b.x0, text) + if sh.page_number: + continue + for kind, fb in furniture: + if b.intersects(fb, pad=0.5): + add(sh, f"furniture-{kind}", min(b.y1, fb.y1) - max(b.y0, fb.y0), text) + return flags + + +def main(argv): + ap = argparse.ArgumentParser(description="Detect text that does not fit its shape in a rendered deck") + ap.add_argument("deck", type=Path) + ap.add_argument("--pdf", type=Path, help="an existing soffice PDF of this deck (skips the render)") + ap.add_argument("--keep-pdf", type=Path, help="directory to keep the rendered PDF in") + ap.add_argument("--slides", help="comma-separated 1-based slide numbers to check (default: all)") + ap.add_argument("--tol", type=float, default=1.5, help="tolerance in points (default 1.5)") + ap.add_argument("--json", action="store_true") + args = ap.parse_args(argv) + if not args.deck.is_file(): + print(f"deck_fit: {args.deck} not found", file=sys.stderr) + return 2 + tmp = None + try: + if args.pdf: + pdf = args.pdf + else: + missing = check_fonts() + if missing: + print(f"deck_fit: missing brand font(s): {', '.join(missing)}", file=sys.stderr) + return 2 + outdir = args.keep_pdf or Path(tempfile.mkdtemp(prefix="deck_fit_")) + if not args.keep_pdf: + tmp = outdir + outdir.mkdir(parents=True, exist_ok=True) + pdf = render_pdf(args.deck, outdir) + xml_text = subprocess.run(["pdftotext", "-bbox-layout", str(pdf), "-"], check=True, + capture_output=True, text=True).stdout + except (subprocess.CalledProcessError, RuntimeError, FileNotFoundError) as e: + print(f"deck_fit: render failed: {e}", file=sys.stderr) + return 2 + finally: + if tmp: + shutil.rmtree(tmp, ignore_errors=True) + pages = parse_bbox(xml_text) + prs = Presentation(str(args.deck)) + sw, sh_ = prs.slide_width / EMU_PER_PT, prs.slide_height / EMU_PER_PT + visible = [(i, s) for i, s in enumerate(prs.slides, 1) if s._element.get("show") not in ("0", "false")] + if len(visible) != len(pages): + print(f"deck_fit: {len(visible)} visible slides but {len(pages)} PDF pages", file=sys.stderr) + return 2 + want = {int(n) for n in args.slides.split(",")} if args.slides else None + results = [] + for (num, slide), page in zip(visible, pages): + if want and num not in want: + continue + if abs(page["w"] - sw) > 1 or abs(page["h"] - sh_) > 1: + print(f"deck_fit: page {num} is {page['w']}x{page['h']} pt, slide is {sw:.1f}x{sh_:.1f}", file=sys.stderr) + return 2 + got, _ = collect(slide.shapes) + for f in check_slide(page, got["text"], got["pics"], sw, sh_, args.tol): + f.update({"slide": num, "sldId": slide.slide_id}) + results.append(f) + if args.json: + print(json.dumps(results, indent=1, ensure_ascii=False)) + else: + for f in results: + print(f"slide {f['slide']} (sldId {f['sldId']}) shape {f['shape_id']} {f['shape']!r}: " + f"{f['kind']} by {f['by_pt']} pt: {f['text']!r}") + n = len({f['slide'] for f in results}) + print(f"deck_fit: {args.deck.name}: {len(results)} flag(s) on {n} slide(s)") + return 1 if results else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/deck_render.sh b/scripts/deck_render.sh new file mode 100755 index 00000000..ae375a76 --- /dev/null +++ b/scripts/deck_render.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# deck_render.sh - render slides of a .pptx to PNG for visual inspection. +# +# scripts/deck_render.sh [page ...] +# +# soffice (headless) converts the deck to PDF, pdftoppm writes /-.png at 90 dpi +# (all pages, or only the listed 1-based pages). Aborts when the brand fonts are missing, because +# a render with substituted fonts hides overflow: +# sudo dnf install -y redhat-text-fonts redhat-display-fonts overpass-fonts +# Environment: LO_PROFILE sets the LibreOffice profile directory (default: a per-user cache dir). +set -euo pipefail + +if [[ $# -lt 2 ]]; then + echo "usage: $0 [page ...]" >&2 + exit 2 +fi + +deck=$1 +outdir=$2 +shift 2 +pages=("$@") + +if [[ ! -f "$deck" ]]; then + echo "deck_render: $deck not found" >&2 + exit 2 +fi + +fonts=$(fc-list 2>/dev/null || true) +missing=() +for f in "Red Hat Text" "Red Hat Display" "Overpass"; do + if ! grep -qi -- "$f" <<<"$fonts"; then + missing+=("$f") + fi +done +if [[ ${#missing[@]} -gt 0 ]]; then + echo "deck_render: missing brand font(s): ${missing[*]}" >&2 + echo "Install them first: sudo dnf install -y redhat-text-fonts redhat-display-fonts overpass-fonts" >&2 + exit 3 +fi + +profile=${LO_PROFILE:-${XDG_CACHE_HOME:-$HOME/.cache}/deck_render/lo-profile} +mkdir -p "$profile" "$outdir" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +soffice --headless "-env:UserInstallation=file://$profile" --convert-to pdf --outdir "$tmp" "$deck" >/dev/null +pdf="$tmp/$(basename "${deck%.*}").pdf" +if [[ ! -f "$pdf" ]]; then + echo "deck_render: soffice produced no PDF for $deck" >&2 + exit 1 +fi + +base=$(basename "${deck%.*}") +if [[ ${#pages[@]} -eq 0 ]]; then + pdftoppm -png -r 90 "$pdf" "$outdir/$base" +else + for n in "${pages[@]}"; do + pdftoppm -png -r 90 -f "$n" -l "$n" "$pdf" "$outdir/$base" + done +fi +echo "deck_render: wrote PNGs for $base in $outdir" diff --git a/scripts/deck_text.py b/scripts/deck_text.py new file mode 100755 index 00000000..c6f927a9 --- /dev/null +++ b/scripts/deck_text.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +"""Extract the text of every deck (.pptx) and document (.docx) in lgtm-presentation/ +into Markdown copies under lgtm-presentation/text/.md. + +Standard library only (zipfile + xml.etree), so it runs unchanged in CI. + +Usage: + deck_text.py --write [--root DIR] regenerate lgtm-presentation/text/*.md + deck_text.py --check [--root DIR] regenerate in memory and compare; exit 1 on drift, + a missing or extra text file, or slides without notes + deck_text.py --check --allow-missing-notes + report missing notes but do not fail on them + (transition aid; KNOWN_MISSING_NOTES below is the + per-deck list of exceptions that the batches empty) + deck_text.py print this usage + +Output format (pptx) + # then a source line + ## [NNN] EYEBROW · Title (sldId N) one section per slide, in presentation order + body paragraphs shape order in spTree, groups recursed, tables flattened row by row + (cells joined with " | "), bullets as "- " / "1. " + [chip] python · FastAPI the language chip that sits above a code box + ```python ... ``` code boxes (see heuristics) + p. 12 the static page-number text box + > notes speaker notes (notesSlide body placeholder), one ">" block + +Heuristics + Title the title / ctrTitle placeholder if present; otherwise the non-monospace text shape + with the largest run font size (ties: first in shape order). Multi-line titles are + joined with a space. + Eyebrow the first text shape before the title that is a single line of at most 60 + characters with a largest font size of 14 pt or less (the red kicker line). Absent + on decks that have none. + Code a shape whose non-empty runs are all monospace (font name matches Mono, Courier, + Consolas, Menlo, Monaco). A single-paragraph monospace shape of at most 11 pt and + 60 characters is a chip, not code; its language is the text before " · ", lower + cased, and tags the next code box in the same container. + Page a shape whose whole text is 1 to 3 digits at 11 pt or less. + +Output is deterministic: normalised whitespace, LF endings, trailing newline. Jekyll is told to +exclude lgtm-presentation/, but any `{{` or `{%` is still wrapped in {% raw %} so a stray include of +these files cannot be interpreted as Liquid and the site validator's brace rules hold. +""" +from __future__ import annotations + +import argparse +import difflib +import posixpath +import re +import sys +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + +A = "http://schemas.openxmlformats.org/drawingml/2006/main" +P = "http://schemas.openxmlformats.org/presentationml/2006/main" +R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +PR = "http://schemas.openxmlformats.org/package/2006/relationships" +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + +MONO_RE = re.compile(r"mono|courier|consolas|menlo|monaco", re.I) +REPO = Path(__file__).resolve().parent.parent +DEFAULT_ROOT = REPO / "lgtm-presentation" + +# Decks allowed to have slides without notes while the S12 batches run: {basename: [slide numbers]}. +# Each batch removes its entry; the final state is an empty dict. +KNOWN_MISSING_NOTES: dict[str, list[int]] = { +} + + +def q(ns: str, tag: str) -> str: + return f"{{{ns}}}{tag}" + + +def norm(s: str) -> str: + return re.sub(r"[ \t ]+", " ", s).strip() + + +def guard_liquid(text: str) -> str: + """Wrap a one-line paragraph holding Liquid delimiters in {% raw %}.""" + if "{{" in text or "{%" in text: + return "{% raw %}" + text + "{% endraw %}" + return text + + +def fence(code: str, lang: str = "") -> list[str]: + ticks = "```" + while ticks in code: + ticks += "`" + out = [ticks + lang] + code.split("\n") + [ticks] + if "{{" in code or "{%" in code: + out = ["{% raw %}"] + out + ["{% endraw %}"] + return out + + +# ---------------------------------------------------------------- pptx + +def rels_of(z: zipfile.ZipFile, part: str) -> dict[str, tuple[str, str]]: + d, b = posixpath.split(part) + rp = posixpath.join(d, "_rels", b + ".rels") + out: dict[str, tuple[str, str]] = {} + if rp not in z.namelist(): + return out + for rel in ET.fromstring(z.read(rp)): + tgt = rel.get("Target", "") + if rel.get("TargetMode") != "External": + tgt = posixpath.normpath(posixpath.join(d, tgt)) if not tgt.startswith("/") else tgt[1:] + out[rel.get("Id", "")] = (rel.get("Type", "").rsplit("/", 1)[-1], tgt) + return out + + +class Para: + __slots__ = ("text", "mono", "size", "bullet", "level") + + def __init__(self, text, mono, size, bullet, level): + self.text, self.mono, self.size, self.bullet, self.level = text, mono, size, bullet, level + + +def para_of(p: ET.Element) -> Para: + parts: list[str] = [] + mono_flags: list[bool] = [] + size = 0 + for el in p: + if el.tag == q(A, "r") or el.tag == q(A, "fld"): + t = el.find(q(A, "t")) + txt = t.text or "" if t is not None else "" + parts.append(txt) + if txt.strip(): + rpr = el.find(q(A, "rPr")) + font = None + if rpr is not None: + lat = rpr.find(q(A, "latin")) + font = lat.get("typeface") if lat is not None else None + if rpr.get("sz"): + size = max(size, int(rpr.get("sz"))) + mono_flags.append(bool(font and MONO_RE.search(font))) + elif el.tag == q(A, "br"): + parts.append("\n") + bullet = "" + ppr = p.find(q(A, "pPr")) + level = 0 + if ppr is not None: + level = int(ppr.get("lvl") or 0) + if ppr.find(q(A, "buChar")) is not None: + bullet = "- " + elif ppr.find(q(A, "buAutoNum")) is not None: + bullet = "1. " + return Para("".join(parts), bool(mono_flags) and all(mono_flags), size, bullet, level) + + +class Item: + """One text unit from a slide: kind in text|code|chip|table|page, with shape metadata.""" + def __init__(self, kind, lines, size=0, title_ph=False, raw=""): + self.kind, self.lines, self.size, self.title_ph, self.raw = kind, lines, size, title_ph, raw + + +def text_lines(paras: list[Para]) -> list[str]: + out = [] + for p in paras: + lines = [norm(x) for x in p.text.split("\n")] + lines = [x for x in lines if x] + if not lines: + continue + pre = " " * p.level + p.bullet if p.bullet else "" + out.append(pre + "\n".join(lines)) + return out + + +def shape_items(el: ET.Element, items: list[Item]) -> None: + if el.tag == q(P, "grpSp"): + for c in el: + shape_items(c, items) + return + if el.tag == q(P, "graphicFrame"): + tbl = el.find(f".//{q(A, 'tbl')}") + if tbl is not None: + rows = [] + for tr in tbl.findall(q(A, "tr")): + cells = [] + for tc in tr.findall(q(A, "tc")): + tx = tc.find(q(A, "txBody")) + ps = [para_of(p) for p in tx.findall(q(A, "p"))] if tx is not None else [] + cells.append(norm(" ".join(l for l in text_lines(ps)).replace("\n", " "))) + rows.append(" | ".join(cells)) + items.append(Item("table", rows)) + return + if el.tag != q(P, "sp"): + return + tx = el.find(q(P, "txBody")) + if tx is None: + return + paras = [para_of(p) for p in tx.findall(q(A, "p"))] + nonempty = [p for p in paras if p.text.strip()] + if not nonempty: + return + ph = el.find(f"{q(P, 'nvSpPr')}/{q(P, 'nvPr')}/{q(P, 'ph')}") + title_ph = ph is not None and ph.get("type") in ("title", "ctrTitle") + size = max(p.size for p in nonempty) + full = "\n".join(p.text.strip("\n") for p in nonempty) + if all(p.mono for p in nonempty): + one_line = len(nonempty) == 1 and "\n" not in nonempty[0].text.strip() + if one_line and size <= 1100 and len(full) <= 60: + items.append(Item("chip", [norm(full)], size)) + else: + code = "\n".join(re.sub(r"[ \t]+$", "", p.text.replace(" ", " ").rstrip("\n")) for p in nonempty) + items.append(Item("code", code.split("\n"), size, raw=code)) + return + if len(nonempty) == 1 and size <= 1100 and re.fullmatch(r"\d{1,3}", nonempty[0].text.strip()): + items.append(Item("page", [nonempty[0].text.strip()], size)) + return + items.append(Item("text", text_lines(paras), size, title_ph)) + + +def lang_of(chip: str) -> str: + head = chip.split("·")[0].strip().lower() + head = re.sub(r"[^a-z0-9+#.-]", "", head) + return head + + +def slide_section(z: zipfile.ZipFile, part: str, idx: int, sld_id: str, rels: dict) -> tuple[str, bool]: + root = ET.fromstring(z.read(part)) + tree = root.find(f"{q(P, 'cSld')}/{q(P, 'spTree')}") + items: list[Item] = [] + for c in tree: + shape_items(c, items) + # title + title_i = next((i for i, it in enumerate(items) if it.kind == "text" and it.title_ph), None) + if title_i is None: + best = 0 + for i, it in enumerate(items): + if it.kind == "text" and it.size > best: + best, title_i = it.size, i + if title_i is None: + title_i = next((i for i, it in enumerate(items) if it.kind == "text"), None) + title = norm(" ".join(items[title_i].lines)) if title_i is not None else "(untitled)" + eyebrow_i = None + for i, it in enumerate(items[: title_i if title_i is not None else 0]): + if it.kind == "text" and len(it.lines) == 1 and "\n" not in it.lines[0] and len(it.lines[0]) <= 60 \ + and 0 < it.size <= 1400: + eyebrow_i = i + break + head = f"{items[eyebrow_i].lines[0]} · {title}" if eyebrow_i is not None else title + out = [f"## [{idx:03d}] {head} (sldId {sld_id})", ""] + pending_chip = None + for i, it in enumerate(items): + if i == title_i or i == eyebrow_i: + continue + if it.kind == "chip": + if pending_chip: + out += [guard_liquid("[chip] " + pending_chip), ""] + pending_chip = it.lines[0] + elif it.kind == "code": + lang = lang_of(pending_chip) if pending_chip else "" + if pending_chip: + out += [guard_liquid("[chip] " + pending_chip), ""] + pending_chip = None + out += fence("\n".join(it.lines), lang) + [""] + elif it.kind == "page": + out += [f"p. {it.lines[0]}", ""] + else: # text, table + for ln in it.lines: + out += [guard_liquid(ln), ""] + if pending_chip: + out += [guard_liquid("[chip] " + pending_chip), ""] + notes_text = "" + has_notes = False + for rid, (typ, tgt) in rels.items(): + if typ == "notesSlide" and tgt in z.namelist(): + nroot = ET.fromstring(z.read(tgt)) + for sp in nroot.iter(q(P, "sp")): + ph = sp.find(f"{q(P, 'nvSpPr')}/{q(P, 'nvPr')}/{q(P, 'ph')}") + if ph is not None and ph.get("type") == "body": + tx = sp.find(q(P, "txBody")) + ps = [para_of(p) for p in tx.findall(q(A, "p"))] + lines = text_lines(ps) + if lines: + notes_text = "\n".join(lines) + break + if notes_text: + has_notes = True + nl = [] + for para in notes_text.split("\n"): + nl += ["> " + guard_liquid(para), ">"] + out += nl[:-1] + [""] + # keep list items together: drop the blank line between two consecutive bullets + tight = [] + for i, ln in enumerate(out): + if ln == "" and tight and i + 1 < len(out) and re.match(r"\s*(- |\d+\. )", tight[-1]) \ + and re.match(r"\s*(- |\d+\. )", out[i + 1]): + continue + tight.append(ln) + return "\n".join(tight), has_notes + + +def pptx_to_md(path: Path) -> tuple[str, list[int]]: + """Return (markdown, [slide numbers without notes]).""" + with zipfile.ZipFile(path) as z: + pres = ET.fromstring(z.read("ppt/presentation.xml")) + prels = rels_of(z, "ppt/presentation.xml") + lst = pres.find(q(P, "sldIdLst")) + sections = [] + missing = [] + n = 0 + for sid in lst: + n += 1 + typ, part = prels[sid.get(q(R, "id"))] + sec, has = slide_section(z, part, n, sid.get("id"), rels_of(z, part)) + sections.append(sec) + if not has: + missing.append(n) + head = [f"# {path.stem}", "", f"Source: {path.name} · {n} slides", ""] + return "\n".join(head) + "\n" + "\n".join(sections).rstrip("\n") + "\n", missing + + +# ---------------------------------------------------------------- docx + +def docx_to_md(path: Path) -> tuple[str, list[int]]: + with zipfile.ZipFile(path) as z: + root = ET.fromstring(z.read("word/document.xml")) + styles = ET.fromstring(z.read("word/styles.xml")) if "word/styles.xml" in z.namelist() else None + style_mono = set() + if styles is not None: + for st in styles.findall(q(W, "style")): + rf = st.find(f"{q(W, 'rPr')}/{q(W, 'rFonts')}") + name = st.find(q(W, "name")) + sid = st.get(q(W, "styleId"), "") + if (rf is not None and MONO_RE.search(rf.get(q(W, "ascii"), ""))) or \ + (name is not None and re.fullmatch(r"(macro text|code|source code|preformatted.*)", name.get(q(W, "val"), ""), re.I)): + style_mono.add(sid) + + def runs_of(p): + out = [] + for r in p.iter(q(W, "r")): + txt = "" + for c in r: + if c.tag == q(W, "t"): + txt += c.text or "" + elif c.tag == q(W, "tab"): + txt += "\t" + elif c.tag in (q(W, "br"), q(W, "cr")): + txt += "\n" + rf = r.find(f"{q(W, 'rPr')}/{q(W, 'rFonts')}") + mono = bool(rf is not None and MONO_RE.search(rf.get(q(W, "ascii"), "") + rf.get(q(W, "hAnsi"), ""))) + out.append((txt, mono)) + return out + + def para(p): + ps = p.find(f"{q(W, 'pPr')}/{q(W, 'pStyle')}") + style = ps.get(q(W, "val")) if ps is not None else "" + runs = [(t, m) for t, m in runs_of(p)] + pm = style in style_mono + visible = [(t, m) for t, m in runs if t.strip()] + all_mono = pm or (bool(visible) and all(m for _, m in visible)) + if all_mono: + return style, "".join(t for t, _ in runs).rstrip("\n"), True + buf, i = "", 0 + while i < len(runs): + t, m = runs[i] + if m and t.strip(): + j, seg = i, "" + while j < len(runs) and (runs[j][1] or not runs[j][0].strip()): + seg += runs[j][0] + j += 1 + buf += "`" + seg.strip() + "`" + (" " if seg.endswith(" ") else "") + i = j + else: + buf += t + i += 1 + return style, buf, False + + out = [f"# {path.stem}", "", f"Source: {path.name}", ""] + code: list[str] = [] + num = 0 + + def flush(): + nonlocal code + if code: + out.extend(fence("\n".join(code)) + [""]) + code = [] + + body = root.find(q(W, "body")) + for el in body: + if el.tag == q(W, "p"): + style, text, mono = para(el) + if mono: + if text.strip(): + num = 0 + code.append(text.replace(" ", " ").rstrip()) + continue + flush() + text = "\n".join(norm(x) for x in text.split("\n")).strip() + if not text: + continue + m = re.fullmatch(r"Heading(\d)", style) + if style == "Title": + out += ["# " + text, ""] + elif m: + out += ["#" * int(m.group(1)) + " " + guard_liquid(text), ""] + elif style.startswith("ListBullet"): + lvl = int(style[10:] or 1) - 1 + num = 0 + out += [" " * lvl + "- " + guard_liquid(text)] + continue + elif style.startswith("ListNumber"): + num += 1 + lvl = int(style[10:] or 1) - 1 + out += [" " * lvl + f"{num}. " + guard_liquid(text)] + continue + else: + out += [guard_liquid(text), ""] + num = 0 + elif el.tag == q(W, "tbl"): + flush() + if out and out[-1] != "": + out.append("") + for tr in el.iter(q(W, "tr")): + cells = [] + for tc in tr.findall(q(W, "tc")): + cells.append(norm(" ".join(para(p)[1].replace("\n", " ") for p in tc.iter(q(W, "p")) if para(p)[1].strip()))) + out.append(guard_liquid(" | ".join(cells))) + out.append("") + num = 0 + # list items are tight; close the list with a blank line before the next non-list block + if out and out[-1] != "" and el.tag == q(W, "p"): + nxt = el.getnext() + if nxt is None or nxt.tag != q(W, "p") or not _is_list(nxt): + out.append("") + flush() + text = "\n".join(out) + text = re.sub(r"\n{3,}", "\n\n", text).rstrip("\n") + "\n" + return text, [] + + +def _is_list(p: ET.Element) -> bool: + ps = p.find(f"{q(W, 'pPr')}/{q(W, 'pStyle')}") + return ps is not None and ps.get(q(W, "val"), "").startswith(("ListBullet", "ListNumber")) + + +# ---------------------------------------------------------------- driver + +def extract_all(root: Path) -> tuple[dict[str, str], dict[str, list[int]]]: + files, missing = {}, {} + for f in sorted(root.glob("*.pptx")): + md, miss = pptx_to_md(f) + files[f.stem + ".md"] = md + missing[f.stem] = miss + for f in sorted(root.glob("*.docx")): + md, _ = docx_to_md(f) + files[f.stem + ".md"] = md + return files, missing + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Extract deck text to lgtm-presentation/text/*.md", + usage="deck_text.py (--write | --check [--allow-missing-notes]) [--root DIR]") + ap.add_argument("--write", action="store_true") + ap.add_argument("--check", action="store_true") + ap.add_argument("--allow-missing-notes", action="store_true") + ap.add_argument("--root", type=Path, default=DEFAULT_ROOT, help="the lgtm-presentation directory") + args = ap.parse_args(argv) + if not (args.write or args.check): + print(__doc__) + return 0 + root: Path = args.root + outdir = root / "text" + files, missing = extract_all(root) + if args.write: + outdir.mkdir(exist_ok=True) + for name in {p.name for p in outdir.glob("*.md")} - set(files): + (outdir / name).unlink() + for name, md in files.items(): + (outdir / name).write_bytes(md.encode("utf-8")) + for name, md in files.items(): + print(f"wrote {outdir / name} ({len(md.encode('utf-8'))} bytes)") + return 0 + rc = 0 + on_disk = {p.name for p in outdir.glob("*.md")} if outdir.is_dir() else set() + for name in sorted(set(files) | on_disk): + if name not in on_disk: + print(f"MISSING text copy: {outdir / name}") + rc = 1 + elif name not in files: + print(f"EXTRA text copy (no source deck): {outdir / name}") + rc = 1 + else: + cur = (outdir / name).read_text(encoding="utf-8") + if cur != files[name]: + diff = list(difflib.unified_diff(cur.splitlines(), files[name].splitlines(), + f"text/{name} (committed)", f"text/{name} (regenerated)", lineterm="", n=1)) + print(f"STALE text copy: {outdir / name} ({len(diff)} diff lines; first 40 follow)") + print("\n".join(diff[:40])) + rc = 1 + for deck, miss in sorted(missing.items()): + known = set(KNOWN_MISSING_NOTES.get(deck, [])) + unexpected = [n for n in miss if n not in known] + stale_known = [n for n in known if n not in miss] + if unexpected: + print(f"NOTES missing in {deck}: slides {unexpected}") + rc = 1 + if stale_known: + print(f"KNOWN_MISSING_NOTES entry now has notes (remove it) in {deck}: slides {stale_known}") + rc = 1 + if known and (set(miss) & known): + print(f"note: {deck} still has {len(set(miss) & known)} allowed slides without notes") + if not args.allow_missing_notes: + rc = 1 + print("deck_text --check:", "FAILED" if rc else "ok") + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/gen_29_diagrams.py b/scripts/gen_29_diagrams.py index 381375e5..8b28df1c 100644 --- a/scripts/gen_29_diagrams.py +++ b/scripts/gen_29_diagrams.py @@ -141,7 +141,7 @@ {"x": 30, "y": 140, "w": 360, "h": 50, "style": "box", "lines": ["COPY source", "NuGet package cache is a BuildKit cache mount"]}, {"x": 30, "y": 210, "w": 360, "h": 50, "style": "box", - "lines": ["dotnet publish -c Release", "restore from the mount, compile C# → CIL"]}, + "lines": ["dotnet publish -c Release", "restore, then compile C# → CIL"]}, {"x": 30, "y": 290, "w": 170, "h": 55, "style": "ink", "lines": ["/app output", "*.dll + deps", "~30–80 MB"]}, # Trimming note @@ -173,8 +173,8 @@ {"x1": 210, "y1": 190, "x2": 210, "y2": 210, "label": "step 2"}, {"x1": 210, "y1": 260, "x2": 115, "y2": 290}, # Copy to runtime - {"x1": 200, "y1": 317, "x2": 450, "y2": 165, "amber": True, - "label": "COPY --from=build"}, + # unlabelled: the target box names the step, and a mid-line label sat on the publish box + {"x1": 200, "y1": 317, "x2": 450, "y2": 165, "amber": True}, # Runtime flow {"x1": 650, "y1": 82, "x2": 670, "y2": 82}, {"x1": 650, "y1": 190, "x2": 650, "y2": 210}, diff --git a/scripts/validate_site.py b/scripts/validate_site.py index b297d321..786df95f 100755 --- a/scripts/validate_site.py +++ b/scripts/validate_site.py @@ -10,8 +10,10 @@ 5. every excalidraw figure has assets/diagrams/.svg and .excalidraw 6. no positional terms in prose (outside code fences and inline code) - 7. no `podman` anywhere outside the historical plans and lgtm-presentation/ - (on by default; --allow-podman turns it off) + 7. no `podman` anywhere outside the historical plans + (on by default; --allow-podman turns it off); lgtm-presentation/ is included + 8. no positional terms in lgtm-presentation/*.md and lgtm-presentation/text/*.md + 9. the stale-stack denylist (scripts/deck-denylist.json) over lgtm-presentation/text/*.md Optional: --allow-podman, --fail-on-warn. Output is `path:line: message`. Exit 1 on errors. Needs python3 stdlib and PyYAML. @@ -19,6 +21,7 @@ from __future__ import annotations import argparse +import json import re import sys from pathlib import Path @@ -283,7 +286,7 @@ def check_file(path: Path, rep: Report) -> None: check_positional(text, path, rep, fm_lines) -PODMAN_SKIP_DIRS = {"node_modules", ".git", "lgtm-presentation", "_site", ".bundle", "vendor"} +PODMAN_SKIP_DIRS = {"node_modules", ".git", "_site", ".bundle", "vendor"} PODMAN_HISTORY = { "_plans/phase3-backlog.md", "_plans/qa-pass-r70.md", @@ -291,7 +294,7 @@ def check_file(path: Path, rep: Report) -> None: "_plans/iteration-plan.md", "scripts/validate_site.py", } -PODMAN_SCAN_ROOTS = ["examples", "assets/diagrams", "scripts", "_plans", "_docs", +PODMAN_SCAN_ROOTS = ["examples", "assets/diagrams", "scripts", "_plans", "_docs", "lgtm-presentation", "_parts", "_example_pages", ".github", "_includes", "_layouts"] PODMAN_SCAN_SUFFIXES = { ".md", ".html", ".yml", ".yaml", ".py", ".sh", ".svg", ".excalidraw", ".json", @@ -334,6 +337,39 @@ def check_podman(rep: Report, site_files: list[Path]) -> None: rep.error(p, i, f"podman reference: {line.strip()[:100]}") +DECK_DENYLIST = ROOT / "scripts" / "deck-denylist.json" +DECK_MD_GLOBS = ["lgtm-presentation/*.md", "lgtm-presentation/text/*.md"] + + +def check_deck_markdown(rep: Report) -> None: + """Positional language over the deck Markdown (text copies, companions, BUILD.md).""" + for g in DECK_MD_GLOBS: + for p in sorted(ROOT.glob(g)): + check_positional(p.read_text(encoding="utf-8"), p, rep, 0) + + +def check_denylist(rep: Report) -> None: + """Stale-stack denylist: every line of the deck text copies (code included). + + An entry is {id, regex, flags?, exempt?, files?, why}; a hit is an error unless the + exemption matches the same line; `files` limits it to text copies whose name contains + one of the given strings. + """ + entries = json.loads(DECK_DENYLIST.read_text(encoding="utf-8")) + rules = [] + for e in entries: + fl = re.I if "i" in e.get("flags", "") else 0 + rules.append((e, re.compile(e["regex"], fl), + re.compile(e["exempt"], re.I) if e.get("exempt") else None)) + for p in sorted(ROOT.glob("lgtm-presentation/text/*.md")): + for i, line in enumerate(p.read_text(encoding="utf-8").split("\n"), 1): + for e, rx, ex in rules: + if e.get("files") and not any(k in p.name for k in e["files"]): + continue + if rx.search(line) and not (ex and ex.search(line)): + rep.error(p, i, f"denylist [{e['id']}] {e['why']}: {line.strip()[:100]}") + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--allow-podman", action="store_true", @@ -349,6 +385,8 @@ def main() -> int: check_file(f, rep) if not args.allow_podman: check_podman(rep, files) + check_deck_markdown(rep) + check_denylist(rep) for line in rep.errors + rep.warnings: print(line) diff --git a/scripts/verify-matrix.sh b/scripts/verify-matrix.sh index f09a727b..6b7584e2 100755 --- a/scripts/verify-matrix.sh +++ b/scripts/verify-matrix.sh @@ -44,10 +44,12 @@ for t in "${targets[@]}"; do done done -running="$(docker ps -q | wc -l)" +# The quarkus-agent MCP's doc-search containers (label quarkus-agent-mcp) are tooling, not a workshop. +others="$(docker ps --format '{{.Names}}\t{{.Label "quarkus-agent-mcp"}}' | awk -F'\t' '$2 == "" {print $1}')" +running="$(printf '%s' "$others" | grep -c . || true)" if [ "$running" -ne 0 ]; then echo "refusing to start: $running container(s) already running (one workshop at a time)" >&2 - docker ps --format ' {{.Names}}' >&2 + printf '%s\n' "$others" | sed 's/^/ /' >&2 exit 2 fi diff --git a/scripts/voice-scan.sh b/scripts/voice-scan.sh index 03849b73..47653317 100755 --- a/scripts/voice-scan.sh +++ b/scripts/voice-scan.sh @@ -8,9 +8,11 @@ # with PATHs (relative to the current directory), scan only # those; paths under examples/ are scanned as Markdown # -# Two scopes, scanned separately because --ext applies to a whole scan.sh run: +# Three scopes, scanned separately because --ext applies to a whole scan.sh run: # site the Jekyll content, layouts, homepage, README and diagram SVGs (default extensions) # examples the example READMEs and PREREQUISITES.md (--ext md, so example source is not scanned) +# decks lgtm-presentation Markdown only (--ext md): the text copies of the decks, the +# policy-deck companions and BUILD.md; the .pptx and .docx files are never scanned # Add a new reader-facing surface to SITE_ROOTS. The scanner is vendored from lgtm-skills; see # scripts/voice/README.md and fix it upstream rather than here. set -euo pipefail @@ -21,17 +23,19 @@ ROOT="$PWD" SCAN=(bash scripts/voice/scan.sh) SITE_ROOTS=(_docs _parts _example_pages _includes _layouts index.html README.md assets/diagrams) -# S12: lgtm-presentation joins the gate when the decks are reviewed. EXAMPLE_ROOTS=(examples) +DECK_ROOTS=(lgtm-presentation) -# scan_scopes -- -- +# scan_scopes -- -- -- # Runs each non-empty scope; returns non-zero if either run fails. scan_scopes() { - local site=() examples=() opts=() rc=0 + local site=() examples=() decks=() opts=() rc=0 while [[ $# -gt 0 && "$1" != -- ]]; do site+=("$1"); shift; done shift while [[ $# -gt 0 && "$1" != -- ]]; do examples+=("$1"); shift; done shift + while [[ $# -gt 0 && "$1" != -- ]]; do decks+=("$1"); shift; done + shift opts=("$@") if [[ ${#site[@]} -gt 0 ]]; then echo "== site" @@ -41,10 +45,14 @@ scan_scopes() { echo "== examples (Markdown only; excerpt paths are relative to examples/ when the whole tree is scanned)" "${SCAN[@]}" --ext md "${opts[@]}" "${examples[@]}" || rc=1 fi + if [[ ${#decks[@]} -gt 0 ]]; then + echo "== decks (Markdown only: text copies, policy-deck companions, BUILD.md)" + "${SCAN[@]}" --ext md "${opts[@]}" "${decks[@]}" || rc=1 + fi return "$rc" } -run_both() { scan_scopes "${SITE_ROOTS[@]}" -- "${EXAMPLE_ROOTS[@]}" -- "$@"; } +run_both() { scan_scopes "${SITE_ROOTS[@]}" -- "${EXAMPLE_ROOTS[@]}" -- "${DECK_ROOTS[@]}" -- "$@"; } if [[ $# -eq 0 ]]; then if run_both --tier ban --fail; then @@ -63,7 +71,7 @@ if [[ "$1" == --all-tiers-summary ]]; then fi # Split options (and their values) from paths. Options that take a value are listed explicitly. -opts=() site=() examples=() +opts=() site=() examples=() decks=() while [[ $# -gt 0 ]]; do case "$1" in --tier|--hits|--ext|--exclude|--patterns) @@ -77,14 +85,15 @@ while [[ $# -gt 0 ]]; do rel="${path#"$ROOT"}"; rel="${rel#/}"; rel="${rel:-.}" case "$rel" in examples|examples/*) examples+=("$rel") ;; + lgtm-presentation|lgtm-presentation/*) decks+=("$rel") ;; *) site+=("$rel") ;; esac shift ;; esac done -if [[ ${#site[@]} -eq 0 && ${#examples[@]} -eq 0 ]]; then +if [[ ${#site[@]} -eq 0 && ${#examples[@]} -eq 0 && ${#decks[@]} -eq 0 ]]; then run_both "${opts[@]}" else - scan_scopes "${site[@]}" -- "${examples[@]}" -- "${opts[@]}" + scan_scopes "${site[@]}" -- "${examples[@]}" -- "${decks[@]}" -- "${opts[@]}" fi diff --git a/scripts/voice/README.md b/scripts/voice/README.md index b0ce8380..31c62638 100644 --- a/scripts/voice/README.md +++ b/scripts/voice/README.md @@ -7,7 +7,7 @@ |---|---| | Source | https://github.com/patterncatalyst/lgtm-skills | | Path | `skills/lgtm-professional-voice/scripts/{scan.sh,patterns.tsv}` | -| Commit | `a3350074a1df343b3bbd58a563f26bf0311a265a` (2026-10-11, lgtm-skills PRs #48 and #49) | +| Commit | `a93f1288cad4f8a099028814de0ca57d01de0421` (2026-10-12, lgtm-skills PR #51, wider ALL-CAPS emphasis list) | | License | the upstream repository has no LICENSE file; both repositories have the same owner (patterncatalyst), who vendored the copy here | Do not edit these files here. Fix the scanner or its patterns upstream, merge, then re-vendor: diff --git a/scripts/voice/patterns.tsv b/scripts/voice/patterns.tsv index a829610d..03c13415 100644 --- a/scripts/voice/patterns.tsv +++ b/scripts/voice/patterns.tsv @@ -28,7 +28,7 @@ ban i deliberately / intentionally \b(deliberately|intentionally)\b watch i deliberate (adjective) \bdeliberate\b ban i not a toy / not a stub \bnot (just |merely |simply )?(a |an )?(toy|mock|mockup|stub|sketch|slideware|hello[- ]world|snippet)\b ban i real, running / for real \breal(,| and) (running|working|live)\b|\b(genuinely|actually|truly) real\b|\bfor real\b -watch c ALL-CAPS emphasis \b(NOT|IS|ARE|NEVER|ALWAYS|ONLY|SAME|AFTER|BEFORE|EVERY|DOES|BOTH)\b +watch c ALL-CAPS emphasis \b(NOT|IS|ARE|NEVER|ALWAYS|ONLY|SAME|AFTER|BEFORE|EVERY|DOES|BOTH|ALL|AND|ANY|ALSO|DO|EXACTLY|FIRST|FREE|IMMEDIATELY|INSIDE|MUST|NO|ONE|PER|SINGLE|THIS|THAT|WHEN|WHERE|WHO|WITH|WITHOUT|WRONG)\b watch i isn't X — it's Y n['’]?t (just |merely |only |simply )?[^.;:]{1,40} [—–]+ ?(it|they|this|that)(['’]s|['’]re| is| are)\b # --- Meta-narration (the text describing itself instead of the subject)